diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 076a180..a18bf1f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "name": "okf-graph-eng", "source": "./", "description": "Graph engineering for OKF repos \u2014 impact analysis, agent/harness graphs, progressive disclosure, typed edges, TicketLink/worklog bridges. Works in Claude Code and Grok Build.", - "version": "0.4.1", + "version": "0.5.0", "author": { "name": "Rick Hightower" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f1ebfd1..35ed283 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "okf-graph-eng", - "version": "0.4.1", + "version": "0.5.0", "description": "Graph engineering for OKF repos — impact analysis, agent/harness graphs, progressive disclosure, typed edges, TicketLink/worklog bridges, and curation. Works in Claude Code and Grok Build.", "author": { "name": "Rick Hightower", diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index 29bb757..9471c16 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -1,13 +1,13 @@ { "name": "okf-plugin-marketplace", "description": "Optional native Grok marketplace metadata. Grok Build already loads Claude plugins with zero config; this file pins identity for Grok marketplace listings.", - "version": "0.4.1", + "version": "0.5.0", "plugins": [ { "name": "okf-graph-eng", "source": ".", "description": "OKF graph engineering — impact analysis, agent graphs, progressive disclosure. Claude-compatible.", - "version": "0.4.1", + "version": "0.5.0", "compatibility": { "claude_plugin": true, "zero_config": true diff --git a/CHANGELOG.md b/CHANGELOG.md index d488335..47d49d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ Notable changes to **okf-graph-eng**. Newest first. Released sections are frozen — corrections go in the next release's notes. +## 0.5.0 — 2026-08-13 + +### Added + +- **Shared concept schema pack** at `schemas/okf-concepts/`. Canonical + `BaseConcept` (required: `type` + `title` only; `additionalProperties: true`) + plus TicketLink, Feature, DecisionRecord, Project, Catalog, ContextPack. +- `scripts/okf_schema.py` — stdlib subset validator. Soft by default. Merges + sibling plugin schema directories so a mixed second brain validates as one. +- `okf-graph.py schemas` lists the merged registry. +- `okf-graph.py validate` now runs schema checks (unknown types fall back to + BaseConcept as info; missing recommended fields are warnings). +- `truth_state` union: `current | snapshot | superseded | archived | historical | proposed`. +- TicketLink `kind=bug` refinement: warn unless the ticket links to a + Module/Package/Release/CodeChange or sets `branch`. Epic/story/task/bug + remain WikiTicket axes, not new concept types. +- Catalog ownership map in `registry.json` so plugins do not rewrite foreign catalogs. + ## 0.4.1 — 2026-08-10 ### Fixed diff --git a/README.md b/README.md index 5520f86..a0010ef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Works in **Claude Code** and **Grok Build** (zero-config: Grok Build reads Claud |---|---| | **Plugin name** | `okf-graph-eng` | | **Repo** | [SpillwaveSolutions/okf-plugin](https://github.com/SpillwaveSolutions/okf-plugin) | -| **Version** | 0.4.1 | +| **Version** | 0.5.0 | | **License** | MIT | ## Why this plugin diff --git a/marketplace.json b/marketplace.json index 975af24..c38727c 100644 --- a/marketplace.json +++ b/marketplace.json @@ -7,7 +7,7 @@ "name": "okf-graph-eng", "source": "./", "description": "Graph engineering for OKF repos", - "version": "0.4.1" + "version": "0.5.0" } ] } diff --git a/schemas/okf-concepts/BaseConcept.schema.json b/schemas/okf-concepts/BaseConcept.schema.json new file mode 100644 index 0000000..80b15a0 --- /dev/null +++ b/schemas/okf-concepts/BaseConcept.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/BaseConcept.schema.json", + "title": "OKF BaseConcept", + "description": "Shared frontmatter envelope for every OKF concept. Required fields stay at type + title forever on v1. extra properties are always allowed. truth_state accepts the union of PKC/SAC and DEKC vocabularies.", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { + "type": "string", + "minLength": 1, + "description": "Concept type. Unknown types fall back to this envelope." + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Recommended, never required on v1." + }, + "timestamp": { + "type": "string", + "description": "ISO-8601. Recommended, never required on v1." + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "status": { + "type": "string", + "description": "Free string. Domain plugins may recommend enums; unknown values warn, never error." + }, + "verified": { "type": "boolean" }, + "generated": { "type": "boolean" }, + "wiki_key": { "type": "string" }, + "truth_state": { + "type": "string", + "enum": [ + "current", + "snapshot", + "superseded", + "archived", + "historical", + "proposed" + ], + "description": "Union of PKC/SAC (current|snapshot|superseded|archived) and DEKC (current|historical|proposed)." + }, + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Emitted on new writes. Never required on read." + }, + "owner": { "type": "string" }, + "owners": { + "type": "array", + "items": { "type": "string" } + }, + "sources": { + "type": "array", + "items": { "type": "string" } + }, + "stale_after": { "type": "string" }, + "source_fingerprint": { "type": "string" }, + "worklog_id": { "type": "string" }, + "external_id": { "type": ["string", "number"] }, + "external_system": { "type": "string" }, + "cloud": { "type": "string" }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { + "type": "string", + "description": "Absolute in-bundle path, e.g. /features/checkout.md, or federation::/path" + }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/Catalog.schema.json b/schemas/okf-concepts/Catalog.schema.json new file mode 100644 index 0000000..276a1d4 --- /dev/null +++ b/schemas/okf-concepts/Catalog.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/Catalog.schema.json", + "title": "Catalog", + "description": "Index page for a catalog directory. Structural; usually skipped by concept walkers.", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "Catalog" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/ContextPack.schema.json b/schemas/okf-concepts/ContextPack.schema.json new file mode 100644 index 0000000..5f8fdb3 --- /dev/null +++ b/schemas/okf-concepts/ContextPack.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/ContextPack.schema.json", + "title": "ContextPack", + "description": "Generated progressive-disclosure view. May freely include nodes from any plugin. Not a catalog-owned type.", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "ContextPack" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "generated": { "type": "boolean" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/DecisionRecord.schema.json b/schemas/okf-concepts/DecisionRecord.schema.json new file mode 100644 index 0000000..71a23b8 --- /dev/null +++ b/schemas/okf-concepts/DecisionRecord.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/DecisionRecord.schema.json", + "title": "DecisionRecord", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "DecisionRecord" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "status": { "type": "string" }, + "wiki_key": { "type": "string" }, + "truth_state": { "type": "string" }, + "verified": { "type": "boolean" }, + "generated": { "type": "boolean" }, + "schema_version": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/Feature.schema.json b/schemas/okf-concepts/Feature.schema.json new file mode 100644 index 0000000..825c95e --- /dev/null +++ b/schemas/okf-concepts/Feature.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/Feature.schema.json", + "title": "Feature", + "description": "Product capability. Materialized from WikiTicket epic/story items.", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "Feature" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "status": { "type": "string" }, + "priority": { "type": "string" }, + "level": { "type": "string", "enum": ["epic", "story"] }, + "kind": { "type": "string" }, + "worklog_id": { "type": "string" }, + "external_id": { "type": ["string", "number"] }, + "external_system": { "type": "string" }, + "wiki_key": { "type": "string" }, + "truth_state": { "type": "string" }, + "verified": { "type": "boolean" }, + "generated": { "type": "boolean" }, + "schema_version": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/Project.schema.json b/schemas/okf-concepts/Project.schema.json new file mode 100644 index 0000000..b40464c --- /dev/null +++ b/schemas/okf-concepts/Project.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/Project.schema.json", + "title": "Project", + "description": "Top-level container for a multi-repo or federated second brain. Owns Features, Releases, and Packages.", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "Project" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "status": { "type": "string" }, + "wiki_key": { "type": "string" }, + "truth_state": { "type": "string" }, + "verified": { "type": "boolean" }, + "schema_version": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schemas/okf-concepts/README.md b/schemas/okf-concepts/README.md new file mode 100644 index 0000000..94d38fd --- /dev/null +++ b/schemas/okf-concepts/README.md @@ -0,0 +1,38 @@ +# Shared OKF concept schemas + +This is the **canonical envelope** for every plugin in the second-brain family +(okf-plugin, PKC, SAC, DEKC, AGER). + +## Rules (load-bearing) + +1. Markdown body is always free-form. Only YAML frontmatter is schema-checked. +2. **Required on v1: `type` + `title` only.** Never tighten without a major + `schema_version` and a migration. +3. `additionalProperties: true` forever on BaseConcept. +4. Soft validation is the default. Old files must produce **zero errors**. +5. `truth_state` accepts the union of PKC/SAC and DEKC values: + `current | snapshot | superseded | archived | historical | proposed`. +6. Unknown `type` values fall back to BaseConcept (info, not error). +7. Epic / Story / Task / Subtask / Bug are **not** concept types. They live + as `level` / `kind` on `TicketLink` (and `level` on `Feature` for epic/story). +8. Domain plugins add types under their own `schemas/okf-concepts/` and the + validator merges directories. Do not fork BaseConcept. + +## Catalog ownership + +A plugin may only rewrite catalogs listed under its name in `registry.json` +`catalog_ownership`. Foreign catalogs are read-only. + +In a shared second brain: + +- `Module` lives in `modules/` (SAC convention). +- `Package` lives in `packages/`. +- Cross-plugin links are absolute in-bundle paths (`/tables/orders.md`). + +## Validate + +```bash +python3 scripts/okf_schema.py list +python3 scripts/okf-graph.py validate sample-okf +python3 scripts/okf-graph.py validate sample-okf --schema +``` diff --git a/schemas/okf-concepts/TicketLink.schema.json b/schemas/okf-concepts/TicketLink.schema.json new file mode 100644 index 0000000..8f15a01 --- /dev/null +++ b/schemas/okf-concepts/TicketLink.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spillwave.solutions/schemas/okf-concepts/TicketLink.schema.json", + "title": "TicketLink", + "description": "Bridge node from WikiTicket work items into the knowledge graph. Epics/stories/tasks/bugs are NOT top-level types — they are level + kind on this node (and on Feature for epic/story).", + "type": "object", + "required": ["type", "title"], + "properties": { + "type": { "const": "TicketLink" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "timestamp": { "type": "string" }, + "status": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "verified": { "type": "boolean" }, + "generated": { "type": "boolean" }, + "wiki_key": { "type": "string" }, + "truth_state": { "type": "string" }, + "schema_version": { "type": "integer" }, + "worklog_id": { "type": "string" }, + "external_id": { "type": ["string", "number"] }, + "external_system": { "type": "string" }, + "level": { + "type": "string", + "enum": ["epic", "story", "task", "subtask"], + "description": "WikiTicket level. Not a concept type." + }, + "kind": { + "type": "string", + "enum": ["feature", "bug", "ops", "triage"], + "description": "WikiTicket kind. Not a concept type." + }, + "branch": { + "type": "string", + "description": "Source branch. Field, not a concept type." + }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { "type": "string" }, + "rel": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "bug" } }, + "required": ["kind"] + }, + "then": { + "description": "Bugs should point at the thing they affect. Recommended (warn, not error): at least one of affects / reproduces_in / fixed_in / lands_in / implements targeting a Module, Package, Release, or CodeChange.", + "x-recommended-link-rels": ["affects", "reproduces_in", "fixed_in", "lands_in", "implements"] + } + } + ] +} diff --git a/schemas/okf-concepts/registry.json b/schemas/okf-concepts/registry.json new file mode 100644 index 0000000..ad32744 --- /dev/null +++ b/schemas/okf-concepts/registry.json @@ -0,0 +1,58 @@ +{ + "name": "okf-core-concept-schemas", + "version": "1.0.0", + "okf_version": "0.2", + "schema_version": 1, + "description": "Shared OKF concept envelope and core types. Domain plugins add their own schemas/okf-concepts/ directories; validators merge registries.", + "base": "BaseConcept.schema.json", + "plugin": "okf-plugin", + "concepts": [ + "BaseConcept", + "Catalog", + "ContextPack", + "Project", + "TicketLink", + "DecisionRecord", + "Feature", + "AgentNode", + "Workflow", + "Harness", + "SharedState", + "ToolCapability", + "Dataset", + "Table", + "Metric", + "Playbook", + "Runbook", + "API", + "Reference" + ], + "catalogs": { + "tickets": { "owner": "okf-plugin", "types": ["TicketLink"] }, + "decisions": { "owner": "project-knowledge-capture", "types": ["DecisionRecord"] }, + "features": { "owner": "project-knowledge-capture", "types": ["Feature"] }, + "projects": { "owner": "okf-plugin", "types": ["Project"] }, + "packs": { "owner": "shared", "types": ["ContextPack"], "indexed": false }, + "agents": { "owner": "okf-plugin", "types": ["AgentNode"] }, + "workflows": { "owner": "okf-plugin", "types": ["Workflow"] } + }, + "catalog_ownership": { + "okf-plugin": ["tickets", "agents", "workflows"], + "project-knowledge-capture": [ + "meetings", "experiments", "discoveries", "decisions", "assumptions", + "questions", "features", "requirements", "specs", "designs", "releases", + "code", "packages", "tickets", "risks", "acceptance" + ], + "system-architecture-capture": ["modules", "services", "systems", "apis", "environments"], + "data-engineering-knowledge-capture": [ + "tables", "views", "streams", "lakes", "marts", "metrics", "dashboards" + ], + "okf-agent-graph": ["runtime", "ops", "memory", "evaluation"] + }, + "notes": { + "Module": "In a shared second brain, Module lives in modules/ (SAC convention). PKC historically aliased Module → packages/; new writes should use modules/.", + "Package": "Build unit. Always packages/.", + "Dashboard": "DEKC = BI dashboard. SAC = observability dashboard. Same type name, different catalogs (dashboards/ vs observability/).", + "required_fields": "BaseConcept v1 requires only type + title. Never tighten without a major schema_version and a migration." + } +} diff --git a/scripts/okf-graph.py b/scripts/okf-graph.py index 31491e2..cecb0ce 100755 --- a/scripts/okf-graph.py +++ b/scripts/okf-graph.py @@ -9,6 +9,7 @@ okf-graph.py edges [--from PATH] [--rel REL] okf-graph.py graph [--format mermaid|json|html] [--focus PATH] [--hops N] okf-graph.py validate [--strict] + okf-graph.py schemas okf-graph.py orphans """ @@ -25,6 +26,15 @@ from pathlib import Path from typing import Any +_SCRIPTS = Path(__file__).resolve().parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) +try: + from okf_schema import load_default_registry # type: ignore +except ImportError: # pragma: no cover — always present next to this file + load_default_registry = None # type: ignore + + FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) # The label alternation keeps `[^\]]` — the entirety of the previous pattern's # label language — and *adds* balanced `[...]` pairs, tried first. Keeping the @@ -1044,6 +1054,7 @@ def cmd_graph(bundle: Path, fmt: str, focus: str | None, hops: int) -> int: def cmd_validate(bundle: Path, strict: bool = False) -> int: concepts = load_bundle(bundle) issues: list[dict[str, str]] = [] + schema_registry = load_default_registry(start=bundle) if load_default_registry else None if not (bundle / "index.md").exists(): issues.append({"severity": "error", "message": "missing root index.md"}) for rel, c in concepts.items(): @@ -1083,6 +1094,14 @@ def cmd_validate(bundle: Path, strict: bool = False) -> int: "message": "TicketLink missing external_id/worklog_id", } ) + if schema_registry is not None and not structural: + for issue in schema_registry.validate_frontmatter(c.meta, path=rel): + # Avoid duplicating the type/title checks already emitted above. + if issue.message.startswith("missing required `type`"): + continue + if issue.message.startswith("missing required `title`"): + continue + issues.append(issue.as_dict()) inbound = build_inbound(concepts) orphans = [ rel @@ -1115,6 +1134,7 @@ def cmd_validate(bundle: Path, strict: bool = False) -> int: "error_count": errors, "warn_count": warnings, "strict": strict, + "schema_dirs": [str(d) for d in schema_registry.dirs] if schema_registry else [], }, indent=2, ) @@ -1184,7 +1204,27 @@ def main() -> int: s = sub.add_parser("orphans") s.add_argument("bundle") + sub.add_parser("schemas", help="List merged concept schemas") + args = p.parse_args() + if args.cmd == "schemas": + if load_default_registry is None: + print(json.dumps({"error": "okf_schema.py not found"})) + return 1 + reg = load_default_registry() + print( + json.dumps( + { + "dirs": [str(d) for d in reg.dirs], + "types": sorted(reg.known_types), + "base_required": (reg.base or {}).get("required"), + "catalog_ownership": reg.catalog_ownership, + }, + indent=2, + ) + ) + return 0 + bundle = Path(args.bundle).resolve() if not bundle.is_dir(): print(json.dumps({"error": f"bundle not found: {bundle}"})) diff --git a/scripts/okf_schema.py b/scripts/okf_schema.py new file mode 100644 index 0000000..44af9a5 --- /dev/null +++ b/scripts/okf_schema.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Shared OKF concept-schema loader and subset validator. + +Stdlib only. Soft by default: missing recommended fields and unknown types +are warnings. Required on BaseConcept v1 is type + title only. + +Domain plugins call: + + from okf_schema import SchemaRegistry, discover_schema_dirs + + reg = SchemaRegistry() + for d in discover_schema_dirs(extra=args.schema_dirs): + reg.load_dir(d) + issues = reg.validate_frontmatter(fm) +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +CORE_SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schemas" / "okf-concepts" + +TRUTH_STATES = frozenset( + { + "current", + "snapshot", + "superseded", + "archived", # PKC / SAC + "historical", + "proposed", # DEKC + } +) + +BUG_RECOMMENDED_RELS = frozenset( + {"affects", "reproduces_in", "fixed_in", "lands_in", "implements"} +) + + +@dataclass +class Issue: + severity: str # error | warn | info + message: str + path: str = "" + type: str = "" + + def as_dict(self) -> dict[str, str]: + d = {"severity": self.severity, "message": self.message} + if self.path: + d["path"] = self.path + if self.type: + d["type"] = self.type + return d + + +class SchemaRegistry: + def __init__(self) -> None: + self.schemas: dict[str, dict[str, Any]] = {} + self.base: dict[str, Any] | None = None + self.dirs: list[Path] = [] + self.known_types: set[str] = set() + self.catalog_ownership: dict[str, list[str]] = {} + + def load_dir(self, directory: Path) -> int: + directory = directory.resolve() + if not directory.is_dir(): + return 0 + if directory in self.dirs: + return 0 + self.dirs.append(directory) + loaded = 0 + registry = directory / "registry.json" + if registry.is_file(): + try: + doc = json.loads(registry.read_text(encoding="utf-8")) + except json.JSONDecodeError: + doc = {} + for name in doc.get("concepts") or []: + self.known_types.add(str(name)) + owners = doc.get("catalog_ownership") or {} + if isinstance(owners, dict): + for plugin, catalogs in owners.items(): + self.catalog_ownership.setdefault(str(plugin), []) + for c in catalogs or []: + if c not in self.catalog_ownership[str(plugin)]: + self.catalog_ownership[str(plugin)].append(c) + for path in sorted(directory.glob("*.schema.json")): + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + stem = path.name[: -len(".schema.json")] + self.schemas[stem] = schema + self.known_types.add(stem) + loaded += 1 + if stem == "BaseConcept": + self.base = schema + return loaded + + def schema_for(self, type_name: str) -> dict[str, Any] | None: + if type_name in self.schemas: + return self.schemas[type_name] + if type_name == "Dataset": + return self.schemas.get("Table") + return None + + def validate_frontmatter( + self, + fm: dict[str, Any], + *, + path: str = "", + strict: bool = False, + ) -> list[Issue]: + issues: list[Issue] = [] + type_name = str(fm.get("type") or "") + title = fm.get("title") + if not type_name: + issues.append(Issue("error", "missing required `type`", path)) + if title in (None, ""): + issues.append(Issue("error", "missing required `title`", path, type_name)) + + schema = self.schema_for(type_name) if type_name else None + if type_name and schema is None: + if type_name not in ("", "Unknown", "Index", "Catalog"): + issues.append( + Issue( + "info", + f"unknown type `{type_name}` — falling back to BaseConcept", + path, + type_name, + ) + ) + schema = self.base + + if schema: + issues.extend(_check_schema(fm, schema, path=path, type_name=type_name, strict=strict)) + + ts = fm.get("truth_state") + if ts and ts not in TRUTH_STATES: + issues.append( + Issue("warn", f"unusual truth_state `{ts}`", path, type_name) + ) + + if type_name == "TicketLink" and fm.get("kind") == "bug": + issues.extend(_check_bug_refinement(fm, path=path)) + + return issues + + +def _type_ok(value: Any, schema_type: Any) -> bool: + if isinstance(schema_type, list): + return any(_type_ok(value, t) for t in schema_type) + if schema_type == "string": + return isinstance(value, str) + if schema_type == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if schema_type == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if schema_type == "boolean": + return isinstance(value, bool) + if schema_type == "array": + return isinstance(value, list) + if schema_type == "object": + return isinstance(value, dict) + return True + + +def _check_schema( + fm: dict[str, Any], + schema: dict[str, Any], + *, + path: str, + type_name: str, + strict: bool, +) -> list[Issue]: + issues: list[Issue] = [] + required = schema.get("required") or [] + props = schema.get("properties") or {} + for key in required: + if fm.get(key) in (None, ""): + # type+title already reported; extra required would be domain-local + if key in ("type", "title"): + continue + sev = "error" if strict else "warn" + issues.append(Issue(sev, f"missing required `{key}`", path, type_name)) + for key, value in fm.items(): + if key not in props: + continue + p = props[key] + if "const" in p and value != p["const"]: + issues.append( + Issue("warn", f"`{key}` must be {p['const']!r}, got {value!r}", path, type_name) + ) + if "enum" in p and value not in p["enum"] and value != "": + # soft: unknown enum is a warning so old files keep working + issues.append( + Issue( + "warn", + f"`{key}` value {value!r} not in enum {p['enum']}", + path, + type_name, + ) + ) + if "type" in p and not _type_ok(value, p["type"]): + issues.append( + Issue("warn", f"`{key}` wrong type for {p['type']}", path, type_name) + ) + if p.get("type") == "array" and isinstance(value, list): + item = p.get("items") or {} + if item.get("type") == "object": + for i, el in enumerate(value): + if not isinstance(el, dict): + issues.append( + Issue("error", f"`{key}[{i}]` must be object", path, type_name) + ) + continue + for rk in item.get("required") or []: + if rk not in el: + issues.append( + Issue( + "error", + f"`{key}[{i}]` missing `{rk}`", + path, + type_name, + ) + ) + return issues + + +def _check_bug_refinement(fm: dict[str, Any], *, path: str) -> list[Issue]: + links = fm.get("links") or [] + rels = set() + if isinstance(links, list): + for link in links: + if isinstance(link, dict) and link.get("rel"): + rels.add(str(link["rel"])) + if rels & BUG_RECOMMENDED_RELS: + return [] + if fm.get("branch"): + return [] + return [ + Issue( + "warn", + "kind=bug should link to a Module/Package/Release/CodeChange " + f"(rels {sorted(BUG_RECOMMENDED_RELS)}) or set `branch`", + path, + "TicketLink", + ) + ] + + +def discover_schema_dirs(*, extra: list[Path] | None = None, start: Path | None = None) -> list[Path]: + """Find schema packs: core okf-plugin first, then sibling checkouts, then extras.""" + found: list[Path] = [] + seen: set[Path] = set() + + def _add(p: Path) -> None: + p = p.resolve() + if p.is_dir() and p not in seen: + seen.add(p) + found.append(p) + + _add(CORE_SCHEMA_DIR) + env = os.environ.get("OKF_SCHEMA_DIRS", "") + for part in env.split(os.pathsep): + if part.strip(): + _add(Path(part.strip())) + + roots: list[Path] = [] + if start: + roots.append(start.resolve()) + here = Path(__file__).resolve().parent.parent + roots.append(here.parent) # .../repos + for root in roots: + for name in ( + "okf-plugin", + "project-knowledge-capture", + "system-architecture-capture", + "data-engineering-knowledge-capture", + "okf-agent-graph", + ): + _add(root / name / "schemas" / "okf-concepts") + _add(root / name / "schemas") + + for p in extra or []: + _add(p) + return found + + +def load_default_registry(*, extra: list[Path] | None = None, start: Path | None = None) -> SchemaRegistry: + reg = SchemaRegistry() + for d in discover_schema_dirs(extra=extra, start=start): + reg.load_dir(d) + return reg + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="OKF shared concept schemas") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("list", help="List known types from merged registries") + p_val = sub.add_parser("validate-fm", help="Validate a JSON frontmatter object from stdin") + p_val.add_argument("--strict", action="store_true") + args = parser.parse_args(argv) + + reg = load_default_registry() + if args.cmd == "list": + print( + json.dumps( + { + "dirs": [str(d) for d in reg.dirs], + "types": sorted(reg.known_types), + "catalog_ownership": reg.catalog_ownership, + "base_required": (reg.base or {}).get("required"), + }, + indent=2, + ) + ) + return 0 + if args.cmd == "validate-fm": + fm = json.load(sys.stdin) + issues = reg.validate_frontmatter(fm, strict=args.strict) + print(json.dumps([i.as_dict() for i in issues], indent=2)) + errors = sum(1 for i in issues if i.severity == "error") + return 1 if errors else 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/mixed-second-brain/code/pr-99-timeout.md b/tests/fixtures/mixed-second-brain/code/pr-99-timeout.md new file mode 100644 index 0000000..72cf044 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/code/pr-99-timeout.md @@ -0,0 +1,20 @@ +--- +type: CodeChange +title: "PR #99 Raise Stripe timeout" +description: Bump client timeout to 45s. +timestamp: 2026-08-13T00:00:00Z +pr_number: 99 +branch: fix/stripe-timeout +merged_at: 2026-08-13T12:00:00Z +status: merged +verified: true +truth_state: current +tags: [code, pr] +links: + - target: /features/checkout.md + rel: implements + - target: /releases/v1-0-0.md + rel: lands_in +--- + +# PR #99 diff --git a/tests/fixtures/mixed-second-brain/decisions/use-stripe.md b/tests/fixtures/mixed-second-brain/decisions/use-stripe.md new file mode 100644 index 0000000..355c63c --- /dev/null +++ b/tests/fixtures/mixed-second-brain/decisions/use-stripe.md @@ -0,0 +1,28 @@ +--- +type: DecisionRecord +title: Use Stripe for card processing +description: Charge cards via Stripe rather than a custom processor. +timestamp: 2026-08-13T00:00:00Z +status: accepted +verified: true +truth_state: current +wiki_key: adr-use-stripe +tags: [decision, adr] +links: + - target: /features/checkout.md + rel: decides +--- + +# Use Stripe + +## Context + +Need a PCI-compliant processor. + +## Decision + +Stripe. + +## Consequences + +Webhook + idempotency keys. diff --git a/tests/fixtures/mixed-second-brain/features/checkout.md b/tests/fixtures/mixed-second-brain/features/checkout.md new file mode 100644 index 0000000..cf74ac5 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/features/checkout.md @@ -0,0 +1,27 @@ +--- +type: Feature +title: Checkout +description: One-click checkout for returning customers. +timestamp: 2026-08-13T00:00:00Z +status: active +level: story +kind: feature +verified: false +truth_state: current +schema_version: 1 +wiki_key: feature-checkout +tags: [feature, pkc] +links: + - target: /decisions/use-stripe.md + rel: designed_by + - target: /modules/checkout-domain.md + rel: implements + - target: /tables/gold-orders.md + rel: related_to + - target: /tickets/ticket-bug-timeout.md + rel: tracks +--- + +# Checkout + +Product capability that spans architecture (SAC Module) and data (DEKC Table). diff --git a/tests/fixtures/mixed-second-brain/index.md b/tests/fixtures/mixed-second-brain/index.md new file mode 100644 index 0000000..2e1b60a --- /dev/null +++ b/tests/fixtures/mixed-second-brain/index.md @@ -0,0 +1,12 @@ +--- +okf_version: "0.2" +title: Mixed second brain (PKC + SAC + DEKC + AGER) +description: End-to-end fixture proving one knowledge root can hold every plugin's types. +timestamp: 2026-08-13T00:00:00Z +--- + +# Mixed second brain + +Cross-plugin chain: + +Feature (PKC) → DecisionRecord (PKC) → Module (SAC) → Package (SAC) → Table (DEKC) → TicketLink bug (PKC) → CodeChange (PKC) → Release (PKC) diff --git a/tests/fixtures/mixed-second-brain/log.md b/tests/fixtures/mixed-second-brain/log.md new file mode 100644 index 0000000..8e96762 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/log.md @@ -0,0 +1,3 @@ +# Log + +- 2026-08-13: mixed second-brain fixture created for schema e2e. diff --git a/tests/fixtures/mixed-second-brain/modules/checkout-domain.md b/tests/fixtures/mixed-second-brain/modules/checkout-domain.md new file mode 100644 index 0000000..3b07f27 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/modules/checkout-domain.md @@ -0,0 +1,19 @@ +--- +type: Module +title: checkout.domain +description: Domain logic for checkout. +timestamp: 2026-08-13T00:00:00Z +status: active +verified: true +truth_state: current +tags: [module, sac] +links: + - target: /packages/npm-checkout.md + rel: related_to + - target: /features/checkout.md + rel: implements +--- + +# checkout.domain + +SAC Module living in `modules/` (shared-brain convention). diff --git a/tests/fixtures/mixed-second-brain/packages/npm-checkout.md b/tests/fixtures/mixed-second-brain/packages/npm-checkout.md new file mode 100644 index 0000000..cbee0d9 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/packages/npm-checkout.md @@ -0,0 +1,15 @@ +--- +type: Package +title: "@northstar/checkout" +description: npm package that ships the checkout client. +timestamp: 2026-08-13T00:00:00Z +status: active +verified: true +truth_state: current +tags: [package] +links: + - target: /modules/checkout-domain.md + rel: related_to +--- + +# @northstar/checkout diff --git a/tests/fixtures/mixed-second-brain/projects/northstar.md b/tests/fixtures/mixed-second-brain/projects/northstar.md new file mode 100644 index 0000000..23be77f --- /dev/null +++ b/tests/fixtures/mixed-second-brain/projects/northstar.md @@ -0,0 +1,18 @@ +--- +type: Project +title: Northstar Commerce +description: Top-level container for the mixed second brain. +timestamp: 2026-08-13T00:00:00Z +status: active +verified: true +truth_state: current +schema_version: 1 +tags: [project] +links: + - target: /features/checkout.md + rel: owns + - target: /releases/v1-0-0.md + rel: owns +--- + +# Northstar Commerce diff --git a/tests/fixtures/mixed-second-brain/releases/v1-0-0.md b/tests/fixtures/mixed-second-brain/releases/v1-0-0.md new file mode 100644 index 0000000..4234171 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/releases/v1-0-0.md @@ -0,0 +1,19 @@ +--- +type: Release +title: v1.0.0 +description: First checkout release. +timestamp: 2026-08-13T00:00:00Z +version: 1.0.0 +tag: v1.0.0 +status: shipped +verified: true +truth_state: current +tags: [release] +links: + - target: /features/checkout.md + rel: lands_in + - target: /code/pr-99-timeout.md + rel: released_in +--- + +# v1.0.0 diff --git a/tests/fixtures/mixed-second-brain/tables/gold-orders.md b/tests/fixtures/mixed-second-brain/tables/gold-orders.md new file mode 100644 index 0000000..9246393 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/tables/gold-orders.md @@ -0,0 +1,18 @@ +--- +type: Table +title: gold_order_daily +description: Daily order grain in the gold layer. +timestamp: 2026-08-13T00:00:00Z +layer: gold +fqn: retail.gold.order_daily +truth_state: historical +cloud: azure +tags: [table, dekc] +links: + - target: /features/checkout.md + rel: related_to +--- + +# gold_order_daily + +DEKC Table. `truth_state: historical` must be accepted by the shared envelope. diff --git a/tests/fixtures/mixed-second-brain/tickets/ticket-bug-timeout.md b/tests/fixtures/mixed-second-brain/tickets/ticket-bug-timeout.md new file mode 100644 index 0000000..88bad22 --- /dev/null +++ b/tests/fixtures/mixed-second-brain/tickets/ticket-bug-timeout.md @@ -0,0 +1,27 @@ +--- +type: TicketLink +title: Checkout times out on slow cards +description: Bug ticket for the 30s Stripe timeout. +timestamp: 2026-08-13T00:00:00Z +status: open +kind: bug +level: task +worklog_id: 01KBUGTIMEOUT000000000001 +external_id: "88" +external_system: github +verified: false +truth_state: current +schema_version: 1 +tags: [ticket, bug] +links: + - target: /modules/checkout-domain.md + rel: affects + - target: /features/checkout.md + rel: tracks + - target: /code/pr-99-timeout.md + rel: fixed_in +--- + +# Checkout times out + +`kind: bug` with structural links — must not warn. diff --git a/tests/test_okf_schema.py b/tests/test_okf_schema.py new file mode 100644 index 0000000..9f30f06 --- /dev/null +++ b/tests/test_okf_schema.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Tests for the shared OKF concept schema pack.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "scripts")) +from okf_schema import SchemaRegistry, TRUTH_STATES, load_default_registry # noqa: E402 + + +def test_base_concept_required_is_only_type_and_title(): + base = json.loads((REPO / "schemas/okf-concepts/BaseConcept.schema.json").read_text()) + assert base["required"] == ["type", "title"] + assert base["additionalProperties"] is True + enum = set(base["properties"]["truth_state"]["enum"]) + assert enum == set(TRUTH_STATES) + + +def test_legacy_minimal_frontmatter_has_zero_errors(): + reg = load_default_registry() + issues = reg.validate_frontmatter({"type": "Playbook", "title": "How to pack"}) + assert not any(i.severity == "error" for i in issues), issues + + +def test_missing_title_is_error(): + reg = load_default_registry() + issues = reg.validate_frontmatter({"type": "Feature"}) + assert any(i.severity == "error" and "title" in i.message for i in issues) + + +def test_truth_state_union_accepts_dekc_and_pkc(): + reg = load_default_registry() + for ts in ("current", "snapshot", "superseded", "archived", "historical", "proposed"): + issues = reg.validate_frontmatter( + {"type": "Feature", "title": "x", "truth_state": ts} + ) + assert not any("truth_state" in i.message for i in issues), (ts, issues) + issues = reg.validate_frontmatter( + {"type": "Feature", "title": "x", "truth_state": "nope"} + ) + assert any(i.severity == "warn" and "truth_state" in i.message for i in issues) + + +def test_bug_kind_warns_without_structural_link(): + reg = load_default_registry() + issues = reg.validate_frontmatter( + {"type": "TicketLink", "title": "crash", "kind": "bug"} + ) + assert any(i.severity == "warn" and "kind=bug" in i.message for i in issues) + issues = reg.validate_frontmatter( + { + "type": "TicketLink", + "title": "crash", + "kind": "bug", + "links": [{"target": "/modules/auth.md", "rel": "affects"}], + } + ) + assert not any("kind=bug" in i.message for i in issues) + + +def test_sample_okf_has_zero_schema_errors(): + proc = subprocess.run( + [sys.executable, str(REPO / "scripts/okf-graph.py"), "validate", "sample-okf"], + cwd=REPO, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + out = json.loads(proc.stdout) + assert out["error_count"] == 0, out["issues"] + assert out["concept_count"] == 22 + + +def test_schemas_subcommand_lists_base(): + proc = subprocess.run( + [sys.executable, str(REPO / "scripts/okf-graph.py"), "schemas"], + cwd=REPO, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["base_required"] == ["type", "title"] + assert "TicketLink" in out["types"] + assert "Project" in out["types"] + + + + +def test_mixed_fixture_validates(): + fixture = REPO / "tests/fixtures/mixed-second-brain" + if not fixture.is_dir(): + return + proc = subprocess.run( + [sys.executable, str(REPO / "scripts/okf-graph.py"), "validate", str(fixture)], + cwd=REPO, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + out = json.loads(proc.stdout) + assert out["error_count"] == 0, out["issues"] + + +if __name__ == "__main__": + tests = [v for k, v in globals().items() if k.startswith("test_")] + failed = 0 + for fn in tests: + try: + fn() + print(f"ok {fn.__name__}") + except Exception as exc: + failed += 1 + print(f"FAIL {fn.__name__}: {exc}") + raise SystemExit(1 if failed else 0)