diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dbe932e..076a180 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.0", + "version": "0.4.1", "author": { "name": "Rick Hightower" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1562e4f..f1ebfd1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "okf-graph-eng", - "version": "0.4.0", + "version": "0.4.1", "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 786a612..29bb757 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.0", + "version": "0.4.1", "plugins": [ { "name": "okf-graph-eng", "source": ".", "description": "OKF graph engineering — impact analysis, agent graphs, progressive disclosure. Claude-compatible.", - "version": "0.4.0", + "version": "0.4.1", "compatibility": { "claude_plugin": true, "zero_config": true diff --git a/CHANGELOG.md b/CHANGELOG.md index b0609c4..d488335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ Notable changes to **okf-graph-eng**. Newest first. Released sections are frozen — corrections go in the next release's notes. +## 0.4.1 — 2026-08-10 + +### Fixed + +- **`KNOWN_RELS` only knew 11 of the ~170 typed relations the four sibling + capture plugins declare, so `validate` buried real typos in noise on any + bundle built with them.** Each of `okf-agent-graph` (AGER), + `project-knowledge-capture` (PKC), `system-architecture-capture` (SAC), + and `data-engineering-knowledge-capture` (DEKC) declares its own + typed-edge vocabulary — in `docs/AGER_SPEC.md` / `docs/typed-edges.md`, + cross-checked against each plugin's own `DEFAULT_RELATIONS` constant and, + for SAC, its `schemas/types.json` relation registry (the one + `sac_validate.py` actually loads at runtime, which turned out to be more + complete than SAC's own prose doc — it was missing the entire C4 vocabulary + and 6 code-structure relations). `KNOWN_RELS` is now `CORE_RELS | + AGER_RELS | PKC_RELS | SAC_RELS | DEKC_RELS` (11 + 26 + 15 + 84 + 38 = 161 + after de-duplication), each a named, source-cited `frozenset` instead of + one flat literal. On the field-ops-knowledge-base project's two live + bundles this took `non-standard rel (allowed but uncommon)` info lines + from 8 → 0 (`knowledge/`, mostly PKC's `originates_from`) and 14 → 0 + (`agent-graph/`, AGER — already fixed by the first half of this change). + The drift-guard test now parses each installed sibling plugin's live + vocabulary source at test time (`test_known_rels_covers_sibling_plugin_vocabularies`) + instead of comparing two hardcoded literals, so a future plugin release + adding a relation fails the test instead of silently degrading back into + info-line noise; it falls back to a subset sanity check when the sibling + plugins aren't installed (e.g. in CI). (#51) + ## 0.4.0 — 2026-08-10 ### Fixed diff --git a/README.md b/README.md index 95262f5..5520f86 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.0 | +| **Version** | 0.4.1 | | **License** | MIT | ## Why this plugin diff --git a/marketplace.json b/marketplace.json index 162e4f0..975af24 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.0" + "version": "0.4.1" } ] } diff --git a/scripts/okf-graph.py b/scripts/okf-graph.py index d80781b..31491e2 100755 --- a/scripts/okf-graph.py +++ b/scripts/okf-graph.py @@ -33,8 +33,13 @@ # The quantifier is `+`, not `*`, so an empty label stays unmatched as before. LINK_RE = re.compile(r"\[((?:\[[^\[\]]*\]|[^\]])+)\]\(([^)]+)\)") -# Common typed-edge relations (non-breaking; Markdown links remain canonical) -KNOWN_RELS = frozenset( +# Common typed-edge relations (non-breaking; Markdown links remain canonical). +# +# Split by declaring plugin so provenance stays legible and adding a plugin's +# next release is a one-frozenset diff instead of a search through a 150+ +# entry flat literal. KNOWN_RELS itself is the union — that's the only name +# other code should reference. +CORE_RELS = frozenset( { "depends_on", "routes_to", @@ -50,6 +55,219 @@ } ) +# okf-agent-graph (AGER) 0.5.0, docs/AGER_SPEC.md "## Typed edges (AGER +# additions)" (same 31 rels also tabulated in +# skills/ager-author/references/typed-edges.md). 5 of AGER's 31 rels already +# overlap CORE_RELS (depends_on, implements, related_to, routes_to, uses); +# these are the other 26. Kept in sync by +# test_known_rels_covers_ager_vocabulary in tests/test_okf_graph.py. +AGER_RELS = frozenset( + { + "aggregates_from", + "appends_to", + "binds_secret", + "blocks", + "budgets", + "compensates_with", + "controlled_by", + "delegates_to", + "derived_from", + "fans_in_from", + "fans_out_to", + "guards", + "handoffs_to", + "isolates_context", + "judges", + "models_with", + "on_failure", + "output_of", + "rate_limited_by", + "reads_from", + "records_to", + "retries_with", + "retrieves_from", + "spawns", + "triggered_by", + "writes_to", + } +) + +# project-knowledge-capture (PKC) 0.6.0, docs/typed-edges.md. Extensions +# beyond CORE_RELS only (`released_in` is already in CORE_RELS, listed there +# as an alias of PKC's `lands_in`). Matches pkc_common.py DEFAULT_RELATIONS +# exactly — no doc/code drift found for this plugin. +PKC_RELS = frozenset( + { + "answers", + "assumes", + "blocks", + "decides", + "designed_by", + "discovered_in", + "exposes", + "informs", + "invalidates", + "lands_in", + "mitigates", + "originates_from", + "satisfies", + "validates", + "verified_by", + } +) + +# system-architecture-capture (SAC) 0.3.0, schemas/types.json +# relations.sac (the structured registry sac_validate.py itself loads at +# runtime as its known-rels source), not docs/typed-edges.md. The prose doc +# undercounts by 12: it omits the whole C4 vocabulary (c4_contains, +# c4_delivers, c4_implements, c4_uses, c4_view_of, zooms_into — documented +# instead in docs/c4-integration.md) and a handful of code-structure rels +# (defines, has_field, invokes, owns_capability, source_of, syncs_with) that +# only appear in sac_common.py DEFAULT_RELATIONS / schemas/types.json. +# schemas/types.json's "sac" bucket is a strict superset of both the doc +# table and DEFAULT_RELATIONS (after subtracting the rels SAC inherits from +# PKC, which PKC_RELS above already covers), so it's used as-is here. +SAC_RELS = frozenset( + { + "alerts_on", + "authenticates_via", + "authorizes_with", + "backed_by", + "backs_up", + "belongs_to_domain", + "builds", + "c4_contains", + "c4_delivers", + "c4_implements", + "c4_uses", + "c4_view_of", + "caches", + "calls", + "calls_function", + "complies_with", + "configures", + "connects_to", + "consumes_api", + "consumes_event", + "contains", + "contains_module", + "controls", + "declared_in", + "defines", + "depends_on_package", + "deploys_to", + "diagrams", + "dlq_for", + "emits", + "encrypts_with", + "exposes_api", + "exposes_ui", + "extends", + "flagged_by", + "flows_to", + "for_channel", + "has_class", + "has_field", + "has_function", + "has_method", + "hosted_on", + "illustrated_by", + "impacts", + "implements_interface", + "in_context", + "indexes", + "instantiates", + "integrates_with", + "invokes", + "journeys_through", + "measured_by", + "migrates", + "models", + "observed_by", + "owned_by", + "owns_capability", + "part_of", + "produces_artifact", + "provisions", + "publishes_event", + "publishes_to", + "reads_from", + "registers_schema", + "replicates_to", + "runs_in", + "schedules", + "secured_by", + "secured_by_waf", + "served_by", + "served_by_cdn", + "source_of", + "stores_in", + "streams_to", + "subscribes", + "subscribes_to", + "syncs_with", + "tested_by", + "triggers", + "trusts", + "visualizes", + "wireframes", + "writes_to", + "zooms_into", + } +) + +# data-engineering-knowledge-capture (DEKC) 0.2.0, docs/typed-edges.md UNION +# dekc_common.py DEFAULT_RELATIONS — the two disagree in both directions and +# neither is a superset, so this is the union rather than a pick. The doc +# has 4 rels the code doesn't (documented_by, has_wireframe, validated_by, +# wireframes); the code (which dekc_link.py's own CLI help calls "documented +# relations") has 4 the doc doesn't (aggregates, computes, documents_diagram, +# joins). +DEKC_RELS = frozenset( + { + "aggregates", + "belongs_to_domain", + "businessizes", + "cataloged_in", + "computes", + "consumes_stream", + "contains", + "defines", + "derived_from", + "documented_by", + "documents_diagram", + "feeds", + "glosses", + "has_wireframe", + "implements_contract", + "ingested_by", + "ingests_from", + "joins", + "lands_as", + "lands_into", + "layered_as", + "measures", + "models", + "part_of_lake", + "part_of_mart", + "promotes_to", + "publishes", + "quality_of", + "queries", + "reads_from", + "sourced_from", + "stored_in", + "transforms_to", + "validated_by", + "validates", + "visualizes", + "wireframes", + "writes_to", + } +) + +KNOWN_RELS = CORE_RELS | AGER_RELS | PKC_RELS | SAC_RELS | DEKC_RELS + HIGH_IMPACT_TYPES = frozenset({"AgentNode", "Workflow", "Harness", "SharedState"}) MEDIUM_IMPACT_TYPES = frozenset({"Dataset", "Table", "Metric", "API", "ToolCapability"}) diff --git a/tests/test_okf_graph.py b/tests/test_okf_graph.py index 86c86c8..5d1d51d 100644 --- a/tests/test_okf_graph.py +++ b/tests/test_okf_graph.py @@ -501,6 +501,168 @@ def test_released_in_is_a_known_rel(): assert "released_in" in f.read_text(), f"{rel} missing released_in" +def test_known_rels_covers_ager_vocabulary(): + """KNOWN_RELS must be a superset of AGER's declared typed-edge vocabulary. + + Source: okf-agent-graph's docs/AGER_SPEC.md, "## Typed edges (AGER + additions)" section (same 31 rels also tabulated in + skills/ager-author/references/typed-edges.md). okf-plugin cannot import + the sibling plugin, so its vocabulary is pinned here as a literal + constant. A real AGER bundle previously produced 15 'non-standard rel' + info lines, of which 13 were false positives from rels this allow-list + didn't know about yet — noise that buried 2 genuine typos. Without this + guard, a future AGER spec addition silently regresses back into that + noise instead of failing a test.""" + AGER_VOCAB = frozenset( + { + "routes_to", + "delegates_to", + "spawns", + "judges", + "aggregates_from", + "fans_out_to", + "fans_in_from", + "handoffs_to", + "guards", + "reads_from", + "writes_to", + "appends_to", + "records_to", + "models_with", + "isolates_context", + "uses", + "blocks", + "budgets", + "controlled_by", + "retries_with", + "compensates_with", + "on_failure", + "triggered_by", + "derived_from", + "output_of", + "retrieves_from", + "rate_limited_by", + "binds_secret", + "depends_on", + "implements", + "related_to", + } + ) + assert len(AGER_VOCAB) == 31, f"AGER vocab drifted from spec: {len(AGER_VOCAB)}" + missing = AGER_VOCAB - g.KNOWN_RELS + assert not missing, f"KNOWN_RELS is missing AGER relations: {sorted(missing)}" + + +PLUGIN_CACHE_ROOT = Path.home() / ".claude" / "plugins" / "cache" + +_BACKTICK_REL_RE = re.compile(r"`([a-z][a-z0-9_]*)`") + + +def _newest_installed_version(plugin_root: Path) -> Path | None: + """Highest-numbered `//` dir, or None if not installed.""" + if not plugin_root.is_dir(): + return None + versions = [ + d for d in plugin_root.iterdir() if d.is_dir() and re.fullmatch(r"\d+\.\d+\.\d+", d.name) + ] + if not versions: + return None + return max(versions, key=lambda d: tuple(int(p) for p in d.name.split("."))) + + +def _rels_from_markdown(path: Path, noise: frozenset[str] = frozenset()) -> set[str]: + """Every backtick-quoted `snake_case` token in a doc, minus known non-rel noise + (CLI flag names, YAML field names, AGER's non-rel spec params like `deadline`).""" + if not path.exists(): + return set() + return set(_BACKTICK_REL_RE.findall(path.read_text())) - noise + + +def _rels_from_py_tuple(path: Path, varname: str) -> set[str]: + """String literals inside a top-level `varname = (...)` tuple, e.g. DEFAULT_RELATIONS.""" + if not path.exists(): + return set() + m = re.search(rf"{varname}\s*=\s*\((.*?)\n\)", path.read_text(), re.DOTALL) + if not m: + return set() + return set(re.findall(r'"([a-z][a-z0-9_]*)"', m.group(1))) + + +def _rels_from_json_bucket(path: Path, key: str) -> set[str]: + if not path.exists(): + return set() + data = json.loads(path.read_text()) + return set((data.get("relations") or {}).get(key) or []) + + +def test_known_rels_covers_sibling_plugin_vocabularies(): + """Drift guard for PKC/SAC/DEKC, the three sibling plugins added on top of + AGER in this change. + + When the plugins are installed locally (they are on a dev machine that's + used all four), this test PARSES each one's own declared-vocabulary + source at run time and asserts every relation it declares is in + KNOWN_RELS — a genuine check that fails the moment a plugin adds a + relation this file hasn't caught up with yet, not a literal-vs-literal + tautology. + + Sources parsed (newest installed version of each, matching how + KNOWN_RELS was derived — see the comments above PKC_RELS/SAC_RELS/ + DEKC_RELS in scripts/okf-graph.py): + - PKC: docs/typed-edges.md (matches pkc_common.py DEFAULT_RELATIONS) + - SAC: docs/typed-edges.md is a known undercount (missing the C4 + vocabulary); schemas/types.json relations.sac is checked too, + since that's what sac_validate.py itself loads at runtime. + - DEKC: docs/typed-edges.md UNION dekc_common.py DEFAULT_RELATIONS — + the two disagree in both directions, so both are checked. + + In CI, none of these plugin caches exist, so this falls back to + asserting the five embedded frozensets (CORE_RELS/AGER_RELS/PKC_RELS/ + SAC_RELS/DEKC_RELS) are each a subset of KNOWN_RELS — trivially true by + construction, but it still catches a copy-paste slip where a rel was + added to a named set but the `|`-union wasn't updated to include it. + """ + plugins = { + "PKC": PLUGIN_CACHE_ROOT / "pkc-plugin-marketplace" / "project-knowledge-capture", + "SAC": PLUGIN_CACHE_ROOT / "sac-plugin-marketplace" / "system-architecture-capture", + "DEKC": PLUGIN_CACHE_ROOT + / "dekc-plugin-marketplace" + / "data-engineering-knowledge-capture", + } + any_installed = False + for name, root in plugins.items(): + version_dir = _newest_installed_version(root) + if version_dir is None: + continue + any_installed = True + declared: set[str] = set() + declared |= _rels_from_markdown( + version_dir / "docs" / "typed-edges.md", + noise=frozenset({"rel", "target", "capture_acceptance"}), + ) + if name == "SAC": + declared |= _rels_from_json_bucket(version_dir / "schemas" / "types.json", "sac") + if name in ("PKC", "DEKC"): + common_file = version_dir / "scripts" / f"{name.lower()}_common.py" + declared |= _rels_from_py_tuple(common_file, "DEFAULT_RELATIONS") + declared -= g.CORE_RELS + missing = declared - g.KNOWN_RELS + assert not missing, ( + f"KNOWN_RELS is missing {name} {version_dir.name} relations " + f"declared in {version_dir}: {sorted(missing)}" + ) + + if not any_installed: + for name, rels in ( + ("CORE_RELS", g.CORE_RELS), + ("AGER_RELS", g.AGER_RELS), + ("PKC_RELS", g.PKC_RELS), + ("SAC_RELS", g.SAC_RELS), + ("DEKC_RELS", g.DEKC_RELS), + ): + assert rels <= g.KNOWN_RELS, f"{name} is not a subset of KNOWN_RELS" + + def main() -> int: quiet = "-q" in sys.argv tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]