From c74d2b1c7336bb9cffbbe0756cc0e49b25cc56c0 Mon Sep 17 00:00:00 2001 From: Chase Date: Mon, 10 Aug 2026 00:49:40 +0800 Subject: [PATCH 1/4] feat: add multi-product governance validation --- README.md | 9 + README.zh-CN.md | 7 + USAGE.md | 83 ++++ USAGE.zh-CN.md | 77 ++++ schemas/governance.schema.json | 123 ++++++ scripts/smoke-installed-package.mjs | 1 + src/cli.mjs | 9 + src/governance-graph.mjs | 387 ++++++++++++++++++ src/governance.mjs | 267 ++++++++++++ src/indexer.mjs | 23 +- src/validator.mjs | 30 +- src/workspace-resolver.mjs | 217 +++++++++- .../governance/invalid/exposure-leak.yaml | 27 ++ .../governance/invalid/forbidden-direct.yaml | 27 ++ .../invalid/forbidden-transitive.yaml | 39 ++ .../governance/invalid/group-cycle.yaml | 22 + .../governance/invalid/missing-target.yaml | 15 + .../governance/invalid/overlapping-roots.yaml | 22 + .../governance/invalid/product-cycle.yaml | 27 ++ .../governance/invalid/unassigned-source.yaml | 15 + .../invalid/undeclared-cross-product.yaml | 27 ++ .../governance/invalid/unknown-exposure.yaml | 15 + .../opendomain/governance.yaml | 48 +++ .../ecosystem/contexts/alpha.ecosystem.md | 21 + .../alpha/internal/contexts/alpha.internal.md | 21 + .../alpha/private/contexts/alpha.private.md | 21 + .../products/alpha/public/contexts/alpha.md | 21 + .../products/beta/public/contexts/beta.md | 21 + tests/governance-conformance.test.mjs | 129 ++++++ tests/governance-graph.test.mjs | 126 ++++++ tests/governance-integration.test.mjs | 107 +++++ tests/governance.test.mjs | 197 +++++++++ tests/packaged-resources.test.mjs | 2 + tests/workspace-resolver.test.mjs | 139 +++++++ 34 files changed, 2313 insertions(+), 9 deletions(-) create mode 100644 schemas/governance.schema.json create mode 100644 src/governance-graph.mjs create mode 100644 src/governance.mjs create mode 100644 tests/fixtures/governance/invalid/exposure-leak.yaml create mode 100644 tests/fixtures/governance/invalid/forbidden-direct.yaml create mode 100644 tests/fixtures/governance/invalid/forbidden-transitive.yaml create mode 100644 tests/fixtures/governance/invalid/group-cycle.yaml create mode 100644 tests/fixtures/governance/invalid/missing-target.yaml create mode 100644 tests/fixtures/governance/invalid/overlapping-roots.yaml create mode 100644 tests/fixtures/governance/invalid/product-cycle.yaml create mode 100644 tests/fixtures/governance/invalid/unassigned-source.yaml create mode 100644 tests/fixtures/governance/invalid/undeclared-cross-product.yaml create mode 100644 tests/fixtures/governance/invalid/unknown-exposure.yaml create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/governance.yaml create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/ecosystem/contexts/alpha.ecosystem.md create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/internal/contexts/alpha.internal.md create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/private/contexts/alpha.private.md create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/public/contexts/alpha.md create mode 100644 tests/fixtures/valid/governed-multi-product/opendomain/products/beta/public/contexts/beta.md create mode 100644 tests/governance-conformance.test.mjs create mode 100644 tests/governance-graph.test.mjs create mode 100644 tests/governance-integration.test.mjs create mode 100644 tests/governance.test.mjs diff --git a/README.md b/README.md index a067fd9..a679134 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ The current alpha includes: - accepted concepts, rules, lifecycles, events, and evidence; - Candidate-first AI inference with explicit human review; - deterministic Semantic Closure and derived read-first indexes; +- optional multi-product workspace governance with deterministic exposure and publication-closure validation; - Grounding Request, Grounding Pack, and advisory/enforced Assurance; - built-in OpenSpec grounding and declarative Integration Profiles; - managed Codex instructions, Skills, updates, and diagnostics; @@ -157,6 +158,14 @@ OpenDomain is suitable for bounded trials and public iteration. The format and CLI may still change before a stable release, and it should not yet be the sole governance source for production-critical domain decisions. +For a multi-product canonical workspace, add a versioned +`opendomain/governance.yaml` and place each domain group's normal semantic +directories under its declared `source_root`. `opendomain validate --json` +then returns product/group ownership, dependency graphs, exposure diagnostics, +and derived public dependency closures. A passing closure is static evidence; +it does not publish files, grant permissions, change Git, or require EchoPath. +See [Multi-product workspace governance](USAGE.md#multi-product-workspace-governance). + ## Public Resources - [Usage Guide](USAGE.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0166a11..0ab0f3b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -137,6 +137,7 @@ opendomain validate - accepted 概念、规则、生命周期、事件与证据; - Candidate-first AI 推断和显式人工审查; - 确定性 Semantic Closure 与派生 read-first index; +- 可选的多产品 workspace 治理、exposure 传播与 public dependency closure 校验; - Grounding Request、Grounding Pack 和 advisory/enforced Assurance; - 内置 OpenSpec grounding 与声明式 Integration Profile; - 受管 Codex 指令、Skills、更新和诊断; @@ -145,6 +146,12 @@ opendomain validate OpenDomain 当前适合限定范围试点和公开迭代。稳定版之前格式与 CLI 仍可能变化,暂时 不应成为生产关键领域决策的唯一治理来源。 +多产品 canonical workspace 可以增加版本化的 `opendomain/governance.yaml`,并把每个 +domain group 的普通语义目录放入声明的 `source_root`。`opendomain validate --json` +会返回 product/group owner、依赖图、exposure 诊断与派生 public closure。closure 通过 +只是静态证据,不会发布文件、授予权限、修改 Git,也不要求安装 EchoPath。详见 +[多产品 Workspace 治理](USAGE.zh-CN.md#多产品-workspace-治理)。 + ## 公开资料 - [简体中文使用指南](USAGE.zh-CN.md) diff --git a/USAGE.md b/USAGE.md index 38601a7..c5644e0 100644 --- a/USAGE.md +++ b/USAGE.md @@ -123,6 +123,89 @@ Codex initialization manages: Existing content outside the managed block remains user-owned. A conflicting user-owned Skill is reported rather than overwritten. +## Multi-Product Workspace Governance + +The existing single-product layout remains the default. When one physical +`opendomain/` root must contain several independently owned products, add +`opendomain/governance.yaml`: + +```yaml +schema_version: "1.0" +products: + - id: public_api + owners: [api-team] + exposure: public + dependencies: [shared_contracts] + forbidden_dependencies: [desktop_private] + - id: shared_contracts + owners: [platform-team] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: desktop_private + owners: [desktop-team] + exposure: private + dependencies: [public_api] + forbidden_dependencies: [] +domain_groups: + - id: public_api.core + product: public_api + source_root: products/public-api/core + owners: [api-team] + exposure: public + dependencies: [shared_contracts.core] + forbidden_dependencies: [desktop_private.context] + - id: shared_contracts.core + product: shared_contracts + source_root: products/shared-contracts/core + owners: [platform-team] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: desktop_private.context + product: desktop_private + source_root: products/desktop-private/context + owners: [desktop-team] + exposure: private + dependencies: [public_api.core] + forbidden_dependencies: [] +``` + +Each `source_root` contains the normal `contexts/`, `concepts/`, `rules/`, +`lifecycles/`, `events/`, and `candidates/` directories. Roots must be real, +disjoint directories confined to `opendomain/`; every governed semantic source +must belong to exactly one domain group. + +Exposure is fixed from least to most restrictive: + +```text +public < ecosystem < internal < private +``` + +A node may only depend on an equal or less restrictive target. Cross-product +group dependencies also require the corresponding product dependency. +`forbidden_dependencies` applies transitively and reports the dependency path. + +Validate for humans or automation: + +```bash +opendomain validate +opendomain validate --json +``` + +The JSON result adds `governance.dependency_graph` and +`governance.publication_closures`, including manifest provenance, included +nodes/files, and selection paths. Unknown schema versions/exposure values, +cycles, missing targets, overlaps, unassigned sources, forbidden paths, and +private-to-public leakage fail closed. + +Publication closure is rebuildable static evidence. It does not publish a +repository, copy a public projection, grant access, modify Git, or prove that a +release occurred. The npm package and standalone executable evaluate the same +manifest without requiring EchoPath, AGW, a package-manager workspace, or a +private sibling repository. If `governance.yaml` is absent, current canonical, +legacy, and explicit-target behavior is unchanged. + ## First Read-Only Domain Exploration Ask: diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index 8706a9e..309659a 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -111,6 +111,83 @@ Codex 初始化会管理: 受管区块外的既有内容仍由用户拥有。遇到同名用户 Skill 时,OpenDomain 会报告冲突, 不会覆盖。 +## 多产品 Workspace 治理 + +现有单产品布局仍是默认行为。当一个物理 `opendomain/` 根需要容纳多个独立 owner 的 +产品时,增加 `opendomain/governance.yaml`: + +```yaml +schema_version: "1.0" +products: + - id: public_api + owners: [api-team] + exposure: public + dependencies: [shared_contracts] + forbidden_dependencies: [desktop_private] + - id: shared_contracts + owners: [platform-team] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: desktop_private + owners: [desktop-team] + exposure: private + dependencies: [public_api] + forbidden_dependencies: [] +domain_groups: + - id: public_api.core + product: public_api + source_root: products/public-api/core + owners: [api-team] + exposure: public + dependencies: [shared_contracts.core] + forbidden_dependencies: [desktop_private.context] + - id: shared_contracts.core + product: shared_contracts + source_root: products/shared-contracts/core + owners: [platform-team] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: desktop_private.context + product: desktop_private + source_root: products/desktop-private/context + owners: [desktop-team] + exposure: private + dependencies: [public_api.core] + forbidden_dependencies: [] +``` + +每个 `source_root` 继续使用普通的 `contexts/`、`concepts/`、`rules/`、 +`lifecycles/`、`events/` 与 `candidates/`。所有 root 必须是 `opendomain/` +内部互不重叠的真实目录;每个受治理语义 source 必须且只能属于一个 domain group。 + +exposure 从最公开到最严格固定为: + +```text +public < ecosystem < internal < private +``` + +节点只能依赖相同或更公开的目标。跨产品 group 依赖还必须声明对应 product 依赖。 +`forbidden_dependencies` 会传递检查并返回完整依赖路径。 + +面向人或自动化执行: + +```bash +opendomain validate +opendomain validate --json +``` + +JSON 会增加 `governance.dependency_graph` 和 +`governance.publication_closures`,包含 manifest provenance、纳入的节点/文件与 +selection path。未知 schema version/exposure、循环、缺失 target、root 重叠、未归属 +source、forbidden path 与 private-to-public 泄漏都会 fail closed。 + +publication closure 是可重建的静态证据。它不会发布仓库、复制公开投影、授予权限、 +修改 Git 或证明 release 已发生。npm 包与独立 executable 使用相同规则,不依赖 +EchoPath、AGW、package-manager workspace 或私有 sibling。没有 `governance.yaml` 时, +现有 canonical、legacy 与 explicit-target 行为保持不变。 + ## 第一次只读了解业务 可以说: diff --git a/schemas/governance.schema.json b/schemas/governance.schema.json new file mode 100644 index 0000000..289fa6e --- /dev/null +++ b/schemas/governance.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opendomain.dev/schemas/governance.schema.json", + "title": "OpenDomain Multi-Product Governance Manifest v1.0", + "type": "object", + "required": ["schema_version", "products", "domain_groups"], + "properties": { + "schema_version": { + "const": "1.0" + }, + "products": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/product" + } + }, + "domain_groups": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/domainGroup" + } + } + }, + "$defs": { + "productId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "domainGroupId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$" + }, + "owners": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@/-]*$" + } + }, + "exposure": { + "enum": ["public", "ecosystem", "internal", "private"] + }, + "productDependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/productId" + } + }, + "domainGroupDependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/domainGroupId" + } + }, + "product": { + "type": "object", + "required": ["id", "owners", "exposure", "dependencies", "forbidden_dependencies"], + "properties": { + "id": { + "$ref": "#/$defs/productId" + }, + "owners": { + "$ref": "#/$defs/owners" + }, + "exposure": { + "$ref": "#/$defs/exposure" + }, + "dependencies": { + "$ref": "#/$defs/productDependencies" + }, + "forbidden_dependencies": { + "$ref": "#/$defs/productDependencies" + } + }, + "additionalProperties": false + }, + "domainGroup": { + "type": "object", + "required": [ + "id", + "product", + "source_root", + "owners", + "exposure", + "dependencies", + "forbidden_dependencies" + ], + "properties": { + "id": { + "$ref": "#/$defs/domainGroupId" + }, + "product": { + "$ref": "#/$defs/productId" + }, + "source_root": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$" + }, + "owners": { + "$ref": "#/$defs/owners" + }, + "exposure": { + "$ref": "#/$defs/exposure" + }, + "dependencies": { + "$ref": "#/$defs/domainGroupDependencies" + }, + "forbidden_dependencies": { + "$ref": "#/$defs/domainGroupDependencies" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index dc502dd..f43aea2 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -62,6 +62,7 @@ try { await access(path.join(installedRoot, "schemas", "domain-declaration.schema.json")); await access(path.join(installedRoot, "schemas", "assurance-result.schema.json")); await access(path.join(installedRoot, "schemas", "workspace-config.schema.json")); + await access(path.join(installedRoot, "schemas", "governance.schema.json")); for (const publicDocument of [ "README.md", "README.zh-CN.md", diff --git a/src/cli.mjs b/src/cli.mjs index 11723d2..7f05aee 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1256,6 +1256,15 @@ function printValidationResult(result, stream) { stream.write(".\n"); } + if (result.governance) { + const closures = result.governance.publication_closures ?? []; + stream.write( + `Governance ${result.governance.schema_version}: ${result.governance.products.length} products, ` + + `${result.governance.domain_groups.length} domain groups, ${closures.length} public closures passed ` + + "(derived evidence; no publication performed).\n" + ); + } + for (const issue of [...result.errors, ...result.warnings]) { printIssue(issue, stream); } diff --git a/src/governance-graph.mjs b/src/governance-graph.mjs new file mode 100644 index 0000000..24e77df --- /dev/null +++ b/src/governance-graph.mjs @@ -0,0 +1,387 @@ +import { EXPOSURE_ORDER } from "./governance.mjs"; + +const EXPOSURE_RANK = new Map(EXPOSURE_ORDER.map((value, index) => [value, index])); + +export function analyzeGovernance(governance, options = {}) { + const products = governance.products ?? governance.manifest?.products ?? []; + const domainGroups = governance.domainGroups ?? governance.manifest?.domain_groups ?? []; + const file = governance.file ?? "opendomain/governance.yaml"; + const sourceFilesByGroup = options.sourceFilesByGroup ?? new Map(); + const prerequisiteFailed = options.prerequisiteFailed === true; + const productGraph = buildGraph("product", products, file); + const groupGraph = buildGraph("domain_group", domainGroups, file); + const errors = [...productGraph.errors, ...groupGraph.errors]; + const productsById = new Map(products.map((node) => [node.id, node])); + const groupsById = new Map(domainGroups.map((node) => [node.id, node])); + + errors.push(...validateGroupParents(domainGroups, productsById, file)); + errors.push(...validateCrossProductDependencies(domainGroups, groupsById, productsById, file)); + errors.push(...validateExposureEdges("product", productGraph, file)); + errors.push(...validateExposureEdges("domain_group", groupGraph, file)); + errors.push(...validateForbiddenDependencies("product", productGraph, file)); + errors.push(...validateForbiddenDependencies("domain_group", groupGraph, file)); + + const orderedErrors = uniqueIssues(errors).sort(compareIssues); + const publicationClosures = orderedErrors.length === 0 && !prerequisiteFailed + ? buildPublicationClosures({ + products, + domainGroups, + productGraph, + groupGraph, + sourceFilesByGroup + }) + : []; + + return { + schema_version: governance.manifest?.schema_version ?? null, + manifest: file, + derived: true, + authoritative_source: "OpenDomain governance manifest and semantic source files, not this derived graph", + prerequisite_status: prerequisiteFailed ? "fail" : "pass", + products: products.map(publicNode), + domain_groups: domainGroups.map(publicGroup), + dependency_graph: { + products: graphEvidence(productGraph), + domain_groups: graphEvidence(groupGraph) + }, + publication_closures: publicationClosures, + errors: orderedErrors, + warnings: [] + }; +} + +function buildGraph(nodeType, nodes, file) { + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const adjacency = new Map(nodes.map((node) => [node.id, []])); + const errors = []; + + for (const node of nodes) { + for (const target of node.dependencies) { + if (target === node.id) { + errors.push(issue({ + code: "self_dependency", + file, + field: `${nodeType}.${node.id}.dependencies`, + problem: `${label(nodeType)} '${node.id}' depends on itself.`, + fix: "Remove the self dependency." + })); + continue; + } + if (!nodesById.has(target)) { + errors.push(issue({ + code: "missing_dependency_target", + file, + field: `${nodeType}.${node.id}.dependencies`, + problem: `${label(nodeType)} '${node.id}' depends on unknown target '${target}'.`, + fix: `Declare ${label(nodeType).toLowerCase()} '${target}' or correct the dependency id.` + })); + continue; + } + adjacency.get(node.id).push(target); + } + adjacency.get(node.id).sort(compareText); + + for (const target of node.forbidden_dependencies) { + if (!nodesById.has(target)) { + errors.push(issue({ + code: "missing_forbidden_dependency_target", + file, + field: `${nodeType}.${node.id}.forbidden_dependencies`, + problem: `${label(nodeType)} '${node.id}' forbids unknown target '${target}'.`, + fix: `Declare ${label(nodeType).toLowerCase()} '${target}' or correct the forbidden dependency id.` + })); + } + } + } + + const cycles = findCycles([...nodesById.keys()].sort(compareText), adjacency); + for (const cycle of cycles) { + errors.push(issue({ + code: "dependency_cycle", + file, + field: `${nodeType}.dependencies`, + problem: `${label(nodeType)} dependency cycle detected: ${cycle.join(" -> ")}.`, + fix: "Remove or reverse at least one dependency edge so the graph is acyclic." + })); + } + + return { nodeType, nodesById, adjacency, cycles, errors }; +} + +function validateGroupParents(groups, productsById, file) { + const errors = []; + for (const group of groups) { + const product = productsById.get(group.product); + if (!product) { + continue; + } + if (rank(group.exposure) < rank(product.exposure)) { + errors.push(issue({ + code: "group_more_public_than_product", + file, + field: `domain_group.${group.id}.exposure`, + problem: `Domain group '${group.id}' exposure '${group.exposure}' is more public than product '${product.id}' exposure '${product.exposure}'.`, + fix: "Make the group exposure equal to or more restrictive than its owning product." + })); + } + } + return errors; +} + +function validateCrossProductDependencies(groups, groupsById, productsById, file) { + const errors = []; + for (const group of groups) { + const sourceProduct = productsById.get(group.product); + if (!sourceProduct) { + continue; + } + for (const targetId of group.dependencies) { + const target = groupsById.get(targetId); + if (!target || target.product === group.product) { + continue; + } + if (!sourceProduct.dependencies.includes(target.product)) { + errors.push(issue({ + code: "undeclared_product_dependency", + file, + field: `domain_group.${group.id}.dependencies`, + problem: `Cross-product group dependency '${group.id}' -> '${target.id}' lacks product dependency '${group.product}' -> '${target.product}'.`, + fix: `Declare '${target.product}' in product '${group.product}' dependencies or remove the cross-product group edge.` + })); + } + } + } + return errors; +} + +function validateExposureEdges(nodeType, graph, file) { + const errors = []; + for (const sourceId of [...graph.nodesById.keys()].sort(compareText)) { + const source = graph.nodesById.get(sourceId); + for (const targetId of graph.adjacency.get(sourceId) ?? []) { + const target = graph.nodesById.get(targetId); + if (rank(target.exposure) > rank(source.exposure)) { + errors.push(issue({ + code: "exposure_leak", + file, + field: `${nodeType}.${source.id}.dependencies`, + problem: `${label(nodeType)} dependency leaks from '${source.id}' (${source.exposure}) to more restrictive '${target.id}' (${target.exposure}) via ${source.id} -> ${target.id}.`, + fix: "Remove the dependency or move the target contract to an equal or more public exposure." + })); + } + } + } + return errors; +} + +function validateForbiddenDependencies(nodeType, graph, file) { + const errors = []; + for (const sourceId of [...graph.nodesById.keys()].sort(compareText)) { + const source = graph.nodesById.get(sourceId); + for (const targetId of source.forbidden_dependencies) { + if (!graph.nodesById.has(targetId)) { + continue; + } + const path = shortestPath(sourceId, targetId, graph.adjacency); + if (path) { + errors.push(issue({ + code: "forbidden_dependency", + file, + field: `${nodeType}.${source.id}.forbidden_dependencies`, + problem: `${label(nodeType)} '${source.id}' reaches forbidden dependency '${targetId}' via ${path.join(" -> ")}.`, + fix: "Remove an edge on the reported path or revise the forbidden declaration through review." + })); + } + } + } + return errors; +} + +function buildPublicationClosures({ + products, + domainGroups, + productGraph, + groupGraph, + sourceFilesByGroup +}) { + return products + .filter((product) => product.exposure === "public") + .sort((left, right) => compareText(left.id, right.id)) + .map((product) => { + const productSelection = traverseFrom([product.id], productGraph.adjacency); + const productIds = productSelection.ids; + const groupRoots = domainGroups + .filter((group) => productIds.includes(group.product) && group.exposure === "public") + .map((group) => group.id) + .sort(compareText); + const groupSelection = traverseFrom(groupRoots, groupGraph.adjacency); + const domainGroupIds = groupSelection.ids; + const files = domainGroupIds.flatMap((groupId) => ( + [...(sourceFilesByGroup.get(groupId) ?? [])].sort(compareText) + )).sort(compareText); + + return { + product_id: product.id, + status: "pass", + derived: true, + product_ids: productIds, + domain_group_ids: domainGroupIds, + source_files: files, + selection_paths: [ + ...productSelection.paths.map((entry) => ({ node_type: "product", ...entry })), + ...groupSelection.paths.map((entry) => ({ node_type: "domain_group", ...entry })) + ].sort((left, right) => ( + compareText(left.node_type, right.node_type) + || compareText(left.id, right.id) + || compareText(left.root_id, right.root_id) + )) + }; + }); +} + +function traverseFrom(rootIds, adjacency) { + const roots = [...new Set(rootIds)].sort(compareText); + const queue = roots.map((id) => ({ id, rootId: id, path: [id] })); + const seen = new Set(roots); + const paths = []; + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + paths.push({ id: current.id, root_id: current.rootId, path: current.path }); + for (const target of adjacency.get(current.id) ?? []) { + if (seen.has(target)) { + continue; + } + seen.add(target); + queue.push({ id: target, rootId: current.rootId, path: [...current.path, target] }); + } + } + + return { + ids: [...seen].sort(compareText), + paths: paths.sort((left, right) => compareText(left.id, right.id)) + }; +} + +function shortestPath(sourceId, targetId, adjacency) { + const queue = [{ id: sourceId, path: [sourceId] }]; + const seen = new Set([sourceId]); + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + if (current.id === targetId && current.path.length > 1) { + return current.path; + } + for (const next of adjacency.get(current.id) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push({ id: next, path: [...current.path, next] }); + } + } + } + return null; +} + +function findCycles(nodeIds, adjacency) { + const visiting = new Set(); + const visited = new Set(); + const stack = []; + const cycles = new Map(); + + function visit(id) { + visiting.add(id); + stack.push(id); + for (const target of adjacency.get(id) ?? []) { + if (visiting.has(target)) { + const start = stack.indexOf(target); + const cycle = canonicalCycle([...stack.slice(start), target]); + cycles.set(cycle.join("\u0000"), cycle); + } else if (!visited.has(target)) { + visit(target); + } + } + stack.pop(); + visiting.delete(id); + visited.add(id); + } + + for (const id of nodeIds) { + if (!visited.has(id)) { + visit(id); + } + } + return [...cycles.values()].sort((left, right) => compareText(left.join("\u0000"), right.join("\u0000"))); +} + +function canonicalCycle(cycle) { + const nodes = cycle.slice(0, -1); + let best = nodes; + for (let index = 1; index < nodes.length; index += 1) { + const rotated = [...nodes.slice(index), ...nodes.slice(0, index)]; + if (compareText(rotated.join("\u0000"), best.join("\u0000")) < 0) { + best = rotated; + } + } + return [...best, best[0]]; +} + +function graphEvidence(graph) { + return { + nodes: [...graph.nodesById.keys()].sort(compareText), + edges: [...graph.adjacency.entries()] + .flatMap(([from, targets]) => targets.map((to) => ({ from, to }))) + .sort((left, right) => compareText(left.from, right.from) || compareText(left.to, right.to)), + cycles: graph.cycles + }; +} + +function publicNode(node) { + return { + id: node.id, + owners: [...node.owners], + exposure: node.exposure, + dependencies: [...node.dependencies], + forbidden_dependencies: [...node.forbidden_dependencies] + }; +} + +function publicGroup(node) { + return { + ...publicNode(node), + product: node.product, + source_root: node.source_root + }; +} + +function rank(exposure) { + return EXPOSURE_RANK.get(exposure) ?? Number.POSITIVE_INFINITY; +} + +function label(nodeType) { + return nodeType === "product" ? "Product" : "Domain group"; +} + +function issue({ code, file, field, problem, fix }) { + return { severity: "error", code, file, field, problem, fix }; +} + +function uniqueIssues(issues) { + const seen = new Set(); + return issues.filter((entry) => { + const key = `${entry.code}\u0000${entry.field}\u0000${entry.problem}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function compareIssues(left, right) { + return compareText(left.code, right.code) + || compareText(left.field, right.field) + || compareText(left.problem, right.problem); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/governance.mjs b/src/governance.mjs new file mode 100644 index 0000000..8381158 --- /dev/null +++ b/src/governance.mjs @@ -0,0 +1,267 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import { parseYamlMapping, FrontMatterError } from "./frontmatter.mjs"; +import { readPackagedText } from "./packaged-resources.mjs"; + +export const GOVERNANCE_MANIFEST_NAME = "governance.yaml"; +export const GOVERNANCE_SCHEMA_FILE = "governance.schema.json"; +export const GOVERNANCE_SCHEMA_VERSION = "1.0"; +export const GOVERNANCE_SCHEMA_ID = "https://opendomain.dev/schemas/governance.schema.json"; +export const EXPOSURE_ORDER = Object.freeze([ + "public", + "ecosystem", + "internal", + "private" +]); + +let defaultValidator; + +export async function loadGovernanceManifest(workspaceRoot, options = {}) { + const manifestPath = path.join(workspaceRoot, GOVERNANCE_MANIFEST_NAME); + const displayPath = options.displayPath + ? path.posix.join(options.displayPath, GOVERNANCE_MANIFEST_NAME) + : GOVERNANCE_MANIFEST_NAME; + const result = { + present: false, + valid: true, + file: displayPath, + absoluteFile: manifestPath, + manifest: null, + products: [], + domainGroups: [], + errors: [], + warnings: [] + }; + + let manifestStat; + try { + manifestStat = await lstat(manifestPath); + } catch (error) { + if (error.code === "ENOENT") { + return result; + } + result.present = true; + result.valid = false; + result.errors.push(issue({ + file: displayPath, + field: "$", + problem: `Unable to inspect governance manifest: ${error.message}`, + fix: "Make the manifest readable or remove it to use the ungoverned single-product workspace mode." + })); + return result; + } + + result.present = true; + if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) { + result.valid = false; + result.errors.push(issue({ + file: displayPath, + field: "$", + problem: manifestStat.isSymbolicLink() + ? "Governance manifest must not be a symbolic link." + : "Governance manifest must be a regular YAML file.", + fix: `Replace '${displayPath}' with a regular file inside the canonical workspace.` + })); + return result; + } + + let manifest; + try { + manifest = parseYamlMapping( + await readFile(manifestPath, "utf8"), + displayPath, + { label: "Governance manifest" } + ); + } catch (error) { + result.valid = false; + result.errors.push(issue({ + file: displayPath, + field: error instanceof FrontMatterError ? error.field : "$", + problem: error instanceof FrontMatterError + ? error.problem + : `Unable to parse governance manifest: ${error.message}`, + fix: "Use a plain YAML mapping without aliases, tags, merge keys, or unsafe property names." + })); + return result; + } + + if (manifest.schema_version !== GOVERNANCE_SCHEMA_VERSION) { + result.valid = false; + result.errors.push(issue({ + file: displayPath, + field: "schema_version", + problem: `Unsupported governance schema version '${String(manifest.schema_version ?? "missing")}'.`, + fix: `Use schema_version: "${GOVERNANCE_SCHEMA_VERSION}" with this OpenDomain version.` + })); + return result; + } + + const validate = options.validate ?? getGovernanceValidator(); + if (!validate(manifest)) { + result.valid = false; + result.errors.push(...(validate.errors ?? []) + .map((error) => schemaIssue(error, displayPath)) + .sort(compareIssues)); + return result; + } + + const products = normalizeNodes(manifest.products); + const domainGroups = normalizeNodes(manifest.domain_groups); + result.errors.push(...validateNodeIdentity(products, domainGroups, displayPath)); + result.valid = result.errors.length === 0; + if (!result.valid) { + return result; + } + + result.manifest = { + schema_version: manifest.schema_version, + products, + domain_groups: domainGroups + }; + result.products = products; + result.domainGroups = domainGroups; + return result; +} + +export function createGovernanceValidator(schema = readGovernanceSchema()) { + const ajv = new Ajv2020({ + allErrors: true, + coerceTypes: false, + removeAdditional: false, + strict: true, + strictRequired: false, + strictTypes: false, + useDefaults: false, + validateSchema: true + }); + ajv.addSchema(schema); + const validate = ajv.getSchema(GOVERNANCE_SCHEMA_ID); + if (!validate) { + throw new Error(`No compiled validator for schemas/${GOVERNANCE_SCHEMA_FILE}.`); + } + return validate; +} + +function getGovernanceValidator() { + if (!defaultValidator) { + defaultValidator = createGovernanceValidator(); + } + return defaultValidator; +} + +function readGovernanceSchema() { + const schema = JSON.parse(readPackagedText(`schemas/${GOVERNANCE_SCHEMA_FILE}`)); + if (schema.$id !== GOVERNANCE_SCHEMA_ID) { + throw new Error(`Packaged schema '${GOVERNANCE_SCHEMA_FILE}' has unexpected $id '${schema.$id ?? "missing"}'.`); + } + return schema; +} + +function normalizeNodes(nodes) { + return nodes + .map((node) => ({ + ...node, + owners: [...node.owners].sort(), + dependencies: [...node.dependencies].sort(), + forbidden_dependencies: [...node.forbidden_dependencies].sort() + })) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function validateNodeIdentity(products, domainGroups, file) { + const errors = []; + const productIds = new Set(); + for (let index = 0; index < products.length; index += 1) { + const product = products[index]; + if (productIds.has(product.id)) { + errors.push(issue({ + file, + field: `products[${index}].id`, + problem: `Duplicate product id '${product.id}'.`, + fix: "Use one globally unique product id per product declaration." + })); + } + productIds.add(product.id); + } + + const groupIds = new Set(); + for (let index = 0; index < domainGroups.length; index += 1) { + const group = domainGroups[index]; + if (groupIds.has(group.id)) { + errors.push(issue({ + file, + field: `domain_groups[${index}].id`, + problem: `Duplicate domain group id '${group.id}'.`, + fix: "Use one globally unique id per domain group declaration." + })); + } + groupIds.add(group.id); + if (!productIds.has(group.product)) { + errors.push(issue({ + file, + field: `domain_groups[${index}].product`, + problem: `Domain group '${group.id}' references unknown product '${group.product}'.`, + fix: "Declare the owning product or correct the product id." + })); + } + if (!group.id.startsWith(`${group.product}.`)) { + errors.push(issue({ + file, + field: `domain_groups[${index}].id`, + problem: `Domain group id '${group.id}' is outside product namespace '${group.product}'.`, + fix: `Prefix the group id with '${group.product}.'.` + })); + } + } + return errors.sort(compareIssues); +} + +function schemaIssue(error, file) { + const segments = decodePointer(error.instancePath); + if (error.keyword === "required" && error.params.missingProperty) { + segments.push(error.params.missingProperty); + } else if (error.keyword === "additionalProperties" && error.params.additionalProperty) { + segments.push(error.params.additionalProperty); + } + const field = formatField(segments); + const detail = error.keyword === "enum" + ? `must be one of ${(error.params.allowedValues ?? []).join(", ")}` + : error.keyword === "const" + ? `must equal '${String(error.params.allowedValue)}'` + : String(error.message ?? `failed '${error.keyword}'`).replace(/[.]+$/, ""); + return issue({ + file, + field, + problem: `Governance manifest field '${field}' violates schemas/${GOVERNANCE_SCHEMA_FILE}: ${detail}.`, + fix: `Update '${field}' to satisfy schemas/${GOVERNANCE_SCHEMA_FILE}.` + }); +} + +function decodePointer(pointer) { + if (!pointer) { + return []; + } + return pointer.slice(1).split("/").map((segment) => ( + segment.replaceAll("~1", "/").replaceAll("~0", "~") + )); +} + +function formatField(segments) { + let field = ""; + for (const segment of segments) { + field += /^(0|[1-9][0-9]*)$/.test(segment) + ? `[${segment}]` + : field ? `.${segment}` : segment; + } + return field || "$"; +} + +function issue({ file, field, problem, fix }) { + return { severity: "error", file, field, problem, fix }; +} + +function compareIssues(left, right) { + return left.field.localeCompare(right.field) + || left.problem.localeCompare(right.problem); +} diff --git a/src/indexer.mjs b/src/indexer.mjs index 203c4ba..5b05e5f 100644 --- a/src/indexer.mjs +++ b/src/indexer.mjs @@ -39,6 +39,17 @@ export async function buildSemanticIndex(targetPath, options = {}) { source_root: validation.workspace?.source_root ?? targetPath ?? "", derived_from: "OpenDomain Markdown source files in Git", authoritative_source: "OpenDomain source files, not this index", + ...(validation.governance + ? { + governance: { + schema_version: validation.governance.schema_version, + manifest: validation.governance.manifest, + derived: true, + authoritative_source: validation.governance.authoritative_source, + publication_closures: validation.governance.publication_closures + } + } + : {}), entries: entries.sort(compareById) }; @@ -208,7 +219,17 @@ async function toIndexEntry(document, cwd, now) { evidence: arrayOrEmpty(frontmatter.evidence), review: frontmatter.review, source_hash: await hashFile(path.resolve(cwd, sourceFile)), - last_indexed_at: now.toISOString() + last_indexed_at: now.toISOString(), + ...(document.ownership + ? { + product_id: document.ownership.product_id, + domain_group_id: document.ownership.domain_group_id, + owners: [...document.ownership.owners], + exposure: document.ownership.exposure, + governance_schema_version: document.ownership.governance_schema_version, + governance_source_root: document.ownership.source_root + } + : {}) }; } diff --git a/src/validator.mjs b/src/validator.mjs index 69f5a5d..0713dd3 100644 --- a/src/validator.mjs +++ b/src/validator.mjs @@ -5,6 +5,7 @@ import { } from "./domain-reference-types.mjs"; import { parseMarkdownFile, FrontMatterError } from "./frontmatter.mjs"; import { validateGroundingDecision } from "./grounding-decision.mjs"; +import { analyzeGovernance } from "./governance-graph.mjs"; import { resolveWorkspaceSources } from "./workspace-resolver.mjs"; import { createDomainSchemaRegistry, @@ -48,7 +49,8 @@ export async function validatePath(targetPath, options = {}) { documents: [], errors: [], warnings: [], - workspace: null + workspace: null, + governance: null }; let schemaRegistry; @@ -64,11 +66,32 @@ export async function validatePath(targetPath, options = {}) { mode: resolution.mode, source_root: resolution.sourceRootDisplay, default_index_path: resolution.defaultIndexPath, - explicit: resolution.explicit + explicit: resolution.explicit, + governed: Boolean(resolution.governance), + governance_manifest: resolution.governance?.file ?? null }; result.errors.push(...resolution.errors); result.warnings.push(...resolution.warnings); + if (resolution.governance) { + const sourceFilesByGroup = new Map(); + for (const file of resolution.files) { + const ownership = resolution.sourceOwnership.get(file); + if (!ownership) { + continue; + } + const files = sourceFilesByGroup.get(ownership.domain_group_id) ?? []; + files.push(documentPath(file, resolution.projectRoot)); + sourceFilesByGroup.set(ownership.domain_group_id, files); + } + result.governance = analyzeGovernance(resolution.governance, { + sourceFilesByGroup, + prerequisiteFailed: resolution.errors.length > 0 + }); + result.errors.push(...result.governance.errors); + result.warnings.push(...result.governance.warnings); + } + const files = resolution.files; for (const file of files) { try { @@ -126,6 +149,9 @@ export async function validatePath(targetPath, options = {}) { absoluteFile: file, type, id: parsed.frontmatter.id, + ...(resolution.sourceOwnership.get(file) + ? { ownership: resolution.sourceOwnership.get(file) } + : {}), frontmatter: parsed.frontmatter, body: parsed.body }); diff --git a/src/workspace-resolver.mjs b/src/workspace-resolver.mjs index dffbbae..2a95e43 100644 --- a/src/workspace-resolver.mjs +++ b/src/workspace-resolver.mjs @@ -1,5 +1,6 @@ import { lstat, readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; +import { loadGovernanceManifest } from "./governance.mjs"; export const CANONICAL_WORKSPACE_DIRECTORY = "opendomain"; export const LEGACY_WORKSPACE_DIRECTORY = "domain"; @@ -85,6 +86,8 @@ export async function resolveWorkspaceSources(targetPath, options = {}) { const result = { ...workspace, files: [], + governance: null, + sourceOwnership: new Map(), explicit: false }; @@ -102,11 +105,39 @@ export async function resolveWorkspaceSources(targetPath, options = {}) { } try { - result.files = await collectImplicitMarkdown( - workspace.sourceRoot, - workspace.sourceRootDisplay, - result.errors - ); + if (workspace.mode === "canonical") { + const governance = await loadGovernanceManifest(workspace.sourceRoot, { + displayPath: workspace.sourceRootDisplay + }); + result.errors.push(...governance.errors); + result.warnings.push(...governance.warnings); + if (governance.present) { + result.governance = governance.valid ? governance : null; + if (!governance.valid) { + return result; + } + const governed = await collectGovernedMarkdown( + workspace.sourceRoot, + workspace.sourceRootDisplay, + governance, + result.errors + ); + result.files = governed.files; + result.sourceOwnership = governed.sourceOwnership; + } else { + result.files = await collectImplicitMarkdown( + workspace.sourceRoot, + workspace.sourceRootDisplay, + result.errors + ); + } + } else { + result.files = await collectImplicitMarkdown( + workspace.sourceRoot, + workspace.sourceRootDisplay, + result.errors + ); + } } catch (error) { result.errors.push(issue({ file: workspace.sourceRootDisplay, @@ -152,6 +183,8 @@ async function resolveExplicitSources(targetPath, cwd) { warnings: [], errors: [], files: [], + governance: null, + sourceOwnership: new Map(), explicit: true }; @@ -329,6 +362,178 @@ async function collectImplicitMarkdown(workspaceRoot, workspaceDisplay, errors) return sortPaths(files); } +async function collectGovernedMarkdown(workspaceRoot, workspaceDisplay, governance, errors) { + const workspaceRealPath = await realpath(workspaceRoot); + const resolvedGroups = []; + const sourceOwnership = new Map(); + + for (const group of governance.domainGroups) { + const sourcePath = path.resolve(workspaceRoot, group.source_root); + const display = `${workspaceDisplay}/${group.source_root}`; + const segmentError = await validateGovernedSourceRoot( + workspaceRoot, + workspaceRealPath, + group, + sourcePath, + display + ); + if (segmentError) { + errors.push(segmentError); + continue; + } + resolvedGroups.push({ group, sourcePath, display }); + } + + for (let leftIndex = 0; leftIndex < resolvedGroups.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < resolvedGroups.length; rightIndex += 1) { + const left = resolvedGroups[leftIndex]; + const right = resolvedGroups[rightIndex]; + if (isWithin(left.sourcePath, right.sourcePath) || isWithin(right.sourcePath, left.sourcePath)) { + errors.push(issue({ + file: governance.file, + field: "domain_groups[].source_root", + problem: `Governed source roots '${left.group.source_root}' and '${right.group.source_root}' overlap.`, + fix: "Use disjoint source roots so every semantic document belongs to exactly one domain group." + })); + } + } + } + + if (errors.length > 0) { + return { files: [], sourceOwnership }; + } + + const files = []; + for (const entry of resolvedGroups) { + const groupErrorsBefore = errors.length; + const groupFiles = await collectImplicitMarkdown(entry.sourcePath, entry.display, errors); + if (groupFiles.length === 0 && errors.length === groupErrorsBefore) { + errors.push(issue({ + file: entry.display, + field: "source_root", + problem: `Domain group '${entry.group.id}' contains no eligible Markdown sources.`, + fix: "Add a semantic source under contexts, concepts, rules, lifecycles, events, or candidates." + })); + continue; + } + for (const file of groupFiles) { + files.push(file); + sourceOwnership.set(file, Object.freeze({ + product_id: entry.group.product, + domain_group_id: entry.group.id, + owners: Object.freeze([...entry.group.owners]), + exposure: entry.group.exposure, + governance_schema_version: governance.manifest.schema_version, + source_root: entry.group.source_root + })); + } + } + + const unassigned = await collectUnassignedSemanticMarkdown( + workspaceRoot, + resolvedGroups.map((entry) => entry.sourcePath) + ); + for (const file of unassigned) { + errors.push(issue({ + file: displayPath(path.dirname(workspaceRoot), file), + field: "source_root", + problem: "Governed semantic source is outside every declared domain-group source root.", + fix: "Move the source under one declared group root or add a disjoint domain-group declaration." + })); + } + + return { + files: sortGovernedFiles(files, sourceOwnership, workspaceRoot), + sourceOwnership + }; +} + +async function validateGovernedSourceRoot(workspaceRoot, workspaceRealPath, group, sourcePath, display) { + const segments = group.source_root.split("/"); + let current = workspaceRoot; + for (const segment of segments) { + current = path.join(current, segment); + let currentStat; + try { + currentStat = await lstat(current); + } catch (error) { + return issue({ + file: display, + field: "source_root", + problem: error.code === "ENOENT" + ? `Domain group '${group.id}' source root does not exist.` + : `Unable to inspect domain group '${group.id}' source root: ${error.message}`, + fix: "Create the declared source root as a real directory inside opendomain/." + }); + } + if (currentStat.isSymbolicLink()) { + return issue({ + file: display, + field: "source_root", + problem: `Domain group '${group.id}' source root traverses a symbolic link.`, + fix: "Use a real directory path contained by opendomain/." + }); + } + } + + const sourceRealPath = await realpath(sourcePath); + const sourceStat = await stat(sourceRealPath); + if (!sourceStat.isDirectory()) { + return issue({ + file: display, + field: "source_root", + problem: `Domain group '${group.id}' source root is not a directory.`, + fix: "Replace the declared source root with a directory." + }); + } + if (!isWithin(workspaceRealPath, sourceRealPath)) { + return issue({ + file: display, + field: "source_root", + problem: `Domain group '${group.id}' source root resolves outside the canonical workspace.`, + fix: "Use a workspace-relative directory contained by opendomain/." + }); + } + return null; +} + +async function collectUnassignedSemanticMarkdown(workspaceRoot, assignedRoots) { + const files = []; + await visit(workspaceRoot); + return sortPaths(files); + + async function visit(directory) { + const entries = (await readdir(directory, { withFileTypes: true })) + .sort((left, right) => compareText(left.name, right.name)); + for (const entry of entries) { + if (entry.isSymbolicLink() || !entry.isDirectory()) { + continue; + } + if ([...SKIPPED_DIRECTORY_NAMES, "generated", "integrations"].includes(entry.name)) { + continue; + } + const child = path.join(directory, entry.name); + if (assignedRoots.some((root) => isWithin(root, child))) { + continue; + } + if (SEMANTIC_SOURCE_DIRECTORIES.includes(entry.name)) { + files.push(...await walkMarkdown(child)); + continue; + } + await visit(child); + } + } +} + +function sortGovernedFiles(files, sourceOwnership, workspaceRoot) { + return files.sort((left, right) => { + const leftOwner = sourceOwnership.get(left); + const rightOwner = sourceOwnership.get(right); + return compareText(leftOwner.domain_group_id, rightOwner.domain_group_id) + || compareText(path.relative(workspaceRoot, left), path.relative(workspaceRoot, right)); + }); +} + async function walkMarkdown(root) { const entries = (await readdir(root, { withFileTypes: true })) .sort((left, right) => compareText(left.name, right.name)); @@ -389,7 +594,7 @@ function issue(fields) { return { severity: fields.severity ?? "error", file: fields.file, - field: "$", + field: fields.field ?? "$", problem: fields.problem, fix: fields.fix }; diff --git a/tests/fixtures/governance/invalid/exposure-leak.yaml b/tests/fixtures/governance/invalid/exposure-leak.yaml new file mode 100644 index 0000000..13e95bb --- /dev/null +++ b/tests/fixtures/governance/invalid/exposure-leak.yaml @@ -0,0 +1,27 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [beta] + forbidden_dependencies: [] + - id: beta + owners: [beta-owner] + exposure: private + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [beta.core] + forbidden_dependencies: [] + - id: beta.core + product: beta + source_root: products/beta/core + owners: [beta-owner] + exposure: private + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/forbidden-direct.yaml b/tests/fixtures/governance/invalid/forbidden-direct.yaml new file mode 100644 index 0000000..5d1bfed --- /dev/null +++ b/tests/fixtures/governance/invalid/forbidden-direct.yaml @@ -0,0 +1,27 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [beta] + forbidden_dependencies: [beta] + - id: beta + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: beta.core + product: beta + source_root: products/beta/core + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/forbidden-transitive.yaml b/tests/fixtures/governance/invalid/forbidden-transitive.yaml new file mode 100644 index 0000000..06d44dd --- /dev/null +++ b/tests/fixtures/governance/invalid/forbidden-transitive.yaml @@ -0,0 +1,39 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [beta] + forbidden_dependencies: [gamma] + - id: beta + owners: [beta-owner] + exposure: public + dependencies: [gamma] + forbidden_dependencies: [] + - id: gamma + owners: [gamma-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: beta.core + product: beta + source_root: products/beta/core + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: gamma.core + product: gamma + source_root: products/gamma/core + owners: [gamma-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/group-cycle.yaml b/tests/fixtures/governance/invalid/group-cycle.yaml new file mode 100644 index 0000000..42378c3 --- /dev/null +++ b/tests/fixtures/governance/invalid/group-cycle.yaml @@ -0,0 +1,22 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.one + product: alpha + source_root: products/alpha/one + owners: [alpha-owner] + exposure: public + dependencies: [alpha.two] + forbidden_dependencies: [] + - id: alpha.two + product: alpha + source_root: products/alpha/two + owners: [alpha-owner] + exposure: public + dependencies: [alpha.one] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/missing-target.yaml b/tests/fixtures/governance/invalid/missing-target.yaml new file mode 100644 index 0000000..c0cdf74 --- /dev/null +++ b/tests/fixtures/governance/invalid/missing-target.yaml @@ -0,0 +1,15 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [missing] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/overlapping-roots.yaml b/tests/fixtures/governance/invalid/overlapping-roots.yaml new file mode 100644 index 0000000..74d27a5 --- /dev/null +++ b/tests/fixtures/governance/invalid/overlapping-roots.yaml @@ -0,0 +1,22 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: alpha.nested + product: alpha + source_root: products/alpha/nested + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/product-cycle.yaml b/tests/fixtures/governance/invalid/product-cycle.yaml new file mode 100644 index 0000000..4c07ba3 --- /dev/null +++ b/tests/fixtures/governance/invalid/product-cycle.yaml @@ -0,0 +1,27 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [beta] + forbidden_dependencies: [] + - id: beta + owners: [beta-owner] + exposure: public + dependencies: [alpha] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: beta.core + product: beta + source_root: products/beta/core + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/unassigned-source.yaml b/tests/fixtures/governance/invalid/unassigned-source.yaml new file mode 100644 index 0000000..769c089 --- /dev/null +++ b/tests/fixtures/governance/invalid/unassigned-source.yaml @@ -0,0 +1,15 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/undeclared-cross-product.yaml b/tests/fixtures/governance/invalid/undeclared-cross-product.yaml new file mode 100644 index 0000000..23821df --- /dev/null +++ b/tests/fixtures/governance/invalid/undeclared-cross-product.yaml @@ -0,0 +1,27 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] + - id: beta + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [beta.core] + forbidden_dependencies: [] + - id: beta.core + product: beta + source_root: products/beta/core + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/governance/invalid/unknown-exposure.yaml b/tests/fixtures/governance/invalid/unknown-exposure.yaml new file mode 100644 index 0000000..042a378 --- /dev/null +++ b/tests/fixtures/governance/invalid/unknown-exposure.yaml @@ -0,0 +1,15 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: partner + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.core + product: alpha + source_root: products/alpha/core + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/governance.yaml b/tests/fixtures/valid/governed-multi-product/opendomain/governance.yaml new file mode 100644 index 0000000..3c9cf4e --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/governance.yaml @@ -0,0 +1,48 @@ +schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [beta] + forbidden_dependencies: [] + - id: beta + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.ecosystem + product: alpha + source_root: products/alpha/ecosystem + owners: [alpha-owner] + exposure: ecosystem + dependencies: [] + forbidden_dependencies: [] + - id: alpha.internal + product: alpha + source_root: products/alpha/internal + owners: [alpha-owner] + exposure: internal + dependencies: [] + forbidden_dependencies: [] + - id: alpha.private + product: alpha + source_root: products/alpha/private + owners: [alpha-owner] + exposure: private + dependencies: [] + forbidden_dependencies: [] + - id: alpha.public + product: alpha + source_root: products/alpha/public + owners: [alpha-owner] + exposure: public + dependencies: [beta.public] + forbidden_dependencies: [] + - id: beta.public + product: beta + source_root: products/beta/public + owners: [beta-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/ecosystem/contexts/alpha.ecosystem.md b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/ecosystem/contexts/alpha.ecosystem.md new file mode 100644 index 0000000..6b3b36e --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/ecosystem/contexts/alpha.ecosystem.md @@ -0,0 +1,21 @@ +--- +type: bounded_context +id: alpha_ecosystem +name: Alpha Ecosystem +status: accepted +owners: + - alpha-owner +evidence: + - type: human_review + location: synthetic:governed-multi-product + summary: Synthetic ecosystem Alpha context excluded from public closure. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha Ecosystem + +Synthetic ecosystem Alpha context. diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/internal/contexts/alpha.internal.md b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/internal/contexts/alpha.internal.md new file mode 100644 index 0000000..1e3cf96 --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/internal/contexts/alpha.internal.md @@ -0,0 +1,21 @@ +--- +type: bounded_context +id: alpha_internal +name: Alpha Internal +status: accepted +owners: + - alpha-owner +evidence: + - type: human_review + location: synthetic:governed-multi-product + summary: Synthetic internal Alpha context excluded from public closure. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha Internal + +Synthetic internal Alpha context. diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/private/contexts/alpha.private.md b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/private/contexts/alpha.private.md new file mode 100644 index 0000000..db674ca --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/private/contexts/alpha.private.md @@ -0,0 +1,21 @@ +--- +type: bounded_context +id: alpha_private +name: Alpha Private +status: accepted +owners: + - alpha-owner +evidence: + - type: human_review + location: synthetic:governed-multi-product + summary: Synthetic private Alpha context excluded from public closure. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha Private + +Synthetic private Alpha context. diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/public/contexts/alpha.md b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/public/contexts/alpha.md new file mode 100644 index 0000000..50de303 --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/products/alpha/public/contexts/alpha.md @@ -0,0 +1,21 @@ +--- +type: bounded_context +id: alpha +name: Alpha +status: accepted +owners: + - alpha-owner +evidence: + - type: human_review + location: synthetic:governed-multi-product + summary: Synthetic public Alpha product context for governance conformance. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha + +Synthetic public Alpha context. diff --git a/tests/fixtures/valid/governed-multi-product/opendomain/products/beta/public/contexts/beta.md b/tests/fixtures/valid/governed-multi-product/opendomain/products/beta/public/contexts/beta.md new file mode 100644 index 0000000..472da4e --- /dev/null +++ b/tests/fixtures/valid/governed-multi-product/opendomain/products/beta/public/contexts/beta.md @@ -0,0 +1,21 @@ +--- +type: bounded_context +id: beta +name: Beta +status: accepted +owners: + - beta-owner +evidence: + - type: human_review + location: synthetic:governed-multi-product + summary: Synthetic public Beta dependency context for governance conformance. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# Beta + +Synthetic public Beta context. diff --git a/tests/governance-conformance.test.mjs b/tests/governance-conformance.test.mjs new file mode 100644 index 0000000..0aaa6a2 --- /dev/null +++ b/tests/governance-conformance.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { 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 { parseYamlMapping } from "../src/frontmatter.mjs"; +import { validatePath } from "../src/validator.mjs"; + +const INVALID_ROOT = path.resolve("tests/fixtures/governance/invalid"); + +for (const fixture of [ + { name: "product-cycle", code: "dependency_cycle", problem: "Product dependency cycle" }, + { name: "group-cycle", code: "dependency_cycle", problem: "Domain group dependency cycle" }, + { name: "undeclared-cross-product", code: "undeclared_product_dependency" }, + { name: "forbidden-direct", code: "forbidden_dependency", problem: "alpha -> beta" }, + { name: "forbidden-transitive", code: "forbidden_dependency", problem: "alpha -> beta -> gamma" }, + { name: "exposure-leak", code: "exposure_leak" }, + { name: "unknown-exposure", field: "products[0].exposure" }, + { name: "missing-target", code: "missing_dependency_target" }, + { name: "overlapping-roots", problem: "overlap" }, + { name: "unassigned-source", problem: "outside every declared" } +]) { + test(`isolated invalid governance fixture fails closed: ${fixture.name}`, async (context) => { + const project = await materializeFixture(context, fixture.name); + const first = await validatePath(undefined, { cwd: project }); + const second = await validatePath(undefined, { cwd: project }); + + assert.deepEqual(first, second); + assert.ok(first.errors.length > 0); + assert.deepEqual(first.governance?.publication_closures ?? [], []); + if (fixture.code) { + assert.ok(first.errors.some((error) => error.code === fixture.code)); + } + if (fixture.field) { + assert.ok(first.errors.some((error) => error.field === fixture.field)); + } + if (fixture.problem) { + assert.ok(first.errors.some((error) => error.problem.includes(fixture.problem))); + } + }); +} + +test("CLI JSON and human output preserve equivalent exposure-leak diagnostics", async (context) => { + const project = await materializeFixture(context, "exposure-leak"); + const direct = await validatePath(undefined, { cwd: project }); + const jsonOut = memoryStream(); + const jsonCode = await runCli(["validate", "--json"], { + cwd: project, + stdout: jsonOut, + stderr: memoryStream() + }); + const payload = JSON.parse(jsonOut.toString()); + const humanOut = memoryStream(); + const humanCode = await runCli(["validate"], { + cwd: project, + stdout: humanOut, + stderr: memoryStream() + }); + const exposureIssue = direct.errors.find((error) => error.code === "exposure_leak"); + + assert.equal(jsonCode, 1); + assert.equal(humanCode, 1); + assert.deepEqual(payload.errors, direct.errors); + assert.match(humanOut.toString(), new RegExp(escapeRegExp(exposureIssue.problem))); + assert.match(humanOut.toString(), new RegExp(escapeRegExp(exposureIssue.field))); + assert.match(humanOut.toString(), new RegExp(escapeRegExp(exposureIssue.fix))); +}); + +async function materializeFixture(context, name) { + const root = await mkdtemp(path.join(os.tmpdir(), `opendomain-${name}-`)); + context.after(() => rm(root, { recursive: true, force: true })); + const workspace = path.join(root, "opendomain"); + await mkdir(workspace, { recursive: true }); + const source = await readFile(path.join(INVALID_ROOT, `${name}.yaml`), "utf8"); + await writeFile(path.join(workspace, "governance.yaml"), source, "utf8"); + const manifest = parseYamlMapping(source, `${name}.yaml`, { label: "Governance fixture" }); + + for (const group of manifest.domain_groups ?? []) { + const id = group.id.replaceAll(".", "_").replaceAll("-", "_"); + const file = path.join(workspace, group.source_root, "contexts", `${id}.md`); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, contextDocument(id), "utf8"); + } + if (name === "unassigned-source") { + const file = path.join(workspace, "orphan/contexts/unassigned.md"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, contextDocument("unassigned"), "utf8"); + } + return root; +} + +function contextDocument(id) { + return `--- +type: bounded_context +id: ${id} +name: ${id} +status: accepted +owners: [fixture-owner] +evidence: + - type: human_review + location: synthetic:governance-invalid-fixture + summary: Synthetic source used to reach governance validation. + confidence: high +review: + state: accepted + reviewed_by: fixture-maintainer + reviewed_at: 2026-08-10 +--- + +# ${id} +`; +} + +function memoryStream() { + let value = ""; + return { + write(chunk) { + value += chunk; + }, + toString() { + return value; + } + }; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/governance-graph.test.mjs b/tests/governance-graph.test.mjs new file mode 100644 index 0000000..20d50ca --- /dev/null +++ b/tests/governance-graph.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analyzeGovernance } from "../src/governance-graph.mjs"; + +test("valid public dependency closure is deterministic and excludes private groups", () => { + const governance = fixture(); + const files = new Map([ + ["alpha.public", ["opendomain/products/alpha/public/contexts/alpha.md"]], + ["alpha.private", ["opendomain/products/alpha/private/rules/secret.md"]], + ["beta.public", ["opendomain/products/beta/public/concepts/beta.md"]] + ]); + + const first = analyzeGovernance(governance, { sourceFilesByGroup: files }); + const second = analyzeGovernance(governance, { sourceFilesByGroup: files }); + + assert.deepEqual(first, second); + assert.deepEqual(first.errors, []); + assert.deepEqual(first.publication_closures[0].product_ids, ["alpha", "beta"]); + assert.deepEqual(first.publication_closures[0].domain_group_ids, ["alpha.public", "beta.public"]); + assert.deepEqual(first.publication_closures[0].source_files, [ + "opendomain/products/alpha/public/contexts/alpha.md", + "opendomain/products/beta/public/concepts/beta.md" + ]); + assert.equal(first.publication_closures[0].source_files.some((file) => file.includes("secret")), false); +}); + +test("product and group dependency cycles fail independently with stable paths", () => { + const governance = fixture(); + governance.products[1].dependencies = ["alpha"]; + governance.domainGroups.find((group) => group.id === "beta.public").dependencies = ["alpha.public"]; + + const result = analyzeGovernance(governance); + + assert.equal(result.publication_closures.length, 0); + assert.ok(result.errors.some((error) => ( + error.code === "dependency_cycle" && error.problem.includes("Product") + ))); + assert.ok(result.errors.some((error) => ( + error.code === "dependency_cycle" && error.problem.includes("Domain group") + ))); + assert.ok(result.errors.some((error) => error.problem.includes("alpha -> beta -> alpha"))); + assert.ok(result.errors.some((error) => error.problem.includes("alpha.public -> beta.public -> alpha.public"))); +}); + +test("graph rejects missing targets, self dependencies, and undeclared cross-product edges", () => { + const governance = fixture(); + governance.products[0].dependencies = ["alpha", "missing"]; + governance.domainGroups.find((group) => group.id === "alpha.public").dependencies = ["beta.public"]; + + const result = analyzeGovernance(governance); + + assert.ok(result.errors.some((error) => error.code === "self_dependency")); + assert.ok(result.errors.some((error) => error.code === "missing_dependency_target")); + assert.ok(result.errors.some((error) => error.code === "undeclared_product_dependency")); +}); + +test("exposure leaks and groups more public than their products fail closed", () => { + const governance = fixture(); + governance.products[1].exposure = "private"; + const betaGroup = governance.domainGroups.find((group) => group.id === "beta.public"); + betaGroup.exposure = "public"; + + const result = analyzeGovernance(governance); + + assert.ok(result.errors.some((error) => error.code === "exposure_leak")); + assert.ok(result.errors.some((error) => error.code === "group_more_public_than_product")); + assert.deepEqual(result.publication_closures, []); +}); + +test("transitive forbidden dependencies report the reproducible path", () => { + const governance = fixture(); + governance.products.push(node("gamma", "public")); + governance.products[1].dependencies = ["gamma"]; + governance.products[0].forbidden_dependencies = ["gamma"]; + governance.domainGroups.push(group("gamma.public", "gamma", "public")); + + const result = analyzeGovernance(governance); + + const error = result.errors.find((entry) => entry.code === "forbidden_dependency"); + assert.ok(error); + assert.match(error.problem, /alpha -> beta -> gamma/); +}); + +test("unknown forbidden targets fail before closure", () => { + const governance = fixture(); + governance.products[0].forbidden_dependencies = ["missing"]; + + const result = analyzeGovernance(governance); + + assert.ok(result.errors.some((error) => error.code === "missing_forbidden_dependency_target")); + assert.deepEqual(result.publication_closures, []); +}); + +function fixture() { + return { + file: "opendomain/governance.yaml", + manifest: { schema_version: "1.0" }, + products: [ + { ...node("alpha", "public"), dependencies: ["beta"] }, + node("beta", "public") + ], + domainGroups: [ + { ...group("alpha.public", "alpha", "public"), dependencies: ["beta.public"] }, + group("alpha.private", "alpha", "private"), + group("beta.public", "beta", "public") + ] + }; +} + +function node(id, exposure) { + return { + id, + owners: [`${id}-owner`], + exposure, + dependencies: [], + forbidden_dependencies: [] + }; +} + +function group(id, product, exposure) { + return { + ...node(id, exposure), + product, + source_root: `products/${product}/${id.split(".").at(-1)}` + }; +} diff --git a/tests/governance-integration.test.mjs b/tests/governance-integration.test.mjs new file mode 100644 index 0000000..b1ebe63 --- /dev/null +++ b/tests/governance-integration.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } 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 { buildSemanticIndex } from "../src/indexer.mjs"; +import { validatePath } from "../src/validator.mjs"; + +const FIXTURE_ROOT = path.resolve("tests/fixtures/valid/governed-multi-product"); + +test("validator exposes deterministic governed publication evidence", async () => { + const first = await validatePath(undefined, { cwd: FIXTURE_ROOT }); + const second = await validatePath(undefined, { cwd: FIXTURE_ROOT }); + + assert.deepEqual(first, second); + assert.equal(first.errors.length, 0); + assert.equal(first.workspace.governed, true); + assert.equal(first.governance.schema_version, "1.0"); + assert.deepEqual(first.governance.publication_closures.map((closure) => closure.product_id), [ + "alpha", + "beta" + ]); + const alpha = first.governance.publication_closures.find((closure) => closure.product_id === "alpha"); + assert.deepEqual(alpha.domain_group_ids, ["alpha.public", "beta.public"]); + assert.equal(alpha.source_files.some((file) => file.includes("alpha.private")), false); + assert.ok(first.documents.every((document) => document.ownership)); +}); + +test("governed semantic index preserves ownership and derived publication provenance", async () => { + const result = await buildSemanticIndex(undefined, { + cwd: FIXTURE_ROOT, + now: new Date("2026-08-10T00:00:00Z") + }); + + assert.equal(result.errors.length, 0); + assert.equal(result.index.governance.schema_version, "1.0"); + assert.equal(result.index.governance.derived, true); + assert.match(result.index.governance.authoritative_source, /manifest and semantic source files/); + const alpha = result.index.entries.find((entry) => entry.id === "alpha"); + assert.equal(alpha.product_id, "alpha"); + assert.equal(alpha.domain_group_id, "alpha.public"); + assert.equal(alpha.exposure, "public"); + assert.deepEqual(alpha.owners, ["alpha-owner"]); +}); + +test("ungoverned semantic index keeps the pre-governance entry shape", async () => { + const result = await buildSemanticIndex("examples/erp", { + cwd: process.cwd(), + now: new Date("2026-08-10T00:00:00Z") + }); + + assert.equal(result.errors.length, 0); + assert.equal(Object.hasOwn(result.index, "governance"), false); + assert.equal(Object.hasOwn(result.index.entries[0], "product_id"), false); + assert.equal(Object.hasOwn(result.index.entries[0], "domain_group_id"), false); +}); + +test("CLI human and JSON validation expose the same governed result without mutation", async (context) => { + const before = await readFile(path.join(FIXTURE_ROOT, "opendomain/governance.yaml"), "utf8"); + const jsonOut = memoryStream(); + const jsonCode = await runCli(["validate", "--json"], { + cwd: FIXTURE_ROOT, + stdout: jsonOut, + stderr: memoryStream() + }); + const payload = JSON.parse(jsonOut.toString()); + const humanOut = memoryStream(); + const humanCode = await runCli(["validate"], { + cwd: FIXTURE_ROOT, + stdout: humanOut, + stderr: memoryStream() + }); + const after = await readFile(path.join(FIXTURE_ROOT, "opendomain/governance.yaml"), "utf8"); + + assert.equal(jsonCode, 0); + assert.equal(humanCode, 0); + assert.equal(payload.errors.length, 0); + assert.match(humanOut.toString(), /2 products, 5 domain groups, 2 public closures passed/); + assert.match(humanOut.toString(), /no publication performed/); + assert.equal(after, before); + + const temporary = await mkdtemp(path.join(os.tmpdir(), "opendomain-governed-no-git-")); + context.after(() => rm(temporary, { recursive: true, force: true })); + assert.equal(await pathExists(path.join(temporary, ".git")), false); +}); + +async function pathExists(file) { + try { + await readFile(file); + return true; + } catch { + return false; + } +} + +function memoryStream() { + let value = ""; + return { + write(chunk) { + value += chunk; + }, + toString() { + return value; + } + }; +} diff --git a/tests/governance.test.mjs b/tests/governance.test.mjs new file mode 100644 index 0000000..58ac738 --- /dev/null +++ b/tests/governance.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + GOVERNANCE_SCHEMA_VERSION, + loadGovernanceManifest +} from "../src/governance.mjs"; + +test("governance loader returns an explicit absent state", async (context) => { + const workspace = await temporaryWorkspace(context); + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.present, false); + assert.equal(result.valid, true); + assert.equal(result.manifest, null); + assert.deepEqual(result.errors, []); +}); + +test("governance loader normalizes a valid version 1.0 manifest", async (context) => { + const workspace = await temporaryWorkspace(context); + await writeManifest(workspace, validManifest()); + + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.present, true); + assert.equal(result.valid, true); + assert.equal(result.manifest.schema_version, GOVERNANCE_SCHEMA_VERSION); + assert.deepEqual(result.products.map((product) => product.id), ["echopath", "opendomain"]); + assert.deepEqual(result.domainGroups.map((group) => group.id), [ + "echopath.context_governance", + "opendomain.core" + ]); +}); + +for (const invalid of [ + { + name: "unknown field", + mutate(manifest) { + manifest.products[0].unexpected = true; + }, + field: "products[0].unexpected" + }, + { + name: "missing owners", + mutate(manifest) { + delete manifest.products[0].owners; + }, + field: "products[0].owners" + }, + { + name: "unknown exposure", + mutate(manifest) { + manifest.domain_groups[0].exposure = "partner"; + }, + field: "domain_groups[0].exposure" + } +]) { + test(`governance loader fails closed for ${invalid.name}`, async (context) => { + const workspace = await temporaryWorkspace(context); + const manifest = validManifest(); + invalid.mutate(manifest); + await writeManifest(workspace, manifest); + + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.valid, false); + assert.ok(result.errors.some((error) => error.field === invalid.field)); + }); +} + +test("governance loader rejects unknown versions before schema evaluation", async (context) => { + const workspace = await temporaryWorkspace(context); + const manifest = validManifest(); + manifest.schema_version = "2.0"; + await writeManifest(workspace, manifest); + + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.valid, false); + assert.deepEqual(result.errors.map((error) => error.field), ["schema_version"]); + assert.match(result.errors[0].problem, /Unsupported governance schema version/); +}); + +test("governance loader rejects duplicate product and group identities", async (context) => { + const workspace = await temporaryWorkspace(context); + const manifest = validManifest(); + manifest.products.push(structuredClone(manifest.products[0])); + manifest.domain_groups.push(structuredClone(manifest.domain_groups[0])); + await writeManifest(workspace, manifest); + + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.valid, false); + assert.ok(result.errors.some((error) => error.problem.includes("Duplicate product id"))); + assert.ok(result.errors.some((error) => error.problem.includes("Duplicate domain group id"))); +}); + +test("governance loader rejects missing parents and invalid group namespaces", async (context) => { + const workspace = await temporaryWorkspace(context); + const manifest = validManifest(); + manifest.domain_groups[0].product = "missing"; + await writeManifest(workspace, manifest); + + const result = await loadGovernanceManifest(workspace, { displayPath: "opendomain" }); + + assert.equal(result.valid, false); + assert.ok(result.errors.some((error) => error.problem.includes("unknown product"))); + assert.ok(result.errors.some((error) => error.problem.includes("outside product namespace"))); +}); + +async function temporaryWorkspace(context) { + const root = await mkdtemp(path.join(os.tmpdir(), "opendomain-governance-")); + context.after(() => rm(root, { recursive: true, force: true })); + const workspace = path.join(root, "opendomain"); + await mkdir(workspace, { recursive: true }); + return workspace; +} + +async function writeManifest(workspace, manifest) { + await writeFile( + path.join(workspace, "governance.yaml"), + toYaml(manifest), + "utf8" + ); +} + +function validManifest() { + return { + schema_version: "1.0", + products: [ + { + id: "opendomain", + owners: ["opendomain-maintainer"], + exposure: "public", + dependencies: [], + forbidden_dependencies: ["echopath"] + }, + { + id: "echopath", + owners: ["echopath-maintainer"], + exposure: "private", + dependencies: ["opendomain"], + forbidden_dependencies: [] + } + ], + domain_groups: [ + { + id: "opendomain.core", + product: "opendomain", + source_root: "products/opendomain/core", + owners: ["opendomain-maintainer"], + exposure: "public", + dependencies: [], + forbidden_dependencies: ["echopath.context_governance"] + }, + { + id: "echopath.context_governance", + product: "echopath", + source_root: "products/echopath/context-governance", + owners: ["echopath-maintainer"], + exposure: "private", + dependencies: ["opendomain.core"], + forbidden_dependencies: [] + } + ] + }; +} + +function toYaml(value) { + const lines = [`schema_version: "${value.schema_version}"`, "products:"]; + for (const product of value.products) { + lines.push(` - id: ${product.id}`); + lines.push(` owners: [${(product.owners ?? []).join(", ")}]`); + lines.push(` exposure: ${product.exposure}`); + lines.push(` dependencies: [${product.dependencies.join(", ")}]`); + lines.push(` forbidden_dependencies: [${product.forbidden_dependencies.join(", ")}]`); + if (product.unexpected !== undefined) { + lines.push(` unexpected: ${String(product.unexpected)}`); + } + if (product.owners === undefined) { + lines.splice(lines.length - (product.unexpected !== undefined ? 5 : 4), 1); + } + } + lines.push("domain_groups:"); + for (const group of value.domain_groups) { + lines.push(` - id: ${group.id}`); + lines.push(` product: ${group.product}`); + lines.push(` source_root: ${group.source_root}`); + lines.push(` owners: [${group.owners.join(", ")}]`); + lines.push(` exposure: ${group.exposure}`); + lines.push(` dependencies: [${group.dependencies.join(", ")}]`); + lines.push(` forbidden_dependencies: [${group.forbidden_dependencies.join(", ")}]`); + } + return `${lines.join("\n")}\n`; +} diff --git a/tests/packaged-resources.test.mjs b/tests/packaged-resources.test.mjs index 1582fac..812265b 100644 --- a/tests/packaged-resources.test.mjs +++ b/tests/packaged-resources.test.mjs @@ -12,12 +12,14 @@ test("packaged resources expose schemas, package metadata, and ERP files", async const packageMetadata = JSON.parse(resources.readPackagedText("package.json")); const installationContract = resources.readPackagedText("INSTALL.md"); const schema = JSON.parse(resources.readPackagedText("schemas/context.schema.json")); + const governanceSchema = JSON.parse(resources.readPackagedText("schemas/governance.schema.json")); const exampleFiles = resources.listPackagedFiles("examples/erp/"); assert.equal(packageMetadata.name, "@echopath-labs/opendomain"); assert.match(installationContract, /OpenDomain Agent Installation Contract/); assert.match(installationContract, /@echopath-labs\/opendomain@alpha/); assert.equal(schema.$id, "https://opendomain.dev/schemas/context.schema.json"); + assert.equal(governanceSchema.$id, "https://opendomain.dev/schemas/governance.schema.json"); assert.ok(exampleFiles.includes("examples/erp/opendomain/contexts/sales.md")); assert.ok(exampleFiles.includes("examples/erp/openspec/changes/order-cancellation/spec.md")); assert.deepEqual(exampleFiles, [...exampleFiles].sort()); diff --git a/tests/workspace-resolver.test.mjs b/tests/workspace-resolver.test.mjs index 4a34b51..2ab73c7 100644 --- a/tests/workspace-resolver.test.mjs +++ b/tests/workspace-resolver.test.mjs @@ -35,6 +35,79 @@ test("canonical workspace is selected without scanning repository examples", asy }); }); +test("governed canonical workspace collects disjoint groups with ownership metadata", async () => { + await withTempProject(async (project) => { + await writeGovernance(project, validGovernance()); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + await writeMarkdown(project, "opendomain/products/beta/core/concepts/beta.md"); + + const result = await resolveWorkspaceSources(undefined, { cwd: project }); + + assert.equal(result.errors.length, 0); + assert.equal(result.governance.manifest.schema_version, "1.0"); + assert.deepEqual(relativeFiles(project, result.files), [ + "opendomain/products/alpha/core/contexts/alpha.md", + "opendomain/products/beta/core/concepts/beta.md" + ]); + assert.deepEqual(result.sourceOwnership.get(result.files[0]), { + product_id: "alpha", + domain_group_id: "alpha.core", + owners: ["alpha-owner"], + exposure: "public", + governance_schema_version: "1.0", + source_root: "products/alpha/core" + }); + }); +}); + +test("governed source roots reject overlap, missing, empty, symlinked, and unassigned sources", async () => { + await withTempProject(async (project) => { + const overlapping = validGovernance(); + overlapping.domain_groups[1].source_root = "products/alpha/core/nested"; + await writeGovernance(project, overlapping); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + await writeMarkdown(project, "opendomain/products/alpha/core/nested/contexts/beta.md"); + let result = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(result.errors.some((error) => error.problem.includes("overlap"))); + + await rm(path.join(project, "opendomain"), { recursive: true, force: true }); + const missing = validGovernance(); + await writeGovernance(project, missing); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + result = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(result.errors.some((error) => error.problem.includes("does not exist"))); + + await rm(path.join(project, "opendomain"), { recursive: true, force: true }); + await writeGovernance(project, validGovernance()); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + await mkdir(path.join(project, "opendomain/products/beta/core"), { recursive: true }); + result = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(result.errors.some((error) => error.problem.includes("no eligible Markdown"))); + + await rm(path.join(project, "opendomain"), { recursive: true, force: true }); + await writeGovernance(project, validGovernance()); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-governed-symlink-")); + try { + await writeMarkdown(external, "concepts/beta.md"); + await mkdir(path.join(project, "opendomain/products/beta"), { recursive: true }); + await symlink(external, path.join(project, "opendomain/products/beta/core")); + result = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(result.errors.some((error) => error.problem.includes("symbolic link"))); + } finally { + await rm(external, { recursive: true, force: true }); + } + + await rm(path.join(project, "opendomain"), { recursive: true, force: true }); + await writeGovernance(project, validGovernance()); + await writeMarkdown(project, "opendomain/products/alpha/core/contexts/alpha.md"); + await writeMarkdown(project, "opendomain/products/beta/core/concepts/beta.md"); + await writeMarkdown(project, "opendomain/orphan/contexts/unassigned.md"); + result = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(result.errors.some((error) => error.problem.includes("outside every declared"))); + }); +}); + test("legacy workspace remains readable with an actionable warning", async () => { await withTempProject(async (project) => { await writeMarkdown(project, "domain/contexts/legacy.md"); @@ -179,6 +252,72 @@ async function writeMarkdown(project, relativePath) { await writeFile(file, "---\ntype: fixture\n---\n", "utf8"); } +async function writeGovernance(project, manifest) { + const file = path.join(project, "opendomain/governance.yaml"); + await mkdir(path.dirname(file), { recursive: true }); + const lines = [`schema_version: "${manifest.schema_version}"`, "products:"]; + for (const product of manifest.products) { + lines.push(` - id: ${product.id}`); + lines.push(` owners: [${product.owners.join(", ")}]`); + lines.push(` exposure: ${product.exposure}`); + lines.push(` dependencies: [${product.dependencies.join(", ")}]`); + lines.push(` forbidden_dependencies: [${product.forbidden_dependencies.join(", ")}]`); + } + lines.push("domain_groups:"); + for (const group of manifest.domain_groups) { + lines.push(` - id: ${group.id}`); + lines.push(` product: ${group.product}`); + lines.push(` source_root: ${group.source_root}`); + lines.push(` owners: [${group.owners.join(", ")}]`); + lines.push(` exposure: ${group.exposure}`); + lines.push(` dependencies: [${group.dependencies.join(", ")}]`); + lines.push(` forbidden_dependencies: [${group.forbidden_dependencies.join(", ")}]`); + } + await writeFile(file, `${lines.join("\n")}\n`, "utf8"); +} + +function validGovernance() { + return { + schema_version: "1.0", + products: [ + { + id: "alpha", + owners: ["alpha-owner"], + exposure: "public", + dependencies: ["beta"], + forbidden_dependencies: [] + }, + { + id: "beta", + owners: ["beta-owner"], + exposure: "public", + dependencies: [], + forbidden_dependencies: [] + } + ], + domain_groups: [ + { + id: "alpha.core", + product: "alpha", + source_root: "products/alpha/core", + owners: ["alpha-owner"], + exposure: "public", + dependencies: ["beta.core"], + forbidden_dependencies: [] + }, + { + id: "beta.core", + product: "beta", + source_root: "products/beta/core", + owners: ["beta-owner"], + exposure: "public", + dependencies: [], + forbidden_dependencies: [] + } + ] + }; +} + function relativeFiles(project, files) { return files.map((file) => path.relative(project, file).split(path.sep).join("/")); } From ca15e48a9eb7acbd6e80b39968020d76629643c1 Mon Sep 17 00:00:00 2001 From: Chase Date: Mon, 10 Aug 2026 10:57:16 +0800 Subject: [PATCH 2/4] feat: add embeddable context export core --- README.md | 9 + README.zh-CN.md | 8 + USAGE.md | 68 ++++ USAGE.zh-CN.md | 62 ++++ package.json | 9 + schemas/context-export.schema.json | 265 ++++++++++++++ scripts/smoke-installed-package.mjs | 86 +++++ scripts/smoke-standalone.mjs | 85 ++++- src/cli.mjs | 205 ++++++++++- src/context-export-schema.mjs | 47 +++ src/core.mjs | 543 ++++++++++++++++++++++++++++ src/indexer.mjs | 129 ++----- src/semantic-query.mjs | 224 ++++++++++++ tests/core.test.mjs | 264 ++++++++++++++ tests/packaged-resources.test.mjs | 2 + tests/semantic-query.test.mjs | 78 ++++ 16 files changed, 1978 insertions(+), 106 deletions(-) create mode 100644 schemas/context-export.schema.json create mode 100644 src/context-export-schema.mjs create mode 100644 src/core.mjs create mode 100644 src/semantic-query.mjs create mode 100644 tests/core.test.mjs create mode 100644 tests/semantic-query.test.mjs diff --git a/README.md b/README.md index a679134..2c4305e 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ The current alpha includes: - Candidate-first AI inference with explicit human review; - deterministic Semantic Closure and derived read-first indexes; - optional multi-product workspace governance with deterministic exposure and publication-closure validation; +- a side-effect-free Embeddable Core v1 with source-first query and versioned context export; - Grounding Request, Grounding Pack, and advisory/enforced Assurance; - built-in OpenSpec grounding and declarative Integration Profiles; - managed Codex instructions, Skills, updates, and diagnostics; @@ -166,6 +167,14 @@ and derived public dependency closures. A passing closure is static evidence; it does not publish files, grant permissions, change Git, or require EchoPath. See [Multi-product workspace governance](USAGE.md#multi-product-workspace-governance). +Host and plugin authors can import the package root or `@echopath-labs/opendomain/core` +to call the same validate, query, and context-export implementation used by the +CLI. `opendomain export context` returns accepted content and reports related +Candidates separately; `--exposure public --product ` succeeds only from a +validated public dependency closure. The API is read-only and does not manage +EchoPath memory, accept Candidates, write projections, or publish releases. See +[Embed Core and export context](USAGE.md#embed-core-and-export-context). + ## Public Resources - [Usage Guide](USAGE.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ab0f3b..c0b8952 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,6 +138,7 @@ opendomain validate - Candidate-first AI 推断和显式人工审查; - 确定性 Semantic Closure 与派生 read-first index; - 可选的多产品 workspace 治理、exposure 传播与 public dependency closure 校验; +- 无进程副作用的 Embeddable Core v1、source-first query 与版本化 context export; - Grounding Request、Grounding Pack 和 advisory/enforced Assurance; - 内置 OpenSpec grounding 与声明式 Integration Profile; - 受管 Codex 指令、Skills、更新和诊断; @@ -152,6 +153,13 @@ domain group 的普通语义目录放入声明的 `source_root`。`opendomain va 只是静态证据,不会发布文件、授予权限、修改 Git,也不要求安装 EchoPath。详见 [多产品 Workspace 治理](USAGE.zh-CN.md#多产品-workspace-治理)。 +Host 或插件作者可以从 package root 或 `@echopath-labs/opendomain/core` 导入与 CLI +完全相同的 validate、query 和 context-export 实现。`opendomain export context` 只把 +accepted content 放进 documents,并单独标记 Candidate; +`--exposure public --product ` 只有在当前 public dependency closure 可证明时才会 +成功。该 API 只读,不管理 EchoPath memory、不接受 Candidate、不写公开投影,也不执行 +release。详见[嵌入 Core 与导出 Context](USAGE.zh-CN.md#嵌入-core-与导出-context)。 + ## 公开资料 - [简体中文使用指南](USAGE.zh-CN.md) diff --git a/USAGE.md b/USAGE.md index c5644e0..93c3e70 100644 --- a/USAGE.md +++ b/USAGE.md @@ -206,6 +206,71 @@ manifest without requiring EchoPath, AGW, a package-manager workspace, or a private sibling repository. If `governance.yaml` is absent, current canonical, legacy, and explicit-target behavior is unchanged. +## Embed Core And Export Context + +Normal users can continue expressing intent to Codex. These interfaces are for +Agent hosts, plugins, CI, and maintainers that need an observable context payload. + +Query current source without creating or reading a generated index: + +```bash +opendomain query --id sales.order --json +opendomain query --context sales --type domain_concept --json +``` + +Export the selected accepted sources, their semantic closure, evidence, review, +source hashes, and related non-authoritative Candidate boundaries: + +```bash +opendomain export context --id sales.order --json +``` + +Selectors are `--id`, `--context`, `--product`, `--domain-group`, `--owner`, +`--lifecycle`, and `--type`. Multiple selectors use logical AND. At least one is +required; an empty or unmatched request fails instead of exporting the whole +workspace. + +In a governed canonical workspace, export one complete public proof with: + +```bash +opendomain export context --product public_api --exposure public --json +``` + +Public export requires the current validated publication closure. It rejects an +ungoverned workspace, non-public product, invalid graph, stale source mapping, +or extra selector that would crop the proof. A passing payload is evidence only; +it does not copy files, change Git, grant access, or publish anything. + +Node host and plugin authors may deliberately depend on the npm package and use +the side-effect-free Core API: + +```js +import { + CORE_API_VERSION, + validateWorkspace, + queryWorkspace, + exportContext +} from "@echopath-labs/opendomain"; + +const context = await exportContext({ + cwd: process.cwd(), + selector: { id: "sales.order" } +}); +``` + +The package root and `@echopath-labs/opendomain/core` expose the same Core API +`1.0`. Calls return structured results and do not write stdout/stderr, set a +process exit code, create an index, mutate source, access Git/network, or manage +EchoPath lifecycle. `opendomain.context-export.v1` contains full accepted +Markdown content and workspace-relative provenance; Candidates remain only in +`candidate_boundaries` with `authoritative: false`. + +Within Core v1, new named exports and optional result fields may be additive. +Removing fields, changing selector conjunction, weakening Candidate isolation, +or reinterpreting exposure proof requires a new API/export version and migration +guidance. Ordinary project installation should still follow the Agent +Installation Contract and must not add a host dependency merely to use the CLI. + ## First Read-Only Domain Exploration Ask: @@ -380,6 +445,9 @@ can continue expressing intent to Codex. | Validate Profiles | `opendomain integrations validate` | | Build a derived index | `opendomain index build` | | Query a domain ID | `opendomain index query ` | +| Query current source | `opendomain query --id ` | +| Export accepted context | `opendomain export context --id --json` | +| Export a public closure | `opendomain export context --product --exposure public --json` | ## ERP Example diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index 309659a..554a26f 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -188,6 +188,65 @@ publication closure 是可重建的静态证据。它不会发布仓库、复制 EchoPath、AGW、package-manager workspace 或私有 sibling。没有 `governance.yaml` 时, 现有 canonical、legacy 与 explicit-target 行为保持不变。 +## 嵌入 Core 与导出 Context + +正常用户仍然可以直接向 Codex 表达意图。以下接口面向需要可观察 context payload 的 +Agent host、插件、CI 和维护者。 + +直接查询当前 source,不创建或读取 generated index: + +```bash +opendomain query --id sales.order --json +opendomain query --context sales --type domain_concept --json +``` + +导出选中的 accepted sources、semantic closure、evidence、review、source hash 和相关但 +非权威的 Candidate boundaries: + +```bash +opendomain export context --id sales.order --json +``` + +selector 包括 `--id`、`--context`、`--product`、`--domain-group`、`--owner`、 +`--lifecycle` 和 `--type`。多个 selector 使用逻辑 AND。请求至少要有一个 selector; +空请求或无匹配请求会失败,不会回退成整个 workspace 导出。 + +在受治理的 canonical workspace 中,可以导出一个完整 public proof: + +```bash +opendomain export context --product public_api --exposure public --json +``` + +public export 必须使用当前验证通过的 publication closure。ungoverned workspace、非 public +product、非法依赖图、无法映射的 source 或会裁剪 proof 的额外 selector 都会 fail closed。 +通过的 payload 也只是证据,不会复制文件、修改 Git、授予权限或发布任何内容。 + +Node host 和插件作者可以有意把 npm 包作为依赖,并使用无副作用 Core API: + +```js +import { + CORE_API_VERSION, + validateWorkspace, + queryWorkspace, + exportContext +} from "@echopath-labs/opendomain"; + +const context = await exportContext({ + cwd: process.cwd(), + selector: { id: "sales.order" } +}); +``` + +package root 与 `@echopath-labs/opendomain/core` 暴露相同的 Core API `1.0`。调用只返回 +结构化结果,不写 stdout/stderr、不设置 process exit code、不创建 index、不修改 source、 +不访问 Git/network,也不管理 EchoPath lifecycle。`opendomain.context-export.v1` 包含完整 +accepted Markdown content 和 workspace-relative provenance;Candidate 只会出现在 +`candidate_boundaries`,并明确标记 `authoritative: false`。 + +Core v1 内可以增加 named export 和可选结果字段。删除字段、改变 selector AND 语义、削弱 +Candidate 隔离或重新解释 exposure proof,都必须使用新的 API/export version 并提供迁移说明。 +普通项目仍应遵循 Agent 安装契约,不能只是为了使用 CLI 就给宿主项目增加依赖。 + ## 第一次只读了解业务 可以说: @@ -346,6 +405,9 @@ opendomain validate --json | 验证 Profile | `opendomain integrations validate` | | 构建派生 index | `opendomain index build` | | 查询 domain ID | `opendomain index query ` | +| 查询当前 source | `opendomain query --id ` | +| 导出 accepted context | `opendomain export context --id --json` | +| 导出 public closure | `opendomain export context --product --exposure public --json` | ## ERP 示例 diff --git a/package.json b/package.json index eeaf1e6..24b051f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,15 @@ "knowledge-management" ], "type": "module", + "main": "./src/core.mjs", + "exports": { + ".": "./src/core.mjs", + "./core": "./src/core.mjs", + "./src/*": "./src/*", + "./schemas/*": "./schemas/*", + "./examples/*": "./examples/*", + "./package.json": "./package.json" + }, "bin": { "opendomain": "bin/opendomain.mjs" }, diff --git a/schemas/context-export.schema.json b/schemas/context-export.schema.json new file mode 100644 index 0000000..c9b5465 --- /dev/null +++ b/schemas/context-export.schema.json @@ -0,0 +1,265 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opendomain.dev/schemas/context-export.schema.json", + "title": "OpenDomain Context Export v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "api_version", + "status", + "generated_at", + "request", + "source", + "selection", + "governance", + "documents", + "candidate_boundaries", + "warnings", + "errors" + ], + "properties": { + "schema": { "const": "opendomain.context-export.v1" }, + "api_version": { "const": "1.0" }, + "status": { "enum": ["pass", "fail"] }, + "generated_at": { "type": "string", "format": "date-time" }, + "request": { "$ref": "#/$defs/request" }, + "source": { "$ref": "#/$defs/source" }, + "selection": { "$ref": "#/$defs/selection" }, + "governance": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/governance" } + ] + }, + "documents": { + "type": "array", + "items": { "$ref": "#/$defs/document" } + }, + "candidate_boundaries": { + "type": "array", + "items": { "$ref": "#/$defs/candidateBoundary" } + }, + "warnings": { + "type": "array", + "items": { "$ref": "#/$defs/issue" } + }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/issue" } + } + }, + "$defs": { + "request": { + "type": "object", + "additionalProperties": false, + "required": ["target", "selector", "exposure"], + "properties": { + "target": { "type": ["string", "null"] }, + "selector": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/$defs/nonEmptyString" }, + "context": { "$ref": "#/$defs/nonEmptyString" }, + "product": { "$ref": "#/$defs/nonEmptyString" }, + "domain_group": { "$ref": "#/$defs/nonEmptyString" }, + "owner": { "$ref": "#/$defs/nonEmptyString" }, + "lifecycle": { "$ref": "#/$defs/nonEmptyString" }, + "type": { "$ref": "#/$defs/nonEmptyString" } + } + }, + "exposure": { + "oneOf": [ + { "type": "null" }, + { "const": "public" } + ] + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["authoritative", "derived", "authority", "workspace"], + "properties": { + "authoritative": { "const": true }, + "derived": { "const": true }, + "authority": { "$ref": "#/$defs/nonEmptyString" }, + "workspace": { + "oneOf": [ + { "type": "null" }, + { "type": "object", "additionalProperties": true } + ] + } + } + }, + "selection": { + "type": "object", + "additionalProperties": false, + "required": ["root_ids", "accepted_ids", "selection_paths"], + "properties": { + "root_ids": { + "type": "array", + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + "accepted_ids": { + "type": "array", + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + "selection_paths": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } + } + }, + "governance": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "manifest", + "derived", + "authoritative_source", + "products", + "domain_groups", + "publication_closure" + ], + "properties": { + "schema_version": { "$ref": "#/$defs/nonEmptyString" }, + "manifest": { "$ref": "#/$defs/nonEmptyString" }, + "derived": { "const": true }, + "authoritative_source": { "$ref": "#/$defs/nonEmptyString" }, + "products": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, + "domain_groups": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, + "publication_closure": { + "oneOf": [ + { "type": "null" }, + { "type": "object", "additionalProperties": true } + ] + } + } + }, + "document": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "name", + "status", + "authoritative", + "frontmatter", + "body", + "summary", + "source", + "review", + "evidence", + "governance" + ], + "properties": { + "id": { "$ref": "#/$defs/nonEmptyString" }, + "type": { "$ref": "#/$defs/nonEmptyString" }, + "name": { "$ref": "#/$defs/nonEmptyString" }, + "status": { "const": "accepted" }, + "context": { "$ref": "#/$defs/nonEmptyString" }, + "authoritative": { "const": true }, + "frontmatter": { "type": "object", "additionalProperties": true }, + "body": { "type": "string" }, + "summary": { "type": "string" }, + "source": { "$ref": "#/$defs/sourceReference" }, + "review": { "type": "object", "additionalProperties": true }, + "evidence": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, + "governance": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/ownership" } + ] + } + } + }, + "candidateBoundary": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "status", + "authoritative", + "target", + "confidence", + "possible_conflicts", + "review", + "summary", + "source", + "governance" + ], + "properties": { + "id": { "$ref": "#/$defs/nonEmptyString" }, + "type": { "const": "domain_candidate" }, + "status": { "$ref": "#/$defs/nonEmptyString" }, + "authoritative": { "const": false }, + "target": { "type": "object", "additionalProperties": true }, + "confidence": { "$ref": "#/$defs/nonEmptyString" }, + "possible_conflicts": { "type": "array" }, + "review": { "type": "object", "additionalProperties": true }, + "summary": { "type": "string" }, + "source": { "$ref": "#/$defs/sourceReference" }, + "governance": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/ownership" } + ] + } + } + }, + "sourceReference": { + "type": "object", + "additionalProperties": false, + "required": ["file", "hash"], + "properties": { + "file": { "$ref": "#/$defs/nonEmptyString" }, + "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "ownership": { + "type": "object", + "additionalProperties": false, + "required": [ + "product_id", + "domain_group_id", + "owners", + "exposure", + "governance_schema_version", + "source_root" + ], + "properties": { + "product_id": { "$ref": "#/$defs/nonEmptyString" }, + "domain_group_id": { "$ref": "#/$defs/nonEmptyString" }, + "owners": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + "exposure": { "enum": ["public", "ecosystem", "internal", "private"] }, + "governance_schema_version": { "$ref": "#/$defs/nonEmptyString" }, + "source_root": { "$ref": "#/$defs/nonEmptyString" } + } + }, + "issue": { + "type": "object", + "additionalProperties": true, + "required": ["severity", "file", "field", "problem", "fix"], + "properties": { + "severity": { "enum": ["error", "warning"] }, + "file": { "$ref": "#/$defs/nonEmptyString" }, + "field": { "$ref": "#/$defs/nonEmptyString" }, + "problem": { "$ref": "#/$defs/nonEmptyString" }, + "fix": { "$ref": "#/$defs/nonEmptyString" } + } + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index f43aea2..8802dad 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -63,6 +63,7 @@ try { await access(path.join(installedRoot, "schemas", "assurance-result.schema.json")); await access(path.join(installedRoot, "schemas", "workspace-config.schema.json")); await access(path.join(installedRoot, "schemas", "governance.schema.json")); + await access(path.join(installedRoot, "schemas", "context-export.schema.json")); for (const publicDocument of [ "README.md", "README.zh-CN.md", @@ -114,6 +115,48 @@ try { assert.equal(doctor.status, "healthy"); assert.deepEqual(doctor.errors, []); + const governedRoot = path.join(consumer, "governed"); + await createGovernedWorkspace(governedRoot); + const coreSmokeFile = path.join(consumer, "core-smoke.mjs"); + await writeFile(coreSmokeFile, ` +import assert from "node:assert/strict"; +import * as root from "@echopath-labs/opendomain"; +import * as core from "@echopath-labs/opendomain/core"; + +const now = new Date("2026-08-10T00:00:00Z"); +assert.equal(root.CORE_API_VERSION, "1.0"); +assert.equal(core.exportContext, root.exportContext); +const query = await root.queryWorkspace({ target: "examples/erp", cwd: process.cwd(), selector: { id: "sales.order" }, now }); +const context = await core.exportContext({ target: "examples/erp", cwd: process.cwd(), selector: { id: "sales.order" }, now }); +const publication = await core.exportContext({ + cwd: ${JSON.stringify(governedRoot)}, + selector: { product: "alpha" }, + exposure: "public", + now +}); +assert.equal(query.status, "pass"); +assert.equal(context.status, "pass"); +assert.equal(publication.status, "pass"); +assert.deepEqual(publication.documents.map((item) => item.id), ["alpha"]); +process.stdout.write(JSON.stringify({ + api: root.CORE_API_VERSION, + query: query.accepted_ids.length, + context: context.documents.length, + candidates: context.candidate_boundaries.length, + public_documents: publication.documents.length +})); +`, "utf8"); + const coreSmoke = JSON.parse((await run(process.execPath, [coreSmokeFile], consumer)).stdout); + assert.equal(coreSmoke.api, "1.0"); + assert.ok(coreSmoke.query > 0); + assert.equal(coreSmoke.context, coreSmoke.query); + assert.ok(coreSmoke.candidates > 0); + assert.equal(coreSmoke.public_documents, 1); + await assert.rejects( + access(path.join(consumer, "opendomain", "generated", "index.json")), + (error) => error?.code === "ENOENT" + ); + const exampleRoot = path.join(consumer, "examples", "erp"); const inspection = await runJsonCli( cli, @@ -159,6 +202,7 @@ try { `Installed-package smoke passed: ${packPayload[0].filename}, ` + `${inspection.valid_profile_count} Profile, ` + `${automatic.read_first.length} grounded sources, ` + + `Core ${coreSmoke.api} with ${coreSmoke.context} exported sources, ` + `Agent integration ${doctor.status}, ` + `Assurance ${assurance.policy.outcome}.\n` ); @@ -166,6 +210,48 @@ try { await rm(temporaryRoot, { recursive: true, force: true }); } +async function createGovernedWorkspace(root) { + const sourceRoot = path.join(root, "opendomain", "products", "alpha", "public", "contexts"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(path.join(root, "opendomain", "governance.yaml"), `schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.public + product: alpha + source_root: products/alpha/public + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +`, "utf8"); + await writeFile(path.join(sourceRoot, "alpha.md"), `--- +type: bounded_context +id: alpha +name: Alpha +status: accepted +owners: [alpha-owner] +evidence: + - type: human_review + location: smoke:installed-package + summary: Synthetic public context for installed-package Core smoke. + confidence: high +review: + state: accepted + reviewed_by: smoke-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha + +Synthetic public Alpha context. +`, "utf8"); +} + async function runJsonCli(cli, args, cwd) { const result = await run(process.execPath, [cli, ...args], cwd); return JSON.parse(result.stdout); diff --git a/scripts/smoke-standalone.mjs b/scripts/smoke-standalone.mjs index 028b26f..d442211 100644 --- a/scripts/smoke-standalone.mjs +++ b/scripts/smoke-standalone.mjs @@ -6,8 +6,10 @@ import { access, constants, mkdtemp, + mkdir, rm, - stat + stat, + writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -64,6 +66,44 @@ async function main() { const workspaceValidation = await runJson(binary, ["validate", "--json"], workspace); assert.deepEqual(workspaceValidation.errors, []); + const sourceQuery = await runJson(binary, [ + "query", + "examples/erp", + "--id", + "sales.order", + "--json" + ], workspace); + assert.deepEqual(sourceQuery.errors, []); + assert.ok(sourceQuery.accepted_ids.includes("sales.order")); + const contextExport = await runJson(binary, [ + "export", + "context", + "examples/erp", + "--id", + "sales.order", + "--json" + ], workspace); + assert.deepEqual(contextExport.errors, []); + assert.equal(contextExport.schema, "opendomain.context-export.v1"); + assert.ok(contextExport.documents.some((item) => item.id === "sales.order")); + assert.ok(contextExport.candidate_boundaries.length > 0); + await assertAbsent(path.join(workspace, "opendomain", "generated", "index.json")); + + const governedRoot = path.join(workspace, "governed"); + await createGovernedWorkspace(governedRoot); + const publicExport = await runJson(binary, [ + "export", + "context", + "--product", + "alpha", + "--exposure", + "public", + "--json" + ], governedRoot); + assert.deepEqual(publicExport.errors, []); + assert.deepEqual(publicExport.documents.map((item) => item.id), ["alpha"]); + assert.equal(publicExport.governance.publication_closure.product_id, "alpha"); + const exampleRoot = path.join(workspace, "examples", "erp"); const exampleValidation = await runJson(binary, ["validate", "--json"], exampleRoot); assert.deepEqual(exampleValidation.errors, []); @@ -86,6 +126,7 @@ async function main() { process.stdout.write( `Standalone smoke passed: ${path.basename(binary)}, ` + `${groundingPack.read_first.length} grounded sources, ` + + `${contextExport.documents.length} Core-equivalent exported sources, ` + `Agent integration ${doctor.status}, ` + `Assurance ${assurance.policy.outcome}.\n` ); @@ -94,6 +135,48 @@ async function main() { } } +async function createGovernedWorkspace(root) { + const sourceRoot = path.join(root, "opendomain", "products", "alpha", "public", "contexts"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(path.join(root, "opendomain", "governance.yaml"), `schema_version: "1.0" +products: + - id: alpha + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +domain_groups: + - id: alpha.public + product: alpha + source_root: products/alpha/public + owners: [alpha-owner] + exposure: public + dependencies: [] + forbidden_dependencies: [] +`, "utf8"); + await writeFile(path.join(sourceRoot, "alpha.md"), `--- +type: bounded_context +id: alpha +name: Alpha +status: accepted +owners: [alpha-owner] +evidence: + - type: human_review + location: smoke:standalone + summary: Synthetic public context for standalone context-export smoke. + confidence: high +review: + state: accepted + reviewed_by: smoke-maintainer + reviewed_at: 2026-08-10 +--- + +# Alpha + +Synthetic public Alpha context. +`, "utf8"); +} + function parseArguments(arguments_) { if (arguments_.length !== 2 || !arguments_[1]) { throw new Error("Usage: smoke-standalone (--binary | --dir )"); diff --git a/src/cli.mjs b/src/cli.mjs index 7f05aee..977078a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,4 +1,9 @@ import { validatePath } from "./validator.mjs"; +import { + exportContext, + queryWorkspace, + validateWorkspace +} from "./core.mjs"; import { emptyGroundingPack, formatGroundingPack, @@ -26,7 +31,8 @@ export async function runCli(argv, options = {}) { const io = { stdout: options.stdout ?? process.stdout, stderr: options.stderr ?? process.stderr, - cwd: options.cwd ?? process.cwd() + cwd: options.cwd ?? process.cwd(), + now: options.now }; const [command, subcommand, ...rest] = argv; @@ -45,6 +51,14 @@ export async function runCli(argv, options = {}) { return runValidate([subcommand, ...rest].filter(Boolean), io); } + if (command === "query") { + return runSourceQuery([subcommand, ...rest].filter(Boolean), io); + } + + if (command === "export" && subcommand === "context") { + return runContextExport(rest, io); + } + if (command === "prepare") { return runPrepare([subcommand, ...rest].filter(Boolean), io); } @@ -115,6 +129,8 @@ Usage: opendomain update [--json] opendomain doctor [--json] opendomain validate [path] [--json] + opendomain query [path] (--id | --context | --product | --domain-group | --owner | --lifecycle | --type ) [--json] + opendomain export context [path] [--exposure public] [--json] opendomain prepare [--integration openspec | --profile ] [--json] opendomain assure [--integration openspec | --profile ] [--mode advisory|enforced] [--json] opendomain integrations list [--json] @@ -145,6 +161,96 @@ function splitArgs(args) { }; } +function parseContextCommandArgs(args, options = {}) { + const selectorFlags = new Map([ + ["--id", "id"], + ["--context", "context"], + ["--product", "product"], + ["--domain-group", "domain_group"], + ["--owner", "owner"], + ["--lifecycle", "lifecycle"], + ["--type", "type"] + ]); + const parsed = { + json: false, + path: undefined, + selector: {}, + exposure: undefined, + errors: [] + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--json") { + parsed.json = true; + continue; + } + if (arg === "--exposure") { + if (!options.allowExposure) { + parsed.errors.push(inputIssue( + "exposure", + "The query command does not accept --exposure.", + "Use opendomain export context --exposure public for publication-proof export." + )); + index += 1; + continue; + } + const value = requiredFlagValue(args, index, arg, parsed.errors); + if (value !== undefined) { + parsed.exposure = value; + } + index += 1; + continue; + } + if (selectorFlags.has(arg)) { + const field = selectorFlags.get(arg); + const value = requiredFlagValue(args, index, arg, parsed.errors); + if (Object.hasOwn(parsed.selector, field)) { + parsed.errors.push(inputIssue( + `selector.${field}`, + `Selector '${field}' was provided more than once.`, + `Provide ${arg} exactly once.` + )); + } else if (value !== undefined) { + parsed.selector[field] = value; + } + index += 1; + continue; + } + if (arg.startsWith("--")) { + parsed.errors.push(inputIssue( + "$", + `Unknown context argument '${arg}'.`, + "Use --id, --context, --product, --domain-group, --owner, --lifecycle, --type, --exposure public, or --json." + )); + continue; + } + if (parsed.path !== undefined) { + parsed.errors.push(inputIssue( + "target", + `Multiple context target paths were provided: '${parsed.path}' and '${arg}'.`, + "Provide at most one optional workspace path." + )); + } else { + parsed.path = arg; + } + } + return parsed; +} + +function requiredFlagValue(args, index, flag, errors) { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + errors.push(inputIssue( + flag.slice(2), + `Option '${flag}' requires a value.`, + `Provide a non-empty value after ${flag}.` + )); + return undefined; + } + return value; +} + async function runInit(args, io) { const parsed = parseInitArgs(args); @@ -252,7 +358,11 @@ function emptyWorkspaceIntegrationResult(target, errors) { async function runValidate(args, io) { const { json, paths } = splitArgs(args); - const result = await validatePath(paths[0], { cwd: io.cwd }); + const result = await validateWorkspace({ + target: paths[0], + cwd: io.cwd, + now: io.now + }); if (json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -263,6 +373,57 @@ async function runValidate(args, io) { return result.errors.length > 0 ? 1 : 0; } +async function runSourceQuery(args, io) { + const parsed = parseContextCommandArgs(args); + const result = await queryWorkspace({ + target: parsed.path, + selector: parsed.selector, + cwd: io.cwd, + now: io.now + }); + if (parsed.errors.length > 0) { + result.status = "fail"; + result.semantic_closure = { policy: null, root_ids: [], selection_paths: [] }; + result.read_first = []; + result.accepted_ids = []; + result.candidate_boundaries = []; + result.verify_with = []; + result.errors = parsed.errors; + } + + if (parsed.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printSourceQueryResult(result, io.stdout); + } + return result.errors.length > 0 ? 1 : 0; +} + +async function runContextExport(args, io) { + const parsed = parseContextCommandArgs(args, { allowExposure: true }); + const result = await exportContext({ + target: parsed.path, + selector: parsed.selector, + exposure: parsed.exposure, + cwd: io.cwd, + now: io.now + }); + if (parsed.errors.length > 0) { + result.status = "fail"; + result.selection = { root_ids: [], accepted_ids: [], selection_paths: [] }; + result.documents = []; + result.candidate_boundaries = []; + result.errors = parsed.errors; + } + + if (parsed.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printContextExportResult(result, io.stdout); + } + return result.errors.length > 0 ? 1 : 0; +} + async function runCandidateList(args, io) { const parsed = parseCandidatePathArgs(args); const result = await listCandidates(parsed.path, { cwd: io.cwd }); @@ -1215,6 +1376,46 @@ function printIndexQueryResult(result, stream) { } } +function printSourceQueryResult(result, stream) { + if (result.errors.length > 0) { + stream.write(`OpenDomain source query failed: ${result.errors.length} errors.\n`); + printIssues([...result.errors, ...result.warnings], stream); + return; + } + + stream.write("OpenDomain Source-First Query\n\n"); + stream.write(`Schema: ${result.schema}\n`); + stream.write(`Accepted sources: ${result.read_first.length}\n`); + stream.write(`Candidate boundaries: ${result.candidate_boundaries.length}\n`); + stream.write("Boundary: OpenDomain source files remain authoritative; no generated index was required.\n"); + for (const item of result.read_first) { + stream.write(`- ${item.id} (${item.type}) -> ${item.source_file}\n`); + } + printIssues(result.warnings, stream); +} + +function printContextExportResult(result, stream) { + if (result.errors.length > 0) { + stream.write(`OpenDomain context export failed: ${result.errors.length} errors.\n`); + printIssues([...result.errors, ...result.warnings], stream); + return; + } + + stream.write("OpenDomain Context Export\n\n"); + stream.write(`Schema: ${result.schema}\n`); + stream.write(`Accepted documents: ${result.documents.length}\n`); + stream.write(`Candidate boundaries: ${result.candidate_boundaries.length}\n`); + if (result.governance?.publication_closure) { + stream.write(`Public product: ${result.governance.publication_closure.product_id}\n`); + stream.write("Publication closure: pass (derived evidence only; no publication performed).\n"); + } + stream.write("Boundary: read-only derived payload; source review, Git, and publication state were not modified.\n"); + for (const item of result.documents) { + stream.write(`- ${item.id} (${item.type}) -> ${item.source.file}\n`); + } + printIssues(result.warnings, stream); +} + async function runOrderCancellationDemo(io) { const result = await validatePath("examples/erp", { cwd: io.cwd }); const feature = result.documents.find((document) => document.id === "spec.order-cancellation"); diff --git a/src/context-export-schema.mjs b/src/context-export-schema.mjs new file mode 100644 index 0000000..49ec05a --- /dev/null +++ b/src/context-export-schema.mjs @@ -0,0 +1,47 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { readPackagedText } from "./packaged-resources.mjs"; + +let validator; + +export function validateContextExportEnvelope(envelope) { + const validate = getValidator(); + const valid = validate(envelope); + return { + valid, + errors: valid ? [] : (validate.errors ?? []).map((error) => ({ + instancePath: error.instancePath, + keyword: error.keyword, + message: error.message, + params: { ...error.params }, + schemaPath: error.schemaPath + })) + }; +} + +export function assertContextExportEnvelope(envelope) { + const result = validateContextExportEnvelope(envelope); + if (!result.valid) { + const detail = result.errors + .map((error) => `${error.instancePath || "$"} ${error.message}`) + .join("; "); + throw new Error(`Internal context export violates schemas/context-export.schema.json: ${detail}`); + } + return envelope; +} + +function getValidator() { + if (validator) { + return validator; + } + const schema = JSON.parse(readPackagedText("schemas/context-export.schema.json")); + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictTypes: false, + validateFormats: true + }); + addFormats(ajv, { mode: "full" }); + validator = ajv.compile(schema); + return validator; +} diff --git a/src/core.mjs b/src/core.mjs new file mode 100644 index 0000000..98cc926 --- /dev/null +++ b/src/core.mjs @@ -0,0 +1,543 @@ +import { + buildSemanticIndexFromValidation +} from "./indexer.mjs"; +import { assertContextExportEnvelope } from "./context-export-schema.mjs"; +import { selectSemanticContext } from "./semantic-query.mjs"; +import { validatePath } from "./validator.mjs"; + +export const CORE_API_VERSION = "1.0"; +export const CONTEXT_QUERY_SCHEMA = "opendomain.context-query.v1"; +export const CONTEXT_EXPORT_SCHEMA = "opendomain.context-export.v1"; + +const SOURCE_AUTHORITY = "OpenDomain Markdown source files and a validated governance manifest when present"; + +export async function validateWorkspace(request = {}) { + const normalized = normalizeCommonRequest(request); + if (normalized.errors.length > 0) { + return { + documents: [], + errors: normalized.errors, + warnings: [], + workspace: null, + governance: null + }; + } + return validatePath(normalized.target, { + cwd: normalized.cwd, + now: normalized.now + }); +} + +export async function queryWorkspace(request = {}) { + const normalized = normalizeCommonRequest(request); + const generatedAt = normalized.now.toISOString(); + if (normalized.errors.length > 0) { + return portableResult(emptyQueryResult(normalized, generatedAt, normalized.errors)); + } + + const snapshot = await createSnapshot(normalized); + if (snapshot.errors.length > 0) { + return portableResult(emptyQueryResult( + normalized, + generatedAt, + snapshot.errors, + snapshot.warnings, + snapshot.validation?.workspace ?? null, + snapshot.validation?.governance ?? null + )); + } + + const selection = selectSemanticContext(snapshot.index, request.selector); + return portableResult({ + schema: CONTEXT_QUERY_SCHEMA, + api_version: CORE_API_VERSION, + status: selection.errors.length === 0 ? "pass" : "fail", + generated_at: generatedAt, + request: { + target: normalized.target ?? null, + selector: selection.selector + }, + source: sourceDescriptor(snapshot.validation.workspace), + governance: governanceForEntries(snapshot.validation.governance, selection.entries, null), + semantic_closure: selection.semantic_closure, + read_first: selection.read_first, + accepted_ids: selection.accepted_ids, + candidate_boundaries: selection.candidate_boundaries, + verify_with: selection.verify_with, + warnings: [...snapshot.warnings, ...selection.warnings], + errors: selection.errors + }); +} + +export async function exportContext(request = {}) { + const normalized = normalizeCommonRequest(request); + const generatedAt = normalized.now.toISOString(); + const exposureErrors = validateExposure(request.exposure); + const initialErrors = [...normalized.errors, ...exposureErrors]; + if (initialErrors.length > 0) { + return finalizeExport(emptyExportResult(normalized, generatedAt, initialErrors, request)); + } + + const snapshot = await createSnapshot(normalized); + if (snapshot.errors.length > 0) { + return finalizeExport(emptyExportResult( + normalized, + generatedAt, + snapshot.errors, + request, + snapshot.warnings, + snapshot.validation?.workspace ?? null, + snapshot.validation?.governance ?? null + )); + } + + const publicMode = request.exposure === "public"; + const selection = publicMode + ? selectPublicClosure(snapshot, request.selector) + : selectSemanticContext(snapshot.index, request.selector); + const errors = [...selection.errors]; + let documents = []; + let candidateBoundaries = []; + + const mapped = mapSelectedDocuments( + errors.length === 0 ? selection.entries : [], + selection.candidate_entries, + snapshot.validation + ); + errors.push(...mapped.errors); + candidateBoundaries = mapped.candidate_boundaries; + if (errors.length === 0) { + documents = mapped.documents; + } + + const result = { + schema: CONTEXT_EXPORT_SCHEMA, + api_version: CORE_API_VERSION, + status: errors.length === 0 ? "pass" : "fail", + generated_at: generatedAt, + request: { + target: normalized.target ?? null, + selector: selection.selector, + exposure: request.exposure ?? null + }, + source: sourceDescriptor(snapshot.validation.workspace), + selection: { + root_ids: selection.semantic_closure.root_ids, + accepted_ids: errors.length === 0 ? selection.accepted_ids : [], + selection_paths: selection.semantic_closure.selection_paths + }, + governance: governanceForEntries( + snapshot.validation.governance, + errors.length === 0 ? selection.entries : [], + selection.publication_closure ?? null + ), + documents, + candidate_boundaries: candidateBoundaries, + warnings: [...snapshot.warnings, ...selection.warnings], + errors + }; + return finalizeExport(result); +} + +async function createSnapshot(normalized) { + const validation = await validatePath(normalized.target, { + cwd: normalized.cwd, + now: normalized.now + }); + if (validation.errors.length > 0) { + return { + validation, + index: null, + warnings: validation.warnings, + errors: validation.errors + }; + } + + const built = await buildSemanticIndexFromValidation(validation, { + cwd: normalized.cwd, + now: normalized.now, + targetPath: normalized.target + }); + return { + validation, + index: built.index, + warnings: built.warnings, + errors: built.errors + }; +} + +function selectPublicClosure(snapshot, selector) { + const errors = []; + const normalized = normalizePublicSelector(selector, errors); + const governance = snapshot.validation.governance; + if (!governance) { + errors.push(inputIssue( + "exposure", + "Public context export requires a governed canonical workspace.", + "Add and validate opendomain/governance.yaml or use a normal semantic selector without public exposure." + )); + } + + const product = governance?.products?.find((entry) => entry.id === normalized.product); + if (governance && (!product || product.exposure !== "public")) { + errors.push(inputIssue( + "selector.product", + `Product '${normalized.product ?? ""}' is not a validated public product.`, + "Select one public product declared by the current governance manifest." + )); + } + + const closure = governance?.publication_closures?.find((entry) => ( + entry.product_id === normalized.product && entry.status === "pass" + )); + if (governance && product?.exposure === "public" && !closure) { + errors.push(inputIssue( + "governance.publication_closures", + `No passing publication closure exists for product '${normalized.product}'.`, + "Resolve governance validation errors and rebuild the export from the current source snapshot." + )); + } + + const sourceFiles = new Set(closure?.source_files ?? []); + const closureEntries = (snapshot.index.entries ?? []) + .filter((entry) => sourceFiles.has(entry.source_file)); + if (closure && (closure.source_files ?? []).some((file) => ( + !closureEntries.some((entry) => entry.source_file === file) + ))) { + errors.push(inputIssue( + "governance.publication_closure.source_files", + "A publication closure source file cannot be mapped to the same validated semantic snapshot.", + "Revalidate the governed workspace and do not reuse an older closure or index." + )); + } + + const entries = closureEntries + .filter((entry) => entry.status === "accepted") + .sort(compareById); + const candidateEntries = closureEntries + .filter((entry) => entry.type === "domain_candidate") + .sort(compareById); + + return { + selector: normalized, + semantic_closure: { + policy: { + id: "opendomain.publication-closure", + version: governance?.schema_version ?? null + }, + root_ids: normalized.product ? [normalized.product] : [], + selection_paths: closure?.selection_paths ?? [] + }, + entries, + candidate_entries: candidateEntries, + accepted_ids: entries.map((entry) => entry.id), + publication_closure: closure ?? null, + warnings: [], + errors + }; +} + +function normalizePublicSelector(selector, errors) { + if (!selector || typeof selector !== "object" || Array.isArray(selector)) { + errors.push(inputIssue( + "selector", + "Public context export requires a selector containing one product.", + "Provide selector: { product: '' }." + )); + return {}; + } + const keys = Object.keys(selector); + for (const key of keys) { + if (key !== "product") { + errors.push(inputIssue( + `selector.${key}`, + `Public context export cannot crop the closure with selector '${key}'.`, + "Use only the required product selector for public export." + )); + } + } + if (typeof selector.product !== "string" || selector.product.trim().length === 0) { + errors.push(inputIssue( + "selector.product", + "Public context export requires a non-empty product id.", + "Select exactly one public product declared by governance.yaml." + )); + return {}; + } + return { product: selector.product.trim() }; +} + +function mapSelectedDocuments(entries, candidateEntries, validation) { + const byId = new Map(validation.documents.map((document) => [document.id, document])); + const errors = []; + const documents = []; + const candidates = []; + + for (const entry of entries) { + const document = byId.get(entry.id); + if (!document || document.status === "proposed") { + errors.push(snapshotMappingIssue(entry)); + continue; + } + if (validation.workspace?.governed && !hasCompleteOwnership(entry)) { + errors.push(ownershipMappingIssue(entry)); + continue; + } + documents.push(exportedDocument(entry, document)); + } + + for (const entry of candidateEntries) { + if (validation.workspace?.governed && !hasCompleteOwnership(entry)) { + errors.push(ownershipMappingIssue(entry)); + continue; + } + candidates.push(exportedCandidateBoundary(entry)); + } + + return { + documents: documents.sort(compareById), + candidate_boundaries: candidates.sort(compareById), + errors + }; +} + +function exportedDocument(entry, document) { + const result = { + id: entry.id, + type: entry.type, + name: entry.name, + status: "accepted", + authoritative: true, + frontmatter: document.frontmatter, + body: document.body, + summary: entry.summary, + source: { + file: entry.source_file, + hash: entry.source_hash + }, + review: entry.review, + evidence: entry.evidence, + governance: ownership(entry) + }; + if (entry.context) { + result.context = entry.context; + } + return result; +} + +function exportedCandidateBoundary(entry) { + return { + id: entry.id, + type: "domain_candidate", + status: entry.status, + authoritative: false, + target: entry.target ?? {}, + confidence: entry.confidence ?? "unknown", + possible_conflicts: entry.possible_conflicts ?? [], + review: entry.review ?? {}, + summary: entry.summary ?? "", + source: { + file: entry.source_file, + hash: entry.source_hash + }, + governance: ownership(entry) + }; +} + +function ownership(entry) { + if (!entry.product_id) { + return null; + } + return { + product_id: entry.product_id, + domain_group_id: entry.domain_group_id, + owners: [...entry.owners], + exposure: entry.exposure, + governance_schema_version: entry.governance_schema_version, + source_root: entry.governance_source_root + }; +} + +function governanceForEntries(governance, entries, publicationClosure) { + if (!governance) { + return null; + } + const productIds = new Set(entries.map((entry) => entry.product_id).filter(Boolean)); + const groupIds = new Set(entries.map((entry) => entry.domain_group_id).filter(Boolean)); + if (publicationClosure) { + for (const id of publicationClosure.product_ids ?? []) { + productIds.add(id); + } + for (const id of publicationClosure.domain_group_ids ?? []) { + groupIds.add(id); + } + } + return { + schema_version: governance.schema_version, + manifest: governance.manifest, + derived: true, + authoritative_source: governance.authoritative_source, + products: (governance.products ?? []).filter((entry) => productIds.has(entry.id)), + domain_groups: (governance.domain_groups ?? []).filter((entry) => groupIds.has(entry.id)), + publication_closure: publicationClosure + }; +} + +function normalizeCommonRequest(request) { + const errors = []; + const source = request && typeof request === "object" && !Array.isArray(request) + ? request + : {}; + if (source !== request) { + errors.push(inputIssue( + "$", + "Core request must be an object.", + "Pass an object containing target, cwd, now, and selector as needed." + )); + } + + let target; + if (source.target !== undefined) { + if (typeof source.target !== "string" || source.target.trim().length === 0) { + errors.push(inputIssue("target", "Target must be a non-empty string.", "Omit target or provide a file/directory path.")); + } else { + target = source.target; + } + } + + let cwd = process.cwd(); + if (source.cwd !== undefined) { + if (typeof source.cwd !== "string" || source.cwd.length === 0) { + errors.push(inputIssue("cwd", "cwd must be a non-empty string.", "Provide an absolute or process-relative working directory.")); + } else { + cwd = source.cwd; + } + } + + let now = source.now instanceof Date ? new Date(source.now.getTime()) : new Date(source.now ?? Date.now()); + if (Number.isNaN(now.getTime())) { + errors.push(inputIssue("now", "now must be a valid Date or date-time value.", "Provide a valid clock value or omit now.")); + now = new Date(0); + } + return { target, cwd, now, errors }; +} + +function validateExposure(exposure) { + if (exposure === undefined || exposure === "public") { + return []; + } + return [inputIssue( + "exposure", + `Unsupported context export exposure '${String(exposure)}'.`, + "Omit exposure for normal context export or use exposure: 'public'." + )]; +} + +function emptyQueryResult(normalized, generatedAt, errors, warnings = [], workspace = null, governance = null) { + return { + schema: CONTEXT_QUERY_SCHEMA, + api_version: CORE_API_VERSION, + status: "fail", + generated_at: generatedAt, + request: { + target: normalized.target ?? null, + selector: {} + }, + source: sourceDescriptor(workspace), + governance: governanceForEntries(governance, [], null), + semantic_closure: { + policy: null, + root_ids: [], + selection_paths: [] + }, + read_first: [], + accepted_ids: [], + candidate_boundaries: [], + verify_with: [], + warnings, + errors + }; +} + +function emptyExportResult(normalized, generatedAt, errors, request, warnings = [], workspace = null, governance = null) { + return { + schema: CONTEXT_EXPORT_SCHEMA, + api_version: CORE_API_VERSION, + status: "fail", + generated_at: generatedAt, + request: { + target: normalized.target ?? null, + selector: {}, + exposure: request?.exposure === "public" ? "public" : null + }, + source: sourceDescriptor(workspace), + selection: { + root_ids: [], + accepted_ids: [], + selection_paths: [] + }, + governance: governanceForEntries(governance, [], null), + documents: [], + candidate_boundaries: [], + warnings, + errors + }; +} + +function sourceDescriptor(workspace) { + return { + authoritative: true, + derived: true, + authority: SOURCE_AUTHORITY, + workspace + }; +} + +function hasCompleteOwnership(entry) { + return typeof entry.product_id === "string" + && typeof entry.domain_group_id === "string" + && Array.isArray(entry.owners) + && entry.owners.length > 0 + && typeof entry.exposure === "string" + && typeof entry.governance_schema_version === "string" + && typeof entry.governance_source_root === "string"; +} + +function snapshotMappingIssue(entry) { + return inputIssue( + "selection", + `Selected entry '${entry.id}' cannot be mapped to the same validated source snapshot.`, + "Re-run the query/export against unchanged OpenDomain source files." + ); +} + +function ownershipMappingIssue(entry) { + return inputIssue( + "governance", + `Selected governed entry '${entry.id}' is missing complete ownership provenance.`, + "Revalidate governance ownership and do not infer missing product, group, owner, or exposure metadata." + ); +} + +function inputIssue(field, problem, fix) { + return { + severity: "error", + file: "", + field, + problem, + fix + }; +} + +function compareById(left, right) { + return left.id.localeCompare(right.id); +} + +function finalizeExport(result) { + assertContextExportEnvelope(result); + return portableResult(result); +} + +function portableResult(value) { + return JSON.parse(JSON.stringify(value)); +} diff --git a/src/indexer.mjs b/src/indexer.mjs index 5b05e5f..db2c7f2 100644 --- a/src/indexer.mjs +++ b/src/indexer.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { collectSemanticClosure } from "./semantic-closure.mjs"; +import { selectSemanticContext } from "./semantic-query.mjs"; import { validatePath } from "./validator.mjs"; import { LEGACY_DEFAULT_INDEX_PATH, @@ -16,6 +16,18 @@ export async function buildSemanticIndex(targetPath, options = {}) { const now = options.now ?? new Date(); const validation = await validatePath(targetPath, { cwd, now }); + return buildSemanticIndexFromValidation(validation, { + cwd, + now, + targetPath + }); +} + +export async function buildSemanticIndexFromValidation(validation, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const now = options.now ?? new Date(); + const targetPath = options.targetPath; + const result = { index: null, errors: validation.errors, @@ -98,72 +110,29 @@ export async function querySemanticIndex(query, options = {}) { } 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 = [...resolutionWarnings]; - const queryMode = query.context ? "context" : "id"; - let selectedEntries = []; - - if (query.context) { - selectedEntries = (index.entries ?? []).filter((entry) => ( - entry.status === "accepted" - && (entry.context === query.context || entry.id === query.context) - )); - if (selectedEntries.length === 0) { - errors.push(issue({ - field: "context", - problem: `No accepted index entries found for context '${query.context}'.`, - fix: "Build the index again or query an existing OpenDomain context id." - })); - } - } else { - const entry = entriesById.get(query.id); - if (!entry) { - errors.push(issue({ - field: "id", - problem: `Index entry '${query.id}' was not found.`, - fix: "Build the index again or query an existing OpenDomain id." - })); - } else if (entry.status === "accepted") { - selectedEntries = [entry]; - } else if (entry.type === "domain_candidate") { - selectedEntries = []; - } else { - warnings.push(issue({ - severity: "warning", - field: "status", - problem: `Index entry '${query.id}' is not accepted knowledge.`, - fix: "Treat it as non-authoritative unless accepted in OpenDomain source." - })); - } - } - - const closure = collectSemanticClosure(selectedEntries.map((entry) => entry.id), index.entries ?? []); - const readFirst = closure.entries; - const readFirstIds = new Set(readFirst.map((entry) => entry.id)); - const candidateBoundaries = collectCandidateBoundaries(index.entries ?? [], readFirstIds, query); - const staleWarnings = await checkFreshness([...readFirst, ...candidateBoundaries], cwd); + const selection = selectSemanticContext(index, query); + const warnings = [...resolutionWarnings, ...selection.warnings]; + const staleWarnings = await checkFreshness( + [...selection.entries, ...selection.candidate_entries], + cwd + ); warnings.push(...staleWarnings); return { - query: queryMode === "context" + query: query.context ? { context: query.context } : { id: query.id }, index_file: loaded.file, schema: index.schema, source_files_authoritative: true, authoritative_source: index.authoritative_source ?? "OpenDomain source files, not this index", - semantic_closure: { - policy: closure.policy, - root_ids: closure.root_ids, - selection_paths: closure.selection_paths - }, - read_first: readFirst.map(toReadFirstItem), - accepted_ids: readFirst.map((entry) => entry.id).sort(), - candidate_boundaries: candidateBoundaries.map(toCandidateBoundary), - verify_with: readFirst.map(toVerificationItem), + semantic_closure: selection.semantic_closure, + read_first: selection.read_first, + accepted_ids: selection.accepted_ids, + candidate_boundaries: selection.candidate_boundaries, + verify_with: selection.verify_with, warnings, - errors + errors: selection.errors }; } @@ -260,18 +229,6 @@ function collectAffectsDomainIds(affectsDomain) { ]; } -function collectCandidateBoundaries(entries, readFirstIds, query) { - return entries - .filter((entry) => entry.type === "domain_candidate") - .filter((entry) => { - const targetId = entry.target?.id; - return readFirstIds.has(targetId) - || (query.id && entry.id === query.id) - || (query.context && entry.context === query.context); - }) - .sort(compareById); -} - async function checkFreshness(entries, cwd) { const warnings = []; for (const entry of entries) { @@ -319,40 +276,6 @@ function summarizeDocument(document) { return paragraph.replace(/\s+/g, " ").slice(0, 240); } -function toReadFirstItem(entry) { - return { - id: entry.id, - type: entry.type, - name: entry.name, - status: entry.status, - context: entry.context, - source_file: entry.source_file, - summary: entry.summary, - referencing_feature_specs: entry.referencing_feature_specs, - source_hash: entry.source_hash - }; -} - -function toCandidateBoundary(entry) { - return { - id: entry.id, - status: entry.status, - target_id: entry.target?.id, - confidence: entry.confidence, - source_file: entry.source_file, - summary: entry.summary - }; -} - -function toVerificationItem(entry) { - return { - id: entry.id, - source_file: entry.source_file, - evidence: entry.evidence, - review: entry.review - }; -} - function arrayOrEmpty(value) { return Array.isArray(value) ? value : []; } diff --git a/src/semantic-query.mjs b/src/semantic-query.mjs new file mode 100644 index 0000000..b2426dc --- /dev/null +++ b/src/semantic-query.mjs @@ -0,0 +1,224 @@ +import { collectSemanticClosure } from "./semantic-closure.mjs"; + +export const SEMANTIC_SELECTOR_FIELDS = Object.freeze([ + "id", + "context", + "product", + "domain_group", + "owner", + "lifecycle", + "type" +]); + +const SELECTOR_FIELD_SET = new Set(SEMANTIC_SELECTOR_FIELDS); +const GOVERNANCE_SELECTOR_FIELDS = new Set(["product", "domain_group", "owner"]); + +export function selectSemanticContext(index, selector) { + const entries = Array.isArray(index?.entries) ? index.entries : []; + const errors = []; + const warnings = []; + const normalized = normalizeSelector(selector, errors); + + for (const field of GOVERNANCE_SELECTOR_FIELDS) { + if (normalized[field] && !entries.some((entry) => hasGovernanceField(entry, field))) { + errors.push(issue({ + field: `selector.${field}`, + problem: `Selector '${field}' requires validated governance metadata.`, + fix: "Use a governed canonical workspace or remove the governance selector." + })); + } + } + + let seeds = []; + if (errors.length === 0) { + seeds = entries + .filter((entry) => entry.status === "accepted") + .filter((entry) => matchesSelector(entry, normalized)) + .sort(compareById); + } + + const directlySelectedCandidate = normalized.id + ? entries.find((entry) => entry.id === normalized.id && entry.type === "domain_candidate") + : null; + + if (errors.length === 0 && seeds.length === 0) { + errors.push(issue({ + field: directlySelectedCandidate ? "selector.id" : "selector", + problem: directlySelectedCandidate + ? `Selector id '${normalized.id}' identifies a non-authoritative Domain Candidate, not accepted knowledge.` + : "No accepted OpenDomain entries match the supplied selector.", + fix: directlySelectedCandidate + ? "Review the Candidate boundary or select an accepted target id." + : "Use a selector that matches accepted knowledge in the validated workspace." + })); + } + + const closure = collectSemanticClosure(seeds.map((entry) => entry.id), entries); + const selected = closure.entries + .filter((entry) => entry.status === "accepted") + .sort(compareById); + const selectedIds = new Set(selected.map((entry) => entry.id)); + const candidateEntries = entries + .filter((entry) => entry.type === "domain_candidate") + .filter((entry) => ( + selectedIds.has(entry.target?.id) + || (directlySelectedCandidate && entry.id === directlySelectedCandidate.id) + || (normalized.context && entry.context === normalized.context) + || matchesGovernanceSelector(entry, normalized) + )) + .sort(compareById); + + return { + selector: normalized, + semantic_closure: { + policy: closure.policy, + root_ids: closure.root_ids, + selection_paths: closure.selection_paths + }, + entries: selected, + candidate_entries: candidateEntries, + read_first: selected.map(toReadFirstItem), + accepted_ids: selected.map((entry) => entry.id), + candidate_boundaries: candidateEntries.map(toCandidateBoundary), + verify_with: selected.map(toVerificationItem), + warnings, + errors + }; +} + +function normalizeSelector(selector, errors) { + if (!selector || typeof selector !== "object" || Array.isArray(selector)) { + errors.push(issue({ + field: "selector", + problem: "A semantic selector object is required.", + fix: `Provide at least one of: ${SEMANTIC_SELECTOR_FIELDS.join(", ")}.` + })); + return {}; + } + + const normalized = {}; + for (const [field, value] of Object.entries(selector)) { + if (!SELECTOR_FIELD_SET.has(field)) { + errors.push(issue({ + field: `selector.${field}`, + problem: `Unknown semantic selector '${field}'.`, + fix: `Use only: ${SEMANTIC_SELECTOR_FIELDS.join(", ")}.` + })); + continue; + } + if (typeof value !== "string" || value.trim().length === 0) { + errors.push(issue({ + field: `selector.${field}`, + problem: `Selector '${field}' must be a non-empty string.`, + fix: `Provide a non-empty ${field} selector value.` + })); + continue; + } + normalized[field] = value.trim(); + } + + if (Object.keys(normalized).length === 0 && errors.length === 0) { + errors.push(issue({ + field: "selector", + problem: "At least one semantic selector is required.", + fix: `Provide one of: ${SEMANTIC_SELECTOR_FIELDS.join(", ")}.` + })); + } + return normalized; +} + +function matchesSelector(entry, selector) { + return (!selector.id || entry.id === selector.id) + && (!selector.context || entry.context === selector.context || entry.id === selector.context) + && (!selector.product || entry.product_id === selector.product) + && (!selector.domain_group || entry.domain_group_id === selector.domain_group) + && (!selector.owner || arrayOrEmpty(entry.owners).includes(selector.owner)) + && (!selector.lifecycle || matchesLifecycle(entry, selector.lifecycle)) + && (!selector.type || entry.type === selector.type); +} + +function matchesGovernanceSelector(entry, selector) { + const governanceFields = ["product", "domain_group", "owner"] + .filter((field) => selector[field]); + return governanceFields.length > 0 && governanceFields.every((field) => { + if (field === "product") { + return entry.product_id === selector.product; + } + if (field === "domain_group") { + return entry.domain_group_id === selector.domain_group; + } + return arrayOrEmpty(entry.owners).includes(selector.owner); + }); +} + +function matchesLifecycle(entry, lifecycle) { + return (entry.type === "lifecycle" && entry.id === lifecycle) + || arrayOrEmpty(entry.lifecycles).includes(lifecycle) + || arrayOrEmpty(entry.related_lifecycle).includes(lifecycle); +} + +function hasGovernanceField(entry, field) { + if (field === "product") { + return typeof entry.product_id === "string"; + } + if (field === "domain_group") { + return typeof entry.domain_group_id === "string"; + } + return Array.isArray(entry.owners); +} + +export function toReadFirstItem(entry) { + return compact({ + id: entry.id, + type: entry.type, + name: entry.name, + status: entry.status, + context: entry.context, + source_file: entry.source_file, + summary: entry.summary, + referencing_feature_specs: entry.referencing_feature_specs, + source_hash: entry.source_hash + }); +} + +export function toCandidateBoundary(entry) { + return compact({ + id: entry.id, + status: entry.status, + target_id: entry.target?.id, + confidence: entry.confidence, + source_file: entry.source_file, + summary: entry.summary + }); +} + +export function toVerificationItem(entry) { + return compact({ + id: entry.id, + source_file: entry.source_file, + evidence: entry.evidence, + review: entry.review + }); +} + +function compact(value) { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); +} + +function arrayOrEmpty(value) { + return Array.isArray(value) ? value : []; +} + +function compareById(left, right) { + return left.id.localeCompare(right.id); +} + +function issue({ field, problem, fix }) { + return { + severity: "error", + file: "", + field, + problem, + fix + }; +} diff --git a/tests/core.test.mjs b/tests/core.test.mjs new file mode 100644 index 0000000..2531ef2 --- /dev/null +++ b/tests/core.test.mjs @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import { access } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + CONTEXT_EXPORT_SCHEMA, + CONTEXT_QUERY_SCHEMA, + CORE_API_VERSION, + exportContext, + queryWorkspace, + validateWorkspace +} from "../src/core.mjs"; +import { runCli } from "../src/cli.mjs"; +import { validateContextExportEnvelope } from "../src/context-export-schema.mjs"; +import { validatePath } from "../src/validator.mjs"; + +const REPOSITORY_ROOT = path.resolve("."); +const ERP_TARGET = "examples/erp"; +const GOVERNED_ROOT = path.resolve("tests/fixtures/valid/governed-multi-product"); +const NOW = new Date("2026-08-10T00:00:00Z"); + +test("package root and core subpath expose one versioned side-effect-free API", async () => { + const root = await import("@echopath-labs/opendomain"); + const subpath = await import("@echopath-labs/opendomain/core"); + assert.equal(root.CORE_API_VERSION, "1.0"); + assert.equal(subpath.CORE_API_VERSION, root.CORE_API_VERSION); + assert.equal(subpath.validateWorkspace, root.validateWorkspace); + assert.equal(subpath.queryWorkspace, root.queryWorkspace); + assert.equal(subpath.exportContext, root.exportContext); + + const previousExitCode = process.exitCode; + const result = await root.queryWorkspace({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }); + assert.equal(result.status, "pass"); + assert.equal(process.exitCode, previousExitCode); + await assert.rejects( + access(path.join(REPOSITORY_ROOT, "examples/erp/.opendomain/index.json")), + (error) => error?.code === "ENOENT" + ); +}); + +test("Core validation delegates to the established validator without changing its result", async () => { + const core = await validateWorkspace({ + target: ERP_TARGET, + cwd: REPOSITORY_ROOT, + now: NOW + }); + const established = await validatePath(ERP_TARGET, { + cwd: REPOSITORY_ROOT, + now: NOW + }); + assert.deepEqual(core, established); +}); + +test("source-first query returns a deterministic accepted closure and Candidate boundary", async () => { + const first = await queryWorkspace({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }); + const second = await queryWorkspace({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }); + + assert.deepEqual(first, second); + assert.equal(first.schema, CONTEXT_QUERY_SCHEMA); + assert.equal(first.api_version, CORE_API_VERSION); + assert.equal(first.status, "pass"); + assert.ok(first.accepted_ids.includes("sales.order-lifecycle")); + assert.deepEqual(first.candidate_boundaries.map((item) => item.id), [ + "candidate-0001-order-lifecycle" + ]); +}); + +test("context export is schema-valid, portable, accepted-only, and Candidate-safe", async () => { + const result = await exportContext({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }); + + assert.equal(result.schema, CONTEXT_EXPORT_SCHEMA); + assert.equal(result.status, "pass"); + assert.deepEqual(validateContextExportEnvelope(result), { valid: true, errors: [] }); + assert.ok(result.documents.every((document) => document.status === "accepted")); + assert.ok(result.documents.every((document) => document.authoritative === true)); + assert.ok(result.documents.every((document) => !path.isAbsolute(document.source.file))); + assert.ok(result.documents.every((document) => document.source.hash.length === 64)); + assert.equal(result.documents.find((document) => document.id === "sales.order").body.includes("# Order"), true); + assert.deepEqual(result.candidate_boundaries.map((candidate) => ({ + id: candidate.id, + authoritative: candidate.authoritative + })), [{ id: "candidate-0001-order-lifecycle", authoritative: false }]); +}); + +test("Candidate id remains a non-authoritative boundary in a failed export", async () => { + const result = await exportContext({ + target: ERP_TARGET, + selector: { id: "candidate-0001-order-lifecycle" }, + cwd: REPOSITORY_ROOT, + now: NOW + }); + + assert.equal(result.status, "fail"); + assert.deepEqual(result.documents, []); + assert.deepEqual(result.candidate_boundaries.map((candidate) => candidate.id), [ + "candidate-0001-order-lifecycle" + ]); + assert.equal(result.candidate_boundaries[0].authoritative, false); + assert.deepEqual(validateContextExportEnvelope(result), { valid: true, errors: [] }); +}); + +test("public export preserves complete current closure proof and excludes stricter groups", async () => { + const result = await exportContext({ + selector: { product: "alpha" }, + exposure: "public", + cwd: GOVERNED_ROOT, + now: NOW + }); + + assert.equal(result.status, "pass"); + assert.deepEqual(result.documents.map((document) => document.id), ["alpha", "beta"]); + assert.ok(result.documents.every((document) => document.governance.exposure === "public")); + assert.deepEqual(result.governance.publication_closure.domain_group_ids, [ + "alpha.public", + "beta.public" + ]); + assert.equal(JSON.stringify(result).includes("alpha_private"), false); + assert.deepEqual(validateContextExportEnvelope(result), { valid: true, errors: [] }); +}); + +test("public export fails closed for ungoverned, unknown, and cropped requests", async () => { + const ungoverned = await exportContext({ + target: ERP_TARGET, + selector: { product: "alpha" }, + exposure: "public", + cwd: REPOSITORY_ROOT, + now: NOW + }); + assert.equal(ungoverned.status, "fail"); + assert.deepEqual(ungoverned.documents, []); + assert.match(ungoverned.errors[0].problem, /requires a governed canonical workspace/); + + const unknown = await exportContext({ + selector: { product: "missing" }, + exposure: "public", + cwd: GOVERNED_ROOT, + now: NOW + }); + assert.equal(unknown.status, "fail"); + assert.deepEqual(unknown.documents, []); + + const cropped = await exportContext({ + selector: { product: "alpha", context: "alpha" }, + exposure: "public", + cwd: GOVERNED_ROOT, + now: NOW + }); + assert.equal(cropped.status, "fail"); + assert.deepEqual(cropped.documents, []); + assert.ok(cropped.errors.some((error) => error.problem.includes("cannot crop"))); +}); + +test("CLI JSON and library results are deeply equivalent with a fixed clock", async () => { + const cases = [ + { + cwd: REPOSITORY_ROOT, + args: ["query", ERP_TARGET, "--id", "sales.order", "--json"], + library: () => queryWorkspace({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }) + }, + { + cwd: REPOSITORY_ROOT, + args: ["export", "context", ERP_TARGET, "--id", "sales.order", "--json"], + library: () => exportContext({ + target: ERP_TARGET, + selector: { id: "sales.order" }, + cwd: REPOSITORY_ROOT, + now: NOW + }) + }, + { + cwd: GOVERNED_ROOT, + args: ["export", "context", "--product", "alpha", "--exposure", "public", "--json"], + library: () => exportContext({ + selector: { product: "alpha" }, + exposure: "public", + cwd: GOVERNED_ROOT, + now: NOW + }) + }, + { + cwd: REPOSITORY_ROOT, + args: ["query", ERP_TARGET, "--id", "missing", "--json"], + library: () => queryWorkspace({ + target: ERP_TARGET, + selector: { id: "missing" }, + cwd: REPOSITORY_ROOT, + now: NOW + }) + } + ]; + + for (const entry of cases) { + const stdout = memoryStream(); + const exitCode = await runCli(entry.args, { + cwd: entry.cwd, + now: NOW, + stdout, + stderr: memoryStream() + }); + const cli = JSON.parse(stdout.toString()); + const library = await entry.library(); + assert.deepEqual(cli, library); + assert.equal(exitCode, library.errors.length > 0 ? 1 : 0); + } +}); + +test("human query and export output are derived from Core status and boundaries", async () => { + const queryOut = memoryStream(); + assert.equal(await runCli(["query", ERP_TARGET, "--id", "sales.order"], { + cwd: REPOSITORY_ROOT, + now: NOW, + stdout: queryOut, + stderr: memoryStream() + }), 0); + assert.match(queryOut.toString(), /OpenDomain Source-First Query/); + assert.match(queryOut.toString(), /Candidate boundaries: 1/); + + const exportOut = memoryStream(); + assert.equal(await runCli(["export", "context", "--product", "alpha", "--exposure", "public"], { + cwd: GOVERNED_ROOT, + now: NOW, + stdout: exportOut, + stderr: memoryStream() + }), 0); + assert.match(exportOut.toString(), /Publication closure: pass/); + assert.match(exportOut.toString(), /no publication performed/); +}); + +function memoryStream() { + let output = ""; + return { + write(chunk) { + output += chunk; + }, + toString() { + return output; + } + }; +} diff --git a/tests/packaged-resources.test.mjs b/tests/packaged-resources.test.mjs index 812265b..08486af 100644 --- a/tests/packaged-resources.test.mjs +++ b/tests/packaged-resources.test.mjs @@ -13,6 +13,7 @@ test("packaged resources expose schemas, package metadata, and ERP files", async const installationContract = resources.readPackagedText("INSTALL.md"); const schema = JSON.parse(resources.readPackagedText("schemas/context.schema.json")); const governanceSchema = JSON.parse(resources.readPackagedText("schemas/governance.schema.json")); + const contextExportSchema = JSON.parse(resources.readPackagedText("schemas/context-export.schema.json")); const exampleFiles = resources.listPackagedFiles("examples/erp/"); assert.equal(packageMetadata.name, "@echopath-labs/opendomain"); @@ -20,6 +21,7 @@ test("packaged resources expose schemas, package metadata, and ERP files", async assert.match(installationContract, /@echopath-labs\/opendomain@alpha/); assert.equal(schema.$id, "https://opendomain.dev/schemas/context.schema.json"); assert.equal(governanceSchema.$id, "https://opendomain.dev/schemas/governance.schema.json"); + assert.equal(contextExportSchema.$id, "https://opendomain.dev/schemas/context-export.schema.json"); assert.ok(exampleFiles.includes("examples/erp/opendomain/contexts/sales.md")); assert.ok(exampleFiles.includes("examples/erp/openspec/changes/order-cancellation/spec.md")); assert.deepEqual(exampleFiles, [...exampleFiles].sort()); diff --git a/tests/semantic-query.test.mjs b/tests/semantic-query.test.mjs new file mode 100644 index 0000000..9a5b9a2 --- /dev/null +++ b/tests/semantic-query.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { buildSemanticIndex } from "../src/indexer.mjs"; +import { selectSemanticContext } from "../src/semantic-query.mjs"; + +const REPOSITORY_ROOT = path.resolve("."); +const ERP_TARGET = "examples/erp"; +const GOVERNED_ROOT = path.resolve("tests/fixtures/valid/governed-multi-product"); +const NOW = new Date("2026-08-10T00:00:00Z"); + +test("shared selector supports semantic id, context, lifecycle, type, and deterministic AND", async () => { + const built = await buildSemanticIndex(ERP_TARGET, { cwd: REPOSITORY_ROOT, now: NOW }); + assert.deepEqual(built.errors, []); + + const byId = selectSemanticContext(built.index, { id: "sales.order" }); + assert.deepEqual(byId.errors, []); + assert.deepEqual(byId.semantic_closure.root_ids, ["sales.order"]); + assert.ok(byId.accepted_ids.includes("sales.order-lifecycle")); + assert.deepEqual(byId.candidate_boundaries.map((item) => item.id), [ + "candidate-0001-order-lifecycle" + ]); + + const byContextAndType = selectSemanticContext(built.index, { + context: "sales", + type: "domain_concept" + }); + assert.deepEqual(byContextAndType.errors, []); + assert.deepEqual(byContextAndType.semantic_closure.root_ids, ["sales.order"]); + + const byLifecycle = selectSemanticContext(built.index, { + lifecycle: "sales.order-lifecycle", + type: "domain_concept" + }); + assert.deepEqual(byLifecycle.errors, []); + assert.deepEqual(byLifecycle.semantic_closure.root_ids, ["sales.order"]); +}); + +test("shared selector supports governed product, group, and owner fields", async () => { + const built = await buildSemanticIndex(undefined, { cwd: GOVERNED_ROOT, now: NOW }); + assert.deepEqual(built.errors, []); + + const byProduct = selectSemanticContext(built.index, { product: "alpha" }); + assert.deepEqual(byProduct.errors, []); + assert.deepEqual(byProduct.semantic_closure.root_ids, [ + "alpha", + "alpha_ecosystem", + "alpha_internal", + "alpha_private" + ]); + + const byGroupAndOwner = selectSemanticContext(built.index, { + domain_group: "alpha.public", + owner: "alpha-owner" + }); + assert.deepEqual(byGroupAndOwner.errors, []); + assert.deepEqual(byGroupAndOwner.semantic_closure.root_ids, ["alpha"]); +}); + +test("shared selector fails closed for missing, unknown, ungoverned, and Candidate-only inputs", async () => { + const built = await buildSemanticIndex(ERP_TARGET, { cwd: REPOSITORY_ROOT, now: NOW }); + + assert.equal(selectSemanticContext(built.index, {}).errors.length, 1); + assert.equal(selectSemanticContext(built.index, { unsupported: "x" }).errors.length, 1); + assert.match( + selectSemanticContext(built.index, { product: "alpha" }).errors[0].problem, + /requires validated governance metadata/ + ); + + const candidate = selectSemanticContext(built.index, { + id: "candidate-0001-order-lifecycle" + }); + assert.equal(candidate.entries.length, 0); + assert.equal(candidate.errors.length, 1); + assert.deepEqual(candidate.candidate_boundaries.map((item) => item.id), [ + "candidate-0001-order-lifecycle" + ]); +}); From b8a8952378b78dc50bb4a9167cf0218008747d97 Mon Sep 17 00:00:00 2001 From: Chase Date: Mon, 10 Aug 2026 19:26:31 +0800 Subject: [PATCH 3/4] chore(release): prepare 0.1.0-alpha.10 projection --- CHANGELOG.md | 10 ++++++++++ package-lock.json | 4 ++-- package.json | 2 +- scripts/smoke-installed-package.mjs | 15 +++++++++++---- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4988d76..88faae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ - Reorganize public guidance around natural-language Codex adoption, add paired Usage Guides, and verify public navigation and npm package contents. +## 0.1.0-alpha.10 - 2026-08-10 + +- Add multi-product governance manifests, exposure propagation, dependency-cycle and forbidden-dependency checks, + and fail-closed public dependency closure while preserving ungoverned single-product compatibility. +- Add stable Core API 1.0 entrypoints for validation, semantic query, and bounded context export; CLI commands reuse + the same implementation and preserve Candidate boundaries outside accepted documents. +- Require governed canonical workspaces for product-level public export and prove direct/transitive Core-only + exposure leaks fail closed. +- Add an explicit offline installed-package smoke mode for clean public projection and local-cache validation. + ## 0.1.0-alpha.9 - 2026-08-04 - Add a canonical Agent installation contract so Codex can install OpenDomain diff --git a/package-lock.json b/package-lock.json index 779f436..fc64593 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@echopath-labs/opendomain", - "version": "0.1.0-alpha.9", + "version": "0.1.0-alpha.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@echopath-labs/opendomain", - "version": "0.1.0-alpha.9", + "version": "0.1.0-alpha.10", "license": "MIT", "dependencies": { "ajv": "^8.20.0", diff --git a/package.json b/package.json index 24b051f..6e20b06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@echopath-labs/opendomain", - "version": "0.1.0-alpha.9", + "version": "0.1.0-alpha.10", "description": "Git-native, evidence-backed domain semantics for AI agents.", "license": "MIT", "author": { diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs index 8802dad..9f3aa57 100644 --- a/scripts/smoke-installed-package.mjs +++ b/scripts/smoke-installed-package.mjs @@ -16,10 +16,10 @@ import { fileURLToPath } from "node:url"; const execFile = promisify(execFileCallback); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "opendomain-package-smoke-")); -const npmEnvironment = { - ...process.env, - npm_config_cache: path.join(temporaryRoot, "npm-cache") -}; +const offline = parseArguments(process.argv.slice(2)); +const npmEnvironment = offline + ? { ...process.env, npm_config_offline: "true" } + : { ...process.env, npm_config_cache: path.join(temporaryRoot, "npm-cache") }; try { const packResult = await run("npm", [ @@ -44,6 +44,7 @@ try { "--ignore-scripts", "--no-audit", "--no-fund", + ...(offline ? ["--offline"] : []), tarball ], consumer, npmEnvironment); @@ -274,3 +275,9 @@ async function run(command, args, cwd, environment = process.env) { ); } } + +function parseArguments(arguments_) { + if (arguments_.length === 0) return false; + if (arguments_.length === 1 && arguments_[0] === "--offline") return true; + throw new Error("Usage: smoke-installed-package [--offline]"); +} From a40adc36e0d51830e4674901c31412a63720c9b2 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 11 Aug 2026 18:29:18 +0800 Subject: [PATCH 4/4] chore(release): finalize 0.1.0-alpha.10 changelog --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88faae8..13cfa86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,9 @@ ## Unreleased -- Correct historical prerelease headings, dates, and notable workspace and - release-boundary entries against published tags and npm registry history. -- Reorganize public guidance around natural-language Codex adoption, add paired - Usage Guides, and verify public navigation and npm package contents. +_No unreleased changes._ -## 0.1.0-alpha.10 - 2026-08-10 +## 0.1.0-alpha.10 - 2026-08-11 - Add multi-product governance manifests, exposure propagation, dependency-cycle and forbidden-dependency checks, and fail-closed public dependency closure while preserving ungoverned single-product compatibility. @@ -16,6 +13,10 @@ - Require governed canonical workspaces for product-level public export and prove direct/transitive Core-only exposure leaks fail closed. - Add an explicit offline installed-package smoke mode for clean public projection and local-cache validation. +- Correct historical prerelease headings, dates, and notable workspace and + release-boundary entries against published tags and npm registry history. +- Reorganize public guidance around natural-language Codex adoption, add paired + Usage Guides, and verify public navigation and npm package contents. ## 0.1.0-alpha.9 - 2026-08-04