diff --git a/AGENTS.md b/AGENTS.md index 3d55319..06b4b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,9 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni ## Key Files - config — JSON5 file with spaces registered -- `schemas/` — Bundled default schema files (json-schema with metadata extension, JSON5 format). Files starting with `_` are "partials" (fragments for `$ref`) and are loaded automatically. Local partials in a schema's directory **must** have unique `$id`s. +- `schemas/` — Bundled default schema files (JSON5) using the ost-tools schema dialect and top-level `$metadata`. Files starting with `_` are "partials" (fragments for `$ref`) and are loaded automatically. Local partials in a schema's directory **must** have unique `$id`s. +- `src/metadata-contract.ts` — Single source of truth for the `$metadata` contract (TS `as const` + inferred types) +- `schemas/generated/_ost_tools_schema_meta.json` — Generated metaschema artifact (regenerate with `bun run generate:schema-meta`) ## Testing @@ -37,4 +39,4 @@ Before starting new work, review [docs/concepts.md](docs/concepts.md) for canoni - `bun run src/index.ts dump ` — Output parsed node data with resolved parents, useful for debugging rule violations ## Hooks -A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them. \ No newline at end of file +A Stop hook runs linting, autoformatting and unit tests. If it reports issues related to change you made, address them. diff --git a/README.md b/README.md index 5048e6d..1744ae8 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ See [docs/concepts.md](docs/concepts.md) for the full terminology reference, inc 2. `~/.config/ost-tools/config.json` (or `$XDG_CONFIG_HOME/ost-tools/config.json`) 3. `./config.json` in the current working directory -See `config.example.json` for the full structure. The config maps space namees to paths, with optional Miro integration fields and global defaults. Paths in config files are resolved relative to the config file. +See `config.example.json` for the full structure. The config maps space names to paths, with optional Miro integration fields and global defaults. Paths in config files are resolved relative to the config file. **Including spaces from other configs:** Use `includeSpacesFrom` to import space definitions from other config files. This is useful for aggregating spaces from multiple projects into a central config, reducing the need to specify `--config` on CLI commands. Duplicate space names are not allowed. @@ -54,7 +54,30 @@ Schemas define the structure and rules for the entities in a space, allowing cus Two schemas (`general` and `strict_ost`) are included. The general schema combines a basic vision/mission/goals hierarchy with a hierarchy loosely based on Opportunity Solution Trees. It is intentionally flexible to support rapid initial adoption. The strict OST schema has a narrower scope, and reflects Teresa Torres' specific recommendations for Opportunity Solution Trees more closely. -Schema hierarchy levels support DAG (multi-parent) relationships via configurable edge fields. Each level in `_metadata.hierarchy` can be a plain type name string (defaults to `parent` field on child nodes) or an object: +ost-tools schemas use a Draft-07-based metaschema that adds a top-level `$metadata` block: + +```json5 +"$metadata": { + "hierarchy": { + "levels": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"], + "allowSkipLevels": false + }, + "aliases": { "experiment": "assumption_test" }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] +} +``` + +Rules are a flat array (`rules[]`) with per-rule `category`. + +Schema hierarchy levels support DAG (multi-parent) relationships via configurable edge fields. Each entry in `$metadata.hierarchy.levels` can be a plain type name string (defaults to `parent` field on child nodes) or an object: ```json { "type": "opportunity", "selfRef": true } @@ -70,8 +93,17 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl | `multiple` | `false` | Whether the field is an array of wikilinks (enables multi-parent DAG) | | `selfRef` | `false` | Whether a node of this type may reference a same-type parent | +Metadata is composable across `$ref` graphs: +- zero or one metadata provider may define `hierarchy` +- `aliases` are shallow-merged (later wins) +- `rules` merge by `id`; conflicts error unless the later rule sets `override: true` +- `$metadata.rules` supports `$ref` imports for reusable rule packs + +If no provider defines `hierarchy`, hierarchy-specific checks are skipped. Reading a `space_on_a_page` file still requires `hierarchy.levels`. + **Customizing Schemas:** - **Partial schemas**: Files starting with an underscore (like `_ost_tools_base.json`) are loaded and used to resolve references (using `$ref`). +- **No-metadata partials**: If a partial has no `$metadata`, prefer `$schema: "http://json-schema.org/draft-07/schema#"` so it validates standalone as plain JSON Schema. - **Loading priority**: Partial schemas are loaded from both the default schema directory and the directory of your specified target schema. - **Transitive resolution**: `$ref` chains are resolved recursively across files/schemas (including nested `allOf` usage in partials). - **Unique IDs**: To encourage clean namespacing, local partial schemas **must** have unique `$id`s that do not collide with the default schemas. If a collision is detected, validation will fail with an error. diff --git a/bun.lock b/bun.lock index 1036ef2..60b4a7e 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@types/bun": "latest", "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.3", + "json-schema-to-ts": "^3.1.1", }, "peerDependencies": { "typescript": "^5", @@ -30,6 +31,8 @@ }, }, "packages": { + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], @@ -114,6 +117,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -230,6 +235,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/docs/concepts.md b/docs/concepts.md index 431bce3..495e9d7 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -107,9 +107,9 @@ Typed pages are distinct from `space on a page` files: a typed page *is* a `spac A **schema** defines the valid structure for nodes in a `space`: the fields, types, constraints, and descriptive `rules` for each entity type. A space uses the default schema unless a custom one is declared in its config. -The schema handles structural validation. It does not encode qualitative or cross-node checks — those are handled by `rules`, which may be embedded within the schema or applied separately. +The schema handles structural validation. Cross-node and workflow checks are handled by executable `rules` defined in `$metadata.rules`. -Schemas are designed to be composable: shared building blocks (common field sets, scoring models, constraint overlays) can be referenced across schema files, letting teams tailor a schema without forking their foundations. *(Schema composability is under development — see [GitHub issue #17](https://github.com/mindsocket/ost-tools/issues/17).)* +Schemas are composable: structural definitions and metadata can be sourced across `$ref` graphs, then merged deterministically (root metadata applied last, single hierarchy provider, aliases merged, rules merged by `id` with explicit override semantics). ### Rules @@ -127,7 +127,7 @@ Rules may be: Rules are distinct from schema validation: the schema checks structure; rules check meaning and quality. -See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `_metadata` field reference. +See [docs/rules.md](rules.md) for the rules reference, including JSONata expression syntax and the full `$metadata` field reference. --- @@ -142,7 +142,7 @@ See [docs/rules.md](rules.md) for the rules reference, including JSONata express ## Hierarchy -The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `_metadata.hierarchy` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `allowSkipLevels` is set). +The **hierarchy** is the ordered list of node types in a space, from root to leaf. It is defined in the schema's `$metadata.hierarchy.levels` array and drives depth-based type inference (for `space on a page`), tree rendering, and hierarchy validation. The root type has no parent; every other type has parents in the level immediately above (unless `$metadata.hierarchy.allowSkipLevels` is set). Relationships between levels are modelled as a layered DAG: a non-root node may have zero parents (orphaned), one parent, or multiple parents. The `show` command renders this as an indented tree, marking repeated nodes with `(*)` where the subtree is already shown elsewhere. @@ -155,6 +155,7 @@ A **hierarchy edge** is a directional link connecting a child node to one or mor | `field` | `"parent"` | The frontmatter field that holds the wikilink(s) | | `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children (reversed direction) | | `multiple` | `false` | When `true`, the field holds an **array** of wikilinks rather than a single one | +| `selfRef` | `false` | When `true`, a node may have a parent of the same resolved type | Dangling wikilinks — edge field values that do not resolve to any known node — are reported as reference errors during validation. diff --git a/docs/rules.md b/docs/rules.md index cc4b319..614e754 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,93 +1,120 @@ # Executable Rules -Rules are JSONata expressions embedded in a schema's `_metadata.rules` block. Each rule is evaluated against applicable nodes at validation time and must return `true` to pass. Rules encode checks that JSON Schema structural validation cannot express — cross-node consistency, quantitative thresholds, and qualitative best practices. +Rules are JSONata expressions in schema metadata (`$metadata.rules`). They run after structural JSON Schema validation and let you enforce cross-node checks and workflow constraints. -For how rules fit into the broader schema metadata, see [docs/schemas.md](schemas.md). +For metadata structure and composition behavior, see [docs/schemas.md](schemas.md). -## Rule Categories +## Rule shape -Rules are grouped into categories under `_metadata.rules`. Categories are informational — they determine how violations are labelled and grouped in output, but do not affect how the rule is evaluated. Use `scope` to control evaluation mode. +Rules are a flat array. -| Category | Purpose | -|---|---| -| `validation` | Structural correctness — a violation means the node is incorrect and should be fixed | -| `coherence` | Cross-node checks — for flagging conflicts or contradictions between nodes | -| `workflow` | Process discipline checks — for keeping the tree in an operational working state (active counts, status consistency) | -| `bestPractice` | Advisory guidance — signals the space may benefit from additional work | - -## Rule Object Structure +```json5 +"rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } +] +``` | Field | Required | Description | |---|---|---| -| `id` | yes | Unique identifier (kebab-case) | -| `description` | yes | Human-readable description of what the rule checks | -| `check` | yes | JSONata expression that must evaluate to `true` to pass | -| `type` | no | If set, only applies to nodes of this resolved type | -| `scope` | no | Set to `'global'` to evaluate the rule once against the full node set | +| `id` | yes | Unique rule identifier | +| `category` | yes | `validation` \| `coherence` \| `workflow` \| `best-practice` | +| `description` | yes | Human-readable rule intent | +| `check` | yes | JSONata expression; must evaluate to `true` | +| `type` | no | Restrict rule to nodes of this `resolvedType` | +| `scope` | no | Use `"global"` to evaluate once for the whole space | +| `override` | no | Only for merge conflicts: allows later duplicate `id` to replace earlier | + +## Categories + +Categories label violations for reporting. They do not change expression execution semantics. + +| Category | Typical use | +|---|---| +| `validation` | Hard correctness constraints | +| `coherence` | Cross-node consistency checks | +| `workflow` | Process/operating-discipline checks | +| `best-practice` | Advisory quality checks | + +## Evaluation model -Rules without `scope: 'global'` are evaluated once per applicable node (all nodes, or only those matching `type`). A global rule is evaluated once and produces at most one violation for the space — use this for aggregate checks, like counts, across all nodes. +- Rules with `scope: "global"` run once. +- Other rules run per applicable node. +- If `type` is set, only nodes with matching `resolvedType` are evaluated. -## JSONata Expression Context +## Expression context -Each expression is evaluated once per applicable node with the following input: +Each evaluation receives: | Variable | Description | |---|---| -| `nodes` | Array of all nodes in the space | -| `current` | The node being evaluated | -| `parent` | First resolved parent node — absent if no parent was resolved. Provided for convenience with single-parent relationships; use `parents` for DAG hierarchies. | -| `parents` | Array of all resolved parent nodes — absent if no parents were resolved | +| `nodes` | All nodes in the space | +| `current` | Current node for this evaluation | +| `parent` | First resolved parent node (if any) | +| `parents` | All resolved parent nodes (if any) | -Nodes include all node properties (title, type, status, parent wikilink, etc.) plus resolved fields: `resolvedType` (canonical type after type alias resolution), `resolvedParentTitle` (first parent title), and `resolvedParentTitles` (array of all parent titles). +Useful resolved fields on nodes: +- `resolvedType` +- `resolvedParentTitle` +- `resolvedParentTitles` -Prefer `resolvedType` over `type` for type comparisons. When aliases are in use, `type` reflects the raw frontmatter value and may not match canonical names. +Prefer `resolvedType` over raw `type` so aliases are handled correctly. -### Referencing `current` inside predicates +## Predicate scoping (`$$`) -Inside a predicate (`nodes[...]`), bare names refer to fields on each item. Use `$$` (JSONata root) to reach outer-scope variables: +Inside `nodes[...]`, bare names refer to each candidate node. Use `$$` to reference outer variables: ```jsonata -// Count solutions whose parent title matches the current node's title $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) ``` -### `parent` vs `current.parent` +## Rule imports (`$ref`) -- `parent` — the resolved parent **node object**; absent if the parent was not found in the space -- `current.parent` — the raw wikilink string from frontmatter (e.g. `[[My Outcome]]`) +`$metadata.rules` can include `$ref` entries that import: +- one rule +- a rule-set object with `rules: []` -Use `$exists(parent)` to test whether the current node has a resolved parent: +Example: -```jsonata -$exists(parent) = false // true for root nodes +```json5 +"rules": [ + { "$ref": "ost-tools://rule-pack#/$defs/workflowRule" }, + { "$ref": "ost-tools://rule-pack#/$defs/coreRuleSet" } +] ``` -## Examples +Imported entries are normalized into the same flat runtime list. + +## Merge and conflict behavior + +When metadata is composed across `$ref`: +- Rules are merged by `id`. +- Different payloads for the same `id` are an error by default. +- A later rule may replace an earlier one only with `override: true`. + +Example override: -```json +```json5 { - "workflow": [ - { - "id": "active-outcome-count", - "description": "Only one outcome should be active at a time", - "scope": "global", - "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" - }, - { - "id": "active-node-parent-active", - "description": "An active node's parent should also be active", - "check": "current.status != 'active' or $exists(parent) = false or parent.status = 'active'" - } - ], - "bestPractice": [ - { - "id": "solution-quantity", - "description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity", - "type": "opportunity", - "check": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3" - } - ] + "id": "active-outcome-count", + "override": true, + "category": "workflow", + "description": "Require exactly one active outcome", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) = 1" } ``` -The first workflow rule uses `scope: 'global'` — evaluated once against the whole space, producing at most one violation. The second runs per-node with no `type` filter, checking every node. The best-practice rule only runs against `opportunity` nodes where status is `exploring` or `active`, using `resolvedParentTitle` to count child solutions. \ No newline at end of file +## Common patterns + +```jsonata +$exists(current.metric) = true +$count(current.sources) >= 1 +$count(nodes[resolvedType='outcome' and status='active']) <= 1 +current.status != 'active' or $exists(parent) = false or parent.status = 'active' +``` diff --git a/docs/schema-dialect-update-proposal.md b/docs/schema-dialect-update-proposal.md new file mode 100644 index 0000000..d6422e1 --- /dev/null +++ b/docs/schema-dialect-update-proposal.md @@ -0,0 +1,496 @@ +# Schema Dialect update Proposal + +Date: 2026-03-10 +Status: Implemented in codebase (Stages A-D complete; smoke-test follow-up pending) + +## Why this doc + +Issue #28 opened the door to reorganize schema metadata, rules, and composition behavior. This proposal now serves as both the decision record and implementation tracker for the updated shape that has landed. + +## Goals + +- Improve composability across schema files and partials. +- Define explicit metadata/rule merge semantics. +- Keep schema editing friendly in IDEs and generic JSON Schema tools. +- Reduce duplicate model declarations between TypeScript runtime and JSON schema metadata contracts. +- Keep rule authoring simple for humans and agent workflows. + +## Non-goals + +- Backward compatibility with legacy `"$defs._metadata"`. +- Solving all qualitative rule expressiveness in one step. +- Replacing AJV/JSON Schema with a different validation stack. + +## Implemented Snapshot + +- Top-level `"$metadata"` is the shipped keyword. +- Hierarchy shape is `"$metadata.hierarchy.levels"` with optional `allowSkipLevels`. +- Rules are a flat `"$metadata.rules"` array with per-rule `category`. +- Metadata is composed across `$ref` graph (DFS, root last) with deterministic merge semantics. +- Rule conflict policy is error by default; explicit `override: true` enables replacement. +- `$metadata.rules` supports `$ref` imports for specific rules and rule sets. +- Metadata contract source is `src/metadata-contract.ts` (`json-schema-to-ts` types), with generated metaschema artifact in `schemas/generated/_ost_tools_schema_meta.json`. + +## Decision Areas and Recommendations + +## 1) Keyword Name and Top-level Shape + +### Options + +- `"$metadata"` top-level object. +- `"metadata"` top-level object. +- `"$ost"` top-level object for explicit namespace ownership. + +### Recommendation + +Use `"$metadata"`, with room to move to `"$ost"` only if we add several ost-tools-specific keywords later. + +Why: + +- `"$"` signals dialect-level keyword rather than user data. +- Low churn from current issue #28 implementation. +- Still clear enough for authors today. + +### Decision and rationale +Agreed. `"$metadata"` +`"$ost"` is appealing but I'm still not final on this being named ost-tools forever given how far it's already gone beyond opportunity solution tree specific uses. Future sub-options: 1. rename project then choose a name, 2. stick with $metadata (it's honestly fine), 3. rename now to something more specific but not tied to project name, eg `"$validation"` or `"$validationMeta"` 4. Move to `"$ost"`. I'd be open to trying sub-option 3 sooner than later. + + +## 2) Hierarchy Shape + +### Current pain + +`allowSkipLevels` sits beside `hierarchy`, even though it semantically configures hierarchy behavior. + +### Recommendation + +Move to a structured hierarchy object: + +```json +"$metadata": { + "hierarchy": { + "levels": [ + "outcome", + { "type": "opportunity", "selfRef": true }, + "solution", + "assumption_test" + ], + "allowSkipLevels": false + } +} +``` + +Notes: + +- Keep level-entry shorthand as string or object. +- Keep runtime normalization defaults (`field`, `fieldOn`, `multiple`, `selfRef`) in code. +- Optional: include `default` annotations in metaschema for docs/editor hints, but do not rely on `default` for behavior. + +### Decision and rationale +Agreed. Implement +Additional input - I'm of a mind to sort out the weird `const { hierarchy, levels, typeAliases } = loadMetadata(resolvedSchemaPath);` pattern at the same time. The flat hierarchy string[] is a bit pointless and with a richer structure gets confusing. + +## 3) Rule Model: container vs per-rule category + +### Current pain + +Rules are grouped by container keys (`workflow`, `bestPractice`, etc.), which complicates composition and merging. + +### Recommendation + +Store rules as a flat array; make category an attribute. + +```json +"$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + }, + { + "id": "solution-quantity", + "category": "best-practice", + "type": "opportunity", + "check": "$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3" + } + ] +} +``` + +Benefits: + +- Easier merge logic. +- Easier filtering/reporting. +- Better extensibility for future attributes (`severity`, `tags`, `source`). + +### Decision and rationale +Agreed. Implement. + +## 4) Rule scope inside subschemas + +### Question + +Should rules be embeddable in specific object/type definitions? + +### Recommendation + +Phase this in later, not in these updates. + +In scope: + +- Keep canonical executable rule location in `"$metadata.rules"`. +- Add `type`/scope filters there for now. + +Future candidate: + +- Introduce `"$rules"` on subschema nodes. +- Compiler step extracts/normalizes these into the same runtime rule list. + +Rationale: + +- Avoid changing storage model and extraction model at the same time. +- Preserve simple mental model for schema authors during migration. + +### Decision and rationale +Agreed. No action. + +## 5) Composability and merge semantics + +### Current pain + +Metadata merge behavior is implicit and fragile. + +### Recommendation + +Define deterministic merge rules in this update: + +- Metadata sources: traverse `$ref` graph from root schema (DFS), then apply root schema metadata last. +- `hierarchy`: exactly one provider allowed; error if multiple providers define it. +- `aliases`: shallow merge by key; later provider wins. +- `rules`: concatenate, then dedupe by `id`; later provider wins on conflict. +- Validation: fail fast on duplicate rule ids with incompatible payload unless override is explicit. + +This gives predictable composition without inventing a second inheritance mechanism. + +### Composition patterns to choose from + +Below are viable patterns for how schema authors can compose metadata/rules across files. + +### Pattern A: Single metadata owner (root-only) + +- Rule: only root schema may define `"$metadata"`. +- Refs are for structural schema content only. + +Pros: + +- Minimal ambiguity and simplest implementation. +- Easy mental model for authors. + +Cons: + +- Weak reuse of shared rule packs. +- Tends toward copy/paste for metadata blocks. + +### Pattern B: Multi-source metadata via `$ref` graph (recommended) + +- Rule: referenced schemas may define `"$metadata"` fragments. +- Loader walks `$ref` graph, collects metadata, applies deterministic merge. + +Pros: + +- Strong composability and reuse. +- Enables shared rule libraries and overlays. + +Cons: + +- Needs clear merge and conflict semantics. +- Slightly harder to reason about without good tooling/docs. + +### Pattern C: Explicit metadata imports (custom field) + +- Rule: keep `"$metadata"` root-only, add explicit import list inside it, e.g.: + +```json +"$metadata": { + "imports": [ + "ost-tools://rules/workflow-core", + "ost-tools://rules/strict-ost" + ], + "rules": [...] +} +``` + +Pros: + +- Explicit import intent; easier to audit. +- Avoids overloading structural `$ref` traversal. + +Cons: + +- Introduces new custom mechanism parallel to `$ref`. +- More design and tooling work. + +### Recommended choice + +Use Pattern B now, with strict semantics: + +- Source order: DFS over `$ref` graph, then root schema last. +- `hierarchy`: exactly one provider; error if multiple. +- `aliases`: shallow merge; later wins by key. +- `rules`: merge by `id`. +- Rule collision policy: default is **error** unless incoming rule declares `"override": true`. + +This gives reuse with explicit safety around accidental overrides. + +### Example: composable rule pack via `$ref` + +```json +{ + "$id": "ost-tools://_rules_workflow", + "$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] + } +} +``` + +```json +{ + "$id": "ost-tools://strict_ost", + "allOf": [{ "$ref": "ost-tools://_rules_workflow" }], + "$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "override": true, + "category": "workflow", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) = 1" + } + ] + } +} +``` + +### Decision and rationale +Round 1: I think this is pragmatic. Before proceeding though, let's consider alternatives. I'm not clear yet on where and how refs would be used to include things from other places - are they rules, lists of rules, chunks of metadata? What are the tradeoffs? Add more detail to this section (or in a separate doc) with examples of options. + +Round 2: I agree that option B is directionally right. Can it also be used with `$ref` to import a specific rule (froma `$def` maybe)? + +## 6) Schema ID convention and IDE compatibility + +### Current pain + +Custom `ost-tools://` schema identifiers are not resolvable by common editors, causing warnings. + +### Recommendation + +Use a mixed strategy: + +- Keep `$schema` as a resolvable HTTPS URL to the generated metaschema: + - `https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json` +- Keep bundled schema/partial `$id` values in the internal `ost-tools://...` namespace for stable CLI registry resolution. +- Document that editor-side mappings (`json.schemas`) are optional conveniences and not a correctness mechanism. + +Notes: + +- This reflects the shipped behavior and avoids coupling runtime correctness to editor configuration. +- The CLI remains authoritative for resolution semantics. + +### Decision and rationale +Round 1: Tell me more about json.schemas. Assuming I don't get a domain and hosted metaschemas anytime soon we're already in a pickle. Can these settings solve for `ost-tools://...` references in this current project with vs code settings? If so, let's try it. If that works it might suffice to document the settings needed for schema developers (they'll need to locate the bundled schemas but that's doable). + +Round 2: This proved to be a disaster and there's no good way to get vs code to cooperate. I've reverted to much like what we had. The code still appears to validate, though I note a very worrying `METADATA_KEYWORD_SCHEMA` in schema.ts. What's the point of that? The whole point of _ost_tools_schema_meta.json was to not hardcode the schema again. I'm expecting this will be addressed when we work on item 7 below. + +## 7) Single source of truth for types and runtime validation + +### Current pain + +Model declarations are duplicated between TypeScript interfaces and metaschema contracts. + +### Recommendation + +Use TS-authored metadata contract as the source of truth (`as const`) and infer types with `json-schema-to-ts`, then generate/export the metaschema artifact from that source. + +Shipped workflow: + +- Author metadata schema contract in `src/metadata-contract.ts`. +- Infer TS types from that contract via `json-schema-to-ts`. +- Validate runtime metadata via AJV using the same contract object. +- Generate `schemas/generated/_ost_tools_schema_meta.json` from the in-code contract (`bun run generate:schema-meta`). + +This keeps one authoritative contract while still producing a portable schema artifact for tooling. + +### Alternatives snapshot + +### `json-schema-to-typescript` + +- Input: JSON schema files. +- Output: generated `.d.ts`/TS types. +- Best fit when schemas remain authored as JSON/JSON5. + +### `json-schema-to-ts` + +- Input: schema object literals in TS (`as const`). +- Output: inferred types in type-space only. +- Strong type ergonomics, but pushes schemas into TS source (or requires conversion step). + +### `@profusion/json-schema-to-typescript-definitions` + +- Similar direction to `json-schema-to-ts` patterns. +- Useful if schema authoring is TS-first and codegen files are undesirable. + +### `quicktype` + +- Broad tool (schemas, samples, multiple languages, optional runtime helpers). +- Useful if cross-language generation becomes a goal; heavier than needed for current scope. + +### Recommendation for this repo + +Implemented approach is `json-schema-to-ts` with contract-in-code, plus generated metaschema artifact for compatibility. + +### Decision and rationale +Round 1: I agree with this approach, and like it. Next step is to research alternatives. + +Round 2: Given where things went with previous items I'm inclined to go with `json-schema-to-ts` and move _ost_tools_schema_meta into code. + +## Proposed updated metadata example + +```json +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools://general", + "$metadata": { + "hierarchy": { + "levels": [ + "vision", + "mission", + { "type": "goal", "selfRef": true }, + { "type": "opportunity", "selfRef": true }, + { "type": "solution", "selfRef": true }, + "experiment" + ], + "allowSkipLevels": false + }, + "aliases": { + "outcome": "goal", + "assumption_test": "experiment", + "test": "experiment" + }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='goal' and status='active']) <= 1" + } + ] + } +} +``` + +## Migration plan (breaking) + +- Step 1: Freeze updated dialect contract and merge semantics in docs. +- Step 2: Update metaschema and loader for new hierarchy + rules-array shape. +- Step 3: Migrate bundled schemas (`general`, `strict_ost`, partials). +- Step 4: Update `docs/rules.md`, schema authoring skill docs, and examples. +- Step 5: Add schema composition tests covering merge and conflict behavior. +- Step 6: Keep mixed ID strategy (`$schema` HTTPS + internal `ost-tools://` IDs) and document editor constraints. +- Step 7: Keep metadata contract in code (`json-schema-to-ts`) and generate metaschema artifact from that source. + +## Execution checklist (staged) + +Use this as the implementation tracker. Check items off as they land. + +### Stage A - Contract and metadata shape + +- [x] Freeze final updated metadata contract in this doc (`"$metadata"` retained, hierarchy object shape, rules-array model). +- [x] Replace `loadMetadata` return shape to remove redundant `hierarchy: string[]` and rely on `metadata.hierarchy.levels`. +- [x] Update all call sites that destructure `hierarchy/levels/typeAliases` to use the new shape. +- [x] Move metadata schema contract into a single source in code (TS `as const`) and infer types (`json-schema-to-ts`) for runtime-facing metadata types. +- [x] Remove duplicated hardcoded metadata schema declarations (including `METADATA_KEYWORD_SCHEMA` duplication against the metadata contract source). + +Stage A acceptance criteria: + +- [x] `bun run test` passes. +- [x] `bun run lint` passes. +- [x] No remaining code path depends on legacy `"$defs._metadata"`. + +### Stage B - Rules model migration + +- [x] Convert schema metadata from grouped rule containers to flat `rules: Rule[]` with `category` attribute. +- [x] Update rule evaluation pipeline to iterate over flat rules. +- [x] Preserve current validation behavior for `scope`, `type`, and check expression execution. +- [x] Migrate bundled schemas (`general`, `_ost_strict`) to new rules shape. +- [x] Update tests/fixtures for the flat rules representation. + +Stage B acceptance criteria: + +- [x] `validate-rules` tests confirm behavior parity with prior rule execution. +- [x] Rule category output remains stable in CLI output. +- [x] `bun run test` and `bun run lint` pass. + +### Stage C - Composability and merge semantics + +- [x] Implement metadata collection across `$ref` graph (DFS) with root metadata applied last. +- [x] Enforce single-provider hierarchy (error on multiple providers). +- [x] Implement aliases merge (shallow merge, later provider wins). +- [x] Implement rules merge by `id` with conflict policy: +- [x] default conflict is error; +- [x] allow override only when incoming rule explicitly sets `"override": true`. +- [x] Add support for importing specific rules/rule sets via `$ref` targets (for example under `$defs`) and normalize into merged rule list. +- [x] Add composition-focused tests covering merge order, conflict errors, and targeted rule imports. + +Stage C acceptance criteria: + +- [x] Composition tests demonstrate deterministic merge outcomes. +- [x] Conflicting rules without explicit override fail with clear error messaging. +- [x] `bun run test` and `bun run lint` pass. + +### Stage D - Documentation and release prep + +- [x] Update docs (`README.md`, `docs/schemas.md`, `docs/rules.md`, `docs/concepts.md`) to final updated structure. +- [x] Update agent-facing docs (`AGENTS.md`, `skills/ost-tools/references/*`) to match updated structure. +- [x] Add authoring examples for composable rules (rule packs and override examples). +- [x] Document editor expectations and constraints (do not depend on fragile VS Code-only mappings for correctness). +- [x] Create a concise release checklist entry for breaking-schema migration guidance. + +Stage D acceptance criteria: + +- [x] All docs and examples are internally consistent with the shipped schema shape. +- [ ] Smoke test paths still validate real spaces. +- [x] Ready-to-release changelog notes drafted. + +### Suggested PR boundaries + +- [ ] PR A: Stage A only (contract + metadata shape). +- [ ] PR B: Stage B only (flat rules model). +- [ ] PR C: Stage C only (composition + merge + rule imports). +- [ ] PR D: Stage D only (docs + release prep). + +## Draft changelog notes (release prep) + +- **Breaking**: schema metadata now uses top-level `$metadata` with `hierarchy.levels` (legacy `$defs._metadata` unsupported). +- **Breaking**: `allowSkipLevels` moved under `$metadata.hierarchy`. +- **Breaking**: rule declarations migrated to flat `rules[]` with per-rule `category`. +- **New**: metadata composition across `$ref` graph with deterministic merge semantics. +- **New**: rule imports via `$metadata.rules` `$ref` entries and explicit rule override (`override: true`) behavior. +- **Internal**: metadata contract source consolidated in `src/metadata-contract.ts` with generated metaschema artifact. + +## Open questions for decision + +- Keep `"$metadata"` or rename to `"$ost"` now while breaking changes are allowed? A: Keep $metadata +- Should hierarchy support multiple named hierarchies, or only one? A: one +- Should duplicate rule IDs always override, or be an error unless `override: true` is set? A: I like override, stick with that +- Do we want first-class rule severity (`error`, `warn`, `info`) in thes updates or later? A: Yes, but later +- Do we host metaschemas publicly immediately, or ship local mapping first then host later? A: already hosted diff --git a/docs/schemas.md b/docs/schemas.md index b3b24a3..1450d81 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -1,14 +1,16 @@ # Schemas -This document explains the schema system and the schemas included with ost-tools. +This document explains schema usage, metadata shape, and composition semantics in `ost-tools`. ## Overview -A **schema** defines the valid structure for nodes in a `space`: the fields, types, constraints, and validation rules for each entity type. Schemas use JSON Schema format and support composability through shared definitions. +A **schema** defines the valid structure for nodes in a `space`: entity types, field constraints, hierarchy behavior, type aliases, and executable rules. -## Using Schemas +`ost-tools` uses JSON Schema Draft-07 plus a custom top-level `$metadata` keyword. -To specify a schema for a space, add the `schema` field to your space entry in `config.json`: +## Selecting a schema + +Set `schema` in config: ```json { @@ -18,185 +20,184 @@ To specify a schema for a space, add the `schema` field to your space entry in ` } ``` -You can also specify a schema per-command using the `--schema` flag: +Or pass it per command: ```bash bun run src/index.ts validate my-space --schema schemas/strict_ost.json ``` -If no schema is specified, the default `schemas/general.json` is used. +Resolution order: `--schema` CLI flag > space `schema` > global `schema` > bundled `schemas/general.json`. -## Available Schemas +## Bundled schemas ### `general.json` (default) -A flexible, opinionated schema supporting a multi-level strategy hierarchy alongside Opportunity Solution Tree types. This schema is designed for personal and strategic planning use cases. - -**Node types:** -- `vision` — Root-level vision statement (no parent) -- `mission` — Mission statement with optional vision parent -- `goal` or `outcome` — Goal or outcome node -- `opportunity` — Opportunity with optional numeric assessments (impact, feasibility, resources) -- `solution` — Solution with optional numeric assessments -- `experiment`|`assumption_test`|`test` — Experiment/assumption test +Flexible planning schema spanning strategy + OST-like flow. -**Features:** -- Allows `vision`, `mission`, `goal` hierarchy for strategic planning -- Optional numeric assessment fields (1-5 scale) for opportunities and solutions -- Type aliases: alternative terms accepted for some types -- `additionalProperties: true` allows extensibility - -**Use when:** -- You want a flexible planning tool that combines strategy hierarchy with OST concepts -- You're using ost-tools for personal planning or broader strategic work +Main types: +- `vision` +- `mission` +- `goal` (alias: `outcome`) +- `opportunity` +- `solution` +- `experiment` (aliases: `assumption_test`, `test`) ### `strict_ost.json` -A schema following the canonical 4-level Opportunity Solution Tree structure, based on Teresa Torres' methodology as described in "Continuous Discovery Habits" (2021) and at producttalk.org. - -**Node types:** -- `outcome` — Root-level outcome (product metric, no parent) -- `opportunity` — Customer pain points, desires, and needs (can be nested) -- `solution` — Solutions to explore for target opportunities -- `assumption_test` — Assumption tests for solutions - -**Fields:** -- `outcome` requires a `metric` field for the product metric -- `opportunity` requires a `source` field to track research origin -- `assumption_test` requires an `assumption` field and accepts an optional `category` - -**Use when:** -- You want to follow Teresa Torres' OST methodology strictly -- You're working on product discovery with a team -- You need research-grounded opportunities with source tracking - -## Shared Definitions - -### `_shared.json` +Canonical 4-level OST structure. -Common definitions used across multiple schemas: +Main types: +- `outcome` +- `opportunity` +- `solution` +- `assumption_test` -- `baseNodeProps` — Base properties (title, content, tags) -- `ostEntityProps` — Common OST entity properties (status, summary, status_tweet) -- `status` — Lifecycle status enum -- `priority` — Priority level enum (p1-p4) -- `assessment` — Numeric assessment (1-5) -- `wikilink` — Wikilink pattern for parent references +This schema composes shared structural defs and strict metadata/rules from partials. -### `_strict.json` +## Metadata dialect -Shared definitions specific to the strict OST schema: +Schemas use this metaschema URL: -- `outcomeProps` — Outcome-specific properties (metric) -- `opportunityProps` — Opportunity properties (source) -- `assumptionTestProps` — Assumption test properties (assumption, category) -- `_metadata` — Hierarchy, type aliases, and executable rules for strict OST validation +- `https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json` -## Schema Metadata - -The `_metadata` block in `$defs` carries non-structural validation configuration. It is not a JSON Schema construct — the tooling reads it separately from the schema validator. +Top-level metadata shape: ```json5 { - "$defs": { - "_metadata": { - "hierarchy": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"], - "aliases": { "test": "assumption_test" }, - "allowSkipLevels": false, - "rules": { ... } - } + "$metadata": { + "hierarchy": { + "levels": [ + "outcome", + { "type": "opportunity", "selfRef": true }, + "solution", + "assumption_test" + ], + "allowSkipLevels": false + }, + "aliases": { + "experiment": "assumption_test" + }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] } } ``` -| Field | Type | Description | -|---|---|---| -| `hierarchy` | `(string \| HierarchyLevel)[]` | Ordered list of types from root to leaf; plain strings use defaults | -| `aliases` | `Record` | Maps alternative type names to canonical types | -| `allowSkipLevels` | `boolean` | When `true`, a node may have any ancestor type above it, not just the immediate parent | -| `rules` | `object` | Executable validation rules — see [docs/rules.md](rules.md) | +### `$metadata` fields -### Hierarchy levels +| Field | Type | Notes | +|---|---|---| +| `hierarchy` | object | Optional per provider; at most one provider may define it after composition | +| `hierarchy.levels` | `(string \| HierarchyLevel)[]` | Ordered root→leaf types | +| `hierarchy.allowSkipLevels` | `boolean` | Optional; allows parent to be any ancestor level | +| `aliases` | `Record` | Optional type alias map | +| `rules` | `Rule[]` | Optional flat rule array | -Each entry in `hierarchy` may be a plain string (shorthand for `{ type: "..." }`) or a `HierarchyLevel` object: +`HierarchyLevel` options: -```json5 -"hierarchy": [ - { "type": "Outcomes" }, // root — no edge config - { "type": "Opportunities", "field": "outcome" }, // child has single wikilink in 'outcome' field instead of 'parent' - { - "type": "Solution", - "field": "has_solutions", - "fieldOn": "parent", // parent (Opportunities) has the field pointing to children - "multiple": true // field is an array of wikilinks - }, - { - "type": "Experiments", - "field": "informs", - "multiple": true // child (Experiments) has array of parent wikilinks instead of single - } -] -``` - -| Level option | Default | Meaning | +| Option | Default | Meaning | |---|---|---| -| `type` | — | Canonical type name | -| `field` | `"parent"` | Frontmatter field holding the wikilink(s) | -| `fieldOn` | `"child"` | `"parent"` means the field is on the **parent** node and points to children | -| `multiple` | `false` | When `true`, the field is an array of wikilinks | -| `selfRef` | `false` | When `true`, a node of this type may have a parent of the same type (e.g. nested opportunities) | +| `type` | required | Canonical type name | +| `field` | `"parent"` | Frontmatter field holding wikilink(s) | +| `fieldOn` | `"child"` | `"parent"` means the parent points to children | +| `multiple` | `false` | Field contains array of wikilinks | +| `selfRef` | `false` | Allows same-type parent | + +String shorthand (`"goal"`) normalizes to: +`{ "type": "goal", "field": "parent", "fieldOn": "child", "multiple": false, "selfRef": false }`. -Plain string entries normalize to `{ type: "...", field: "parent", fieldOn: "child", multiple: false, selfRef: false }`. +## Composition and merge semantics -### Hierarchy validation +Metadata is composed across the `$ref` graph with deterministic behavior: -The validator checks every node type and its parent type(s) against the hierarchy order, with violations flagged. A node may have multiple resolved parents (layered DAG); each is checked independently. +1. Traverse external `$ref` graph in DFS order. +2. Apply root schema metadata last. -`allowSkipLevels` and per-level `selfRef` modify the strictness. For example, `{ "type": "opportunity", "selfRef": true }` permits nested opportunity trees. +Merge rules: +- `hierarchy`: zero or one provider allowed. Multiple providers error. +- `aliases`: shallow merged; later provider wins per key. +- `rules`: merged by `id`. +- Duplicate rule `id` with different payload errors by default. +- A later rule may replace an earlier one only with `"override": true`. -### Type aliases +When no provider defines `hierarchy`, hierarchy-based behavior is disabled (`show` tree shape, hierarchy validation, parent-edge checks). `space_on_a_page` parsing still requires hierarchy and will error without it. -`aliases` maps alternative type names to canonical types. A node with `type: outcome` and `"aliases": { "outcome": "goal" }` will have `resolvedType: goal` and be treated as a `goal` everywhere — in hierarchy checks, rule type filters, and output. +### Rule imports via `$ref` -## Schema Composability +Inside `$metadata.rules`, entries can be inline rules or `$ref` imports: -Schemas are designed to be composable. You can create custom schemas by: +```json5 +"rules": [ + { "$ref": "ost-tools://my-pack#/$defs/workflowRule" }, + { "$ref": "ost-tools://my-pack#/$defs/ruleSet" } +] +``` -1. Creating a new `.json` file in the `schemas/` directory -2. Using `$ref` to reference shared definitions from `_shared.json` or other schemas. This works transitively, including nested `allOf` compositions. -3. Defining your own node types and constraints +Import targets may be: +- a single rule object +- an object containing `rules: []` -Referencing another schema file merges its `$defs` into the compiled schema, including any `_metadata` block. If multiple referenced files each define `_metadata`, only the last one merged is used — `rules` arrays are not combined across sources. +Imported rules are normalized into one executable flat list before validation. -Example of referencing shared definitions: +### Override example ```json5 { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "ost-tools://my-custom-schema", - "oneOf": [ - { - "type": "object", - "allOf": [ - { "$ref": "ost-tools://_shared#/$defs/baseNodeProps" }, - { "$ref": "ost-tools://_shared#/$defs/ostEntityProps" } - ], - "properties": { - "type": { "const": "my-custom-type" } - }, - "required": ["type"], - "additionalProperties": true - } - ] + "$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "override": true, + "category": "workflow", + "description": "Require exactly one active outcome", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) = 1" + } + ] + } } ``` -## JSON5 Format +## Partials and `$ref` + +- Files starting with `_` are auto-loaded partials. +- Both bundled partials and local schema-directory partials are registered. +- Local partial `$id` values must not collide with bundled IDs. +- `$ref` resolution is transitive across files. +- Partials with no `$metadata` should prefer `$schema: "http://json-schema.org/draft-07/schema#"` so they validate standalone as plain JSON Schema fragments. + +## Editor expectations + +Use the shipped metaschema URL in `$schema` for best cross-tool behavior. + +Notes: +- Custom `$id` values like `ost-tools://...` are still supported by the CLI registry. +- Some generic editors may not resolve custom URI schemes for `$ref`; CLI behavior is authoritative. +- Do not rely on editor-only mappings for runtime correctness. + +## Breaking migration checklist (legacy -> current) + +For schemas migrating from older metadata structure: + +1. Move any legacy metadata from `$defs._metadata` to top-level `$metadata`. +2. Convert `hierarchy` array to `hierarchy.levels` object shape. +3. Move `allowSkipLevels` under `hierarchy`. +4. Convert grouped rule containers to flat `rules[]` with per-rule `category`. +5. If duplicate rule IDs are intentional, mark later rules with `override: true`. +6. Re-run `bunx ost-tools schemas show --space ` and `validate` to confirm merged metadata/rules. + +## JSON5 support -Schema files support JSON5 format, allowing inline documentation via `//` comments and more flexible formatting. +Schema files are parsed as JSON5 (`//` comments and trailing commas are allowed). -## Further Reading +## Further reading -- [Teresa Torres' work on Opportunity Solution Trees](https://producttalk.org/2021/02/using-opportunity-solution-trees/) -- "Continuous Discovery Habits" (2021) by Teresa Torres -- [JSON Schema specification](https://json-schema.org/) +- [Executable Rules](rules.md) +- [JSON Schema](https://json-schema.org/) diff --git a/package.json b/package.json index fed4522..5f8a4fa 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "bun run clean && tsc && cp -r schemas dist/ && chmod +x dist/index.js", + "generate:schema-meta": "bun run scripts/generate-schema-meta.ts", + "build": "bun run clean && bun run generate:schema-meta && tsc && cp -r schemas dist/ && chmod +x dist/index.js", "prepublishOnly": "bun run build", "validate": "bun run src/index.ts validate", "diagram": "bun run src/index.ts diagram", @@ -28,7 +29,8 @@ "@biomejs/biome": "^2.4.6", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", - "@types/node": "^25.3.3" + "@types/node": "^25.3.3", + "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "typescript": "^5" diff --git a/schemas/_ost_strict.json b/schemas/_ost_strict.json index a55006d..44a7e46 100644 --- a/schemas/_ost_strict.json +++ b/schemas/_ost_strict.json @@ -1,42 +1,44 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", "$id": "ost-tools://_ost_strict", // Shared definitions for the strict OST (Opportunity Solution Tree) schema. // This schema follows Teresa Torres' canonical 4-level structure as described in // "Continuous Discovery Habits" (2021) and at producttalk.org. - "$defs": { - "_metadata": { - "hierarchy": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"], - "rules": { - "workflow": [ - { - "id": "active-outcome-count", - "description": "Only one outcome should be active at a time", - "scope": "global", - "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" - }, - { - "id": "active-opportunity-count", - "description": "Only one target opportunity should be active at a time", - "scope": "global", - "check": "$count(nodes[resolvedType='opportunity' and status='active']) <= 1" - }, - { - "id": "active-node-parent-active", - "description": "An active node's parent should also be active", - "check": "current.status != 'active' or $exists(parent) = false or parent.status = 'active'" - } - ], - "bestPractice": [ - { - "id": "solution-quantity", - "description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity", - "type": "opportunity", - "check": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3" - } - ] - } + "$metadata": { + "hierarchy": { + "levels": ["outcome", { "type": "opportunity", "selfRef": true }, "solution", "assumption_test"] }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + }, + { + "id": "active-opportunity-count", + "category": "workflow", + "description": "Only one target opportunity should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='opportunity' and status='active']) <= 1" + }, + { + "id": "active-node-parent-active", + "category": "workflow", + "description": "An active node's parent should also be active", + "check": "current.status != 'active' or $exists(parent) = false or parent.status = 'active'" + }, + { + "id": "solution-quantity", + "category": "best-practice", + "description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity", + "type": "opportunity", + "check": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3" + } + ] + }, + "$defs": { "outcomeProps": { "type": "object", "description": "Properties for an Outcome node (product metric, not vision/mission)", diff --git a/schemas/general.json b/schemas/general.json index c2ed154..7bd63c1 100644 --- a/schemas/general.json +++ b/schemas/general.json @@ -1,23 +1,23 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", "$id": "ost-tools://general", "title": "Strategy and opportunity ladder", "description": "Validates frontmatter for space node files", - "$defs": { - "_metadata": { - "hierarchy": [ + "$metadata": { + "hierarchy": { + "levels": [ "vision", "mission", { "type": "goal", "selfRef": true }, { "type": "opportunity", "selfRef": true }, { "type": "solution", "selfRef": true }, "experiment" - ], - "aliases": { - "outcome": "goal", - "assumption_test": "experiment", - "test": "experiment" - } + ] + }, + "aliases": { + "outcome": "goal", + "assumption_test": "experiment", + "test": "experiment" } }, "oneOf": [ diff --git a/schemas/generated/_ost_tools_schema_meta.json b/schemas/generated/_ost_tools_schema_meta.json index 6c8d1cd..91f01a3 100644 --- a/schemas/generated/_ost_tools_schema_meta.json +++ b/schemas/generated/_ost_tools_schema_meta.json @@ -37,10 +37,7 @@ "minLength": 1 }, "fieldOn": { - "enum": [ - "child", - "parent" - ] + "enum": ["child", "parent"] }, "multiple": { "type": "boolean" @@ -49,9 +46,7 @@ "type": "boolean" } }, - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false } ] @@ -61,9 +56,7 @@ "type": "boolean" } }, - "required": [ - "levels" - ], + "required": ["levels"], "additionalProperties": false }, "aliases": { @@ -76,51 +69,54 @@ "rules": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "category": { - "enum": [ - "validation", - "coherence", - "workflow", - "best-practice" - ] - }, - "description": { - "type": "string", - "minLength": 1 - }, - "check": { - "type": "string", - "minLength": 1 - }, - "type": { - "type": "string", - "minLength": 1 + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "category": { + "enum": ["validation", "coherence", "workflow", "best-practice"] + }, + "description": { + "type": "string", + "minLength": 1 + }, + "check": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "scope": { + "enum": ["global"] + }, + "override": { + "type": "boolean" + } + }, + "required": ["id", "category", "description", "check"], + "additionalProperties": false }, - "scope": { - "enum": [ - "global" - ] + { + "type": "object", + "properties": { + "$ref": { + "type": "string", + "minLength": 1 + } + }, + "required": ["$ref"], + "additionalProperties": false } - }, - "required": [ - "id", - "category", - "description", - "check" - ], - "additionalProperties": false + ] } } }, - "required": [ - "hierarchy" - ], "additionalProperties": false } } diff --git a/schemas/strict_ost.json b/schemas/strict_ost.json index f57d437..019af9e 100644 --- a/schemas/strict_ost.json +++ b/schemas/strict_ost.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", "$id": "ost-tools://strict_ost", "title": "Opportunity Solution Tree (Strict)", // Follows Teresa Torres' 4-level OST structure as described in "Continuous Discovery Habits" (2021) diff --git a/scripts/generate-schema-meta.ts b/scripts/generate-schema-meta.ts new file mode 100644 index 0000000..2704d83 --- /dev/null +++ b/scripts/generate-schema-meta.ts @@ -0,0 +1,12 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { OST_TOOLS_DIALECT_META_SCHEMA } from '../src/metadata-contract.js'; + +const OUTPUT_PATH = new URL('../schemas/generated/_ost_tools_schema_meta.json', import.meta.url); + +// Ensure the generated directory exists +await mkdir(new URL('.', OUTPUT_PATH), { recursive: true }); + +// Write the schema as formatted JSON +await writeFile(OUTPUT_PATH, `${JSON.stringify(OST_TOOLS_DIALECT_META_SCHEMA, null, 2)}\n`); + +console.log(`Generated schema metadata: ${OUTPUT_PATH.pathname}`); diff --git a/skills/ost-tools/SKILL.md b/skills/ost-tools/SKILL.md index 0e79b56..47d0a52 100644 --- a/skills/ost-tools/SKILL.md +++ b/skills/ost-tools/SKILL.md @@ -71,7 +71,7 @@ inspect exactly what JSONata rules see when a rule fires unexpectedly. **Rule violations on every node of a type** — the rule may be too strict or misconfigured. Use `dump` to verify what the rule actually sees in the `current` object, then adjust the rule in the schema. -**`show`/`diagram` show only orphans and non-hierarchy types** — the schema's `_metadata.hierarchy` may not have edge configuration for the space's relationship fields. Use `schemas show --space ` to check the hierarchy definition. Each non-root level can define a `field` entry (overriding default `parent:` field (and optionally `fieldOn: "parent"` / `multiple: true`) to wire up the correct relationship field. +**`show`/`diagram` show only orphans and non-hierarchy types** — the schema's `$metadata.hierarchy` may not have edge configuration for the space's relationship fields. Use `schemas show --space ` to check the hierarchy definition. Each non-root level can define a `field` entry (overriding default `parent:` field (and optionally `fieldOn: "parent"` / `multiple: true`) to wire up the correct relationship field. ## Troubleshooting Common Errors @@ -80,10 +80,10 @@ what the rule actually sees in the `current` object, then adjust the rule in the | `File has no type field` | Discriminator field missing or named differently | Check `fieldMap` in config or add `type` to frontmatter | | `must have property 'X'` | Required schema property missing | Check `schemas show --space` to see required properties | | `could not find node '[[Title]]'` | Broken wikilink | Fix the title in the link or ensure the target file exists and has that title | -| `JSONata error: ...` | Syntax error in schema `_metadata.rules` | Verify the expression with `dump` and a JSONata tester | +| `JSONata error: ...` | Syntax error in schema `$metadata.rules` | Verify the expression with `dump` and a JSONata tester | ## References -- **`references/schema-authoring.md`** — schema file structure, `_metadata`, `fieldMap`, JSONata rules +- **`references/schema-authoring.md`** — schema file structure, `$metadata`, `fieldMap`, JSONata rules - **`references/schema-design.md`** — process for designing a schema from existing content -- **`references/commands.md`** — detailed CLI usage and examples \ No newline at end of file +- **`references/commands.md`** — detailed CLI usage and examples diff --git a/skills/ost-tools/references/commands.md b/skills/ost-tools/references/commands.md index 24e3576..8d1361a 100644 --- a/skills/ost-tools/references/commands.md +++ b/skills/ost-tools/references/commands.md @@ -14,7 +14,7 @@ Validates all `.md` files in the space against the JSON schema. For each file: - Skips files with no frontmatter or no `type` field (after `fieldMap` remapping) - Runs JSON schema validation - Runs reference checks (wikilinks → known node titles) -- Runs executable rules (JSONata expressions in `_metadata.rules`) +- Runs executable rules (JSONata expressions in `$metadata.rules`) - Checks hierarchy ordering **Scenarios:** @@ -36,8 +36,8 @@ bunx ost-tools show Prints a hierarchical tree of all nodes, indented by parent→child relationships. Useful for browsing structure, verifying parent links are correct, and spotting orphaned nodes. -Requires nodes to use the `parent` field with wikilinks. Spaces that express relationships -via other fields (e.g. `opportunity`, `solution`) will show a flat list. +Uses hierarchy edge config from `$metadata.hierarchy.levels` (`field`, `fieldOn`, `multiple`). +If those are misconfigured for your content, output will appear flatter than expected. ## dump diff --git a/skills/ost-tools/references/schema-authoring.md b/skills/ost-tools/references/schema-authoring.md index f71bb2a..637c2e0 100644 --- a/skills/ost-tools/references/schema-authoring.md +++ b/skills/ost-tools/references/schema-authoring.md @@ -1,33 +1,72 @@ # Schema Authoring Reference -Schema files use JSON Schema draft-07 with an ost-tools-specific `_metadata` block. -See `~/src/ost-tools/schemas/` for full examples (`general.json`, `strict_ost.json`). - -## `_metadata` (in `$defs`) - -```json -"_metadata": { - "hierarchy": [ - "outcome", - { "type": "opportunity" }, - { "type": "solution", "field": "parent", "selfRef": true }, - { "type": "assumption_test", "field": "assumptions", "fieldOn": "parent", "multiple": true } // Solutions list assumption_tests as an array of wikilinks under `assumptions:` field - ], +Schema files use a Draft-07-based dialect with top-level `$metadata`. +See `~/src/ost-tools/schemas/` for examples (`general.json`, `strict_ost.json`, `_ost_strict.json`). + +## `$metadata` (top-level) + +```json5 +"$metadata": { + "hierarchy": { + "levels": [ + "outcome", + { "type": "opportunity", "selfRef": true }, + "solution", + "assumption_test" + ], + "allowSkipLevels": false + }, "aliases": { "experiment": "assumption_test" }, - "allowSkipLevels": false, - "rules": { "validation": [...], "coherence": [...], "workflow": [...], "bestPractice": [...] } + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Only one outcome should be active at a time", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] } ``` -`hierarchy` is required. Plain strings are shorthand — `"outcome"` equals `{ "type": "outcome", "field": "parent", "fieldOn": "child", "multiple": false, "selfRef": false }`. Types not in `hierarchy` can still be defined and related to other types — they just won't participate in hierarchy order checks. +`hierarchy.levels` is required only when the schema needs hierarchy-based behavior (tree rendering, hierarchy validation, `space_on_a_page` parsing). String entries are shorthand for default edge settings: +`{ "type": "...", "field": "parent", "fieldOn": "child", "multiple": false, "selfRef": false }`. + +Use object entries to override defaults: +- `field` for relationship field name +- `fieldOn: "parent"` when parent points to children +- `multiple: true` for array wikilinks +- `selfRef: true` for same-type parent links + +Rules are a flat array. Categories are labels only (`validation`, `coherence`, `workflow`, `best-practice`). + +## Metadata composition semantics -Use object entries to configure non-default edges: `field` changes the frontmatter field name; `fieldOn: "parent"` means the parent node has the field pointing to children (reversed direction); `multiple: true` means the field is an array of wikilinks. +Across `$ref` graphs: +- metadata providers are traversed DFS, root metadata applied last +- zero or one provider may define `hierarchy` +- `aliases` shallow-merge (later wins) +- `rules` merge by `id` +- duplicate rule IDs with different payloads error unless later rule sets `override: true` -Rule categories are informational labels only; they don't change how rules are evaluated. +## Rule imports in `$metadata.rules` + +Rule entries may be inline or `$ref` imports: + +```json5 +"rules": [ + { "$ref": "ost-tools://my-rule-pack#/$defs/workflowRule" }, + { "$ref": "ost-tools://my-rule-pack#/$defs/ruleSet" } +] +``` + +Import targets can be: +- single rule object +- object with `rules: []` ## `fieldMap` in config -When content uses non-standard field names, remap them in the space config: +When content uses different field names, remap in space config: ```json5 { @@ -41,72 +80,47 @@ When content uses non-standard field names, remap them in the space config: } ``` -The schema always uses the **target** names after remapping. If `record_type` → `type`, -the schema uses `"type": { "const": "opportunity" }`. Document remapped fields in `$defs` -descriptions so maintainers understand the mapping. +Schema definitions use the mapped target names. ## Schema file notes -Schema files support **JSON5** format — `//` comments and trailing commas are allowed. -This is useful for documenting enum values and property intent inline. - -**Partial schemas:** files starting with `_` in the same directory as your schema are loaded -automatically and their `$defs` are available for `$ref`. Use these for reusable definition -groups. Their `$id` must be unique and must not collide with built-in partials -(`_ost_tools_base`, `_ost_strict`) — ost-tools will error on collision. +- Schema files are parsed as JSON5. +- Files starting with `_` in the same directory are auto-loaded partials. +- Local partial `$id` values must be unique and must not collide with bundled IDs. +- If a partial has no `$metadata`, use `$schema: "http://json-schema.org/draft-07/schema#"` so it remains a standalone-valid JSON Schema fragment. ## `$ref` patterns -Run `bunx ost-tools schemas show _ost_tools_base.json` to see all available built-in definitions -and their `$id` URIs. Key ones: `baseNodeProps` (title/content/tags), `wikilink` (`[[...]]` pattern). +Use `bunx ost-tools schemas show _ost_tools_base.json` to inspect built-in defs. -**Convention:** define any field that is or might become a structured concept in `$defs` and -reference it with `$ref`, even if currently a plain string. Makes it easy to add an enum or -constraints later without restructuring the `oneOf` entries. +Convention: +- define reusable concepts in `$defs` +- reference via `$ref` from `oneOf` entries -## Key `oneOf` entry patterns +## `oneOf` authoring pattern -```json +```json5 { - "not": { "required": ["parent"] }, // root types only: explicitly disallow parent field - "additionalProperties": true, // always use — allows future fields without schema breakage - "examples": [{ "type": "my-type", "my_field": "example" }] // used by template-sync + "type": "object", + "allOf": [ + { "$ref": "ost-tools://_ost_tools_base#/$defs/baseNodeProps" }, + { "$ref": "ost-tools://_ost_tools_base#/$defs/ostEntityProps" } + ], + "properties": { + "type": { "const": "opportunity" } + }, + "required": ["type"], + "additionalProperties": true, + "examples": [{ "type": "opportunity", "status": "identified" }] } ``` ## JSONata rules -```json -{ - "id": "solution-has-assumption-test", - "description": "Each solution should have at least one assumption test", - "type": "solution", // optional: only run on this resolvedType (after alias resolution) - "scope": "local", // default: evaluate per-node - "check": "$count(nodes[resolvedParentTitle=$$.current.title and resolvedType='assumption_test']) >= 1" -} -``` - -Each rule receives: `nodes` (all space nodes), `current` (node being evaluated), `parent` -(resolved parent node object — absent if none). +Each rule evaluation receives: `nodes`, `current`, `parent`, `parents`. -**Non-obvious:** `parent` is the resolved node object; `current.parent` is the raw wikilink -string. Use `$exists(parent)` to test whether a parent resolved. - -**Non-obvious:** inside a predicate `nodes[...]`, bare names refer to the predicate's item. -Use `$$` to reach outer scope: +Use `resolvedType` in comparisons (not raw `type`) so aliases are respected. ```jsonata -// Count child solutions of the current node (an opportunity) $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) ``` - -Always use `resolvedType` (not `type`) in comparisons — aliases are resolved to canonical names. - -### Common patterns - -```jsonata -$exists(current.metric) = true // required field present -$count(current.sources) >= 1 // array non-empty -$count(nodes[resolvedType='outcome' and status='active']) <= 1 // global aggregate (use scope: 'global') -current.status != 'active' or $exists(parent) = false or parent.status = 'active' // conditional -``` diff --git a/skills/ost-tools/references/schema-design.md b/skills/ost-tools/references/schema-design.md index 422c1d0..a57affb 100644 --- a/skills/ost-tools/references/schema-design.md +++ b/skills/ost-tools/references/schema-design.md @@ -25,20 +25,19 @@ Produce an inventory table: ## 2. Identify the hierarchy -ost-tools works best with a declared `hierarchy` in `_metadata`. Identify the main chain of +ost-tools works best with a declared `hierarchy` in `$metadata.hierarchy.levels`. Identify the main chain of parent→child relationships. - Which entity types are "root" concepts (no natural parent)? (e.g., `outcome`) - Which are subordinate to others, and via which field? - Does the content use a `parent` field, or a named relationship field (`opportunity`, `solution`, etc.)? -**Note:** ost-tools' built-in hierarchy validation, `show`, and `diagram` commands rely on -nodes using a `parent` field with wikilinks. If content uses named relationship fields -(e.g. `opportunity: "[[Signup is too complex]]"`) instead of `parent`, declare the hierarchy in -`_metadata` for structural context, but tree-based commands won't traverse those relationships -automatically until ost-tools adds support for custom parent fields. +**Note:** `show`, `diagram`, and hierarchy validation support custom relationship fields via +per-level hierarchy edge configuration (`field`, `fieldOn`, `multiple`). If content uses a named +relationship field (e.g. `opportunity: "[[Signup is too complex]]"`), encode that at the relevant +level object in `$metadata.hierarchy.levels`. -For a mixed graph (some hierarchical, some lateral entities), put the main chain in `hierarchy` +For a mixed graph (some hierarchical, some lateral entities), put the main chain in `hierarchy.levels` and include lateral types in `oneOf` without hierarchy constraints. ## 3. Handle naming conflicts @@ -137,4 +136,4 @@ A canonical Opportunity Solution Tree uses: Hierarchy declared as `["outcome", "opportunity", "solution", "assumption_test"]`. -The schema and config paths will vary per project — check the project's `AGENTS.md` or config file location. \ No newline at end of file +The schema and config paths will vary per project — check the project's `AGENTS.md` or config file location. diff --git a/src/commands/schemas.ts b/src/commands/schemas.ts index 171ade6..aba3d76 100644 --- a/src/commands/schemas.ts +++ b/src/commands/schemas.ts @@ -83,8 +83,12 @@ function showDefs(defs: Record): void { } function showMetadata(metadata: SchemaMetadata): void { - const parts = metadata.levels.map((l) => (l.selfRef ? `${l.type}(+)` : l.type)); - console.log(`\nhierarchy: ${parts.join(' → ')}`); + if (metadata.hierarchy?.levels.length) { + const parts = metadata.hierarchy.levels.map((l) => (l.selfRef ? `${l.type}(+)` : l.type)); + console.log(`\nhierarchy: ${parts.join(' → ')}`); + } else { + console.log('\nhierarchy: (none)'); + } if (metadata.typeAliases && Object.keys(metadata.typeAliases).length > 0) { const aliasParts = Object.entries(metadata.typeAliases).map(([k, v]) => `${k} → ${v}`); @@ -92,12 +96,18 @@ function showMetadata(metadata: SchemaMetadata): void { } if (metadata.rules) { - const groups = Object.entries(metadata.rules).filter(([, v]) => Array.isArray(v) && v.length > 0); - if (groups.length > 0) { + const groups = new Map>(); + for (const rule of metadata.rules) { + const rules = groups.get(rule.category) ?? []; + rules.push({ id: rule.id, description: rule.description }); + groups.set(rule.category, rules); + } + + if (groups.size > 0) { console.log('\nRules:'); for (const [group, items] of groups) { console.log(` ${group}:`); - for (const item of items as Record[]) { + for (const item of items) { console.log(` ${item.id}: ${item.description}`); } } diff --git a/src/commands/show.ts b/src/commands/show.ts index a7b34ac..2afe989 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -11,10 +11,11 @@ export async function show(path: string) { const config = loadConfig(); const space = config.spaces.find((s) => resolve(s.path) === absolutePath); const resolvedSchemaPath = resolveSchema(undefined, config, space); - const { hierarchy, levels } = loadMetadata(resolvedSchemaPath); + const metadata = loadMetadata(resolvedSchemaPath); + const levels = metadata.hierarchy?.levels ?? []; const rootType = levels[0]?.type; - const hierarchyTypes = new Set(hierarchy); + const hierarchyTypes = new Set(levels.map((level) => level.type)); let nodes: SpaceNode[]; if (statSync(absolutePath).isFile()) { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index f1c7ad6..9e5e31e 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -87,11 +87,12 @@ export async function validate(path: string, options: { schema: string; template // Build targetIndex for link validation const linkTargetIndex = buildTargetIndex(nodes); + const levels = metadata.hierarchy?.levels ?? []; // Validate edge field references for each hierarchy level - for (let i = 1; i < metadata.levels.length; i++) { - const level = metadata.levels[i]!; - const parentLevel = metadata.levels[i - 1]!; + for (let i = 1; i < levels.length; i++) { + const level = levels[i]!; + const parentLevel = levels[i - 1]!; // Determine which nodes to check based on who has the field const nodesToCheck = diff --git a/src/metadata-contract.ts b/src/metadata-contract.ts new file mode 100644 index 0000000..91f96f4 --- /dev/null +++ b/src/metadata-contract.ts @@ -0,0 +1,93 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const OST_TOOLS_SCHEMA_META_ID = + 'https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json'; + +const HIERARCHY_LEVEL_SCHEMA = { + type: 'object', + properties: { + type: { type: 'string', minLength: 1 }, + field: { type: 'string', minLength: 1 }, + fieldOn: { enum: ['child', 'parent'] }, + multiple: { type: 'boolean' }, + selfRef: { type: 'boolean' }, + }, + required: ['type'], + additionalProperties: false, +} as const; + +const RULE_SCHEMA = { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + category: { enum: ['validation', 'coherence', 'workflow', 'best-practice'] }, + description: { type: 'string', minLength: 1 }, + check: { type: 'string', minLength: 1 }, + type: { type: 'string', minLength: 1 }, + scope: { enum: ['global'] }, + override: { type: 'boolean' }, + }, + required: ['id', 'category', 'description', 'check'], + additionalProperties: false, +} as const; + +const RULE_REF_SCHEMA = { + type: 'object', + properties: { + $ref: { type: 'string', minLength: 1 }, + }, + required: ['$ref'], + additionalProperties: false, +} as const; + +export const OST_TOOLS_METADATA_SCHEMA = { + type: 'object', + properties: { + hierarchy: { + type: 'object', + properties: { + levels: { + type: 'array', + minItems: 1, + items: { + oneOf: [{ type: 'string', minLength: 1 }, HIERARCHY_LEVEL_SCHEMA], + }, + }, + allowSkipLevels: { type: 'boolean' }, + }, + required: ['levels'], + additionalProperties: false, + }, + aliases: { + type: 'object', + additionalProperties: { type: 'string', minLength: 1 }, + }, + rules: { + type: 'array', + items: { + oneOf: [RULE_SCHEMA, RULE_REF_SCHEMA], + }, + }, + }, + additionalProperties: false, +} as const; + +export const OST_TOOLS_DIALECT_META_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: OST_TOOLS_SCHEMA_META_ID, + title: 'ost-tools schema dialect', + description: 'Extends JSON Schema Draft-07 with top-level $metadata for hierarchy and rule metadata.', + type: 'object', + allOf: [{ $ref: 'http://json-schema.org/draft-07/schema#' }], + properties: { + $metadata: OST_TOOLS_METADATA_SCHEMA, + }, +} as const; + +export type MetadataContract = FromSchema; +export type MetadataContractHierarchy = MetadataContract['hierarchy']; +export type MetadataContractRule = FromSchema; +export type MetadataContractRuleRef = FromSchema; +export type MetadataContractRules = Exclude; +export type MetadataContractRuleEntry = MetadataContractRules[number]; +export type MetadataContractResolvedRules = MetadataContractRule[]; diff --git a/src/read-space-directory.ts b/src/read-space-directory.ts index 67c93f8..70a416c 100644 --- a/src/read-space-directory.ts +++ b/src/read-space-directory.ts @@ -17,7 +17,10 @@ export async function readSpaceDirectory( const space = config.spaces.find((s) => resolve(s.path) === absoluteDirectory); const resolvedSchemaPath = resolveSchema(options?.schemaPath, config, space); - const { hierarchy, levels, typeAliases } = loadMetadata(resolvedSchemaPath); + const metadata = loadMetadata(resolvedSchemaPath); + const hierarchyLevels = metadata.hierarchy?.levels ?? []; + const hierarchyTypes = hierarchyLevels.map((level) => level.type); + const { typeAliases } = metadata; const fieldMap = space?.fieldMap; const templateDir = options?.templateDir ?? space?.templateDir ?? config.templateDir; @@ -71,7 +74,7 @@ export async function readSpaceDirectory( const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { pageTitle: fileBase, pageType, - hierarchy, + hierarchy: hierarchyTypes, typeAliases: typeAliases, fieldMap, }); @@ -79,6 +82,6 @@ export async function readSpaceDirectory( } } - resolveLinks(nodes, levels); + resolveLinks(nodes, hierarchyLevels); return { nodes, skipped, nonSpace }; } diff --git a/src/read-space-on-a-page.ts b/src/read-space-on-a-page.ts index 7f21fe5..d9b8a16 100644 --- a/src/read-space-on-a-page.ts +++ b/src/read-space-on-a-page.ts @@ -22,15 +22,23 @@ export function readSpaceOnAPage(filePath: string, schemaPath?: string): SpaceOn const config = loadConfig(); const space = config.spaces.find((s) => resolve(s.path) === resolve(filePath)); const resolvedSchemaPath = resolveSchema(schemaPath, config, space); - const { hierarchy, levels, typeAliases } = loadMetadata(resolvedSchemaPath); + const metadata = loadMetadata(resolvedSchemaPath); + const hierarchyLevels = metadata.hierarchy?.levels; + if (!hierarchyLevels || hierarchyLevels.length === 0) { + throw new Error( + `Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`, + ); + } + const hierarchyTypes = hierarchyLevels.map((level) => level.type); + const { typeAliases } = metadata; const pageTitle = basename(filePath, '.md'); const { nodes, diagnostics } = extractEmbeddedNodes(body, { pageTitle, pageType: 'space_on_a_page', - hierarchy, + hierarchy: hierarchyTypes, typeAliases, }); - resolveLinks(nodes, levels); + resolveLinks(nodes, hierarchyLevels); return { nodes, diagnostics }; } diff --git a/src/schema-refs.ts b/src/schema-refs.ts index 093e0cb..e9deb64 100644 --- a/src/schema-refs.ts +++ b/src/schema-refs.ts @@ -1,5 +1,7 @@ import type { AnySchemaObject } from 'ajv'; +export { isObject, resolveRefTarget, resolveJsonPointer, decodeJsonPointerToken }; + interface ResolvedSchema { schema: AnySchemaObject; rootSchema: AnySchemaObject; diff --git a/src/schema.ts b/src/schema.ts index aba8eed..ebb21da 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,9 +1,19 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; import Ajv, { type ValidateFunction } from 'ajv'; import JSON5 from 'json5'; -import type { HierarchyLevel, RulesMetadata, SchemaMetadata } from './types'; +import { + type MetadataContract, + type MetadataContractRule, + type MetadataContractRuleEntry, + OST_TOOLS_DIALECT_META_SCHEMA, + OST_TOOLS_METADATA_SCHEMA, + OST_TOOLS_SCHEMA_META_ID, +} from './metadata-contract'; +import { isObject, resolveJsonPointer } from './schema-refs'; +import type { HierarchyLevel, RuleCategory, SchemaMetadata } from './types'; const packageDir = dirname(fileURLToPath(import.meta.url)); export const bundledSchemasDir = join(packageDir, '..', 'schemas'); @@ -54,6 +64,7 @@ export function buildFullRegistry(schemaPath: string): Map)?._metadata as Record | undefined; - - // _metadata may live in a partial schema rather than the target - if (!metadata) { - for (const s of buildFullRegistry(schemaPath).values()) { - const m = (s.$defs as Record | undefined)?._metadata; - if (m) { - metadata = m as Record; - break; +interface MetadataProvider { + schemaId: string; + schema: JsonSchemaObject; + metadata: MetadataContract; +} + +const RULE_CATEGORIES = new Set(['validation', 'coherence', 'workflow', 'best-practice']); +const RULE_ALLOWED_KEYS = new Set(['id', 'category', 'description', 'check', 'type', 'scope', 'override']); + +function readTopLevelMetadata(schema: JsonSchemaObject): MetadataContract | undefined { + const metadata = schema.$metadata; + return isObject(metadata) ? (metadata as MetadataContract) : undefined; +} + +function resolveRefTargetForRule( + ref: string, + currentRootSchema: JsonSchemaObject, + registry: Map, +): { value: unknown; rootSchema: JsonSchemaObject; refKey: string } { + if (ref.startsWith('#')) { + const pointer = ref.slice(1); + const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root schema)'; + return { + value: resolveJsonPointer(currentRootSchema, pointer, ref), + rootSchema: currentRootSchema, + refKey: `${rootId}#${pointer}`, + }; + } + + const hashIndex = ref.indexOf('#'); + const baseId = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref; + const pointer = hashIndex >= 0 ? ref.slice(hashIndex + 1) : ''; + const externalSchema = registry.get(baseId); + if (!externalSchema) { + throw new Error(`Cannot resolve external $ref: ${ref}`); + } + + return { + value: resolveJsonPointer(externalSchema, pointer, ref), + rootSchema: externalSchema, + refKey: `${baseId}#${pointer}`, + }; +} + +function collectExternalRefIdsInOrder(schema: unknown): string[] { + const refs: string[] = []; + const seen = new Set(); + + const walk = (value: unknown): void => { + if (!isObject(value)) return; + for (const [key, child] of Object.entries(value)) { + if (key === '$ref' && typeof child === 'string' && !child.startsWith('#')) { + const schemaId = child.split('#', 1)[0] ?? child; + if (!seen.has(schemaId)) { + seen.add(schemaId); + refs.push(schemaId); + } + continue; } + walk(child); } + }; + + walk(schema); + return refs; +} + +function collectMetadataProviders( + rootSchema: JsonSchemaObject, + registry: Map, +): MetadataProvider[] { + const providers: MetadataProvider[] = []; + const visitedSchemaIds = new Set(); + + const walk = (schema: JsonSchemaObject): void => { + const refs = collectExternalRefIdsInOrder(schema); + for (const schemaId of refs) { + if (visitedSchemaIds.has(schemaId)) continue; + visitedSchemaIds.add(schemaId); + + const referencedSchema = registry.get(schemaId); + if (!referencedSchema) continue; + + walk(referencedSchema); + + const metadata = readTopLevelMetadata(referencedSchema); + if (metadata) { + providers.push({ schemaId, schema: referencedSchema, metadata }); + } + } + }; + + walk(rootSchema); + + const rootMetadata = readTopLevelMetadata(rootSchema); + if (rootMetadata) { + const rootSchemaId = typeof rootSchema.$id === 'string' ? rootSchema.$id : '(root schema)'; + providers.push({ schemaId: rootSchemaId, schema: rootSchema, metadata: rootMetadata }); + } + + return providers; +} + +function isRuleRefEntry(value: unknown): value is { $ref: string } { + if (!isObject(value)) return false; + return typeof value.$ref === 'string' && value.$ref.length > 0 && Object.keys(value).length === 1; +} + +function isMetadataRule(value: unknown): value is MetadataContractRule { + if (!isObject(value)) return false; + const record = value as Record; + + if (typeof record.id !== 'string' || record.id.length === 0) return false; + if (typeof record.category !== 'string' || !RULE_CATEGORIES.has(record.category as RuleCategory)) return false; + if (typeof record.description !== 'string' || record.description.length === 0) return false; + if (typeof record.check !== 'string' || record.check.length === 0) return false; + + if ('type' in record && (typeof record.type !== 'string' || record.type.length === 0)) return false; + if ('scope' in record && record.scope !== 'global') return false; + if ('override' in record && typeof record.override !== 'boolean') return false; + + for (const key of Object.keys(record)) { + if (!RULE_ALLOWED_KEYS.has(key)) return false; + } + + return true; +} + +function resolveRuleEntries( + ruleEntry: MetadataContractRuleEntry, + provider: MetadataProvider, + registry: Map, + stack: Set, +): MetadataContractRule[] { + if (isMetadataRule(ruleEntry)) { + return [ruleEntry]; } - const rawHierarchy = metadata?.hierarchy as Array> | undefined; - if (!rawHierarchy || rawHierarchy.length === 0) { + if (!isRuleRefEntry(ruleEntry)) { + throw new Error(`Invalid rule entry in metadata from "${provider.schemaId}".`); + } + + const target = resolveRefTargetForRule(ruleEntry.$ref, provider.schema, registry); + if (stack.has(target.refKey)) { + throw new Error( + `Cyclic rule import detected while loading metadata from "${provider.schemaId}": ${[...stack, target.refKey].join( + ' -> ', + )}`, + ); + } + + stack.add(target.refKey); + try { + const value = target.value; + + const resolveArray = (arr: unknown[]): MetadataContractRule[] => { + const resolvedRules: MetadataContractRule[] = []; + for (const child of arr) { + if (!isObject(child)) { + throw new Error( + `Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". Rule sets must contain objects.`, + ); + } + resolvedRules.push( + ...resolveRuleEntries( + child as MetadataContractRuleEntry, + { ...provider, schema: target.rootSchema }, + registry, + stack, + ), + ); + } + return resolvedRules; + }; + + if (Array.isArray(value)) { + return resolveArray(value); + } + + if (isObject(value) && 'rules' in value) { + const nestedRules = value.rules; + if (!Array.isArray(nestedRules)) { + throw new Error( + `Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". "rules" must be an array.`, + ); + } + return resolveArray(nestedRules); + } + + if (isObject(value)) { + return resolveRuleEntries( + value as MetadataContractRuleEntry, + { ...provider, schema: target.rootSchema }, + registry, + stack, + ); + } + throw new Error( - `Schema at ${schemaPath} must define "$defs._metadata.hierarchy" array for depth-based type inference`, + `Invalid rule import target for "${ruleEntry.$ref}" from "${provider.schemaId}". Expected a rule object or rule set.`, ); + } finally { + stack.delete(target.refKey); + } +} + +function normalizeRule(rule: MetadataContractRule): MetadataContractRule { + const { override, ...normalized } = rule; + return normalized; +} + +function areRulesEquivalent(left: MetadataContractRule, right: MetadataContractRule): boolean { + return isDeepStrictEqual(normalizeRule(left), normalizeRule(right)); +} + +export function loadMetadata(schemaPath: string): SchemaMetadata { + const schema = readRawSchema(schemaPath); + const registry = buildFullRegistry(schemaPath); + const metadataProviders = collectMetadataProviders(schema, registry); + + let hierarchyProvider: string | undefined; + let mergedHierarchy: MetadataContract['hierarchy'] | undefined; + const mergedAliases: Record = {}; + const mergedRules = new Map(); + + for (const provider of metadataProviders) { + if (provider.metadata.hierarchy) { + if (mergedHierarchy) { + throw new Error( + `Multiple metadata providers define "$metadata.hierarchy": "${hierarchyProvider}" and "${provider.schemaId}". Exactly one provider is allowed.`, + ); + } + hierarchyProvider = provider.schemaId; + mergedHierarchy = provider.metadata.hierarchy; + } + + if (provider.metadata.aliases) { + Object.assign(mergedAliases, provider.metadata.aliases); + } + + if (provider.metadata.rules) { + for (const entry of provider.metadata.rules) { + const resolvedRules = resolveRuleEntries(entry, provider, registry, new Set()); + for (const incomingRule of resolvedRules) { + const existingRule = mergedRules.get(incomingRule.id); + if (!existingRule) { + mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule }); + continue; + } + + if (incomingRule.override === true) { + mergedRules.set(incomingRule.id, { providerId: provider.schemaId, rule: incomingRule }); + continue; + } + + if (!areRulesEquivalent(existingRule.rule, incomingRule)) { + throw new Error( + `Conflicting rule "${incomingRule.id}" found in "${existingRule.providerId}" and "${provider.schemaId}". Set "override": true on the later rule to replace it.`, + ); + } + } + } + } } - const levels: HierarchyLevel[] = rawHierarchy.map((entry) => { + const levels: HierarchyLevel[] | undefined = mergedHierarchy?.levels.map((entry) => { if (typeof entry === 'string') { return { type: entry, field: 'parent', fieldOn: 'child', multiple: false, selfRef: false }; } return { - type: entry.type as string, - field: (entry.field as string | undefined) ?? 'parent', - fieldOn: (entry.fieldOn as string | undefined) === 'parent' ? 'parent' : 'child', - multiple: (entry.multiple as boolean | undefined) ?? false, - selfRef: (entry.selfRef as boolean | undefined) ?? false, + type: entry.type, + field: entry.field ?? 'parent', + fieldOn: entry.fieldOn === 'parent' ? 'parent' : 'child', + multiple: entry.multiple ?? false, + selfRef: entry.selfRef ?? false, }; }); - const hierarchy = levels.map((l) => l.type); - return { - hierarchy, - levels, - typeAliases: (metadata?.aliases as Record) ?? undefined, - allowSkipLevels: (metadata?.allowSkipLevels as boolean) ?? undefined, - rules: (metadata?.rules as RulesMetadata) ?? undefined, + hierarchy: + levels !== undefined + ? { + levels, + allowSkipLevels: mergedHierarchy?.allowSkipLevels, + } + : undefined, + typeAliases: Object.keys(mergedAliases).length > 0 ? mergedAliases : undefined, + rules: mergedRules.size > 0 ? [...mergedRules.values()].map(({ rule }) => normalizeRule(rule)) : undefined, }; } diff --git a/src/types.ts b/src/types.ts index c662143..7370525 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { MetadataContractResolvedRules, MetadataContractRule } from './metadata-contract'; + export interface HierarchyLevel { type: string; field: string; // default "parent" @@ -41,23 +43,8 @@ export interface SpaceDirectoryReadResult { export type RuleCategory = 'validation' | 'coherence' | 'workflow' | 'best-practice'; /** A single executable rule with JSONata check expression */ -export interface Rule { - id: string; - description: string; - /** JSONata expression that evaluates to boolean (true = pass) */ - check: string; - /** If set, only applies to nodes of this resolved type */ - type?: string; - /** If 'global', evaluated once against the full node set rather than per node */ - scope?: 'global'; -} - -export interface RulesMetadata { - validation?: Rule[]; - coherence?: Rule[]; - workflow?: Rule[]; - bestPractice?: Rule[]; -} +export type Rule = MetadataContractRule; +export type RulesMetadata = MetadataContractResolvedRules; export interface RuleViolation { file: string; @@ -76,9 +63,10 @@ export interface HierarchyViolation { } export interface SchemaMetadata { - hierarchy: string[]; // derived type-name list (same length/order as levels) - levels: HierarchyLevel[]; // full per-level config + hierarchy?: { + levels: HierarchyLevel[]; // full per-level config + allowSkipLevels?: boolean; + }; typeAliases?: Record; - allowSkipLevels?: boolean; rules?: RulesMetadata; } diff --git a/src/validate-hierarchy.ts b/src/validate-hierarchy.ts index c72bd17..5417fe0 100644 --- a/src/validate-hierarchy.ts +++ b/src/validate-hierarchy.ts @@ -2,7 +2,10 @@ import type { HierarchyViolation, SchemaMetadata, SpaceNode } from './types'; export function validateHierarchy(nodes: SpaceNode[], metadata: SchemaMetadata): HierarchyViolation[] { const violations: HierarchyViolation[] = []; - const { hierarchy, levels, allowSkipLevels = false } = metadata; + const levels = metadata.hierarchy?.levels; + if (!levels || levels.length === 0) return violations; + const hierarchy = levels.map((level) => level.type); + const allowSkipLevels = metadata.hierarchy?.allowSkipLevels ?? false; const nodeIndex = new Map(); for (const node of nodes) { diff --git a/src/validate-rules.ts b/src/validate-rules.ts index c1b288f..93e2123 100644 --- a/src/validate-rules.ts +++ b/src/validate-rules.ts @@ -1,12 +1,12 @@ import { buildEvalContext, evaluateExpression } from './evaluate-rule'; -import type { Rule, RuleCategory, RulesMetadata, RuleViolation, SpaceNode } from './types'; +import type { RulesMetadata, RuleViolation, SpaceNode } from './types'; /** * Validate nodes against rules metadata. * Returns a list of rule violations found. * * @param nodes - All nodes in the space - * @param rules - Rules metadata with categorized rules + * @param rules - Rules metadata list * @returns Array of rule violations */ export async function validateRules(nodes: SpaceNode[], rules: RulesMetadata): Promise { @@ -21,36 +21,31 @@ export async function validateRules(nodes: SpaceNode[], rules: RulesMetadata): P } } - // Collect all rules from each category - const allCategories: Array<{ category: RuleCategory; rules: Rule[] }> = [ - { category: 'validation', rules: rules.validation ?? [] }, - { category: 'coherence', rules: rules.coherence ?? [] }, - { category: 'workflow', rules: rules.workflow ?? [] }, - { category: 'best-practice', rules: rules.bestPractice ?? [] }, - ]; - // Evaluate each rule against applicable nodes - for (const { category, rules: categoryRules } of allCategories) { - for (const rule of categoryRules) { - if (rule.scope === 'global') { - // Global rules are evaluated once against the full node set. - // A sentinel node provides the evaluation context (nodes array is what matters). - const sentinel = nodes[0]; - if (sentinel) { - const context = buildEvalContext(sentinel, nodes, nodeIndex); - const result = await evaluateExpression(rule.check, context); - if (result !== true) { - violations.push({ file: '', ruleId: rule.id, category, description: rule.description }); - } + for (const rule of rules) { + if (rule.scope === 'global') { + // Global rules are evaluated once against the full node set. + // A sentinel node provides the evaluation context (nodes array is what matters). + const sentinel = nodes[0]; + if (sentinel) { + const context = buildEvalContext(sentinel, nodes, nodeIndex); + const result = await evaluateExpression(rule.check, context); + if (result !== true) { + violations.push({ file: '', ruleId: rule.id, category: rule.category, description: rule.description }); } - } else { - const targetNodes = rule.type ? nodes.filter((n) => n.resolvedType === rule.type) : nodes; - for (const node of targetNodes) { - const context = buildEvalContext(node, nodes, nodeIndex); - const result = await evaluateExpression(rule.check, context); - if (result !== true) { - violations.push({ file: node.label, ruleId: rule.id, category, description: rule.description }); - } + } + } else { + const targetNodes = rule.type ? nodes.filter((n) => n.resolvedType === rule.type) : nodes; + for (const node of targetNodes) { + const context = buildEvalContext(node, nodes, nodeIndex); + const result = await evaluateExpression(rule.check, context); + if (result !== true) { + violations.push({ + file: node.label, + ruleId: rule.id, + category: rule.category, + description: rule.description, + }); } } } diff --git a/tests/fixtures/schema-composition/_conflict-pack-a.json b/tests/fixtures/schema-composition/_conflict-pack-a.json new file mode 100644 index 0000000..15bfa5b --- /dev/null +++ b/tests/fixtures/schema-composition/_conflict-pack-a.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/conflict/pack-a", + "$metadata": { + "hierarchy": { + "levels": ["outcome", "opportunity"] + }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Conflict pack A", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_conflict-pack-b.json b/tests/fixtures/schema-composition/_conflict-pack-b.json new file mode 100644 index 0000000..7297571 --- /dev/null +++ b/tests/fixtures/schema-composition/_conflict-pack-b.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/conflict/pack-b", + "$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Conflict pack B", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) = 1" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_hierarchy-conflict-pack.json b/tests/fixtures/schema-composition/_hierarchy-conflict-pack.json new file mode 100644 index 0000000..faef904 --- /dev/null +++ b/tests/fixtures/schema-composition/_hierarchy-conflict-pack.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/hierarchy-conflict/pack", + "$metadata": { + "hierarchy": { + "levels": ["vision", "goal"] + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_merge-leaf.json b/tests/fixtures/schema-composition/_merge-leaf.json new file mode 100644 index 0000000..30c4c4b --- /dev/null +++ b/tests/fixtures/schema-composition/_merge-leaf.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/merge/leaf", + "$metadata": { + "hierarchy": { + "levels": ["vision", "goal"] + }, + "aliases": { + "north_star": "vision" + }, + "rules": [ + { + "id": "leaf-rule", + "category": "coherence", + "description": "Leaf coherence rule", + "check": "true" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_merge-pack-a.json b/tests/fixtures/schema-composition/_merge-pack-a.json new file mode 100644 index 0000000..fc33493 --- /dev/null +++ b/tests/fixtures/schema-composition/_merge-pack-a.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/merge/pack-a", + "allOf": [{ "$ref": "ost-tools-test://schema-composition/merge/leaf" }], + "$metadata": { + "aliases": { + "outcome": "goal" + }, + "rules": [ + { + "id": "pack-a-rule", + "category": "validation", + "description": "Pack A validation rule", + "check": "true" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_merge-pack-b.json b/tests/fixtures/schema-composition/_merge-pack-b.json new file mode 100644 index 0000000..f9a1dc1 --- /dev/null +++ b/tests/fixtures/schema-composition/_merge-pack-b.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/merge/pack-b", + "$metadata": { + "aliases": { + "goal": "result" + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_override-pack-a.json b/tests/fixtures/schema-composition/_override-pack-a.json new file mode 100644 index 0000000..b86b730 --- /dev/null +++ b/tests/fixtures/schema-composition/_override-pack-a.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/override/pack-a", + "$metadata": { + "hierarchy": { + "levels": ["outcome", "opportunity"] + }, + "rules": [ + { + "id": "active-outcome-count", + "category": "workflow", + "description": "Override pack A", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) <= 1" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_override-pack-b.json b/tests/fixtures/schema-composition/_override-pack-b.json new file mode 100644 index 0000000..0da7bd1 --- /dev/null +++ b/tests/fixtures/schema-composition/_override-pack-b.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/override/pack-b", + "$metadata": { + "rules": [ + { + "id": "active-outcome-count", + "override": true, + "category": "workflow", + "description": "Override pack B", + "scope": "global", + "check": "$count(nodes[resolvedType='outcome' and status='active']) = 1" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/_rule-import-pack.json b/tests/fixtures/schema-composition/_rule-import-pack.json new file mode 100644 index 0000000..e292e48 --- /dev/null +++ b/tests/fixtures/schema-composition/_rule-import-pack.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "ost-tools-test://schema-composition/imports/pack", + "$defs": { + "workflowRule": { + "id": "workflow-rule", + "category": "workflow", + "description": "Imported workflow rule", + "scope": "global", + "check": "true" + }, + "coherenceRule": { + "id": "coherence-rule", + "category": "coherence", + "description": "Imported coherence rule", + "check": "true" + }, + "ruleSet": { + "rules": [ + { "$ref": "#/$defs/coherenceRule" }, + { + "id": "validation-rule", + "category": "validation", + "description": "Imported validation rule", + "check": "true" + } + ] + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/compile/_rule-import-pack.json b/tests/fixtures/schema-composition/compile/_rule-import-pack.json new file mode 100644 index 0000000..8555ac9 --- /dev/null +++ b/tests/fixtures/schema-composition/compile/_rule-import-pack.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "ost-tools-test://schema-composition/imports/compile/pack", + "$defs": { + "workflowRule": { + "id": "workflow-rule", + "category": "workflow", + "description": "Imported workflow rule", + "scope": "global", + "check": "true" + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/compile/rule-import-root.json b/tests/fixtures/schema-composition/compile/rule-import-root.json new file mode 100644 index 0000000..3573b10 --- /dev/null +++ b/tests/fixtures/schema-composition/compile/rule-import-root.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/imports/compile/root", + "$metadata": { + "hierarchy": { + "levels": ["goal"] + }, + "rules": [{ "$ref": "ost-tools-test://schema-composition/imports/compile/pack#/$defs/workflowRule" }] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/conflict-root.json b/tests/fixtures/schema-composition/conflict-root.json new file mode 100644 index 0000000..a2dc21b --- /dev/null +++ b/tests/fixtures/schema-composition/conflict-root.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/conflict/root", + "allOf": [ + { "$ref": "ost-tools-test://schema-composition/conflict/pack-a" }, + { "$ref": "ost-tools-test://schema-composition/conflict/pack-b" } + ], + "type": "object" +} diff --git a/tests/fixtures/schema-composition/hierarchy-conflict-root.json b/tests/fixtures/schema-composition/hierarchy-conflict-root.json new file mode 100644 index 0000000..b475189 --- /dev/null +++ b/tests/fixtures/schema-composition/hierarchy-conflict-root.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/hierarchy-conflict/root", + "allOf": [{ "$ref": "ost-tools-test://schema-composition/hierarchy-conflict/pack" }], + "$metadata": { + "hierarchy": { + "levels": ["goal"] + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/merge-root.json b/tests/fixtures/schema-composition/merge-root.json new file mode 100644 index 0000000..d5b8b05 --- /dev/null +++ b/tests/fixtures/schema-composition/merge-root.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/merge/root", + "allOf": [ + { "$ref": "ost-tools-test://schema-composition/merge/pack-a" }, + { "$ref": "ost-tools-test://schema-composition/merge/pack-b" } + ], + "$metadata": { + "aliases": { + "goal": "objective" + }, + "rules": [ + { + "id": "root-rule", + "category": "workflow", + "description": "Root-level workflow rule", + "scope": "global", + "check": "true" + } + ] + }, + "type": "object" +} diff --git a/tests/fixtures/schema-composition/override-root.json b/tests/fixtures/schema-composition/override-root.json new file mode 100644 index 0000000..bbbca23 --- /dev/null +++ b/tests/fixtures/schema-composition/override-root.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/override/root", + "allOf": [ + { "$ref": "ost-tools-test://schema-composition/override/pack-a" }, + { "$ref": "ost-tools-test://schema-composition/override/pack-b" } + ], + "type": "object" +} diff --git a/tests/fixtures/schema-composition/rule-import-root.json b/tests/fixtures/schema-composition/rule-import-root.json new file mode 100644 index 0000000..c86d3e8 --- /dev/null +++ b/tests/fixtures/schema-composition/rule-import-root.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-composition/imports/root", + "$metadata": { + "hierarchy": { + "levels": ["goal"] + }, + "rules": [ + { "$ref": "ost-tools-test://schema-composition/imports/pack#/$defs/workflowRule" }, + { "$ref": "ost-tools-test://schema-composition/imports/pack#/$defs/ruleSet" }, + { "$ref": "#/$defs/localRule" }, + { "$ref": "#/$defs/localRuleSet" } + ] + }, + "$defs": { + "localRule": { + "id": "local-rule", + "category": "validation", + "description": "Local single rule", + "check": "true" + }, + "localRuleSet": { + "rules": [ + { + "id": "local-set-rule", + "category": "best-practice", + "description": "Local rule-set rule", + "check": "true" + } + ] + } + }, + "type": "object" +} diff --git a/tests/fixtures/schema-metadata/alias-only.json b/tests/fixtures/schema-metadata/alias-only.json new file mode 100644 index 0000000..a149e1e --- /dev/null +++ b/tests/fixtures/schema-metadata/alias-only.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-metadata/alias-only", + "$metadata": { + "aliases": { + "outcome": "goal" + } + }, + "type": "object", + "properties": { + "type": { + "const": "goal" + } + }, + "required": ["type"], + "additionalProperties": true +} diff --git a/tests/fixtures/schema-metadata/invalid-metadata.json b/tests/fixtures/schema-metadata/invalid-metadata.json new file mode 100644 index 0000000..36d7b6d --- /dev/null +++ b/tests/fixtures/schema-metadata/invalid-metadata.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-metadata/invalid", + "$metadata": { + "rules": [ + { + "id": "invalid-category", + "category": "not-a-category", + "description": "Invalid category should fail schema validation", + "check": "true" + } + ] + }, + "type": "object", + "properties": { + "type": { + "const": "goal" + } + }, + "required": ["type"], + "additionalProperties": true +} diff --git a/tests/fixtures/schema-metadata/valid.json b/tests/fixtures/schema-metadata/valid.json new file mode 100644 index 0000000..b4e01d2 --- /dev/null +++ b/tests/fixtures/schema-metadata/valid.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/mindsocket/ost-tools/main/schemas/generated/_ost_tools_schema_meta.json", + "$id": "ost-tools-test://schema-metadata/valid", + "$metadata": { + "hierarchy": { + "levels": ["goal"] + }, + "aliases": { + "outcome": "goal" + } + }, + "type": "object", + "properties": { + "type": { + "const": "goal" + } + }, + "required": ["type"], + "additionalProperties": true +} diff --git a/tests/schema-composition.test.ts b/tests/schema-composition.test.ts new file mode 100644 index 0000000..2cc6350 --- /dev/null +++ b/tests/schema-composition.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { createValidator, loadMetadata } from '../src/schema'; + +const FIXTURES_DIR = join(import.meta.dir, 'fixtures/schema-composition'); + +describe('schema composition metadata', () => { + it('merges metadata across $ref graph in DFS order and applies root metadata last', () => { + const metadata = loadMetadata(join(FIXTURES_DIR, 'merge-root.json')); + + expect(metadata.hierarchy.levels.map((level) => level.type)).toEqual(['vision', 'goal']); + expect(metadata.typeAliases).toEqual({ + north_star: 'vision', + outcome: 'goal', + goal: 'objective', + }); + expect(metadata.rules?.map((rule) => rule.id)).toEqual(['leaf-rule', 'pack-a-rule', 'root-rule']); + }); + + it('rejects multiple hierarchy providers', () => { + expect(() => loadMetadata(join(FIXTURES_DIR, 'hierarchy-conflict-root.json'))).toThrow( + 'Multiple metadata providers define "$metadata.hierarchy"', + ); + }); + + it('fails conflicting rule IDs without explicit override', () => { + expect(() => loadMetadata(join(FIXTURES_DIR, 'conflict-root.json'))).toThrow( + 'Conflicting rule "active-outcome-count"', + ); + }); + + it('allows later rule override when override=true', () => { + const metadata = loadMetadata(join(FIXTURES_DIR, 'override-root.json')); + + expect(metadata.rules).toHaveLength(1); + expect(metadata.rules?.[0]).toMatchObject({ + id: 'active-outcome-count', + check: "$count(nodes[resolvedType='outcome' and status='active']) = 1", + }); + expect((metadata.rules?.[0] as Record).override).toBeUndefined(); + }); + + it('imports specific rules and rule sets via $ref targets', () => { + const metadata = loadMetadata(join(FIXTURES_DIR, 'rule-import-root.json')); + + expect(metadata.rules?.map((rule) => rule.id)).toEqual([ + 'workflow-rule', + 'coherence-rule', + 'validation-rule', + 'local-rule', + 'local-set-rule', + ]); + }); + + it('compiles schemas that use rule import refs in $metadata.rules', () => { + expect(() => createValidator(join(FIXTURES_DIR, 'compile/rule-import-root.json'))).not.toThrow(); + }); + + it('compiles schemas where only one metadata provider defines hierarchy', () => { + expect(() => createValidator(join(FIXTURES_DIR, 'merge-root.json'))).not.toThrow(); + }); +}); diff --git a/tests/schema-metadata.test.ts b/tests/schema-metadata.test.ts new file mode 100644 index 0000000..31badfa --- /dev/null +++ b/tests/schema-metadata.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { readSpaceOnAPage } from '../src/read-space-on-a-page'; +import { bundledSchemasDir, createValidator, loadMetadata } from '../src/schema'; + +const FIXTURES_DIR = join(import.meta.dir, 'fixtures/schema-metadata'); +const GENERAL_SCHEMA_PATH = join(bundledSchemasDir, 'general.json'); +const VALID_SCHEMA_PATH = join(FIXTURES_DIR, 'valid.json'); +const ALIAS_ONLY_SCHEMA_PATH = join(FIXTURES_DIR, 'alias-only.json'); +const INVALID_SCHEMA_PATH = join(FIXTURES_DIR, 'invalid-metadata.json'); +const ON_A_PAGE_FIXTURE_PATH = join(import.meta.dir, 'fixtures/general/on-a-page-valid.md'); + +describe('schema metadata', () => { + it('loads top-level $metadata from bundled schemas', () => { + const metadata = loadMetadata(GENERAL_SCHEMA_PATH); + + expect(metadata.hierarchy?.levels.map((level) => level.type)).toEqual([ + 'vision', + 'mission', + 'goal', + 'opportunity', + 'solution', + 'experiment', + ]); + expect(metadata.typeAliases?.outcome).toBe('goal'); + }); + + it('compiles schemas with top-level $metadata', () => { + expect(() => createValidator(VALID_SCHEMA_PATH)).not.toThrow(); + }); + + it('compiles schemas with alias-only $metadata and no hierarchy', () => { + expect(() => createValidator(ALIAS_ONLY_SCHEMA_PATH)).not.toThrow(); + const metadata = loadMetadata(ALIAS_ONLY_SCHEMA_PATH); + expect(metadata.hierarchy).toBeUndefined(); + expect(metadata.typeAliases).toEqual({ outcome: 'goal' }); + }); + + it('fails to read space_on_a_page when hierarchy metadata is absent', () => { + expect(() => readSpaceOnAPage(ON_A_PAGE_FIXTURE_PATH, ALIAS_ONLY_SCHEMA_PATH)).toThrow( + 'must define "$metadata.hierarchy.levels"', + ); + }); + + it('rejects invalid $metadata in ost-tools schema dialect', () => { + expect(() => createValidator(INVALID_SCHEMA_PATH)).toThrow(); + }); +}); diff --git a/tests/validate-hierarchy.test.ts b/tests/validate-hierarchy.test.ts index c2ebf92..2c376cf 100644 --- a/tests/validate-hierarchy.test.ts +++ b/tests/validate-hierarchy.test.ts @@ -15,10 +15,11 @@ describe('validate-hierarchy', () => { describe('hierarchy with selfRef', () => { const hierarchy = ['vision', 'mission', 'goal', 'opportunity', 'solution', 'experiment']; const metadata: SchemaMetadata = { - hierarchy, - levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + hierarchy: { + levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + allowSkipLevels: false, + }, typeAliases: {}, - allowSkipLevels: false, }; it('passes when node has immediate parent in hierarchy', () => { @@ -80,10 +81,11 @@ describe('validate-hierarchy', () => { describe('hierarchy with allowSkipLevels', () => { const hierarchy = ['vision', 'mission', 'goal', 'opportunity', 'solution', 'experiment']; const metadata: SchemaMetadata = { - hierarchy, - levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + hierarchy: { + levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + allowSkipLevels: true, + }, typeAliases: {}, - allowSkipLevels: true, }; it('allows skipping hierarchy levels when allowSkipLevels is true', () => { @@ -112,10 +114,11 @@ describe('validate-hierarchy', () => { describe('edge cases', () => { const hierarchy = ['vision', 'mission', 'goal', 'opportunity', 'solution', 'experiment']; const metadata: SchemaMetadata = { - hierarchy, - levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + hierarchy: { + levels: hierarchy.map((t) => makeLevel(t, { selfRef: ['goal', 'opportunity', 'solution'].includes(t) })), + allowSkipLevels: false, + }, typeAliases: {}, - allowSkipLevels: false, }; it('skips nodes not in hierarchy', () => { @@ -151,10 +154,11 @@ describe('validate-hierarchy', () => { describe('violation format', () => { const hierarchy = ['vision', 'mission', 'goal']; const metadata: SchemaMetadata = { - hierarchy, - levels: hierarchy.map((t) => makeLevel(t)), + hierarchy: { + levels: hierarchy.map((t) => makeLevel(t)), + allowSkipLevels: false, + }, typeAliases: {}, - allowSkipLevels: false, }; it('includes all required fields in violation', () => { diff --git a/tests/validate-rules.test.ts b/tests/validate-rules.test.ts index 15a171c..ffbf320 100644 --- a/tests/validate-rules.test.ts +++ b/tests/validate-rules.test.ts @@ -68,28 +68,29 @@ describe('validate-rules', () => { ]; describe('validation rules', () => { - const validationRules: RulesMetadata = { - validation: [ - { - id: 'solution-parent-type', - description: 'Parent must be an opportunity', - type: 'solution', - check: '$exists(parent) = false or $exists(nodes[title=$$.current.parent and resolvedType="opportunity"])', - }, - { - id: 'experiment-parent-type', - description: 'Parent must be a solution', - type: 'experiment', - check: '$exists(parent) and $exists(nodes[title=$$.current.parent and resolvedType="solution"])', - }, - { - id: 'outcome-no-parent', - description: 'Outcome nodes should not have a parent', - type: 'outcome', - check: '$exists(parent) = false', - }, - ], - }; + const validationRules: RulesMetadata = [ + { + id: 'solution-parent-type', + category: 'validation', + description: 'Parent must be an opportunity', + type: 'solution', + check: '$exists(parent) = false or $exists(nodes[title=$$.current.parent and resolvedType="opportunity"])', + }, + { + id: 'experiment-parent-type', + category: 'validation', + description: 'Parent must be a solution', + type: 'experiment', + check: '$exists(parent) and $exists(nodes[title=$$.current.parent and resolvedType="solution"])', + }, + { + id: 'outcome-no-parent', + category: 'validation', + description: 'Outcome nodes should not have a parent', + type: 'outcome', + check: '$exists(parent) = false', + }, + ]; it('passes validation when all rules are satisfied', async () => { const validNodes = mockNodes.filter((n) => n.label === 'solution.md'); @@ -141,16 +142,15 @@ describe('validate-rules', () => { }); describe('best-practice rules', () => { - const bestPracticeRules: RulesMetadata = { - bestPractice: [ - { - id: 'solution-quantity', - description: 'Explore multiple candidate solutions for the target opportunity', - type: 'opportunity', - check: '$count(nodes[resolvedParentTitle=$$.current.title and resolvedType="solution"]) >= 3', - }, - ], - }; + const bestPracticeRules: RulesMetadata = [ + { + id: 'solution-quantity', + category: 'best-practice', + description: 'Explore multiple candidate solutions for the target opportunity', + type: 'opportunity', + check: '$count(nodes[resolvedParentTitle=$$.current.title and resolvedType="solution"]) >= 3', + }, + ]; it('passes when opportunity has enough solutions', async () => { const opportunityNode: SpaceNode = { @@ -217,21 +217,21 @@ describe('validate-rules', () => { }); describe('workflow rules', () => { - const workflowRules: RulesMetadata = { - workflow: [ - { - id: 'active-outcome-count', - description: 'Only one outcome should be active at a time', - scope: 'global', - check: '$count(nodes[resolvedType="outcome" and status="active"]) <= 1', - }, - { - id: 'active-node-parent-active', - description: "An active node's parent should also be active", - check: "current.status != 'active' or $exists(parent) = false or parent.status = 'active'", - }, - ], - }; + const workflowRules: RulesMetadata = [ + { + id: 'active-outcome-count', + category: 'workflow', + description: 'Only one outcome should be active at a time', + scope: 'global', + check: '$count(nodes[resolvedType="outcome" and status="active"]) <= 1', + }, + { + id: 'active-node-parent-active', + category: 'workflow', + description: "An active node's parent should also be active", + check: "current.status != 'active' or $exists(parent) = false or parent.status = 'active'", + }, + ]; it('passes when only one outcome is active', async () => { const nodes = mockNodes.filter((n) => n.schemaData.type === 'outcome'); @@ -263,9 +263,7 @@ describe('validate-rules', () => { resolvedType: 'solution', }, ]; - const activeCountViolations = await validateRules(multipleActiveOutcomes, { - workflow: [workflowRules.workflow![0]!], - }); + const activeCountViolations = await validateRules(multipleActiveOutcomes, [workflowRules[0]!]); expect(activeCountViolations).toHaveLength(1); // global rule produces one violation regardless of node count expect(activeCountViolations[0]?.ruleId).toBe('active-outcome-count'); expect(activeCountViolations[0]?.category).toBe('workflow'); @@ -293,7 +291,7 @@ describe('validate-rules', () => { resolvedParents: ['Outcome'], resolvedType: 'opportunity', }; - const violations = await validateRules([parentNode, childNode], { workflow: [workflowRules.workflow![1]!] }); + const violations = await validateRules([parentNode, childNode], [workflowRules[1]!]); expect(violations).toHaveLength(1); expect(violations[0]?.ruleId).toBe('active-node-parent-active'); expect(violations[0]?.category).toBe('workflow'); @@ -321,30 +319,28 @@ describe('validate-rules', () => { resolvedParents: ['Outcome'], resolvedType: 'opportunity', }; - const violations = await validateRules([parentNode, childNode], { workflow: [workflowRules.workflow![1]!] }); + const violations = await validateRules([parentNode, childNode], [workflowRules[1]!]); expect(violations).toHaveLength(0); }); }); describe('mixed categories', () => { - const mixedRules: RulesMetadata = { - validation: [ - { - id: 'solution-parent-type', - description: 'Parent must be an opportunity', - type: 'solution', - check: '$exists(parent) = false or $exists(nodes[title=$$.current.parent and resolvedType="opportunity"])', - }, - ], - workflow: [ - { - id: 'active-outcome-count', - description: 'Only one outcome should be active at a time', - scope: 'global', - check: '$count(nodes[resolvedType="outcome" and status="active"]) <= 1', - }, - ], - }; + const mixedRules: RulesMetadata = [ + { + id: 'solution-parent-type', + category: 'validation', + description: 'Parent must be an opportunity', + type: 'solution', + check: '$exists(parent) = false or $exists(nodes[title=$$.current.parent and resolvedType="opportunity"])', + }, + { + id: 'active-outcome-count', + category: 'workflow', + description: 'Only one outcome should be active at a time', + scope: 'global', + check: '$count(nodes[resolvedType="outcome" and status="active"]) <= 1', + }, + ]; it('collects violations from multiple categories', async () => { const nodes: SpaceNode[] = [