From 8eda8fe10aec2daa7a4dcc689b59514f1de74b5d Mon Sep 17 00:00:00 2001 From: Eugeniu Costetchi Date: Fri, 24 Jul 2026 00:10:40 +0300 Subject: [PATCH 1/2] feat(linkml-engineering): vendor tested Neo4j-targeting generators; add generated skill inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two confirmed bugs in the Neo4j Cypher-constraint and neomodel generators (a relationship whose LinkML range is abstract resolved to an undefined class; neomodel's reserved id/deleted/element_id attribute names crashed class definition), enriches the Cypher generator (existence constraints for required-multivalued properties, a Community-safe --profile, schema-driven CREATE INDEX via an opt-in annotation), and vendors both as tested repo tooling in tools/linkml_neo4j/ — a skill's home is knowledge, not a maintained codebase, so linkml-engineering documents and cites them rather than owning the code. project-setup gains a real --neo4j scaffold.sh flag that projects a pinned, refreshable copy, verified by actually running it. Also adds docs/skill-inventory.md: a generated (not hand-written) map of all 22 skills — bundle containment, cross-cutting purpose classification, and two distinct skill-to-skill relation types (mechanically parsed "depends on" vs the weaker "related") rendered as Mermaid diagrams, with a freshness gate so it can't silently drift from the skills it describes. Co-Authored-By: Claude Sonnet 5 --- .../references/generation-and-templates.md | 64 ++- .../project-setup/references/checklists.md | 7 + .../project-setup/references/interview.md | 5 +- .../skills/project-setup/references/layout.md | 4 + .../skills/project-setup/scripts/scaffold.sh | 33 +- AGENTS.md | 9 +- Makefile | 8 +- README.md | 5 +- docs/skill-inventory.md | 319 ++++++++++++++ .../linkml-neo4j-generators/.openspec.yaml | 2 + .../changes/linkml-neo4j-generators/design.md | 188 +++++++++ .../linkml-neo4j-generators/proposal.md | 153 +++++++ .../specs/linkml-engineering/spec.md | 66 +++ .../changes/linkml-neo4j-generators/tasks.md | 197 +++++++++ pytest.ini | 5 + .../references/generation-and-templates.md | 64 ++- skills/project-setup/references/checklists.md | 7 + skills/project-setup/references/interview.md | 5 +- skills/project-setup/references/layout.md | 4 + skills/project-setup/scripts/scaffold.sh | 33 +- tests/fixtures/linkml/hulubul/ATTRIBUTION.md | 18 + tests/fixtures/linkml/hulubul/hulubul.yaml | 26 ++ .../linkml/hulubul/hulubul_agent.yaml | 118 ++++++ .../linkml/hulubul/hulubul_channel.yaml | 89 ++++ .../linkml/hulubul/hulubul_common.yaml | 54 +++ .../linkml/hulubul/hulubul_feedback.yaml | 55 +++ .../linkml/hulubul/hulubul_request.yaml | 183 ++++++++ .../linkml/hulubul/hulubul_service.yaml | 124 ++++++ .../linkml/hulubul/hulubul_spatial.yaml | 149 +++++++ tests/fixtures/linkml/synthetic/library.yaml | 144 +++++++ tests/test_linkml_neo4j_generators.py | 259 ++++++++++++ tests/test_linkml_neo4j_integration.py | 158 +++++++ tests/test_opencode_gen.py | 17 + tests/test_skill_inventory.py | 35 ++ tools/linkml_neo4j/gen_neo4j_constraints.py | 217 ++++++++++ tools/linkml_neo4j/gen_neomodel.py | 271 ++++++++++++ tools/opencode_gen/gen.py | 4 +- tools/skill_inventory.py | 391 ++++++++++++++++++ 38 files changed, 3480 insertions(+), 10 deletions(-) create mode 100644 docs/skill-inventory.md create mode 100644 openspec/changes/linkml-neo4j-generators/.openspec.yaml create mode 100644 openspec/changes/linkml-neo4j-generators/design.md create mode 100644 openspec/changes/linkml-neo4j-generators/proposal.md create mode 100644 openspec/changes/linkml-neo4j-generators/specs/linkml-engineering/spec.md create mode 100644 openspec/changes/linkml-neo4j-generators/tasks.md create mode 100644 pytest.ini create mode 100644 tests/fixtures/linkml/hulubul/ATTRIBUTION.md create mode 100644 tests/fixtures/linkml/hulubul/hulubul.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_agent.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_channel.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_common.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_feedback.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_request.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_service.yaml create mode 100644 tests/fixtures/linkml/hulubul/hulubul_spatial.yaml create mode 100644 tests/fixtures/linkml/synthetic/library.yaml create mode 100644 tests/test_linkml_neo4j_generators.py create mode 100644 tests/test_linkml_neo4j_integration.py create mode 100644 tests/test_skill_inventory.py create mode 100644 tools/linkml_neo4j/gen_neo4j_constraints.py create mode 100644 tools/linkml_neo4j/gen_neomodel.py create mode 100644 tools/skill_inventory.py diff --git a/.opencode/skills/linkml-engineering/references/generation-and-templates.md b/.opencode/skills/linkml-engineering/references/generation-and-templates.md index d45594e..7296a0d 100644 --- a/.opencode/skills/linkml-engineering/references/generation-and-templates.md +++ b/.opencode/skills/linkml-engineering/references/generation-and-templates.md @@ -94,7 +94,69 @@ job** (conditional on the project using LinkML). First-class (wired and tested): **Pydantic, JSON Schema, OWL, SHACL** — the semantic core. Add the rest only when a project consumes the target: **TypeScript** (`gen-typescript`), **SQL DDL / SQLAlchemy** (`gen-sqlddl`/`gen-sqla` — keep the repository around the ORM hand-written, in `adapters/`), -**Markdown/HTML docs** (`gen-doc`), **JSON-LD context** (`gen-jsonld-context`), and **custom generators**. +**Markdown/HTML docs** (`gen-doc`), **JSON-LD context** (`gen-jsonld-context`), **Neo4j constraints + +neomodel OGM** (below), and further **custom generators** as a project needs them. + +### Neo4j constraints + neomodel OGM + +Two custom generators, both subclassing LinkML's `Generator` per the mechanism above. Documented +here because the behavior is LinkML-generator knowledge — but the code itself is **not a skill +asset**: a Skill's home is reusable *knowledge* (`spec/skill-repo-governance.md`), not a maintained, +tested codebase. The generators live as repo tooling at `tools/linkml_neo4j/` (tested by skillery's +own `tests/`, same pattern as `tools/repo_lint`/`tools/opencode_gen`), and `project-setup` is the one +that projects a pinned copy into a consuming repo — this skill cites them, it doesn't own them. +Enable when a project targets Neo4j: + +- **`gen-neo4j-constraints`** emits Cypher DDL (uniqueness, existence, property-type constraints, and + `CREATE INDEX` for any slot annotated `annotations: {neo4j_index: true}`). Targets **Neo4j Community + Edition only** — `--profile community` (uniqueness + indexes, the subset this generator can + currently prove runs on Community) vs `--profile full` (also existence/type, carried over from an + assumption not yet re-verified against a real Community instance; see the generator's own module + docstring for the current state of that verification). It emits no relationship-level constraints + and no application-layer validation (patterns, enum membership, numeric bounds, relationship + cardinality) — that's `gen-neomodel`'s job, on the Python side, not a second Cypher-side mechanism. +- **`gen-neomodel`** emits neomodel `StructuredNode` classes, mirroring the schema's `is_a` hierarchy as + real Python inheritance (abstract LinkML classes become `__abstract_node__ = True` bases) so that a + relationship whose range is an abstract class resolves correctly instead of pointing at an undefined + name. A slot's `any_of` range restriction is documented as a trailing comment on the generated + relationship, not mechanically enforced. Also note: neomodel reserves the Python attribute names + `id`, `deleted`, `element_id` — a schema whose identifier slot is (conventionally) named `id` gets a + `_`-suffixed Python attribute (`id_`) with `db_property=...` preserving the real Neo4j property key. + +#### Installing + +Pick your role: + +| You are… | Do this | +|---|---| +| **Scaffolding a new project** with `project-setup` | Answer LinkML (Q4.5) + Neo4j (Q5.1) in the interview, or pass `--neo4j` directly to `scaffold.sh` (product archetype only). This copies both generators into your repo's `scripts/`, pinned. | +| **Adding it to an existing project** | Re-run `project-setup` (`scaffold.sh ... --neo4j --skip-existing`) — additive, never touches unrelated files. | +| **Working inside skillery itself** | No install needed — run them directly from `tools/linkml_neo4j/`. | + +The pinned copy in your repo is a **snapshot**, not a live link back to skillery — refresh it by +re-running `project-setup` with `--force` (shows what changed; never silently overwritten), same +discipline as the `meaningfy` OpenSpec schema pin (`spine-projection.md`). + +#### Using + +```bash +# From your project root, once scripts/gen_neo4j_constraints.py + gen_neomodel.py exist: +pip install linkml click jinja2 neomodel # generator dependencies (not runtime deps of your project) + +python scripts/gen_neo4j_constraints.py model/schema.yaml --profile community > model/generated/neo4j/constraints.cypher +python scripts/gen_neomodel.py model/schema.yaml > model/generated/neomodel/ogm.py +``` + +Wire both into your project's `make generate-models` (or an equivalent `make neo4j-constraints` / +`make neomodel` target) the same way `gen-pydantic`/`gen-owl`/`gen-shacl` already are — see "The +`make generate-models` bridge" above. `--profile` defaults to `full`; pass `community` explicitly if +you're deploying to Neo4j Community and want only the constraint types this generator can currently +prove run there (see its module docstring for the current verification state). + +Both generators ship with a synthetic fixture schema and a vendored real-world fixture as their test +suite (`tests/test_linkml_neo4j_generators.py`); `project-setup` projects a pinned copy into a +consuming repo, refreshed the same way as the `meaningfy` OpenSpec schema (re-run, review the diff, +never silently overwritten). ## Architectural boundary for generated modules diff --git a/.opencode/skills/project-setup/references/checklists.md b/.opencode/skills/project-setup/references/checklists.md index f35cc8b..b735246 100644 --- a/.opencode/skills/project-setup/references/checklists.md +++ b/.opencode/skills/project-setup/references/checklists.md @@ -29,6 +29,13 @@ multi-component) apply only if the interview selected them. multi-component, model `code-anatomy.md` first, then translate to contracts. - [ ] **Model** (product) — `model/` (LinkML seed) + the `make generate-models` bridge (`conceptual-modelling`). +- [ ] **Neo4j generators** (product, LinkML model source + Neo4j datastore selected, Q4.5 + Q5.1) — + project a **pinned copy** of `gen-neo4j-constraints`/`gen-neomodel` (repo tooling at + `tools/linkml_neo4j/` — documented, not owned, by `linkml-engineering`) into `scripts/`; wire + `make neo4j-constraints` / `make neomodel` targets alongside the existing `make + generate-models` ones. Refresh path: + re-run `project-setup`, review the diff — same discipline as the `meaningfy` OpenSpec schema + pin (`spine-projection.md`), never silently overwritten. - [ ] **Tests** (code) — drop the `tests/` tree with the marker-injecting `conftest.py` and one smoke unit test + one example feature (`testing-setup.md`). - [ ] **Agentic** — render the canonical `CLAUDE.md`; create the `AGENTS.md` symlink diff --git a/.opencode/skills/project-setup/references/interview.md b/.opencode/skills/project-setup/references/interview.md index e008cb7..c5df889 100644 --- a/.opencode/skills/project-setup/references/interview.md +++ b/.opencode/skills/project-setup/references/interview.md @@ -109,7 +109,10 @@ and frameworks apply. A `library` skips them (no process); a `doc-only` skips Gr **Q5.1 Primary datastore(s)?** (multi-select) - Options: **MongoDB** (motor), **PostgreSQL** (SQLAlchemy/asyncpg), **Redis** (cache/streams), - *none / in-memory*. + **Neo4j** (`neomodel`), *none / in-memory*. +- **Neo4j + LinkML model source (Q4.5) together** additionally project the vendored + `gen-neo4j-constraints`/`gen-neomodel` custom generators (repo tooling at `tools/linkml_neo4j/`, + documented by `linkml-engineering`) into `scripts/` — see `checklists.md` and `layout.md`. - Drives runtime deps, the `adapters/` repository skeleton, `infra/compose.yaml` services, `infra/.env.example`, integration-test markers (`tests/integration/`), and the `Datastores / external systems` bullet in `CLAUDE.md`. diff --git a/.opencode/skills/project-setup/references/layout.md b/.opencode/skills/project-setup/references/layout.md index 6baed80..01d9cc3 100644 --- a/.opencode/skills/project-setup/references/layout.md +++ b/.opencode/skills/project-setup/references/layout.md @@ -54,6 +54,10 @@ first: `domain` (the book's `models/`) → `adapters` → `services` → `entryp ├── model/ # conceptual model (LinkML) — PRODUCT archetype only (R5) │ └── schema.yaml # the domain source; make generate-models renders the targets │ +├── scripts/ # PINNED, projected generators — only if LinkML + Neo4j (Q4.5+Q5.1) +│ ├── gen_neo4j_constraints.py # copied from skillery's tools/linkml_neo4j/; refresh +│ └── gen_neomodel.py # = re-run project-setup, review the diff (never silent) +│ ├── openspec/ # the SPINE (see spine-projection.md) — projected into every repo │ ├── config.yaml # schema: meaningfy ; context: ; the 3 thin per-artifact rules │ ├── schemas/meaningfy/ # the PINNED meaningfy schema (copied from skillery) diff --git a/.opencode/skills/project-setup/scripts/scaffold.sh b/.opencode/skills/project-setup/scripts/scaffold.sh index eaed0d9..afb32c2 100644 --- a/.opencode/skills/project-setup/scripts/scaffold.sh +++ b/.opencode/skills/project-setup/scripts/scaffold.sh @@ -16,7 +16,7 @@ PACKAGE="" ; PROJECT_NAME="" ; SLUG="" ; PYVER="3.12" ORG="meaningfy-ws" ; BRANCH="develop" ; DESC="" ; YEAR="$(date +%Y)" ARCHETYPE="product" ; TARGET="$(pwd)" WITH_DOCS=1 ; WITH_INFRA=1 ; WITH_CI=1 ; DEPLOYABLE=0 -FORCE=0 ; SKIP_EXISTING=0 ; NO_LOCK=0 ; DRY_RUN=0 ; MINIMAL=0 +FORCE=0 ; SKIP_EXISTING=0 ; NO_LOCK=0 ; DRY_RUN=0 ; MINIMAL=0 ; NEO4J=0 # OpenSpec version this skill pins schemas against (kept in sync with spine/openspec-version.txt). OPENSPEC_PIN="1.4.1" @@ -33,6 +33,8 @@ Options: -a, --archetype TYPE product|library|doc-only (default: product) legacy aliases service|pipeline|cli -> product --deployable this repo ships a deployable artifact -> CD TODO stub (ci-cd-delivery) + --neo4j project the PINNED gen-neo4j-constraints/gen-neomodel generators into + scripts/ (product + LinkML only — see references/checklists.md) --python VER Python version (default: 3.12) --org ORG GitHub org (default: meaningfy-ws) --branch BRANCH default/PR branch (default: develop) @@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do --desc) DESC="$2"; shift 2;; --target) TARGET="$2"; shift 2;; --deployable) DEPLOYABLE=1; shift;; + --neo4j) NEO4J=1; shift;; --minimal) MINIMAL=1; shift;; --no-docs) WITH_DOCS=0; shift;; --no-infra) WITH_INFRA=0; shift;; @@ -217,6 +220,33 @@ scaffold_openspec() { fi } +# scaffold_neo4j_generators : project the PINNED gen-neo4j-constraints/gen-neomodel +# generators (tools/linkml_neo4j/ — maintained, tested code; NOT a skill asset, see +# linkml-engineering's generation-and-templates.md for why) into scripts/. Only +# meaningful for a product using LinkML with a Neo4j target (--neo4j); see +# references/checklists.md. +scaffold_neo4j_generators() { + local gen_src="$SCRIPT_DIR/../../../tools/linkml_neo4j" + local gen_dst="$TARGET/scripts" + if [[ "$DRY_RUN" -eq 1 ]]; then + [[ -d "$gen_dst" && -f "$gen_dst/gen_neo4j_constraints.py" ]] \ + && echo " = keep scripts/gen_neo4j_constraints.py, scripts/gen_neomodel.py (pinned)" \ + || echo " + create scripts/gen_neo4j_constraints.py, scripts/gen_neomodel.py (pinned, copied from skillery)" + elif [[ -d "$gen_src" ]]; then + mkdir -p "$gen_dst" + for f in gen_neo4j_constraints.py gen_neomodel.py; do + if [[ -f "$gen_dst/$f" && "$FORCE" -ne 1 ]]; then + echo " skip (exists): scripts/$f (re-run with --force to refresh; never clobbered in place)" + else + cp "$gen_src/$f" "$gen_dst/$f" + echo " copied scripts/$f (PINNED — refresh via --force; see references/checklists.md)" + fi + done + else + echo " NOTE: linkml-engineering generators not found ($gen_src) — copy scripts/gen_neo4j_constraints.py + scripts/gen_neomodel.py from skillery manually." + fi +} + # ---- minimal mode: agentic files + .claude/ layout only ------------------- if [[ "$MINIMAL" -eq 1 ]]; then echo "MINIMAL mode — agentic files (CLAUDE.md + AGENTS symlink) + .claude/ layout only." @@ -346,6 +376,7 @@ fi # ---- 5. agentic layer (CLAUDE-canonical) + spine (openspec/) --------------- scaffold_agentic scaffold_openspec +[[ "$PRODUCT" -eq 1 && "$NEO4J" -eq 1 ]] && scaffold_neo4j_generators # ---- 6. docs pillar (Antora) ---------------------------------------------- if [[ "$WITH_DOCS" -eq 1 ]]; then diff --git a/AGENTS.md b/AGENTS.md index 5436c75..883c1f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,14 @@ that adds only Claude-specific guidance. ## How to maintain / extend the catalogue -- **Adding a new skill** — follow [`spec/CREATING_SKILLS.md`](spec/CREATING_SKILLS.md). +- **Adding a new skill** — follow [`spec/CREATING_SKILLS.md`](spec/CREATING_SKILLS.md), **then run + `make skill-inventory`** to regenerate [`docs/skill-inventory.md`](docs/skill-inventory.md) (the + map + per-bundle tables). This part is manual — you (or the agent) must run it after adding, + renaming, re-bundling, re-describing, or re-categorising a skill. Forgetting is caught + automatically, not silently: `tests/test_skill_inventory.py` fails `make test`/`make validate` if + the committed file has drifted from a fresh regeneration. A new skill also needs a `PURPOSE_OF` + entry in [`tools/skill_inventory.py`](tools/skill_inventory.py) — the one hand-curated mapping in + an otherwise fully generated file; generation itself raises loudly if a skill is missing one. - **Assigning to a bundle** — bundles are declared in [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json). - **Boundary / related-skills** — every skill's frontmatter must declare its `boundary` and list any `related_skills`. This keeps triggers crisp and prevents collisions with external neighbours. diff --git a/Makefile b/Makefile index 8a1f375..a11a538 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install validate lint test validate-spine fix-spec-links generate-opencode +.PHONY: install validate lint test validate-spine fix-spec-links generate-opencode skill-inventory install: python3 -m venv .venv && . .venv/bin/activate && pip install -q -r requirements-dev.txt @@ -24,6 +24,12 @@ generate-opencode: test: . .venv/bin/activate && python -m pytest tests/ -q +# skill-inventory = regenerate docs/skill-inventory.md (purpose + related skills, +# derived from each SKILL.md + marketplace.json). Freshness enforced by +# tests/test_skill_inventory.py, part of `make test`. +skill-inventory: + . .venv/bin/activate && python -m tools.skill_inventory + # validate-spine = the structural gate on the OpenSpec spine (needs node + npx). # Kept separate from `validate` so the Python guardrail runs without a node # toolchain. The clarity gate (semantic, on the PLAN) is run by a human/agent, diff --git a/README.md b/README.md index 8b6a360..fb70699 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,10 @@ Bundles are organised by the **role (hat) you wear** — install `meaningfy-core ## What's inside -20 skills in **4 role bundles** — every skill lives in exactly one bundle (no duplication): +22 skills in **4 role bundles** — every skill lives in exactly one bundle (no duplication); the +table below is bundle-level only. For the per-skill picture — purpose, which cross-cutting concern +each one serves, and which skills depend on which — see +[`docs/skill-inventory.md`](docs/skill-inventory.md) (a generated map + tables, not hand-maintained). | Bundle | Skills | Install if you… | |--------|--------|-----------------| diff --git a/docs/skill-inventory.md b/docs/skill-inventory.md new file mode 100644 index 0000000..9443147 --- /dev/null +++ b/docs/skill-inventory.md @@ -0,0 +1,319 @@ +# Skill inventory + + + +22 skills across 4 role bundles. Install `meaningfy-core` plus the bundle(s) matching your role — see the root [`README.md`](../README.md). + +## Map + +**Containers** are bundles. **Dark rectangles** are skills. **Light rounded cards** are the small set of purposes a skill's description was sorted into, cutting across bundles — each with a one-line explanation of what it covers. The line is *classified as* (skill → purpose). + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 12, 'rankSpacing': 45, 'curve': 'basis'}}}%% +flowchart LR + subgraph b_meaningfy_core["meaningfy-core"] + s_technical_writing["technical-writing"] + s_explanatory_writing["explanatory-writing"] + s_meaningfy_git_workflow["meaningfy-git-workflow"] + s_guardrails["guardrails"] + end + subgraph b_meaningfy_consulting["meaningfy-consulting"] + s_semantic_consulting_coach["semantic-consulting-coach"] + s_decision_package["decision-package"] + s_proposal_writing["proposal-writing"] + s_estimation["estimation"] + s_executive_communication["executive-communication"] + end + subgraph b_meaningfy_architecture["meaningfy-architecture"] + s_architecture["architecture"] + s_conceptual_modelling["conceptual-modelling"] + s_modelling_conventions["modelling-conventions"] + s_linkml_engineering["linkml-engineering"] + end + subgraph b_meaningfy_building["meaningfy-building"] + s_epic_planning["epic-planning"] + s_spec_stewardship["spec-stewardship"] + s_clarity_gate["clarity-gate"] + s_bdd_gherkin["bdd-gherkin"] + s_meaningfy_code_review["meaningfy-code-review"] + s_cosmic_python["cosmic-python"] + s_project_setup["project-setup"] + s_ci_cd_delivery["ci-cd-delivery"] + s_meaningfy_release["meaningfy-release"] + end + + p_consulting_business("Consulting & Business
front-of-funnel advisory work") + p_delivery_ops("Delivery & Ops
scaffolding, CI/CD, releases") + p_modelling_architecture("Modelling & Architecture
domain models, system design, LinkML") + p_process_governance("Process & Governance
the spine build loop + agentic guardrails") + p_quality_review("Quality & Review
tests, BDD, pre-PR review") + p_writing_communication("Writing & Communication
clear prose, persuasion, teaching") + + s_architecture --> p_modelling_architecture + s_bdd_gherkin --> p_quality_review + s_ci_cd_delivery --> p_delivery_ops + s_clarity_gate --> p_process_governance + s_conceptual_modelling --> p_modelling_architecture + s_cosmic_python --> p_modelling_architecture + s_decision_package --> p_consulting_business + s_epic_planning --> p_process_governance + s_estimation --> p_consulting_business + s_executive_communication --> p_writing_communication + s_explanatory_writing --> p_writing_communication + s_guardrails --> p_process_governance + s_linkml_engineering --> p_modelling_architecture + s_meaningfy_code_review --> p_quality_review + s_meaningfy_git_workflow --> p_delivery_ops + s_meaningfy_release --> p_delivery_ops + s_modelling_conventions --> p_modelling_architecture + s_project_setup --> p_delivery_ops + s_proposal_writing --> p_consulting_business + s_semantic_consulting_coach --> p_consulting_business + s_spec_stewardship --> p_process_governance + s_technical_writing --> p_writing_communication + + style b_meaningfy_core fill:#eaf3ff,stroke:#999,stroke-width:1px + style b_meaningfy_consulting fill:#fff2e0,stroke:#999,stroke-width:1px + style b_meaningfy_architecture fill:#eafaf0,stroke:#999,stroke-width:1px + style b_meaningfy_building fill:#f5eaff,stroke:#999,stroke-width:1px + style p_consulting_business fill:#fdeef7,color:#000000,stroke:#333,stroke-width:1.5px + style p_delivery_ops fill:#f6f5ef,color:#000000,stroke:#333,stroke-width:1.5px + style p_modelling_architecture fill:#eafaf5,color:#000000,stroke:#333,stroke-width:1.5px + style p_process_governance fill:#eef1fb,color:#000000,stroke:#333,stroke-width:1.5px + style p_quality_review fill:#fdeef0,color:#000000,stroke:#333,stroke-width:1.5px + style p_writing_communication fill:#fdf6e3,color:#000000,stroke:#333,stroke-width:1.5px + style s_architecture fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_bdd_gherkin fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_ci_cd_delivery fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_clarity_gate fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_conceptual_modelling fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_cosmic_python fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_decision_package fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_epic_planning fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_estimation fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_executive_communication fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_explanatory_writing fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_guardrails fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_linkml_engineering fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_meaningfy_code_review fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_meaningfy_git_workflow fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_meaningfy_release fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_modelling_conventions fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_project_setup fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_proposal_writing fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_semantic_consulting_coach fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_spec_stewardship fill:#1e1e1e,color:#ffffff,stroke:#000 + style s_technical_writing fill:#1e1e1e,color:#ffffff,stroke:#000 +``` + +## Relations + +Same bundle containers as the Map, but skill boxes are now colour-coded by **purpose** (the same six categories, one dark shade each) instead of uniform dark — at this many edges, colour is what makes the grouping legible without tracing every line. Two line types, not one: a **thick solid** arrow is *depends on* (39 edges, mechanically parsed from each skill's own "Delegates" text — a skill explicitly handing something off to another); a **thin dashed** arrow is *related* (52 edges, everything else in the "Related" list — weaker, a "see also" rather than a hand-off). Kept separate from the Map's classification edges — 91 relation edges plus 22 classification edges in one diagram was tried and was a long-crossing-line mess, confirmed by actually rendering it. + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 12, 'rankSpacing': 45, 'curve': 'basis'}}}%% +flowchart LR + subgraph b_meaningfy_core["meaningfy-core"] + s_technical_writing["technical-writing"] + s_explanatory_writing["explanatory-writing"] + s_meaningfy_git_workflow["meaningfy-git-workflow"] + s_guardrails["guardrails"] + end + subgraph b_meaningfy_consulting["meaningfy-consulting"] + s_semantic_consulting_coach["semantic-consulting-coach"] + s_decision_package["decision-package"] + s_proposal_writing["proposal-writing"] + s_estimation["estimation"] + s_executive_communication["executive-communication"] + end + subgraph b_meaningfy_architecture["meaningfy-architecture"] + s_architecture["architecture"] + s_conceptual_modelling["conceptual-modelling"] + s_modelling_conventions["modelling-conventions"] + s_linkml_engineering["linkml-engineering"] + end + subgraph b_meaningfy_building["meaningfy-building"] + s_epic_planning["epic-planning"] + s_spec_stewardship["spec-stewardship"] + s_clarity_gate["clarity-gate"] + s_bdd_gherkin["bdd-gherkin"] + s_meaningfy_code_review["meaningfy-code-review"] + s_cosmic_python["cosmic-python"] + s_project_setup["project-setup"] + s_ci_cd_delivery["ci-cd-delivery"] + s_meaningfy_release["meaningfy-release"] + end + + s_ci_cd_delivery ==> s_project_setup + s_ci_cd_delivery ==> s_cosmic_python + s_conceptual_modelling ==> s_linkml_engineering + s_cosmic_python ==> s_architecture + s_cosmic_python ==> s_conceptual_modelling + s_cosmic_python ==> s_linkml_engineering + s_cosmic_python ==> s_meaningfy_git_workflow + s_cosmic_python ==> s_guardrails + s_decision_package ==> s_proposal_writing + s_epic_planning ==> s_clarity_gate + s_epic_planning ==> s_spec_stewardship + s_epic_planning ==> s_bdd_gherkin + s_epic_planning ==> s_cosmic_python + s_epic_planning ==> s_technical_writing + s_epic_planning ==> s_explanatory_writing + s_estimation ==> s_proposal_writing + s_estimation ==> s_epic_planning + s_estimation ==> s_decision_package + s_executive_communication ==> s_clarity_gate + s_executive_communication ==> s_technical_writing + s_guardrails ==> s_clarity_gate + s_guardrails ==> s_cosmic_python + s_guardrails ==> s_meaningfy_code_review + s_linkml_engineering ==> s_project_setup + s_project_setup ==> s_cosmic_python + s_project_setup ==> s_architecture + s_project_setup ==> s_epic_planning + s_project_setup ==> s_technical_writing + s_project_setup ==> s_meaningfy_git_workflow + s_proposal_writing ==> s_estimation + s_proposal_writing ==> s_executive_communication + s_proposal_writing ==> s_decision_package + s_proposal_writing ==> s_semantic_consulting_coach + s_semantic_consulting_coach ==> s_executive_communication + s_semantic_consulting_coach ==> s_decision_package + s_semantic_consulting_coach ==> s_proposal_writing + s_semantic_consulting_coach ==> s_estimation + s_spec_stewardship ==> s_epic_planning + s_spec_stewardship ==> s_clarity_gate + + s_architecture -.-> s_cosmic_python + s_architecture -.-> s_epic_planning + s_bdd_gherkin -.-> s_architecture + s_bdd_gherkin -.-> s_epic_planning + s_bdd_gherkin -.-> s_clarity_gate + s_bdd_gherkin -.-> s_spec_stewardship + s_bdd_gherkin -.-> s_cosmic_python + s_ci_cd_delivery -.-> s_meaningfy_release + s_clarity_gate -.-> s_epic_planning + s_clarity_gate -.-> s_bdd_gherkin + s_clarity_gate -.-> s_spec_stewardship + s_clarity_gate -.-> s_guardrails + s_clarity_gate -.-> s_technical_writing + s_conceptual_modelling -.-> s_modelling_conventions + s_conceptual_modelling -.-> s_architecture + s_conceptual_modelling -.-> s_cosmic_python + s_cosmic_python -.-> s_meaningfy_code_review + s_cosmic_python -.-> s_bdd_gherkin + s_cosmic_python -.-> s_ci_cd_delivery + s_decision_package -.-> s_semantic_consulting_coach + s_decision_package -.-> s_executive_communication + s_decision_package -.-> s_conceptual_modelling + s_decision_package -.-> s_architecture + s_epic_planning -.-> s_architecture + s_executive_communication -.-> s_proposal_writing + s_executive_communication -.-> s_decision_package + s_executive_communication -.-> s_semantic_consulting_coach + s_executive_communication -.-> s_explanatory_writing + s_explanatory_writing -.-> s_technical_writing + s_explanatory_writing -.-> s_executive_communication + s_explanatory_writing -.-> s_clarity_gate + s_linkml_engineering -.-> s_modelling_conventions + s_linkml_engineering -.-> s_conceptual_modelling + s_linkml_engineering -.-> s_architecture + s_linkml_engineering -.-> s_cosmic_python + s_meaningfy_code_review -.-> s_cosmic_python + s_meaningfy_code_review -.-> s_guardrails + s_meaningfy_code_review -.-> s_meaningfy_git_workflow + s_meaningfy_git_workflow -.-> s_cosmic_python + s_meaningfy_git_workflow -.-> s_meaningfy_release + s_meaningfy_git_workflow -.-> s_meaningfy_code_review + s_meaningfy_release -.-> s_meaningfy_git_workflow + s_meaningfy_release -.-> s_ci_cd_delivery + s_meaningfy_release -.-> s_project_setup + s_modelling_conventions -.-> s_conceptual_modelling + s_modelling_conventions -.-> s_linkml_engineering + s_modelling_conventions -.-> s_architecture + s_modelling_conventions -.-> s_cosmic_python + s_spec_stewardship -.-> s_bdd_gherkin + s_technical_writing -.-> s_clarity_gate + s_technical_writing -.-> s_epic_planning + s_technical_writing -.-> s_explanatory_writing + + style b_meaningfy_core fill:#eaf3ff,stroke:#999,stroke-width:1px + style b_meaningfy_consulting fill:#fff2e0,stroke:#999,stroke-width:1px + style b_meaningfy_architecture fill:#eafaf0,stroke:#999,stroke-width:1px + style b_meaningfy_building fill:#f5eaff,stroke:#999,stroke-width:1px + style s_architecture fill:#0f5c48,color:#ffffff,stroke:#000 + style s_bdd_gherkin fill:#7a2030,color:#ffffff,stroke:#000 + style s_ci_cd_delivery fill:#4a4a3a,color:#ffffff,stroke:#000 + style s_clarity_gate fill:#2b3a67,color:#ffffff,stroke:#000 + style s_conceptual_modelling fill:#0f5c48,color:#ffffff,stroke:#000 + style s_cosmic_python fill:#0f5c48,color:#ffffff,stroke:#000 + style s_decision_package fill:#6b1f4d,color:#ffffff,stroke:#000 + style s_epic_planning fill:#2b3a67,color:#ffffff,stroke:#000 + style s_estimation fill:#6b1f4d,color:#ffffff,stroke:#000 + style s_executive_communication fill:#7a5c00,color:#ffffff,stroke:#000 + style s_explanatory_writing fill:#7a5c00,color:#ffffff,stroke:#000 + style s_guardrails fill:#2b3a67,color:#ffffff,stroke:#000 + style s_linkml_engineering fill:#0f5c48,color:#ffffff,stroke:#000 + style s_meaningfy_code_review fill:#7a2030,color:#ffffff,stroke:#000 + style s_meaningfy_git_workflow fill:#4a4a3a,color:#ffffff,stroke:#000 + style s_meaningfy_release fill:#4a4a3a,color:#ffffff,stroke:#000 + style s_modelling_conventions fill:#0f5c48,color:#ffffff,stroke:#000 + style s_project_setup fill:#4a4a3a,color:#ffffff,stroke:#000 + style s_proposal_writing fill:#6b1f4d,color:#ffffff,stroke:#000 + style s_semantic_consulting_coach fill:#6b1f4d,color:#ffffff,stroke:#000 + style s_spec_stewardship fill:#2b3a67,color:#ffffff,stroke:#000 + style s_technical_writing fill:#7a5c00,color:#ffffff,stroke:#000 +``` + +## meaningfy-core + +Cross-cutting basics everyone installs: clear technical writing, explanatory-writing craft, the Meaningfy git/PR workflow, and agentic guardrails. + +| Skill | Purpose | Depends on | Related | +|---|---|---|---| +| [`technical-writing`](../skills/technical-writing/SKILL.md) | Produce clear documentation, explanations, summaries, and docstrings — AsciiDoc/Antora or Markdown — with a lightweight clarity check. | — | `clarity-gate`, `epic-planning`, `explanatory-writing` | +| [`explanatory-writing`](../skills/explanatory-writing/SKILL.md) | Apply explanatory craft so that Explanation-quadrant prose reads clearly — one controlling metaphor, a concrete example beside every abstract claim, self-answered question pivots, short declaratives, coin-and-explain, a confident grounded close. | — | `technical-writing`, `executive-communication`, `clarity-gate` | +| [`meaningfy-git-workflow`](../skills/meaningfy-git-workflow/SKILL.md) | Meaningfy git and GitHub conventions — Conventional Commits (imperative, no trailing punctuation), branch naming, rebase/merge etiquette, the pull-request workflow, free-tier GitHub constraints, and dev-environment hygiene. | — | `cosmic-python`, `meaningfy-release`, `commit-commands`, `code-review`, `meaningfy-code-review` | +| [`guardrails`](../skills/guardrails/SKILL.md) | Apply agentic guardrails to every step where an LLM agent acts — decision bounds, output validation, and prompt-injection defence. | `clarity-gate`, `cosmic-python`, `meaningfy-code-review` | `clarity-gate`, `meaningfy-code-review`, `cosmic-python` | + +## meaningfy-consulting + +The advisory / front-of-funnel role: semantic-technologies coaching, the Decision Package, proposals, estimation, and executive communication. + +| Skill | Purpose | Depends on | Related | +|---|---|---|---| +| [`semantic-consulting-coach`](../skills/semantic-consulting-coach/SKILL.md) | Use when someone running or building a semantic-technologies / data consulting business (ontologies, knowledge graphs, data governance, MDM, semantic interoperability) is thinking through a business, service-design, pricing, partnering, engagement-process, client-situation (B2B sale or B2G tender), negotiation, or executive-message decision and wants to think it through before committing, rather than have delivery work done or a quick factual answer given. | `executive-communication`, `decision-package`, `proposal-writing`, `estimation` | `decision-package`, `proposal-writing`, `executive-communication`, `estimation` | +| [`decision-package`](../skills/decision-package/SKILL.md) | Produce the Decision Package — the paid keystone deliverable of a semantic/data consulting engagement's P1 Decision Phase. | `proposal-writing` | `semantic-consulting-coach`, `executive-communication`, `conceptual-modelling`, `architecture` | +| [`proposal-writing`](../skills/proposal-writing/SKILL.md) | Produce the proposal + Statement of Work (SoW) that frames the paid Decision Phase (P1) offer — with an explicit in/out scope boundary, priced as a fixed frame. | `estimation`, `executive-communication`, `decision-package`, `semantic-consulting-coach` | `estimation`, `decision-package`, `executive-communication`, `semantic-consulting-coach` | +| [`estimation`](../skills/estimation/SKILL.md) | A lightweight fixed-cost scoping / estimation discipline that de-risks fixed-cost bids — a CHECKLIST + METHOD, not a heavy model. | `proposal-writing`, `epic-planning`, `decision-package` | `proposal-writing`, `epic-planning`, `decision-package` | +| [`executive-communication`](../skills/executive-communication/SKILL.md) | Use when turning rough input into a clear, concise, persuasive executive message or analysis: a board paper, proposal, client recommendation, email or Slack note, spoken narrative, slide outline, or a strategic problem to be solved. | `clarity-gate`, `technical-writing` | `proposal-writing`, `decision-package`, `semantic-consulting-coach`, `technical-writing`, `explanatory-writing` | + +## meaningfy-architecture + +The design / modelling role: system architecture (C4, ArchiMate/UML, ADRs, contracts), the living conceptual model, the shared modelling conventions, and the LinkML craft (LinkML → typed artefacts + OWL/SHACL). + +| Skill | Purpose | Depends on | Related | +|---|---|---|---| +| [`architecture`](../skills/architecture/SKILL.md) | System-level solution architecture — C4 levels (Context, Container, Component, Code), ArchiMate and UML notations, ADRs, and contracts (OpenAPI/AsyncAPI/LinkML). | — | `cosmic-python`, `stream-coding`, `epic-planning` | +| [`conceptual-modelling`](../skills/conceptual-modelling/SKILL.md) | Build and evolve a living, representation-agnostic conceptual model for a product (programming) project — the domain's entities, attributes, relationships, and meaning — and choose how it is rendered. | `linkml-engineering` | `modelling-conventions`, `linkml-engineering`, `architecture`, `cosmic-python` | +| [`modelling-conventions`](../skills/modelling-conventions/SKILL.md) | The shared, representation-agnostic modelling craft reused across the modelling skills — naming discipline, modelling anti-patterns, and the guardrails a modeller follows while working, plus the two load-bearing principles (decouple attributes into reusable first-class properties; identify everything by a stable URI, implicit by default). | — | `conceptual-modelling`, `linkml-engineering`, `architecture`, `cosmic-python` | +| [`linkml-engineering`](../skills/linkml-engineering/SKILL.md) | The operational LinkML craft, downstream of an existing model or spec — never greenfield. | `project-setup` | `modelling-conventions`, `conceptual-modelling`, `architecture`, `cosmic-python`, `project-setup` | + +## meaningfy-building + +The delivery / developer role: the spine build loop (epic-planning, spec-stewardship, clarity-gate, BDD, code review), clean layered Python (cosmic-python), repo scaffolding (project-setup), CD/deploy (ci-cd-delivery), and the release lifecycle (meaningfy-release). + +| Skill | Purpose | Depends on | Related | +|---|---|---|---| +| [`epic-planning`](../skills/epic-planning/SKILL.md) | Shape an EPIC from human seeds, then derive its clarity-gated PLAN. | `clarity-gate`, `spec-stewardship`, `bdd-gherkin`, `cosmic-python`, `technical-writing`, `explanatory-writing` | `clarity-gate`, `spec-stewardship`, `bdd-gherkin`, `architecture`, `technical-writing`, `explanatory-writing`, `stream-coding` | +| [`spec-stewardship`](../skills/spec-stewardship/SKILL.md) | Steward the living specification spine after an EPIC is authored — the EPIC↔change lifecycle, archiving completed changes, grooming the durable specs/ store, and keeping the orientation index in sync. | `epic-planning`, `clarity-gate` | `epic-planning`, `clarity-gate`, `bdd-gherkin` | +| [`clarity-gate`](../skills/clarity-gate/SKILL.md) | Pre-ingestion quality gate for specifications and documents before they drive implementation. | — | `epic-planning`, `bdd-gherkin`, `spec-stewardship`, `guardrails`, `stream-coding`, `technical-writing` | +| [`bdd-gherkin`](../skills/bdd-gherkin/SKILL.md) | Write BDD Gherkin feature files and fabricate test data from a specification. | — | `architecture`, `epic-planning`, `clarity-gate`, `spec-stewardship`, `cosmic-python` | +| [`meaningfy-code-review`](../skills/meaningfy-code-review/SKILL.md) | The Meaningfy pre-PR review — two modes (standalone = five lens subagents fanned out in parallel, one lens each; interactive main-thread), a methodical catalogue-complete review procedure (traverse the whole `cosmic-python` region a lens owns, not a fixed subset), and a fit-and-refactoring investigation, reported by priority. | — | `cosmic-python`, `guardrails`, `meaningfy-git-workflow`, `code-review` | +| [`cosmic-python`](../skills/cosmic-python/SKILL.md) | Clean Architecture and Cosmic Python guidance for well-tested, layered Python systems. | `architecture`, `conceptual-modelling`, `linkml-engineering`, `meaningfy-git-workflow`, `guardrails` | `architecture`, `meaningfy-code-review`, `bdd-gherkin`, `guardrails`, `conceptual-modelling`, `linkml-engineering`, `ci-cd-delivery`, `meaningfy-git-workflow` | +| [`project-setup`](../skills/project-setup/SKILL.md) | Scaffold or modernise a Meaningfy-standard repo and PROJECT the Meaningfy spine into it — a top-level package (no src/), Poetry + dedicated root tool configs, cosmic-python layering with import-linter guardrails, TDD+BDD tests, a CLAUDE-canonical agentic setup (CLAUDE.md is canonical; AGENTS.md is an optional symlink), the openspec/ spine (config + pinned meaningfy schema + /opsx:* commands + golden thread), three archetypes (product/library/doc-only) with fixed gate profiles, conditional model/ and CD seam, Antora docs, infra, and CI. | `cosmic-python`, `architecture`, `epic-planning`, `technical-writing`, `meaningfy-git-workflow` | `superpowers`, `stream-coding` | +| [`ci-cd-delivery`](../skills/ci-cd-delivery/SKILL.md) | Standardise the application-repo Continuous Delivery side of Meaningfy systems — the deploy trigger, the reusable deploy mechanism, and the release/image standard. | `project-setup`, `cosmic-python` | `project-setup`, `cosmic-python`, `meaningfy-release` | +| [`meaningfy-release`](../skills/meaningfy-release/SKILL.md) | The Meaningfy release lifecycle — semantic versioning policy (MAJOR/MINOR/PATCH + -rc.N pre-releases), GitFlow release/hotfix branches, changelog + GitHub release notes, semi-automated releases via release-please, publishing Python libraries to PyPI with Trusted Publishing (OIDC, no tokens), opt-in supply-chain hardening (signing/provenance/SBOM), and release governance (SECURITY.md, yanking, deprecation). | — | `meaningfy-git-workflow`, `ci-cd-delivery`, `project-setup` | diff --git a/openspec/changes/linkml-neo4j-generators/.openspec.yaml b/openspec/changes/linkml-neo4j-generators/.openspec.yaml new file mode 100644 index 0000000..3d5319a --- /dev/null +++ b/openspec/changes/linkml-neo4j-generators/.openspec.yaml @@ -0,0 +1,2 @@ +schema: meaningfy +created: 2026-07-23 diff --git a/openspec/changes/linkml-neo4j-generators/design.md b/openspec/changes/linkml-neo4j-generators/design.md new file mode 100644 index 0000000..6b5933a --- /dev/null +++ b/openspec/changes/linkml-neo4j-generators/design.md @@ -0,0 +1,188 @@ +> Parent: `openspec/changes/linkml-neo4j-generators/proposal.md` (EPIC: Vendored, tested +> Neo4j-targeting LinkML generators) + +## Context + +Two generator scripts exist today in `hulubul-broker/scripts/`, both subclassing +`linkml.utils.generator.Generator` exactly as `linkml-engineering`'s +`references/generation-and-templates.md` already recommends for custom generators: + +- `gen_neo4j_constraints.py` → Cypher DDL. Emits, per entity class (concrete + has an identifier + slot): a `CREATE CONSTRAINT ... IS UNIQUE` on the identifier, and per non-identifier scalar/enum + slot, `IS NOT NULL` (if required) + `IS :: `. Explicitly skips: multivalued slots (any), + object-valued slots (treated as relationships and dropped entirely — no relationship-level + constraint of any kind is ever emitted). +- `gen_neomodel.py` → neomodel `StructuredNode` classes via a Jinja2 template. Every concrete class + becomes a **flat, standalone** class (the docstring calls this out as deliberate: "no Python + inheritance, mirroring the flat per-label mapping"). Object-valued slots become + `RelationshipTo(, ...)` with a cardinality derived from `required`/`multivalued`. + +Both were read against `hulubul-broker`'s real schema (7 LinkML files, 798 lines) and its +already-committed generated output (`model/generated/neo4j/constraints.cypher`, +`model/generated/neomodel/hulubul_ogm.py`). Two real bugs and one real drift risk were confirmed +empirically (not hypothesised) — see EPIC "Why". Zero tests exist for either generator today. + +`hulubul-broker` also hand-maintains `infra/cypher/schema.cypher`: a Community-safe subset of the +generator's uniqueness constraints, copied by hand, plus 15 hand-picked `CREATE INDEX` statements +the generator has no way to produce. Deployed Neo4j is `neo4j:5.26-community` (from that project's +`docker-compose.yaml`) — Community Edition, not Enterprise, which is the edition the current +generator's own comments assume existence/type constraints need. + +## Goals / Non-Goals + +**Goals:** +- Fix the abstract-relationship-target bug in both generators, at the root cause (DEC-4 for + neomodel: real inheritance; equivalent correctness fix for Cypher). +- Close the Cypher generator's gap versus neomodel for constructs Neo4j 5.x **Community** can + actually enforce: required-multivalued existence(+type), and a generated (not hand-copied) + Community-safe profile. +- Make index generation schema-driven (an opt-in annotation) instead of permanently hand-maintained. +- Vendor both generators as tested skill assets, with a projection path via `project-setup`. +- Empirically verify the Community/Enterprise boundary against the actual pinned image + (`neo4j:5.26-community`) rather than trust existing comments. + +**Non-Goals:** +- Achieving constraint parity for anything Neo4j cannot natively check (DEC-2) — enum membership, + patterns, numeric bounds, relationship-cardinality "at least one" stay neomodel's job. +- A generic pluggable custom-generator framework (Rabbit-holes). +- Touching `hulubul-broker` itself, or its hand-maintained `schema.cypher`/seed/demo files, in this + change (No-gos) — this EPIC only produces the capability skillery ships; adopting it in that repo + is a separate, later change there. + +## Decisions + +Cites EPIC DEC-1 through DEC-6 (asset+projection distribution, no app-layer duplication, +Community-only target, neomodel inheritance, annotation-driven indexes, two-tier fixtures — see +proposal.md). New for design: + +- **Community/Enterprise spike is a real test, not a one-off.** Rather than a manual spike whose + result then gets hand-encoded as a comment (the exact pattern that produced the current stale + assumption), the empirical check becomes a permanent integration test (testcontainers, `neo4j: + 5.26-community`) that applies each candidate constraint type and asserts accept/reject. If Neo4j + changes this boundary in a future image, the test catches it instead of the code silently lying + again. +- **Index annotation key**: `annotations: {neo4j_index: true}` on a slot (LinkML's generic + `annotations` dict, not a schema extension) — read via `SchemaView.get_slot(...).annotations`. + Chosen over a new LinkML schema extension/mixin because `annotations` already exists in LinkML + for exactly this "generator-specific hint" purpose and needs no schema-language change. +- **Community-safe profile as a CLI flag**, e.g. `--profile community` (default) vs + `--profile full` (the reference/Enterprise-annotated file), on the *same* generator — not two + separate scripts — so there is exactly one code path and the two outputs cannot diverge from each + other structurally, only in which constraint lines they include. + +## Algorithm / approach + +### Fixing the abstract-relationship-target bug (worked example) + +Today (`hasPickUpLocation`, range `SpatialObject`, abstract, `any_of: [Address, Place]`): +```python +# gen_neomodel.py today — broken: SpatialObject is never defined as a class anywhere +hasPickUpLocation = RelationshipTo('SpatialObject', 'HAS_PICK_UP_LOCATION', cardinality=One) +``` + +After DEC-4 (real inheritance), `SpatialObject` **is** generated, as an abstract neomodel base: +```python +class SpatialObject(StructuredNode): + __abstract_node__ = True + comment = StringProperty() + hasCoordinates = RelationshipTo('GeoCoordinates', 'HAS_COORDINATES', cardinality=ZeroOrOne) + +class Address(SpatialObject): + number = StringProperty(required=True) + ... + +class Place(SpatialObject): + ... +``` +`RelationshipTo('SpatialObject', ...)` now resolves correctly — neomodel's own polymorphic +node-loading resolves a string target against its class registry, and `Address`/`Place`/`Area` are +all real, registered subclasses of the real, registered `SpatialObject` base. The `any_of` +restriction (pickup location is `Address ∪ Place`, not `Area`) is **not** mechanically enforceable +this way (neomodel has no union-of-subclasses relationship constraint) — documented as a comment on +the generated relationship line, consistent with DEC-2 (no new app-layer validation surface; this is +a documentation gap, not a missing feature to build). + +### Cypher: required-multivalued existence (worked example) + +Today `serviceType` (multivalued, required, enum `ServiceType`) on `TransportService` gets **no** +constraint at all (multivalued slots are unconditionally skipped). After this change: +```cypher +CREATE CONSTRAINT transportservice_serviceType_exists IF NOT EXISTS +FOR (n:`TransportService`) REQUIRE n.`serviceType` IS NOT NULL; +``` +Neo4j's existence constraint checks property-key presence regardless of whether the stored value is +a scalar or a list — this needs no new Neo4j feature, just removing the blanket +`if slot.multivalued: continue` for the existence case. Type constraints on lists +(`IS :: LIST`) are added **only if** the Community spike confirms Neo4j 5.26-community +actually accepts them — this is exactly the kind of claim the spike test verifies rather than +assumes. + +### Index annotation (worked example) + +```yaml +# hulubul_request.yaml, hasStatus slot (hypothetical annotation addition — schema-side, not code) +hasStatus: + range: RequestStatus + required: true + annotations: + neo4j_index: true +``` +Cypher generator emits, alongside the existing property constraints: +```cypher +CREATE INDEX deliveryrequest_hasStatus_idx IF NOT EXISTS FOR (n:`DeliveryRequest`) ON (n.`hasStatus`); +``` +This directly replaces the class of statement `hulubul-broker`'s `infra/cypher/schema.cypher` +currently hand-maintains — once a project's schema is annotated and it adopts the vendored +generator, the index block becomes generated, not hand-written. (Annotating `hulubul-broker`'s own +schema is that project's follow-up, per the EPIC's no-gos — this design only needs the generator to +support the annotation, verified against the synthetic fixture.) + +### Anti-patterns + +- ❌ Encoding the Community/Enterprise boundary as a source-code comment again, without the + accompanying test that would catch it going stale (the exact anti-pattern found in the current + `gen_neo4j_constraints.py`). +- ❌ Two separate scripts/files for "full" vs "community" profiles — one code path, one flag + (Decisions, above); two files is exactly the drift shape found in `hulubul-broker`. +- ❌ Adding a Cypher-side workaround (trigger, APOC procedure) for anything Neo4j cannot natively + constrain — that violates DEC-2 outright, no matter how tempting mid-implementation. +- ❌ Silently keeping the flat/no-inheritance neomodel mapping "for now" and patching the + abstract-target bug with a string special-case — that fixes the symptom, not the cause, and the + next abstract-ranged slot added to any schema breaks again. + +## Error matrix + +| Failure mode | Expected handling | +|---|---| +| A slot's range is abstract with **no** `any_of` restriction (e.g. `fromProvider: AgentInRole`) | Resolved by DEC-4's real inheritance — the abstract class is generated as a `__abstract_node__` base; no special-case needed | +| A slot's range is abstract **with** `any_of` (e.g. `hasPickUpLocation`) | Same DEC-4 fix resolves the *reference*; the union restriction itself is documented as a comment, not mechanically enforced (Non-Goal) | +| Community spike finds a constraint type is genuinely Enterprise-only on 5.26 | Excluded from the `--profile community` output; kept (with a clear comment) in `--profile full` only | +| A schema has no `neo4j_index` annotations at all | Generator emits zero `CREATE INDEX` statements — no behavior change from today's baseline for a schema that hasn't opted in | +| The vendored `hulubul-broker` fixture schema changes upstream after being copied in | Treated exactly like the OpenSpec schema pin: a stale copy is not auto-detected, refreshing it is a deliberate follow-up task, not a CI failure — this fixture is a richness/regression check, not a live contract with the sibling repo | +| A project runs the generator against a schema with a relationship slot whose range has **no** identifier and is **not** abstract (a plain value object, e.g. `GeoCoordinates`) | Already handled by the existing "value object → neomodel node reached by relationship, no Cypher uniqueness" split; unchanged by this EPIC except that its required scalar properties now also get existence/type constraints in the Cypher output where previously it got none at all (it has no identifier so `_is_entity` excludes it — worth confirming during implementation whether that exclusion should relax to "concrete class, id or not" so `GeoCoordinates.latitude required` gets a constraint; parked as an Open Question below) | + +## Risks / Trade-offs + +- [Risk] Real inheritance in `gen_neomodel.py` (DEC-4) changes the shape of every previously-flat + generated class for any adopting project — a bigger diff than a point patch. → Mitigation: this is + exactly why the fix belongs in a properly tested, vendored generator rather than a quick patch + copied by hand into `hulubul-broker`; the generated-code diff for that project is its own + follow-up change to review, not silently absorbed here. +- [Risk] A testcontainers-based integration test is a heavier CI dependency (Docker required) than + anything in skillery's test suite today (currently pure-Python, no external services). → + Mitigation: mark it as an opt-in/slow test (e.g. a pytest marker), not part of the default fast + `make test` path, so the existing test suite's speed/dependency profile is unaffected for + contributors who don't touch this generator. +- [Risk] Vendoring `hulubul-broker`'s schema as a fixture could drift from that project's actual + current schema over time. → Mitigation: it's a snapshot fixture (like any test fixture), not a + live sync; explicitly named as such in the Error matrix above. + +## Open Questions + +- Should the Cypher generator's definition of "entity" (currently: concrete + has an identifier) + relax to "concrete, identifier or not" so that non-identified-but-concrete classes like + `GeoCoordinates` also get existence/type constraints on their required scalar properties (they + already become real neomodel nodes today, so the two generators currently disagree on whether + such a class is "real")? Leaning yes for consistency between the two generators, but this changes + what counts as a Neo4j label at all for the Cypher side and deserves a decision during + implementation rather than being folded silently into DEC-4. diff --git a/openspec/changes/linkml-neo4j-generators/proposal.md b/openspec/changes/linkml-neo4j-generators/proposal.md new file mode 100644 index 0000000..e228e1b --- /dev/null +++ b/openspec/changes/linkml-neo4j-generators/proposal.md @@ -0,0 +1,153 @@ +# EPIC: Vendored, tested Neo4j-targeting LinkML generators + +## Appetite + +Medium — two generator scripts to fix and enrich, a real (empirically-verified) constraint-support +spike against Neo4j Community, a synthetic fixture schema, a vendored real-schema fixture, a unit +test suite, and a documentation/projection seam. Not small (real bugs to fix correctly, not +patch over); not large (two generators, no new distribution mechanism, no app-layer validation +layer). + +## Why + +Two custom LinkML generators — `gen_neo4j_constraints.py` (Cypher DDL) and `gen_neomodel.py` +(neomodel OGM classes) — already exist in a sibling project (`hulubul-broker`), built on LinkML's +own `Generator` base class exactly as `linkml-engineering`'s `generation-and-templates.md` already +recommends for custom generators. They have zero tests, and inspecting their actual generated +output against that project's real schema turned up two confirmed bugs (a relationship target that +resolves to nothing when the range is an abstract class) and a real drift risk (a hand-maintained +Cypher file duplicating a subset of the generator's own output). The Cypher generator is also +markedly less expressive than the neomodel generator today — it emits zero relationship-level +constructs and skips every multivalued property outright. Right now, if another project wants this +capability, it copies untested code from `hulubul-broker` by hand and inherits its bugs and its +drift risk. `linkml-engineering` should own a single, tested, correct version instead. + +## Solution outline + +Fix both generators against their real, confirmed bugs; enrich the Cypher generator to close the +gaps versus neomodel that Neo4j can *actually* enforce natively (no app-layer validation duplicate — +neomodel already is the app-layer companion); empirically verify what Neo4j 5.x Community Edition +can enforce (rather than trust the existing generator's Enterprise-only comments) and generate a +Community-safe profile automatically instead of hand-copying one; make index generation +schema-driven via an opt-in LinkML annotation instead of a permanently hand-maintained file. Vendor +both generators as tested skill assets in `linkml-engineering` (mirroring how `project-setup` +already vendors and projects templates — the `openspec/schemas/meaningfy/` pinned-copy-with-refresh +pattern is the direct precedent), with a fast synthetic unit-test fixture plus a second fixture +vendored from `hulubul-broker`'s real (CC-BY-4.0-licensed) schema as a richness/regression check. +`project-setup` gains the conditional projection step; `generation-and-templates.md`'s existing +"enable-on-demand target matrix" gains the two generators as named, documented targets. + +## Key decisions + +- **DEC-1** *(revised — see tasks.md §6/§8 for the correction history)*: The generator code is + vendored as **repo tooling** under `tools/linkml_neo4j/` — **not** a skill asset — tested by a + real pytest suite in skillery's own `tests/`, and **projected** into a consuming repo by + `project-setup` (conditional on the project using LinkML + Neo4j) — not published as a separate + PyPI package, and not left as a copy-by-hand-per-project pattern. `linkml-engineering` documents + and cites the generators (the behavior is LinkML-generator knowledge); it does not own the code, + because a Skill's home is reusable *knowledge* per `spec/skill-repo-governance.md`, not a + maintained, tested codebase — the same "cite, don't own" boundary this catalogue already applies + to external skills. This reuses the exact pinned-copy/refresh discipline `project-setup` already + applies to the `meaningfy` OpenSpec schema (see `skills/project-setup/references/ + spine-projection.md`, which is *also* not inside any skill's own folder), so the drift-risk class + found in `hulubul-broker` (a hand-maintained shadow copy silently diverging from its generated + source) cannot recur here: the master copy's tests live in skillery, refreshing a project's copy + is a deliberate, reviewed act (diff shown, never silently overwritten), same as the schema pin. +- **DEC-2**: No app-layer validation output. Where Neo4j cannot natively enforce something + (enum-membership, patterns, numeric bounds, relationship-cardinality "at least one"), the + `gen_neomodel.py` generator already is the app-layer companion — this EPIC does not add a third + validation surface. The Cypher generator's enrichment is scoped strictly to what Neo4j 5.x can + natively enforce. +- **DEC-3**: Target Neo4j **Community Edition** only. The existing generator's comments assume + existence/type constraints need Enterprise; this EPIC verifies that empirically against the + actual pinned version (`neo4j:5.26-community`, per `hulubul-broker`'s own `docker-compose.yaml`) + rather than trusting the comment, and the generator emits only what Community actually accepts. + No feature is gated behind an assumed Enterprise requirement without that empirical check. +- **DEC-4**: `gen_neomodel.py` switches from flat, standalone classes to **real Python inheritance + mirroring each LinkML `is_a` hierarchy** (abstract LinkML class → `__abstract_node__ = True` + neomodel base; concrete subclasses inherit from it in Python). This is a deliberate reversal of + the current generator's documented "no inheritance, flat mapping" choice, and is the root fix for + the abstract-relationship-target bug: neomodel's own polymorphic node resolution then makes + `RelationshipTo('SpatialObject', ...)` resolve correctly to any concrete subclass carrying that + label, instead of pointing at a Python name that is never defined. +- **DEC-5**: Index generation becomes schema-driven: an opt-in LinkML slot `annotations` key (e.g. + `neo4j_index: true`) that the Cypher generator recognises and lowers to `CREATE INDEX`, replacing + the fully hand-maintained index block found in `hulubul-broker`'s `infra/cypher/schema.cypher`. +- **DEC-6**: Two-tier test data. A small, hand-authored synthetic LinkML fixture (readable, + deliberately exercises every construct this EPIC touches: `is_a` + abstract base, `any_of` + range restriction, required multivalued scalar/enum, a value object with no identifier reached by + relationship, a self-referencing relationship, a `slot_usage` cardinality override) drives fast + unit tests. A second fixture is a vendored copy of `hulubul-broker`'s actual LinkML schema + (CC-BY-4.0, attributed) as a real-world richness/regression check — skillery does not take a + runtime or CI dependency on the sibling repo itself, only a committed copy of its schema files. + +## Rabbit-holes + +- Don't try to make Cypher constraints emulate what Neo4j fundamentally cannot check (enum + membership, regex patterns, numeric bounds, "at least one relationship of type X") — per DEC-2, + name the boundary and move on; neomodel already covers it. +- Don't design a generic "any custom generator" plugin framework — this EPIC ships exactly two + generators (Neo4j constraints, neomodel), following the pattern `generation-and-templates.md` + already documents for custom generators. A generic framework is speculative until a third + generator actually needs one. +- The empirical Community-vs-Enterprise check (DEC-3) is a real spike with a real Neo4j container — + don't skip it and guess from documentation or the existing (possibly stale) code comments. + +## No-gos + +- `gen_mermaid_classdiagram.py` and `gen_operational_schemas.py` (the other two scripts read during + exploration) are untouched — out of scope, unrelated to Neo4j. +- No PyPI package, no new distribution mechanism beyond the existing `project-setup` + projection/pinning pattern (DEC-1). +- No app-layer validators, triggers, or APOC procedures generated as a Cypher-can't-do-it fallback + (DEC-2). +- No change to `hulubul-broker` itself in this EPIC — it is a separate, private sibling repo. + Migrating it to consume the vendored generators (deleting its own copies and the hand-maintained + `schema.cypher`) is a natural follow-up, explicitly deferred to a later change in that repo, not + this one. +- No Enterprise-only Neo4j feature emitted unconditionally (DEC-3) — Community Edition is the + target, full stop. +- No generic "any LinkML construct → Cypher" completeness goal. Scope is the constructs actually + found in real modelling (this EPIC's two fixtures), not an exhaustive LinkML-spec walk. + +--- + +## What Changes + +- Fix: relationship targets that are abstract LinkML classes no longer generate broken references + in either generator (root-caused via DEC-4 for neomodel; verified/documented for Cypher). +- Enrich `gen_neo4j_constraints.py`: existence (+ type, where Community allows it per the DEC-3 + spike) constraints for required multivalued scalar/enum properties (currently skipped entirely); + a generated Community-safe profile (replacing the hand-maintained subset file pattern found in + `hulubul-broker`); schema-driven index generation via an opt-in annotation (DEC-5). +- Enrich `gen_neomodel.py`: real Python inheritance mirroring `is_a` (DEC-4). +- Vendor both generators as skill assets under `skills/linkml-engineering/assets/generators/` with + a pytest suite in skillery's `tests/`, covering the synthetic fixture and the vendored + `hulubul-broker` schema fixture (DEC-6). +- `project-setup` gains a conditional projection step for the two generators (parallel to the + existing conditional LinkML branch), pinned-and-refreshable like the OpenSpec schema copy. +- `generation-and-templates.md`'s "Enable-on-demand target matrix" names both generators as + documented, opt-in targets. + +## Capabilities + +### New Capabilities +(none — this is new asset/tooling scope inside the existing `linkml-engineering` capability) + +### Modified Capabilities +- `linkml-engineering`: gains a requirement for the two vendored, tested Neo4j-targeting custom + generators (assets, tests, projection), and the existing "Dual-CLI parity and deferred scope" + requirement's "no new tooling" no-go (written for the original, docs-only `add-linkml-engineering- + skill` change) is updated — that deferral is what this EPIC now deliberately picks up. + +## Impact + +- `skills/linkml-engineering/`: new `assets/generators/` (the two generator scripts), updates to + `SKILL.md` and `references/generation-and-templates.md`. +- `skills/project-setup/`: conditional projection step for the two generators (references/ + spine-projection.md-style pinning), touching its LinkML branch. +- `tests/`: new pytest suite for the two generators, two fixture schemas (synthetic + + vendored-from-hulubul-broker). +- `openspec/specs/linkml-engineering/spec.md`: modified requirement (see above). +- No change to any other skill, to `hulubul-broker`, or to the dual-CLI generation/distribution + mechanism itself (skill assets already sync to `.opencode/` like any other skill file). diff --git a/openspec/changes/linkml-neo4j-generators/specs/linkml-engineering/spec.md b/openspec/changes/linkml-neo4j-generators/specs/linkml-engineering/spec.md new file mode 100644 index 0000000..6be4943 --- /dev/null +++ b/openspec/changes/linkml-neo4j-generators/specs/linkml-engineering/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Vendored, tested Neo4j-targeting custom generators +The skill SHALL provide two custom LinkML generators — a Neo4j Cypher-constraint generator and a +neomodel OGM generator — as tested skill assets under `skills/linkml-engineering/assets/generators/`, +each built on LinkML's `Generator` base class per the existing custom-generator mechanism. Both +generators SHALL resolve relationship targets correctly when the LinkML range is an abstract class, +by mirroring the schema's `is_a` hierarchy as real inheritance in generated code rather than +producing a reference to an undefined class. The Cypher generator SHALL emit only constraint and +index forms verified to work on Neo4j Community Edition, SHALL emit existence constraints for +required multivalued scalar/enum properties, and SHALL support an opt-in LinkML slot annotation +that lowers to a generated index statement. Neither generator SHALL emit application-layer +validation (triggers, procedures, or any construct standing in for something Neo4j cannot natively +enforce) as a substitute for what the neomodel generator's own Python-layer validation already +covers. `project-setup` SHALL project both generators into a consuming repository as a pinned, +refreshable copy, conditional on the project using LinkML with a Neo4j target, following the same +projection discipline already used for the `meaningfy` OpenSpec schema. + +#### Scenario: A relationship's range is an abstract LinkML class +- **WHEN** a slot's range is an abstract class in the LinkML schema +- **THEN** the generated neomodel code defines that class as an abstract base with real Python + inheritance to its concrete subclasses, and the relationship referencing it resolves to a real, + registered class rather than an undefined name + +#### Scenario: A required slot is multivalued +- **WHEN** a scalar or enum slot is both `required: true` and `multivalued: true` +- **THEN** the Cypher generator emits an existence constraint for that property, rather than + skipping it because it is multivalued + +#### Scenario: A slot is annotated for indexing +- **WHEN** a slot in the schema carries an opt-in index annotation +- **THEN** the Cypher generator emits a corresponding `CREATE INDEX` statement, without requiring + a hand-maintained index file + +#### Scenario: Projecting the generators into a consuming project +- **WHEN** `project-setup` scaffolds or modernises a project that uses LinkML with a Neo4j target +- **THEN** it projects a pinned copy of both generators, refreshable later by re-running the skill + and reviewing the diff, the same discipline already used for the `meaningfy` OpenSpec schema + +#### Scenario: Something Neo4j cannot natively enforce +- **WHEN** a LinkML construct (an enum's permissible values, a pattern, numeric bounds, or a + relationship cardinality lower bound) has no native Neo4j constraint equivalent +- **THEN** neither generator emits a substitute enforcement mechanism for it; the neomodel + generator's existing Python-layer validation remains the only enforcement for that construct + +## MODIFIED Requirements + +### Requirement: Dual-CLI parity and deferred scope +The skill SHALL ship to both Claude Code and opencode from the Claude source with a regenerated, +never-hand-edited `.opencode/` mirror, and its body SHALL stay CLI-agnostic. This skill SHALL NOT +introduce a model2owl configuration/transformation skill, an ontology/semantic-modelling skill, or a +greenfield LinkML workflow. Custom generator tooling IS in scope where it is a tested skill asset +projected by `project-setup` (see the vendored Neo4j-targeting generators requirement above) — the +prior blanket deferral of "new tooling or CLI" applied to the skill's original, documentation-only +scope and does not extend to this narrower, tested-and-projected class of addition. + +#### Scenario: The catalogue is validated +- **WHEN** `make validate` runs over the change +- **THEN** `linkml-engineering` resolves under `meaningfy-architecture`, the `.opencode/` mirror is + in sync, the version-sync gate passes, and no deferred artefact (model2owl skill, ontology skill, + greenfield LinkML workflow) is present + +#### Scenario: A new custom generator is proposed +- **WHEN** a change proposes adding a custom generator as a skill asset +- **THEN** it is in scope for this skill provided it ships with tests and a `project-setup` + projection path, distinguishing it from the general "new tooling or CLI" deferral diff --git a/openspec/changes/linkml-neo4j-generators/tasks.md b/openspec/changes/linkml-neo4j-generators/tasks.md new file mode 100644 index 0000000..e310501 --- /dev/null +++ b/openspec/changes/linkml-neo4j-generators/tasks.md @@ -0,0 +1,197 @@ +> Derived from EPIC `linkml-neo4j-generators` + +## 1. Spike: empirical Community/Enterprise boundary (DEC-3) + +- [ ] 1.1 **BLOCKED (this session)** — Stand up `neo4j:5.26-community` (testcontainers or local + docker), try each candidate constraint type by hand: node property existence + (`IS NOT NULL`), node property type (`IS :: TYPE`), list-typed property + (`IS :: LIST`), node key. Record what Community actually accepts vs rejects. + Docker daemon is inactive in this sandbox and starting it needs sudo; user chose to skip + Docker-dependent tasks for this session rather than wait. Generator defaults to the + conservative pre-existing assumption (uniqueness = Community-safe; existence/type/list-typed + = unverified, kept out of the `community` profile) until this actually runs. +- [ ] 1.2 **BLOCKED (this session)** — same reason as 1.1. The permanent test is written (task 5.3) + but not executed. + +## 2. Fixtures (DEC-6) + +- [x] 2.1 Author a small synthetic LinkML fixture schema that deliberately exercises: an abstract + base class (`is_a` hierarchy) with 2+ concrete subclasses, a slot whose range is that abstract + class, an `any_of`-restricted relationship range, a required+multivalued scalar slot, a + required+multivalued enum slot, a value object with no identifier reached by a relationship, + a self-referencing relationship (`X.rel -> X`), and a `slot_usage` cardinality override. + → `tests/fixtures/linkml/synthetic/library.yaml` (Media/Book/DVD/Dimensions/Category/Shelf/ + Loan). Verified loading via `SchemaView` — all constructs present as designed. +- [x] 2.2 Vendor a copy of `hulubul-broker`'s real LinkML schema (`model/linkml/*.yaml`, CC-BY-4.0) + into skillery's test fixtures with an attribution header noting source repo + license + the + date copied. No CI or runtime dependency on the sibling repo itself. + → `tests/fixtures/linkml/hulubul/*.yaml` + `ATTRIBUTION.md`. + +## 3. Fix + enrich `gen_neo4j_constraints.py` + +- [x] 3.1 Add required-multivalued scalar/enum → existence constraint (remove the blanket + multivalued skip for the existence case). Type constraint for multivalued stays out (task 3.2). +- [ ] 3.2 **DEFERRED, needs 1.1** — list-typed property constraints (`IS :: LIST`) for + multivalued properties. Not added: 1.1 never ran (Docker unavailable this session), and DEC-3 + says don't emit unverified constraint forms. Existence-for-multivalued (3.1) needed no such + verification — list-vs-scalar doesn't change existence-constraint semantics — so it shipped; + this one genuinely needs the empirical check. +- [x] 3.3 Add `--profile community` (default `full`) to the same generator/CLI — one code path. + `community` = uniqueness + indexes only; `full` = also existence/type, explicitly commented + as carried-over-unverified pending the spike (not asserted as confirmed-safe). +- [x] 3.4 Add opt-in `annotations: {neo4j_index: true}` slot recognition → `CREATE INDEX` emission. + Indexes are not edition-gated, so emitted in both profiles. +- [x] 3.5 Resolve/verify relationship-target correctness against an abstract range — confirmed the + Cypher generator never referenced relationship targets by name at all (it only checks + `slot.range in class_names` to decide "skip, this is a relationship"), so there was nothing to + break here; the bug was neomodel-only (task 4). +- [x] 3.6 Decided the Open Question: relaxed `_is_entity` → `_is_node_label` to "concrete class, + identifier or not" (not "concrete + has identifier"). A value object like `Dimensions` now + gets existence/type constraints on its own required properties (no uniqueness constraint, + since there's no natural key) — existence/type don't depend on uniqueness, and this makes the + Cypher and neomodel generators finally agree on what counts as a node. + +## 4. Fix + enrich `gen_neomodel.py` + +- [x] 4.1 Switch from flat, standalone classes to real Python inheritance mirroring each schema's + `is_a` hierarchy; abstract LinkML classes become `__abstract_node__ = True` neomodel bases. + Classes now emitted in parent-before-child topological order; each renders only its *direct* + slots (inherited ones come down through Python inheritance instead of being redeclared). +- [x] 4.2 Confirmed relationships whose range is an abstract class now resolve correctly — verified + two ways: (a) string assertions in the unit tests, (b) **actually importing the generated + module and instantiating the classes through real neomodel** (no Docker needed — neomodel + registers classes at definition time, no DB connection required). `SpatialObject`/ + `AgentInRole` are now real, registered classes with real, registered subclasses. +- [x] 4.3 Added a trailing comment on any relationship whose LinkML slot carries `any_of`, naming + the restricted concrete targets (documentation only, per DEC-2 — no new validation code). +- [x] **Unplanned but found while doing 4.2**: a *third* real, previously-undiscovered bug — + neomodel reserves the Python attribute names `id`, `deleted`, `element_id` (raises + `ValueError` at class-definition time). Both fixtures' identifier slot is named `id` + (LinkML's own convention), so the *original* generator (in both `hulubul-broker` and this + change's first draft) has always produced code that crashes the instant neomodel actually + tries to build the class — undetected because neither project had a test that imports the + generated output; string-matching tests wouldn't have caught it either. Fixed: reserved + names get a Python-side `_` suffix (`id_`) while `db_property=...` preserves the real Neo4j + property key, so the Cypher constraint generator's property references are unaffected. + +## 5. Tests + +- [x] 5.1 Unit tests against the synthetic fixture (task 2.1): assert exact/partial expected Cypher + and neomodel output for every construct the fixture exercises, including the two fixed bugs. + → `tests/test_linkml_neo4j_generators.py` (`TestNeo4jConstraintsSynthetic`, + `TestNeomodelSynthetic`) plus `TestNeomodelGeneratedCodeIsRealNeomodel` — the latter actually + executes the generated source and instantiates real neomodel classes (not just string + matching), which is what caught the reserved-attribute-name bug (task 4, unplanned item). +- [x] 5.2 Unit tests against the vendored real fixture (task 2.2): assert the generator runs + without error over the real, messy schema and that the previously-broken relationship targets + now resolve. → `TestHulubulFixtureRegression` + the hulubul case of + `TestNeomodelGeneratedCodeIsRealNeomodel`. Skipped the "golden-file constraint-set diff" — + the exact-match assertions on the abstract-class fix and the multivalued-existence fix are + the regression signal that matters; a full golden file would need updating every time either + generator's cosmetic output changes, for no extra safety. +- [x] 5.3 Integration test written (opt-in `docker` marker, testcontainers + + `neo4j:5.26-community`) → `tests/test_linkml_neo4j_integration.py`. Applies the generated + Community-profile Cypher, checks a violating write is rejected, AND runs the actual + task-1.1 spike (existence/type/list-typed/NODE KEY probes against a real Community + container) so the result is a repeatable test, not a comment. **Not executed this session** + (Docker unavailable) — marked clearly in the file's own docstring; run it for real before + trusting its "passed" status, then update gen_neo4j_constraints.py's docstring with the + actual Community-support boundary it finds. +- [x] 5.4 Confirmed via `pytest.ini` (`addopts = -m "not docker"`, `docker` marker registered) — + verified empirically: `make test` / `pytest tests/ -q` shows the 3 integration-test classes' + cases deselected (7 items), not run, not even collected-and-skipped-slowly. + +## 6. Vendor as repo tooling *(relocated — see §10)* + +- [x] 6.1 Moved both generator scripts into `skills/linkml-engineering/assets/generators/`; the + test suite imports them by file path (`importlib.util`), no packaging/install step needed. + **Superseded by §10**: relocated to `tools/linkml_neo4j/` — a skill's home is knowledge, not + a maintained codebase (`spec/skill-repo-governance.md`). +- [x] 6.2 Ran `make generate-opencode` — needed a real fix, not just "confirm": `tools/opencode_gen`'s + `map_skill` mirrored a stray `__pycache__` (created as a side effect of running the tests + locally) into `.opencode/`, because no prior skill ever shipped importable Python and the + generator had never needed to exclude bytecode-cache artifacts. Fixed `map_skill` to skip + `__pycache__`/`.pyc`/`.pyo`, added a regression test (`tests/test_opencode_gen.py:: + test_skill_pycache_excluded`). `make validate`'s two gates (drift, repo_lint) are green; + full suite is 88 passed, 0 failed, 7 deselected (the opt-in Docker tests). *(This fix stands + regardless of §10 — some future skill may still ship importable Python.)* + +## 7. Docs + +- [x] 7.1 Added both generators to `generation-and-templates.md`'s "Enable-on-demand target matrix" + as a new named subsection, alongside TypeScript/SQL/docs. +- [x] 7.2 Noted the Community-Edition-only scope, the `--profile` split, the annotation-driven + index mechanism, and the reserved-attribute-name handling in that same subsection (no new + doc file needed — it fit cleanly). + +## 8. `project-setup` projection + +- [x] 8.1 Added the conditional projection step (LinkML model source + Neo4j datastore selected). + Found along the way: **Neo4j wasn't even a listed datastore option** in `interview.md`'s Q5.1 + (only MongoDB/PostgreSQL/Redis) — added it. **Self-correction**: this task was first marked + done on documentation alone (interview.md/checklists.md/layout.md prose) — when the user + asked "how is this installed", checking `scaffold.sh` (the script that actually DOES the + projection `spine-projection.md` only describes) showed no code path existed at all: no + `--neo4j` flag, no `scaffold_neo4j_generators` function, nothing called. The "pinned/ + refreshable exactly like the OpenSpec schema copy" claim was aspirational, not real. Fixed: + added a real `--neo4j` flag and `scaffold_neo4j_generators()` to `scaffold.sh`, mirroring + `scaffold_openspec()`'s exact copy/skip/force discipline, gated on `$PRODUCT && $NEO4J`. + **Verified by actually running it** (dry-run, real run, re-run without `--force` → skipped, + re-run with `--force` → refreshed, a second run without `--neo4j` → no `scripts/` at all) — + not just read for plausibility. +- [x] 8.2 Documented the refresh path (re-run `project-setup`, review diff) in `checklists.md`'s new + "Neo4j generators" item and in `scaffold.sh`'s own inline copy, citing `spine-projection.md`'s + established discipline rather than restating it. Added the `scripts/` entry to `layout.md`'s + reference tree and the `--neo4j` flag to `scaffold.sh`'s `usage()` text. + +## 9. Validate + +- [x] 9.1 `openspec validate --changes linkml-neo4j-generators --strict` → passed (2/2, including + the unrelated pre-existing `example-spine-roundtrip` change). +- [x] 9.2 `make validate` green: lint (repo_lint exit 0 — the printed notes are pre-existing, + non-blocking advisories, not new failures) + test (88 passed, 0 failed, 7 deselected — the + opt-in Docker-marked integration tests, confirmed excluded from the default path per 5.4). + Also required a real fix along the way (task 6.2's `__pycache__` mirroring bug) — not just a + pass-through check. + +## 10. Course correction — code home (post-completion, user-flagged) + +After §1-9 were all checked off, the user asked "how is this installed/used" — checking that +surfaced two real problems, both fixed: + +- [x] 10.1 **`scaffold.sh` never actually projected anything.** §8 was marked done on documentation + alone (interview.md/checklists.md/layout.md prose describing a `--neo4j` flag and a + `scaffold_neo4j_generators` step) — neither existed in the actual script. Added both for + real, mirroring `scaffold_openspec()`'s exact copy/skip/force discipline. **Verified by + running it**, not just reading it: dry-run, real copy (byte-identical to source), re-run + without `--force` → skipped, re-run with `--force` → refreshed, a run without `--neo4j` → no + `scripts/` at all. +- [x] 10.2 **The code didn't belong inside `skills/linkml-engineering/` at all.** User: "so linkml + skill carries this burden not the project setup?" Checked `spec/skill-repo-governance.md`: + a Skill's home is *reusable knowledge*, not a maintained/tested codebase — every other skill + in the catalogue is prose. Compared against the actual precedent (the OpenSpec schema pin): + it lives at a neutral `openspec/schemas/meaningfy/`, **outside any skill's folder**, with + `project-setup` the one that copies it — not inside project-setup's own folder either. + Relocated both generators to `tools/linkml_neo4j/` (the existing home for skillery's own + maintained-and-tested Python tooling — `repo_lint`, `opencode_gen`), updated every reference + (`scaffold.sh`'s source path, both test files' `GENERATORS_DIR`, `generation-and-templates.md`, + `checklists.md`, `interview.md`, `layout.md`), and re-verified end to end: scaffold.sh still + copies correctly from the new location, full suite still 88 passed / 0 failed / 7 deselected, + `repo_lint` exit 0. `linkml-engineering` now documents and cites the generators; it does not + own them — the DEC-1 pattern used everywhere else in this catalogue ("referenced, not + vendored"). + +## Roadmap + +- [x] 1.1 · [x] 1.2 (blocked, documented) · [x] 2.1 · [x] 2.2 · [x] 3.1 · [x] 3.2 (deferred, + documented) · [x] 3.3 · [x] 3.4 · [x] 3.5 · [x] 3.6 · + [x] 4.1 · [x] 4.2 · [x] 4.3 · [x] 5.1 · [x] 5.2 · [x] 5.3 (written, unexecuted) · [x] 5.4 · + [x] 6.1 · [x] 6.2 · + [x] 7.1 · [x] 7.2 · [x] 8.1 · [x] 8.2 · [x] 9.1 · [x] 9.2 + +## Verification + +`openspec validate --strict` for structure; the new pytest suite (fast unit tests in the default +`make test` path, the Neo4j integration test opt-in) is the functional verification; a manual read +of the generated output against both fixtures confirms the two originally-confirmed bugs no longer +reproduce. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8d10101 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +markers = + docker: requires a reachable Docker daemon (opt-in; excluded from the default run). + Run explicitly with: pytest -m docker tests/test_linkml_neo4j_integration.py +addopts = -m "not docker" diff --git a/skills/linkml-engineering/references/generation-and-templates.md b/skills/linkml-engineering/references/generation-and-templates.md index d45594e..7296a0d 100644 --- a/skills/linkml-engineering/references/generation-and-templates.md +++ b/skills/linkml-engineering/references/generation-and-templates.md @@ -94,7 +94,69 @@ job** (conditional on the project using LinkML). First-class (wired and tested): **Pydantic, JSON Schema, OWL, SHACL** — the semantic core. Add the rest only when a project consumes the target: **TypeScript** (`gen-typescript`), **SQL DDL / SQLAlchemy** (`gen-sqlddl`/`gen-sqla` — keep the repository around the ORM hand-written, in `adapters/`), -**Markdown/HTML docs** (`gen-doc`), **JSON-LD context** (`gen-jsonld-context`), and **custom generators**. +**Markdown/HTML docs** (`gen-doc`), **JSON-LD context** (`gen-jsonld-context`), **Neo4j constraints + +neomodel OGM** (below), and further **custom generators** as a project needs them. + +### Neo4j constraints + neomodel OGM + +Two custom generators, both subclassing LinkML's `Generator` per the mechanism above. Documented +here because the behavior is LinkML-generator knowledge — but the code itself is **not a skill +asset**: a Skill's home is reusable *knowledge* (`spec/skill-repo-governance.md`), not a maintained, +tested codebase. The generators live as repo tooling at `tools/linkml_neo4j/` (tested by skillery's +own `tests/`, same pattern as `tools/repo_lint`/`tools/opencode_gen`), and `project-setup` is the one +that projects a pinned copy into a consuming repo — this skill cites them, it doesn't own them. +Enable when a project targets Neo4j: + +- **`gen-neo4j-constraints`** emits Cypher DDL (uniqueness, existence, property-type constraints, and + `CREATE INDEX` for any slot annotated `annotations: {neo4j_index: true}`). Targets **Neo4j Community + Edition only** — `--profile community` (uniqueness + indexes, the subset this generator can + currently prove runs on Community) vs `--profile full` (also existence/type, carried over from an + assumption not yet re-verified against a real Community instance; see the generator's own module + docstring for the current state of that verification). It emits no relationship-level constraints + and no application-layer validation (patterns, enum membership, numeric bounds, relationship + cardinality) — that's `gen-neomodel`'s job, on the Python side, not a second Cypher-side mechanism. +- **`gen-neomodel`** emits neomodel `StructuredNode` classes, mirroring the schema's `is_a` hierarchy as + real Python inheritance (abstract LinkML classes become `__abstract_node__ = True` bases) so that a + relationship whose range is an abstract class resolves correctly instead of pointing at an undefined + name. A slot's `any_of` range restriction is documented as a trailing comment on the generated + relationship, not mechanically enforced. Also note: neomodel reserves the Python attribute names + `id`, `deleted`, `element_id` — a schema whose identifier slot is (conventionally) named `id` gets a + `_`-suffixed Python attribute (`id_`) with `db_property=...` preserving the real Neo4j property key. + +#### Installing + +Pick your role: + +| You are… | Do this | +|---|---| +| **Scaffolding a new project** with `project-setup` | Answer LinkML (Q4.5) + Neo4j (Q5.1) in the interview, or pass `--neo4j` directly to `scaffold.sh` (product archetype only). This copies both generators into your repo's `scripts/`, pinned. | +| **Adding it to an existing project** | Re-run `project-setup` (`scaffold.sh ... --neo4j --skip-existing`) — additive, never touches unrelated files. | +| **Working inside skillery itself** | No install needed — run them directly from `tools/linkml_neo4j/`. | + +The pinned copy in your repo is a **snapshot**, not a live link back to skillery — refresh it by +re-running `project-setup` with `--force` (shows what changed; never silently overwritten), same +discipline as the `meaningfy` OpenSpec schema pin (`spine-projection.md`). + +#### Using + +```bash +# From your project root, once scripts/gen_neo4j_constraints.py + gen_neomodel.py exist: +pip install linkml click jinja2 neomodel # generator dependencies (not runtime deps of your project) + +python scripts/gen_neo4j_constraints.py model/schema.yaml --profile community > model/generated/neo4j/constraints.cypher +python scripts/gen_neomodel.py model/schema.yaml > model/generated/neomodel/ogm.py +``` + +Wire both into your project's `make generate-models` (or an equivalent `make neo4j-constraints` / +`make neomodel` target) the same way `gen-pydantic`/`gen-owl`/`gen-shacl` already are — see "The +`make generate-models` bridge" above. `--profile` defaults to `full`; pass `community` explicitly if +you're deploying to Neo4j Community and want only the constraint types this generator can currently +prove run there (see its module docstring for the current verification state). + +Both generators ship with a synthetic fixture schema and a vendored real-world fixture as their test +suite (`tests/test_linkml_neo4j_generators.py`); `project-setup` projects a pinned copy into a +consuming repo, refreshed the same way as the `meaningfy` OpenSpec schema (re-run, review the diff, +never silently overwritten). ## Architectural boundary for generated modules diff --git a/skills/project-setup/references/checklists.md b/skills/project-setup/references/checklists.md index f35cc8b..b735246 100644 --- a/skills/project-setup/references/checklists.md +++ b/skills/project-setup/references/checklists.md @@ -29,6 +29,13 @@ multi-component) apply only if the interview selected them. multi-component, model `code-anatomy.md` first, then translate to contracts. - [ ] **Model** (product) — `model/` (LinkML seed) + the `make generate-models` bridge (`conceptual-modelling`). +- [ ] **Neo4j generators** (product, LinkML model source + Neo4j datastore selected, Q4.5 + Q5.1) — + project a **pinned copy** of `gen-neo4j-constraints`/`gen-neomodel` (repo tooling at + `tools/linkml_neo4j/` — documented, not owned, by `linkml-engineering`) into `scripts/`; wire + `make neo4j-constraints` / `make neomodel` targets alongside the existing `make + generate-models` ones. Refresh path: + re-run `project-setup`, review the diff — same discipline as the `meaningfy` OpenSpec schema + pin (`spine-projection.md`), never silently overwritten. - [ ] **Tests** (code) — drop the `tests/` tree with the marker-injecting `conftest.py` and one smoke unit test + one example feature (`testing-setup.md`). - [ ] **Agentic** — render the canonical `CLAUDE.md`; create the `AGENTS.md` symlink diff --git a/skills/project-setup/references/interview.md b/skills/project-setup/references/interview.md index e008cb7..c5df889 100644 --- a/skills/project-setup/references/interview.md +++ b/skills/project-setup/references/interview.md @@ -109,7 +109,10 @@ and frameworks apply. A `library` skips them (no process); a `doc-only` skips Gr **Q5.1 Primary datastore(s)?** (multi-select) - Options: **MongoDB** (motor), **PostgreSQL** (SQLAlchemy/asyncpg), **Redis** (cache/streams), - *none / in-memory*. + **Neo4j** (`neomodel`), *none / in-memory*. +- **Neo4j + LinkML model source (Q4.5) together** additionally project the vendored + `gen-neo4j-constraints`/`gen-neomodel` custom generators (repo tooling at `tools/linkml_neo4j/`, + documented by `linkml-engineering`) into `scripts/` — see `checklists.md` and `layout.md`. - Drives runtime deps, the `adapters/` repository skeleton, `infra/compose.yaml` services, `infra/.env.example`, integration-test markers (`tests/integration/`), and the `Datastores / external systems` bullet in `CLAUDE.md`. diff --git a/skills/project-setup/references/layout.md b/skills/project-setup/references/layout.md index 6baed80..01d9cc3 100644 --- a/skills/project-setup/references/layout.md +++ b/skills/project-setup/references/layout.md @@ -54,6 +54,10 @@ first: `domain` (the book's `models/`) → `adapters` → `services` → `entryp ├── model/ # conceptual model (LinkML) — PRODUCT archetype only (R5) │ └── schema.yaml # the domain source; make generate-models renders the targets │ +├── scripts/ # PINNED, projected generators — only if LinkML + Neo4j (Q4.5+Q5.1) +│ ├── gen_neo4j_constraints.py # copied from skillery's tools/linkml_neo4j/; refresh +│ └── gen_neomodel.py # = re-run project-setup, review the diff (never silent) +│ ├── openspec/ # the SPINE (see spine-projection.md) — projected into every repo │ ├── config.yaml # schema: meaningfy ; context: ; the 3 thin per-artifact rules │ ├── schemas/meaningfy/ # the PINNED meaningfy schema (copied from skillery) diff --git a/skills/project-setup/scripts/scaffold.sh b/skills/project-setup/scripts/scaffold.sh index eaed0d9..afb32c2 100755 --- a/skills/project-setup/scripts/scaffold.sh +++ b/skills/project-setup/scripts/scaffold.sh @@ -16,7 +16,7 @@ PACKAGE="" ; PROJECT_NAME="" ; SLUG="" ; PYVER="3.12" ORG="meaningfy-ws" ; BRANCH="develop" ; DESC="" ; YEAR="$(date +%Y)" ARCHETYPE="product" ; TARGET="$(pwd)" WITH_DOCS=1 ; WITH_INFRA=1 ; WITH_CI=1 ; DEPLOYABLE=0 -FORCE=0 ; SKIP_EXISTING=0 ; NO_LOCK=0 ; DRY_RUN=0 ; MINIMAL=0 +FORCE=0 ; SKIP_EXISTING=0 ; NO_LOCK=0 ; DRY_RUN=0 ; MINIMAL=0 ; NEO4J=0 # OpenSpec version this skill pins schemas against (kept in sync with spine/openspec-version.txt). OPENSPEC_PIN="1.4.1" @@ -33,6 +33,8 @@ Options: -a, --archetype TYPE product|library|doc-only (default: product) legacy aliases service|pipeline|cli -> product --deployable this repo ships a deployable artifact -> CD TODO stub (ci-cd-delivery) + --neo4j project the PINNED gen-neo4j-constraints/gen-neomodel generators into + scripts/ (product + LinkML only — see references/checklists.md) --python VER Python version (default: 3.12) --org ORG GitHub org (default: meaningfy-ws) --branch BRANCH default/PR branch (default: develop) @@ -73,6 +75,7 @@ while [[ $# -gt 0 ]]; do --desc) DESC="$2"; shift 2;; --target) TARGET="$2"; shift 2;; --deployable) DEPLOYABLE=1; shift;; + --neo4j) NEO4J=1; shift;; --minimal) MINIMAL=1; shift;; --no-docs) WITH_DOCS=0; shift;; --no-infra) WITH_INFRA=0; shift;; @@ -217,6 +220,33 @@ scaffold_openspec() { fi } +# scaffold_neo4j_generators : project the PINNED gen-neo4j-constraints/gen-neomodel +# generators (tools/linkml_neo4j/ — maintained, tested code; NOT a skill asset, see +# linkml-engineering's generation-and-templates.md for why) into scripts/. Only +# meaningful for a product using LinkML with a Neo4j target (--neo4j); see +# references/checklists.md. +scaffold_neo4j_generators() { + local gen_src="$SCRIPT_DIR/../../../tools/linkml_neo4j" + local gen_dst="$TARGET/scripts" + if [[ "$DRY_RUN" -eq 1 ]]; then + [[ -d "$gen_dst" && -f "$gen_dst/gen_neo4j_constraints.py" ]] \ + && echo " = keep scripts/gen_neo4j_constraints.py, scripts/gen_neomodel.py (pinned)" \ + || echo " + create scripts/gen_neo4j_constraints.py, scripts/gen_neomodel.py (pinned, copied from skillery)" + elif [[ -d "$gen_src" ]]; then + mkdir -p "$gen_dst" + for f in gen_neo4j_constraints.py gen_neomodel.py; do + if [[ -f "$gen_dst/$f" && "$FORCE" -ne 1 ]]; then + echo " skip (exists): scripts/$f (re-run with --force to refresh; never clobbered in place)" + else + cp "$gen_src/$f" "$gen_dst/$f" + echo " copied scripts/$f (PINNED — refresh via --force; see references/checklists.md)" + fi + done + else + echo " NOTE: linkml-engineering generators not found ($gen_src) — copy scripts/gen_neo4j_constraints.py + scripts/gen_neomodel.py from skillery manually." + fi +} + # ---- minimal mode: agentic files + .claude/ layout only ------------------- if [[ "$MINIMAL" -eq 1 ]]; then echo "MINIMAL mode — agentic files (CLAUDE.md + AGENTS symlink) + .claude/ layout only." @@ -346,6 +376,7 @@ fi # ---- 5. agentic layer (CLAUDE-canonical) + spine (openspec/) --------------- scaffold_agentic scaffold_openspec +[[ "$PRODUCT" -eq 1 && "$NEO4J" -eq 1 ]] && scaffold_neo4j_generators # ---- 6. docs pillar (Antora) ---------------------------------------------- if [[ "$WITH_DOCS" -eq 1 ]]; then diff --git a/tests/fixtures/linkml/hulubul/ATTRIBUTION.md b/tests/fixtures/linkml/hulubul/ATTRIBUTION.md new file mode 100644 index 0000000..b807f20 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/ATTRIBUTION.md @@ -0,0 +1,18 @@ +# Attribution + +The `hulubul_*.yaml` / `hulubul.yaml` LinkML schema files in this directory are a **vendored +copy**, unmodified, of the real conceptual model from the `hulubul-broker` project (a sibling, +private Meaningfy repository — not a dependency of skillery, and not kept in sync automatically). + +- **Source:** `hulubul-broker/model/linkml/*.yaml` +- **License:** each file declares `license: https://creativecommons.org/licenses/by/4.0/` + (CC BY 4.0) — copying with attribution is permitted. +- **Copied:** 2026-07-23, as part of `openspec/changes/linkml-neo4j-generators` (skillery), to + serve as a real-world richness/regression fixture for the vendored Neo4j-targeting LinkML + generators (`skills/linkml-engineering/assets/generators/`). + +This is a **snapshot fixture**, not a live sync — see +`skills/linkml-engineering/assets/generators/` tests for how it's used, and this change's +`design.md` Error matrix for why it isn't treated as a contract with the source project. Refreshing +it (if `hulubul-broker`'s model changes materially) is a deliberate, manual follow-up, not an +automated or CI-enforced step. diff --git a/tests/fixtures/linkml/hulubul/hulubul.yaml b/tests/fixtures/linkml/hulubul/hulubul.yaml new file mode 100644 index 0000000..5b55ef6 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul.yaml @@ -0,0 +1,26 @@ +id: http://meaningfy.ws/ontology/hulubul/hulubul +name: hulubul +title: Hulubul V1 Conceptual Model +description: >- + Umbrella schema for the Hulubul V1 conceptual model — a parcel-request + intermediation service between Moldova, the diaspora and international + destinations. Imports the six domain modules (spatial, channel, agent, service, + request, feedback) over the shared common base. Faithful to the five source + Enterprise Architect diagrams (2026-07-10); flagged transcription artifacts are + resolved to their plausible reading with an inline note. Intended targets: + Neo4j (via linkml-store) and Pydantic. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + - hulubul_spatial + - hulubul_channel + - hulubul_agent + - hulubul_service + - hulubul_request + - hulubul_feedback diff --git a/tests/fixtures/linkml/hulubul/hulubul_agent.yaml b/tests/fixtures/linkml/hulubul/hulubul_agent.yaml new file mode 100644 index 0000000..9217f26 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_agent.yaml @@ -0,0 +1,118 @@ +id: http://meaningfy.ws/ontology/hulubul/agent +name: hulubul_agent +title: Hulubul Agent & Roles Model +description: >- + The party-role pattern for Hulubul V1. Agent is the enduring party (identity, + channels, location, optional standing service). AgentInRole is the episodic, + reified participation an agent plays within a request; Transporter, Sender and + Receiver are its subtypes. An Agent never *is* a role — it *plays* one. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + - hulubul_spatial + - hulubul_channel + +classes: + + Agent: + class_uri: hlb:Agent + description: >- + An enduring party — a person or an organisation — that participates in + Hulubul. Holds stable identity, communication channels, an optional main + location, and may publish a standing transport service and play episodic + roles. + aliases: [Party, Actor] + slots: + - id + - name + - description + slot_usage: + name: + required: true + attributes: + identifier: + slot_uri: hlb:identifier + description: Stable unique business identifier of the agent within Hulubul. + required: true + hasContactPoint: + slot_uri: hlb:hasContactPoint + description: >- + Communication channels through which the agent can be reached and, + when validated, authenticated. + range: Channel + multivalued: true + inlined: false + hasMainContactPoint: + slot_uri: hlb:hasMainContactPoint + description: The agent's primary/default channel; also the trusted login channel. + range: Channel + inlined: false + hasMainLocation: + slot_uri: hlb:hasMainLocation + description: The agent's principal location. + range: Address + inlined: false + providesService: + slot_uri: hlb:providesService + description: >- + The standing, request-agnostic transport offering published by this + agent (present only for transporters). + range: TransportService + inlined: false + + AgentInRole: + class_uri: hlb:AgentInRole + abstract: true + description: >- + The episodic, situation-dependent role an Agent plays within a specific + interaction; a reified participation. Anti-rigid: the same Agent plays + different roles across different requests. + aliases: [Participation, Role, RoleAssignment] + slots: + - id + attributes: + playedBy: + slot_uri: hlb:playedBy + description: The enduring agent enacting this role. + range: Agent + required: true + inlined: false + hasAltContactPointInRole: + slot_uri: hlb:hasAltContactPointInRole + description: >- + An alternative channel to use for this participation, overriding the + agent's default. + range: Channel + inlined: false + + Transporter: + class_uri: hlb:Transporter + is_a: AgentInRole + description: The role of the agent that carries the parcel(s) for a delivery request. + aliases: [Carrier, Courier] + + Sender: + class_uri: hlb:Sender + is_a: AgentInRole + description: >- + The role of the agent that initiates a delivery request and hands over the + parcel(s). The sender also names the receiver. + aliases: [Consignor, Shipper] + + Receiver: + class_uri: hlb:Receiver + is_a: AgentInRole + description: The role of the agent intended to take delivery of the parcel(s). + aliases: [Consignee, Recipient] + attributes: + deliveryNote: + slot_uri: hlb:deliveryNote + description: >- + A note from or for the receiver regarding delivery + (e.g. handover instructions). diff --git a/tests/fixtures/linkml/hulubul/hulubul_channel.yaml b/tests/fixtures/linkml/hulubul/hulubul_channel.yaml new file mode 100644 index 0000000..9396253 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_channel.yaml @@ -0,0 +1,89 @@ +id: http://meaningfy.ws/ontology/hulubul/channel +name: hulubul_channel +title: Hulubul Channel Model +description: >- + Communication channels for Hulubul V1. A Channel is both the endpoint through + which an agent is reached and — when validated — the credential that + authenticates it (control-is-credential, bot-mediated-only assumption). No + separate User/Account class exists in V1. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + +classes: + + Channel: + class_uri: hlb:Channel + description: >- + A communication channel bound to a single agent — the endpoint through + which the agent is reached and, when validated, authenticated. + aliases: [ContactPoint, Contact Channel, Account, Principal] + slots: + - id + - description + attributes: + alias: + slot_uri: hlb:alias + description: Human label for the channel (e.g. "mum's WhatsApp"). + systemID: + slot_uri: hlb:systemID + description: Provider/platform-issued identifier (e.g. Telegram chat/user id). + required: true + email: + slot_uri: hlb:email + description: Email address, when the medium is email. + telephone: + slot_uri: hlb:telephone + description: Phone number, when the medium is phone-based. + hasMedium: + slot_uri: hlb:hasMedium + description: The medium/platform of this channel. + range: Medium + required: true + validationStatus: + slot_uri: hlb:validationStatus + description: Whether control of the channel has been verified. + range: ChannelValidationStatus + required: true + +enums: + + Medium: + enum_uri: hlb:Medium + description: The medium/platform of a communication channel. + permissible_values: + GSM: + meaning: hlb:GSM + description: Mobile phone / SMS / voice. + WhatsApp: + meaning: hlb:WhatsApp + description: WhatsApp messaging. + Telegram: + meaning: hlb:Telegram + description: Telegram messaging. + Viber: + meaning: hlb:Viber + description: Viber messaging. + email: + meaning: hlb:email + description: Email. + + ChannelValidationStatus: + enum_uri: hlb:ChannelValidationStatus + description: Whether control of a channel has been verified. + permissible_values: + valid: + meaning: hlb:valid + description: Control verified; usable as a trusted login. + invalid: + meaning: hlb:invalid + description: Verification failed or channel unreachable. + needsReview: + meaning: hlb:needsReview + description: Not yet verified; contact-only until validated. diff --git a/tests/fixtures/linkml/hulubul/hulubul_common.yaml b/tests/fixtures/linkml/hulubul/hulubul_common.yaml new file mode 100644 index 0000000..13311a5 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_common.yaml @@ -0,0 +1,54 @@ +id: http://meaningfy.ws/ontology/hulubul/common +name: hulubul_common +title: Hulubul Common Base +description: >- + Shared foundation for the Hulubul V1 LinkML model: prefixes, default namespace, + datatype defaults, and the reusable slots that recur across several modules + (id, name, description, comment). Every other Hulubul module imports this one. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ + linkml: https://w3id.org/linkml/ + xsd: http://www.w3.org/2001/XMLSchema# + rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# + rdfs: http://www.w3.org/2000/01/rdf-schema# + skos: http://www.w3.org/2004/02/skos/core# + geo: http://www.opengis.net/ont/geosparql# + +# hlb is the default: every un-prefixed class/slot mints a hlb: URI implicitly +# (class_uri = hlb:ClassName, slot_uri = hlb:slotName). Names are kept in the +# exact camelCase drawn in the source diagrams so the implicit URIs match the +# transcribed ontology terms verbatim. +default_prefix: hlb +default_range: string + +imports: + - linkml:types + +slots: + # ---- graph identity (infrastructure, not a domain attribute) -------------- + # Every entity class carries `id` so linkml-store can map it to a Neo4j node + # and reference it by relationship. Value objects (GeoCoordinates) omit it and + # are therefore inlined rather than promoted to nodes. + id: + description: Graph node identity for this instance (distinct from any business identifier). + identifier: true + range: uriorcurie + required: true + + # ---- reusable descriptive slots ------------------------------------------ + name: + slot_uri: hlb:name + description: Human-readable name. + range: string + + description: + slot_uri: hlb:description + description: Free-text description. + range: string + + comment: + slot_uri: hlb:comment + description: Free-text remark or annotation. + range: string diff --git a/tests/fixtures/linkml/hulubul/hulubul_feedback.yaml b/tests/fixtures/linkml/hulubul/hulubul_feedback.yaml new file mode 100644 index 0000000..831695b --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_feedback.yaml @@ -0,0 +1,55 @@ +id: http://meaningfy.ws/ontology/hulubul/feedback +name: hulubul_feedback +title: Hulubul Feedback Model +description: >- + Reified rating/comment left by one participation about another, optionally + scoped to a delivery request. Feedback attaches to AgentInRole (the + participation) — you rate a party as they acted in a given role — not to Agent + directly. It is the raw material from which public reputation is computed. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + - hulubul_agent + - hulubul_request + +classes: + + Feedback: + class_uri: hlb:Feedback + description: >- + A reified rating and/or comment left by one participation about another, + optionally scoped to a delivery request. + aliases: [Rating, Review, Testimonial] + slots: + - id + attributes: + rating: + slot_uri: hlb:rating + description: Numeric score. + range: integer + comment: + slot_uri: hlb:comment + description: Free-text remark. + fromProvider: + slot_uri: hlb:fromProvider + description: The participation that authored the feedback. + range: AgentInRole + required: true + inlined: false + toRecipient: + slot_uri: hlb:toRecipient + description: The participation the feedback is about. + range: AgentInRole + required: true + inlined: false + aboutRequest: + slot_uri: hlb:aboutRequest + description: The delivery request the feedback concerns. + range: DeliveryRequest + inlined: false diff --git a/tests/fixtures/linkml/hulubul/hulubul_request.yaml b/tests/fixtures/linkml/hulubul/hulubul_request.yaml new file mode 100644 index 0000000..abc2f3a --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_request.yaml @@ -0,0 +1,183 @@ +id: http://meaningfy.ws/ontology/hulubul/request +name: hulubul_request +title: Hulubul Delivery Request Model +description: >- + The central transaction of Hulubul V1. A DeliveryRequest ties sender, receiver, + transporter, parcels, pickup/drop-off locations and status together. Its + RequestStatus is the only lifecycle in the model and is what all access-control + disclosure gates read. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + - hulubul_spatial + - hulubul_agent + +classes: + + DeliveryRequest: + class_uri: hlb:DeliveryRequest + description: >- + A request to move one or more parcels from a pickup location to a drop-off + location, carrying its participants, status and lifecycle timestamps. + aliases: [Transport Request, Shipment Request, ParcelRequest] + slots: + - id + attributes: + requestNote: + slot_uri: hlb:requestNote + description: Free-text notes attached to the request. + multivalued: true + preferredPeriod: + slot_uri: hlb:preferredPeriod + description: The requester's preferred timeframe (orientative, free text). + created: + slot_uri: hlb:created + description: When the request was created. + range: datetime + required: true + updated: + slot_uri: hlb:updated + description: When the request was last modified. + range: datetime + required: true + closed: + slot_uri: hlb:closed + description: When the request was closed/terminated. + range: datetime + required: false # ponytail: source drew [1] but an open request has no + # close time → [0..1]. + hasSender: + slot_uri: hlb:hasSender + description: The sender participation for this request. + range: Sender + required: true + inlined: false + hasReceiver: + slot_uri: hlb:hasReceiver + description: The primary receiver participation. + range: Receiver + required: true + inlined: false + hasTransporter: + slot_uri: hlb:hasTransporter + description: The assigned transporter participation, once one is selected. + range: Transporter + inlined: false + hasStatus: + slot_uri: hlb:hasStatus + description: Current lifecycle status. + range: RequestStatus + required: true + hasDeliveryItem: + slot_uri: hlb:hasDeliveryItem + description: The parcel(s) to be delivered. + range: Parcel + multivalued: true + required: true + inlined: false + hasPickUpLocation: + slot_uri: hlb:hasPickUpLocation + description: Where the parcel(s) are collected — an Address or a named Place. + range: SpatialObject + required: true + inlined: false + any_of: # ponytail: range restricted to Address ∪ Place (Area is too + - range: Address # coarse to be a pickup point); source could only draw + - range: Place # the abstract SpatialObject. + hasDropOffLocation: + slot_uri: hlb:hasDropOffLocation + description: Where the parcel(s) are delivered — an Address or a named Place. + range: SpatialObject + required: true + inlined: false + any_of: + - range: Address + - range: Place + hasAltDropOffLocation: + slot_uri: hlb:hasAltDropOffLocation + description: >- + An alternative drop-off location (Address or Place). Owner resolved to + request-level per diagram 2 (a Receiver-level variant was drawn in + diagram 3 — not modelled here). + range: SpatialObject + inlined: false + any_of: + - range: Address + - range: Place + + Parcel: + class_uri: hlb:Parcel + description: A physical item to be delivered as part of a request. + aliases: [Package, Item, Shipment Item] + slots: + - id + attributes: + declaredContent: + slot_uri: hlb:declaredContent + description: The sender's declared description of contents. + required: true + photoURL: + slot_uri: hlb:photoURL + description: URLs of photos of the parcel. + range: uri + multivalued: true + weightKg: + slot_uri: hlb:weightKg + description: Weight in kilograms. + range: float + dimensions: + slot_uri: hlb:dimensions + description: Free-text dimensions (e.g. "30x20x10 cm"). + hasAltReceiver: + slot_uri: hlb:hasAltReceiver + description: An alternative receiver for this specific parcel. + range: Receiver + inlined: false + +enums: + + RequestStatus: + enum_uri: hlb:RequestStatus + description: >- + Lifecycle states of a DeliveryRequest. Listed in intended progression + order; access gates read these as reached-milestone, not numeric compare. + permissible_values: + new: + meaning: hlb:new + description: Just created; not yet triaged. + needsClarification: + meaning: hlb:needsClarification + description: Awaiting more information from the sender. + complete: + meaning: hlb:complete + description: Fully specified and ready to be matched. + optionsProposed: + meaning: hlb:optionsProposed + description: One or more transporters have been proposed/recommended (recommended gate). + waitingResponse: + meaning: hlb:waitingResponse + description: Awaiting a party's response to proposed options. + accepted: + meaning: hlb:accepted + description: A transporter has committed; the job is on (accepted gate). + rejected: + meaning: hlb:rejected + description: The proposed option(s) were declined. + pickUpPlanned: + meaning: hlb:pickUpPlanned + description: Pickup has been scheduled. + pickedUp: + meaning: hlb:pickedUp + description: Parcel(s) collected. + delivered: + meaning: hlb:delivered + description: Parcel(s) delivered. + cancelled: + meaning: hlb:cancelled + description: Request cancelled. diff --git a/tests/fixtures/linkml/hulubul/hulubul_service.yaml b/tests/fixtures/linkml/hulubul/hulubul_service.yaml new file mode 100644 index 0000000..1a586e6 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_service.yaml @@ -0,0 +1,124 @@ +id: http://meaningfy.ws/ontology/hulubul/service +name: hulubul_service +title: Hulubul Service / Offering Model +description: >- + The standing-capability layer of Hulubul V1. A TransportService is a + request-agnostic offering published by a transporter agent, declaring what it + carries and its base (pickup) and destination (delivery) service areas, each a + ServiceOffer pairing an Area with an orientative Frequency. It carries no link + to any DeliveryRequest. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + - hulubul_spatial + +classes: + + TransportService: + class_uri: hlb:TransportService + description: >- + A standing, request-agnostic offering published by a transporter agent, + declaring service types and its base and destination service areas. + aliases: [Transporter Profile, Service Offering, Offer Profile] + slots: + - id + - description + slot_usage: + description: + required: true + attributes: + serviceTitle: + slot_uri: hlb:serviceTitle + description: Short title of the offering. + required: true + serviceType: + slot_uri: hlb:serviceType + description: What the service carries (people and/or parcels). + range: ServiceType + multivalued: true + required: true + hasBaseArea: + slot_uri: hlb:hasBaseArea + description: >- + Pickup footprint — areas (with frequency) where the transporter can + collect. + range: ServiceOffer + multivalued: true + required: true + inlined: false + hasDestinationArea: + slot_uri: hlb:hasDestinationArea + description: >- + Delivery reach — areas (with frequency) the transporter serves as + destinations. + range: ServiceOffer + multivalued: true + required: true + inlined: false + + ServiceOffer: + class_uri: hlb:ServiceOffer + description: >- + A served area paired with an orientative frequency. The same shape serves + both base and destination offers; direction is given by which association + (hasBaseArea vs hasDestinationArea) it sits under, not by a flag. Given an + id (graph node) because it carries an outgoing edge to Area. + aliases: [Service Area Offer, Area Offer, ServiceLocationSchedule] + slots: + - id + - description + - withinArea + slot_usage: + description: + required: true + withinArea: + required: true # [1]: the served administrative area. + attributes: + withFrequency: + slot_uri: hlb:withFrequency + description: How often the transporter is in/through the area (orientative). + range: Frequency + required: true + +enums: + + ServiceType: + enum_uri: hlb:ServiceType + description: What a transport service carries. + permissible_values: + peopleTransport: + meaning: hlb:peopleTransport + description: Carries people. + parcelTransport: + meaning: hlb:parcelTransport + description: Carries parcels. + + Frequency: + enum_uri: hlb:Frequency + description: >- + Orientative coarse human-coordination label for how often a transporter + serves an area — not a timetable/RRULE. + permissible_values: + daily: + meaning: hlb:daily + description: About once a day. + weekly: + meaning: hlb:weekly + description: About once a week. + biweekly: + meaning: hlb:biweekly + description: >- + Presumed about twice a week (fortnightly covers every-two-weeks). + Ambiguous in the source — confirm; twiceWeekly would disambiguate. + fortnightly: + meaning: hlb:fortnightly + description: About once every two weeks. + monthly: + meaning: hlb:monthly + description: About once a month. diff --git a/tests/fixtures/linkml/hulubul/hulubul_spatial.yaml b/tests/fixtures/linkml/hulubul/hulubul_spatial.yaml new file mode 100644 index 0000000..9ff9c70 --- /dev/null +++ b/tests/fixtures/linkml/hulubul/hulubul_spatial.yaml @@ -0,0 +1,149 @@ +id: http://meaningfy.ws/ontology/hulubul/spatial +name: hulubul_spatial +title: Hulubul Spatial Model +description: >- + GeoSPARQL-aligned spatial hierarchy for Hulubul V1. SpatialObject is the + abstract root; Address (precise), Area (coarse, nested) and Place (named point) + are its subtypes; GeoCoordinates is a composed point geometry attached via + hasCoordinates. The coarse Area vs precise Address split is load-bearing for + matching and for access-control disclosure-by-reachability. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + hlb: http://meaningfy.ws/ontology/hulubul/ + geo: http://www.opengis.net/ont/geosparql# +default_prefix: hlb +default_range: string + +imports: + - hulubul_common + +slots: + # Reused by Address, Area (reflexive), Place and ServiceOffer (in the service + # module) — all with "is within this area" semantics and range Area. + withinArea: + slot_uri: hlb:withinArea + description: The area this object sits within. + range: Area + inlined: false + + hasCoordinates: + slot_uri: hlb:hasCoordinates + description: The point geometry of this spatial object. + range: GeoCoordinates + multivalued: false + +classes: + + SpatialObject: + class_uri: hlb:SpatialObject + abstract: true + description: >- + Root of the spatial hierarchy (aligned to geo:SpatialObject). Anything with + spatial identity that may carry a geometry. + aliases: [Spatial Thing, Location] + slots: + - id + - comment + - hasCoordinates + slot_usage: + comment: + required: false # ponytail: source drew [1] (no marker) but a mandatory + # comment on every spatial object is implausible → [0..1]. + + Address: + class_uri: hlb:Address + is_a: SpatialObject + description: A precise, pin-level postal address. + aliases: [Postal Address, Street Address] + attributes: + number: + slot_uri: hlb:number + description: House/building number. + required: true + street: + slot_uri: hlb:street + description: Street name. + required: true + postCode: + slot_uri: hlb:postCode + description: Postal code. + required: true + slots: + - withinArea + slot_usage: + withinArea: + required: true # [1]: the coarse area this address falls within. + + Area: + class_uri: hlb:Area + is_a: SpatialObject + description: >- + A coarse administrative or geographic unit (locality, county, state, + country) forming a nested containment hierarchy. Transporter service offers + and matching operate on Area; exact pickup/delivery use Address. + aliases: [Region, Locality, Administrative Area, Zone] + attributes: + locality: + slot_uri: hlb:locality + description: Town/city/locality name. + county: + slot_uri: hlb:county + description: County/district (raion). + country: + slot_uri: hlb:country + description: Country name. + state: + slot_uri: hlb:state + description: State/region within country. + slots: + - withinArea + slot_usage: + withinArea: + required: false + multivalued: false # ponytail: source drew [0..*] but containment is a + # tree (one parent) → [0..1]. Reflexive: Area→Area. + + Place: + class_uri: hlb:Place + is_a: SpatialObject + description: >- + A named, typed point/place of interest (landmark, depot, pickup point) + sitting within an Area. Distinct from coarse Area and precise Address. + aliases: [Named Place, Point of Interest, Landmark] + attributes: + name: + slot_uri: hlb:name + description: Human-readable name of the place. + hasIdentifier: + slot_uri: hlb:hasIdentifier + description: Identifier of the place (e.g. gazetteer id). + required: true + hasType: + slot_uri: hlb:hasType + description: Type of the place (e.g. depot, landmark, pickup point). + required: true + slots: + - withinArea + slot_usage: + withinArea: + required: false # [0..1] + + GeoCoordinates: + class_uri: hlb:GeoCoordinates + description: >- + A WGS84 latitude/longitude point — the geometry attached to a spatial + object (GeoSPARQL geo:Geometry analogue). Composed value object, inlined + (no id) rather than a node. + aliases: [Coordinates, Point, Geometry, LatLong] + attributes: + latitude: + slot_uri: hlb:latitude + description: WGS84 latitude. + range: float + required: true + longitude: + slot_uri: hlb:longitude + description: WGS84 longitude. + range: float + required: true diff --git a/tests/fixtures/linkml/synthetic/library.yaml b/tests/fixtures/linkml/synthetic/library.yaml new file mode 100644 index 0000000..0a00d71 --- /dev/null +++ b/tests/fixtures/linkml/synthetic/library.yaml @@ -0,0 +1,144 @@ +id: http://meaningfy.ws/ontology/test-fixtures/library +name: library +title: Synthetic Library Fixture Schema +description: >- + A small, hand-authored LinkML schema for testing the Neo4j-targeting custom + generators (skills/linkml-engineering/assets/generators/). Deliberately exercises, + in one small schema: an abstract base class with concrete subclasses (Media -> + Book, DVD); a relationship whose range is that abstract class, restricted by + any_of (Loan.borrowedItem); a required+multivalued scalar slot (Book.authorNames); + a required+multivalued enum slot (Loan.returnCondition); a value object with no + identifier reached by a relationship (Book.hasDimensions -> Dimensions); a + self-referencing relationship (Category.partOf -> Category); and a slot_usage + cardinality override (partOf required on Shelf, optional on Category). Not a + real domain model — a test fixture only. +license: https://creativecommons.org/licenses/by/4.0/ + +prefixes: + lib: http://meaningfy.ws/ontology/test-fixtures/library/ + linkml: https://w3id.org/linkml/ +default_prefix: lib +default_range: string + +imports: + - linkml:types + +slots: + id: + description: Graph node identity for this instance. + identifier: true + range: uriorcurie + required: true + + name: + description: Human-readable name. + range: string + + partOf: + description: The broader grouping this belongs to (reflexive on Category). + range: Category + inlined: false + +classes: + + Media: + abstract: true + description: Abstract root of the lendable-media hierarchy. + slots: + - id + - name + + Book: + is_a: Media + description: A physical book. + attributes: + isbn: + description: International Standard Book Number. + required: true + annotations: + neo4j_index: true + authorNames: + description: Names of the book's authors. + multivalued: true + required: true + hasDimensions: + description: Physical dimensions of this book. + range: Dimensions + + DVD: + is_a: Media + description: A physical DVD. + attributes: + durationMinutes: + description: Runtime in minutes. + range: integer + + Dimensions: + description: >- + A physical size — a value object (no identifier), reached by a relationship + rather than promoted to its own graph node in the Cypher-constraint sense. + attributes: + widthCm: + description: Width in centimetres. + range: float + required: true + heightCm: + description: Height in centimetres. + range: float + required: true + + Category: + description: >- + A classification grouping, nested via the reflexive `partOf` relationship. + Root categories have no parent (partOf is optional here). + slots: + - id + - name + - partOf + slot_usage: + partOf: + required: false + + Shelf: + description: >- + A physical shelf, always filed under exactly one category (partOf is + required here — a slot_usage override of the same top-level slot used + reflexively by Category). + slots: + - id + - partOf + slot_usage: + partOf: + required: true + + Loan: + description: >- + A loan of one lendable item, restricted to Book or DVD even though the + underlying slot range is the abstract Media class. + slots: + - id + attributes: + borrowedItem: + description: The item on loan — a Book or a DVD, never a bare Media. + range: Media + required: true + any_of: + - range: Book + - range: DVD + returnCondition: + description: Condition of each returned unit (at least one entry required). + range: ConditionStatus + multivalued: true + required: true + +enums: + + ConditionStatus: + description: Condition of a returned loaned item. + permissible_values: + good: + description: No visible damage. + damaged: + description: Visible damage but usable. + lost: + description: Not returned. diff --git a/tests/test_linkml_neo4j_generators.py b/tests/test_linkml_neo4j_generators.py new file mode 100644 index 0000000..9ab04b6 --- /dev/null +++ b/tests/test_linkml_neo4j_generators.py @@ -0,0 +1,259 @@ +"""Unit tests for the vendored Neo4j-targeting LinkML generators. + +Covers openspec/changes/archive/linkml-neo4j-generators tasks 5.1 (synthetic fixture) +and 5.2 (vendored hulubul-broker fixture). No Docker/Neo4j required — these tests only +exercise the generators' Python string output. See test_linkml_neo4j_integration.py for +the Docker-gated real-database check. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +GENERATORS_DIR = Path(__file__).resolve().parents[1] / "tools/linkml_neo4j" +SYNTHETIC_SCHEMA = Path(__file__).resolve().parent / "fixtures/linkml/synthetic/library.yaml" +HULUBUL_SCHEMA = Path(__file__).resolve().parent / "fixtures/linkml/hulubul/hulubul.yaml" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def neo4j_gen_module(): + return _load_module("gen_neo4j_constraints", GENERATORS_DIR / "gen_neo4j_constraints.py") + + +@pytest.fixture(scope="module") +def neomodel_gen_module(): + return _load_module("gen_neomodel", GENERATORS_DIR / "gen_neomodel.py") + + +# --------------------------------------------------------------------------- +# gen_neo4j_constraints — synthetic fixture +# --------------------------------------------------------------------------- + + +class TestNeo4jConstraintsSynthetic: + def test_required_multivalued_scalar_gets_existence_constraint(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "book_authorNames_exists" in out + assert "FOR (n:`Book`) REQUIRE n.`authorNames` IS NOT NULL" in out + + def test_required_multivalued_enum_gets_existence_constraint(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "loan_returnCondition_exists" in out + assert "FOR (n:`Loan`) REQUIRE n.`returnCondition` IS NOT NULL" in out + + def test_community_profile_is_uniqueness_only(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="community") + out = gen.serialize() + assert "IS UNIQUE" in out + assert "IS NOT NULL" not in out + assert "IS ::" not in out + + def test_full_profile_includes_existence_and_type(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="full") + out = gen.serialize() + assert "IS NOT NULL" in out + assert "IS ::" in out + + def test_default_profile_is_full(self, neo4j_gen_module): + default_out = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)).serialize() + full_out = neo4j_gen_module.Neo4jConstraintGenerator( + str(SYNTHETIC_SCHEMA), profile="full" + ).serialize() + assert default_out == full_out + + def test_annotated_slot_emits_index(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "CREATE INDEX book_isbn_idx IF NOT EXISTS FOR (n:`Book`) ON (n.`isbn`)" in out + + def test_unannotated_slot_gets_no_index(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "CREATE INDEX" not in out.replace( + "CREATE INDEX book_isbn_idx IF NOT EXISTS FOR (n:`Book`) ON (n.`isbn`);", "" + ) + + def test_community_profile_still_emits_indexes(self, neo4j_gen_module): + # Indexes are not a constraint kind gated by edition — Community supports them. + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="community") + out = gen.serialize() + assert "CREATE INDEX book_isbn_idx" in out + + def test_relationship_slots_still_skipped(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "borrowedItem" not in out + assert "partOf" not in out + + def test_value_object_without_identifier_still_gets_property_constraints(self, neo4j_gen_module): + # Dimensions has no `id` slot but neomodel already promotes it to a real + # node — the Cypher side should agree it's a label and constrain its + # required scalar properties, just with no uniqueness constraint (there + # is no natural key to be unique on). + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="full") + out = gen.serialize() + assert "dimensions_widthCm_exists" in out + assert "dimensions_heightCm_exists" in out + assert "dimensions_id_unique" not in out + assert "FOR (n:`Dimensions`) REQUIRE n.`id`" not in out + + +# --------------------------------------------------------------------------- +# gen_neomodel — synthetic fixture +# --------------------------------------------------------------------------- + + +class TestNeomodelSynthetic: + def test_abstract_class_becomes_abstract_node_base(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "class Media(StructuredNode):" in out + media_block = out.split("class Media(StructuredNode):")[1].split("\n\n\nclass ")[0] + assert "__abstract_node__ = True" in media_block + + def test_concrete_subclasses_inherit_from_abstract_base(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "class Book(Media):" in out + assert "class DVD(Media):" in out + + def test_inherited_slots_not_redeclared_on_subclass(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + book_block = out.split("class Book(Media):")[1].split("\n\n\nclass ")[0] + # id and name are declared on Media; Book must not redeclare them. + assert "id = StringProperty" not in book_block + assert "name = StringProperty" not in book_block + assert "isbn = StringProperty" in book_block # Book's own slot still present + + def test_relationship_to_abstract_range_resolves_to_real_class(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "RelationshipTo('Media', 'BORROWED_ITEM'" in out + # And Media is actually defined as a class (the bug: it wasn't, before). + assert "class Media(StructuredNode):" in out + + def test_any_of_restriction_is_documented(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + loan_block = out.split("class Loan(StructuredNode):")[1].split("\n\n\nclass ")[0] + assert "Book" in loan_block and "DVD" in loan_block # restriction named in a comment + + def test_value_object_without_identifier_is_still_a_node(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + assert "class Dimensions(StructuredNode):" in out + + def test_self_referencing_relationship(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + category_block = out.split("class Category(StructuredNode):")[1].split("\n\n\nclass ")[0] + assert "RelationshipTo('Category'" in category_block + + def test_slot_usage_cardinality_override(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)) + out = gen.serialize() + category_block = out.split("class Category(StructuredNode):")[1].split("\n\n\nclass ")[0] + shelf_block = out.split("class Shelf(StructuredNode):")[1].split("\n\n\nclass ")[0] + assert "cardinality=ZeroOrOne" in category_block # partOf optional on Category + assert "cardinality=One" in shelf_block # partOf required on Shelf + + +# --------------------------------------------------------------------------- +# Both generators — vendored real-world hulubul-broker fixture (task 5.2) +# --------------------------------------------------------------------------- + + +class TestHulubulFixtureRegression: + """Confirms the two originally-confirmed bugs no longer reproduce on the real schema.""" + + def test_neomodel_generates_without_error(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(HULUBUL_SCHEMA)) + out = gen.serialize() + assert "class SpatialObject(StructuredNode):" in out + assert "class AgentInRole(StructuredNode):" in out + + def test_previously_broken_relationship_targets_now_resolve(self, neomodel_gen_module): + gen = neomodel_gen_module.NeomodelGenerator(str(HULUBUL_SCHEMA)) + out = gen.serialize() + # SpatialObject and AgentInRole must now actually be defined classes. + assert "class SpatialObject(StructuredNode):" in out + assert "class AgentInRole(StructuredNode):" in out + # And the concrete subclasses must inherit from them, not stand alone. + assert "class Address(SpatialObject):" in out + assert "class Area(SpatialObject):" in out + assert "class Place(SpatialObject):" in out + assert "class Sender(AgentInRole):" in out + assert "class Receiver(AgentInRole):" in out + assert "class Transporter(AgentInRole):" in out + + def test_neo4j_constraints_generates_without_error(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(HULUBUL_SCHEMA)) + out = gen.serialize() + assert "CREATE CONSTRAINT" in out + + def test_previously_skipped_multivalued_required_now_constrained(self, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(HULUBUL_SCHEMA)) + out = gen.serialize() + # TransportService.serviceType: multivalued + required enum — was silently skipped. + assert "transportservice_serviceType_exists" in out + + +# --------------------------------------------------------------------------- +# The generated neomodel code must actually BE valid neomodel, not just look +# right as a string — this is how the `id`/reserved-attribute-name bug (a +# THIRD real bug, undiscovered by reading code, found only by actually +# importing the generator's own output) was caught. No Docker/live database +# needed: neomodel registers StructuredNode classes at class-definition time, +# with no connection required. +# --------------------------------------------------------------------------- + + +def _exec_generated_module(source: str, module_name: str): + import types + + module = types.ModuleType(module_name) + exec(compile(source, f"", "exec"), module.__dict__) + return module + + +class TestNeomodelGeneratedCodeIsRealNeomodel: + def test_synthetic_fixture_imports_and_registers(self, neomodel_gen_module): + source = neomodel_gen_module.NeomodelGenerator(str(SYNTHETIC_SCHEMA)).serialize() + module = _exec_generated_module(source, "library_ogm_generated") + assert {c.__name__ for c in module.Media.__subclasses__()} == {"Book", "DVD"} + # The reserved-name rename: attribute is `id_`, but the real Neo4j + # property key is preserved as `id` via db_property. + assert module.Book.id_.db_property == "id" + + def test_hulubul_fixture_imports_and_registers(self, neomodel_gen_module): + source = neomodel_gen_module.NeomodelGenerator(str(HULUBUL_SCHEMA)).serialize() + module = _exec_generated_module(source, "hulubul_ogm_generated") + assert {c.__name__ for c in module.SpatialObject.__subclasses__()} == { + "Address", + "Area", + "Place", + } + assert {c.__name__ for c in module.AgentInRole.__subclasses__()} == { + "Sender", + "Receiver", + "Transporter", + } + # The relationships that pointed at an undefined class name before the + # fix now resolve to real, defined, registered classes. + assert module.DeliveryRequest.hasPickUpLocation is not None + assert module.Feedback.fromProvider is not None diff --git a/tests/test_linkml_neo4j_integration.py b/tests/test_linkml_neo4j_integration.py new file mode 100644 index 0000000..b6ac15e --- /dev/null +++ b/tests/test_linkml_neo4j_integration.py @@ -0,0 +1,158 @@ +"""Integration test: the empirical Neo4j-Community constraint spike, made permanent. + +This is task 1.2's permanent form of the task 1.1 spike (openspec/changes/archive/ +linkml-neo4j-generators): rather than a one-off manual check whose result gets +hand-encoded as a comment (the exact anti-pattern that produced the original +generator's stale Enterprise-only assumption), the check is a real, repeatable +test against a real `neo4j:5.26-community` container. + +Requires Docker. Skipped automatically if the Docker daemon isn't reachable — +this must NOT run as part of the default `make test` path (task 5.4); it's +opt-in via `pytest -m docker` or by running this file directly once Docker is +available. + +NOTE: this test was written but never executed in the session that authored it +— the sandbox's Docker daemon was inactive and starting it needed a sudo +password unavailable there. Its assertions encode the *intended* verification; +run it for real before trusting its "passed" status, and update +gen_neo4j_constraints.py's module docstring + this file's TODO once it has. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +GENERATORS_DIR = Path(__file__).resolve().parents[1] / "tools/linkml_neo4j" +SYNTHETIC_SCHEMA = Path(__file__).resolve().parent / "fixtures/linkml/synthetic/library.yaml" + +pytestmark = pytest.mark.docker + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _docker_available() -> bool: + try: + import docker + + docker.from_env().ping() + return True + except Exception: + return False + + +requires_docker = pytest.mark.skipif(not _docker_available(), reason="Docker daemon not reachable") + + +@pytest.fixture(scope="module") +def neo4j_gen_module(): + return _load_module("gen_neo4j_constraints", GENERATORS_DIR / "gen_neo4j_constraints.py") + + +@pytest.fixture(scope="module") +def neo4j_driver(): + from testcontainers.neo4j import Neo4jContainer + + with Neo4jContainer(image="neo4j:5.26-community") as container: + yield container.get_driver() + + +@requires_docker +class TestCommunityEditionConstraintSupport: + """TODO(1.1/1.2): run this for real once Docker is available, then update + gen_neo4j_constraints.py's module docstring with the actual result instead + of "unverified".""" + + def test_uniqueness_constraint_accepted(self, neo4j_driver): + with neo4j_driver.session() as session: + session.run( + "CREATE CONSTRAINT test_unique IF NOT EXISTS " + "FOR (n:TestNode) REQUIRE n.key IS UNIQUE" + ) + result = session.run("SHOW CONSTRAINTS YIELD name WHERE name = 'test_unique' RETURN name") + assert result.single() is not None + + def test_property_existence_constraint(self, neo4j_driver): + """The open question: does Community 5.26 accept this, or does it still need Enterprise?""" + with neo4j_driver.session() as session: + try: + session.run( + "CREATE CONSTRAINT test_exists IF NOT EXISTS " + "FOR (n:TestNode2) REQUIRE n.prop IS NOT NULL" + ) + except Exception as exc: # noqa: BLE001 — recording the boundary, not hiding it + pytest.skip(f"Community rejected property existence constraint: {exc}") + result = session.run("SHOW CONSTRAINTS YIELD name WHERE name = 'test_exists' RETURN name") + assert result.single() is not None + + def test_property_type_constraint(self, neo4j_driver): + with neo4j_driver.session() as session: + try: + session.run( + "CREATE CONSTRAINT test_type IF NOT EXISTS " + "FOR (n:TestNode3) REQUIRE n.prop IS :: STRING" + ) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"Community rejected property type constraint: {exc}") + result = session.run("SHOW CONSTRAINTS YIELD name WHERE name = 'test_type' RETURN name") + assert result.single() is not None + + def test_list_typed_property_constraint(self, neo4j_driver): + with neo4j_driver.session() as session: + try: + session.run( + "CREATE CONSTRAINT test_list_type IF NOT EXISTS " + "FOR (n:TestNode4) REQUIRE n.prop IS :: LIST" + ) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"Community rejected list-typed property constraint: {exc}") + result = session.run( + "SHOW CONSTRAINTS YIELD name WHERE name = 'test_list_type' RETURN name" + ) + assert result.single() is not None + + def test_node_key_constraint(self, neo4j_driver): + with neo4j_driver.session() as session: + try: + session.run( + "CREATE CONSTRAINT test_node_key IF NOT EXISTS " + "FOR (n:TestNode5) REQUIRE (n.a, n.b) IS NODE KEY" + ) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"Community rejected NODE KEY constraint: {exc}") + result = session.run( + "SHOW CONSTRAINTS YIELD name WHERE name = 'test_node_key' RETURN name" + ) + assert result.single() is not None + + +@requires_docker +class TestGeneratedCommunityProfileAppliesCleanly: + """The generated `--profile community` Cypher must apply without error, and + actually enforce what it claims (a violating write is rejected).""" + + def test_community_profile_applies_without_error(self, neo4j_driver, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="community") + statements = [s.strip() for s in gen.serialize().split(";") if s.strip() and "//" not in s.split("\n")[0]] + with neo4j_driver.session() as session: + for statement in statements: + session.run(statement) + + def test_uniqueness_is_actually_enforced(self, neo4j_driver, neo4j_gen_module): + gen = neo4j_gen_module.Neo4jConstraintGenerator(str(SYNTHETIC_SCHEMA), profile="community") + statements = [s.strip() for s in gen.serialize().split(";") if s.strip() and "//" not in s.split("\n")[0]] + with neo4j_driver.session() as session: + for statement in statements: + session.run(statement) + session.run("CREATE (:Book {id: 'b-1'})") + with pytest.raises(Exception): # noqa: PT011 — driver-specific constraint-violation type + session.run("CREATE (:Book {id: 'b-1'})") diff --git a/tests/test_opencode_gen.py b/tests/test_opencode_gen.py index 7810047..c184c7f 100644 --- a/tests/test_opencode_gen.py +++ b/tests/test_opencode_gen.py @@ -44,6 +44,23 @@ def test_skill_passthrough_copies_body(tmp_path): assert tree[".opencode/skills/foo/SKILL.md"] == b"---\nname: foo\ndescription: d\n---\nbody\n" +def test_skill_pycache_excluded(tmp_path): + """A skill may ship importable Python assets; its bytecode cache must never + be mirrored — it's a local build artifact, not a source file (found via + linkml-engineering's vendored generators tripping this for the first time: + no prior skill shipped executable Python, so this exclusion never mattered + before).""" + repo = _make_repo(tmp_path, skills={"foo": "---\nname: foo\ndescription: d\n---\nbody\n"}) + pycache = repo / "skills" / "foo" / "__pycache__" + pycache.mkdir() + (pycache / "mod.cpython-314.pyc").write_bytes(b"\x00") + (repo / "skills" / "foo" / "mod.py").write_text("x = 1\n", encoding="utf-8") + tree, gaps = gen.map_skill(repo, "foo") + assert gaps == [] + assert ".opencode/skills/foo/mod.py" in tree + assert not any("__pycache__" in path or path.endswith(".pyc") for path in tree) + + def test_skill_missing_frontmatter_field_fails(tmp_path): repo = _make_repo(tmp_path, skills={"foo": "---\nname: foo\n---\nbody\n"}) with pytest.raises(ValueError, match="missing 'description'"): diff --git a/tests/test_skill_inventory.py b/tests/test_skill_inventory.py new file mode 100644 index 0000000..821443d --- /dev/null +++ b/tests/test_skill_inventory.py @@ -0,0 +1,35 @@ +"""Codegen-freshness gate for docs/skill-inventory.md — same pattern as +tools/opencode_gen's drift check: regenerate and diff against the committed +file, so a skill/bundle change without regeneration fails the build.""" + +from pathlib import Path + +from tools import skill_inventory + +REPO = Path(__file__).resolve().parents[1] + + +def test_real_repo_generation_is_noop(): + assert skill_inventory.drift_errors(REPO) == [] + + +def test_first_sentence_trims_dense_description(): + description = 'Use to do X. Trigger on "a", "b", "c". Not a greenfield skill.' + assert skill_inventory._first_sentence(description) == "Use to do X." + + +def test_related_extracts_backticked_names_across_lines(): + body = ( + "## Boundary & Related Skills\n\n" + "**Owns:** something.\n\n" + "**Related:** `foo`, `bar`,\n`baz` (aside).\n\n" + "## Next Section\n" + ) + assert skill_inventory._related(body) == ["foo", "bar", "baz"] + + +def test_tolerant_frontmatter_survives_unquoted_colon(): + text = '---\nname: x\ndescription: A: b, C: d\n---\nbody\n' + fm = skill_inventory._frontmatter(text) + assert fm["name"] == "x" + assert "description" in fm diff --git a/tools/linkml_neo4j/gen_neo4j_constraints.py b/tools/linkml_neo4j/gen_neo4j_constraints.py new file mode 100644 index 0000000..9a3932d --- /dev/null +++ b/tools/linkml_neo4j/gen_neo4j_constraints.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Neo4j constraint generator, built on the LinkML ``Generator`` infrastructure. + +Subclasses ``linkml.utils.generator.Generator`` so it reuses the standard schema +loading, import resolution, ``SchemaView`` access, and the shared ``gen-*`` CLI +(``--format``, ``--stacktrace``, version handling) — identical in shape to +``gen-owl`` / ``gen-shacl``. + +Emits only the constraints Neo4j can actually enforce, derived unambiguously +from LinkML (SHACL is a poor source — it has no uniqueness): + + identifier slot -> uniqueness constraint (Community) + required scalar/enum -> existence constraint (assumed Enterprise; + unverified — see below) + typed scalar/enum -> property-type constraint (assumed Enterprise; + unverified — see below) + required multivalued -> existence constraint too — Neo4j's existence + scalar/enum check is on property-key presence, regardless + of whether the stored value is a scalar or a + list, so there is no reason to skip it + annotated slot -> index (`neo4j_index: true` in the slot's + (`neo4j_index: true`) `annotations`) — indexes are not edition-gated + +Mapping rules (mirror the linkml-store Neo4j mapping documented in the Makefile): + * concrete class = a :Label node, identifier or not — a value object with no + natural key (e.g. a composed value like Dimensions/GeoCoordinates) still + gets a label and existence/type constraints on its own properties; it just + gets no uniqueness constraint, since there is no natural key to be unique on + (this makes the Cypher and neomodel generators agree on what is a node — + neomodel already promotes every concrete class, keyed or not) + * abstract class = never instantiated -> skipped (concrete subclasses carry + their own inherited ``id`` and get constraints individually) + * object-valued slot (range is a class) -> a relationship, not a node property + -> skipped (this generator emits no relationship-level constraints; Neo4j has + no native "at least one relationship of type X" constraint at all, and none + of this schema's relationships carry their own properties, so relationship + property existence/type constraints have nothing to attach to yet) + +Community vs Enterprise: the ``profile`` option controls what's emitted. +``community`` emits only uniqueness constraints and indexes — the subset this +generator can currently *prove* runs on Neo4j Community Edition. ``full`` (the +default) additionally emits existence and property-type constraints, carried +over from this generator's original assumption that they need Enterprise. That +assumption has NOT been empirically re-verified against a real Neo4j Community +instance (openspec/changes/archive/linkml-neo4j-generators, tasks 1.1/1.2/5.3 — +blocked on Docker access when this generator was last touched). Treat ``full`` +as the reference/aspirational output and ``community`` as the only profile +currently safe to apply blind to a Community deployment, until that spike runs +for real and this comment is updated with its result. + +Usage: python gen_neo4j_constraints.py schema.yaml [--profile community|full] + or: gen-neo4j-constraints schema.yaml --profile community (once installed) +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import click +from linkml.utils.generator import Generator, shared_arguments + +# LinkML scalar range -> Neo4j property type (for `IS :: ` constraints). +NEO4J_TYPES = { + "string": "STRING", + "uriorcurie": "STRING", + "uri": "STRING", + "curie": "STRING", + "ncname": "STRING", + "integer": "INTEGER", + "float": "FLOAT", + "double": "FLOAT", + "decimal": "FLOAT", + "boolean": "BOOLEAN", + "date": "DATE", + "datetime": "DATETIME", + "time": "LOCAL_TIME", +} + +PROFILES = ("community", "full") + + +def _index_annotation(slot) -> bool: + """True iff the slot carries a truthy ``neo4j_index`` annotation.""" + ann = getattr(slot.annotations, "neo4j_index", None) + return bool(ann.value) if ann is not None else False + + +@dataclass +class Neo4jConstraintGenerator(Generator): + """Generate Neo4j constraint DDL (Cypher) for the enforceable LinkML subset.""" + + generatorname = "gen-neo4j-constraints" + generatorversion = "2.0.0" + valid_formats = ["cypher"] + file_extension = "cypher" + uses_schemaloader = False # we drive everything through self.schemaview + + profile: str = field(default="full") + + def __post_init__(self, *args, **kwargs): + if self.profile not in PROFILES: + raise ValueError(f"profile must be one of {PROFILES}, got {self.profile!r}") + super().__post_init__(*args, **kwargs) + + def _is_node_label(self, class_name: str) -> bool: + """A class becomes a Neo4j `:Label` iff it is concrete. + + No longer requires an identifier slot: a value object with no natural key + (e.g. a composed value like ``Dimensions``/``GeoCoordinates``) is still + promoted to a real node by the neomodel generator's mapping, so the two + generators should agree it's a label. Existence/type constraints don't + depend on uniqueness — only the uniqueness constraint itself is + conditional on there actually being an identifier slot to be unique on + (see ``_class_constraints``). + """ + cls = self.schemaview.get_class(class_name) + return not cls.abstract + + @staticmethod + def _neo4j_type(range_: str | None) -> str: + """Neo4j property type for a scalar/enum range (enums store as STRING).""" + return NEO4J_TYPES.get(range_ or "string", "STRING") + + def _class_constraints(self, class_name: str) -> list[str]: + sv = self.schemaview + label = class_name + id_slot = sv.get_identifier_slot(class_name) # may be None — value object, no natural key + class_names = set(sv.all_classes()) + full = self.profile == "full" + + out = [f"// ---- {label} " + "-" * max(0, 68 - len(label))] + + # Identifier -> uniqueness (Community). NODE KEY (unique+exists) is the + # Enterprise upgrade; noted rather than emitted so this runs on Community. + # Only emitted when the class actually has a natural key. + if id_slot is not None: + p = id_slot.name + out.append( + f"CREATE CONSTRAINT {label.lower()}_{p}_unique IF NOT EXISTS\n" + f"FOR (n:`{label}`) REQUIRE n.`{p}` IS UNIQUE;" + " // NODE KEY on Enterprise" + ) + + for slot in sv.class_induced_slots(class_name): + if id_slot is not None and slot.name == id_slot.name: + continue + if slot.range in class_names: # object-valued -> relationship + continue + p = slot.name + + # Existence: checks property-key presence regardless of scalar vs + # list storage, so multivalued is no reason to skip this one. + if full and slot.required: + out.append( + f"CREATE CONSTRAINT {label.lower()}_{p}_exists IF NOT EXISTS\n" + f"FOR (n:`{label}`) REQUIRE n.`{p}` IS NOT NULL;" + " // Enterprise (unverified on Community — see module docstring)" + ) + + # Property-type: skipped for multivalued for now — a list-typed + # constraint (`IS :: LIST`) needs its own Community check, + # not assumed correct just because scalar existence is safe to add. + if full and not slot.multivalued: + out.append( + f"CREATE CONSTRAINT {label.lower()}_{p}_type IF NOT EXISTS\n" + f"FOR (n:`{label}`) REQUIRE n.`{p}` IS :: {self._neo4j_type(slot.range)};" + " // Enterprise (unverified on Community — see module docstring)" + ) + + if _index_annotation(slot): + out.append( + f"CREATE INDEX {label.lower()}_{p}_idx IF NOT EXISTS " + f"FOR (n:`{label}`) ON (n.`{p}`);" + ) + return out + + def serialize(self, **kwargs) -> str: + sv = self.schemaview + header = [ + f"// Neo4j constraints generated from {self.schema.name}", + "// DO NOT EDIT — regenerate with `make neo4j-constraints`.", + f"// Profile: {self.profile}.", + ( + "// Uniqueness and indexes are Community-safe. Existence/type constraints are" + if self.profile == "full" + else "// Uniqueness and indexes only — the Community-safe subset." + ), + ] + if self.profile == "full": + header.append( + "// carried over unverified (see module docstring); omitted from --profile community." + ) + header.append("") + body = [ + "\n".join(self._class_constraints(cn)) + for cn in sorted(sv.all_classes()) + if self._is_node_label(cn) + ] + return "\n".join(header) + "\n\n".join(body) + "\n" + + +@shared_arguments(Neo4jConstraintGenerator) +@click.command(name="gen-neo4j-constraints") +@click.option( + "--profile", + type=click.Choice(PROFILES), + default="full", + help="community = uniqueness + indexes only (Community-safe); full = also existence/type.", +) +@click.version_option(Neo4jConstraintGenerator.generatorversion, "-V", "--version") +def cli(yamlfile, profile, **kwargs): + """Generate Neo4j constraint DDL from a LinkML schema.""" + gen = Neo4jConstraintGenerator(yamlfile, profile=profile, **kwargs) + print(gen.serialize()) + + +if __name__ == "__main__": + cli() diff --git a/tools/linkml_neo4j/gen_neomodel.py b/tools/linkml_neo4j/gen_neomodel.py new file mode 100644 index 0000000..6074276 --- /dev/null +++ b/tools/linkml_neo4j/gen_neomodel.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""neomodel OGM generator, built on the LinkML ``Generator`` infrastructure. + +Subclasses ``linkml.utils.generator.Generator`` (like ``gen_neo4j_constraints``) +so it reuses schema loading, import resolution, ``SchemaView`` and the shared +``gen-*`` CLI. Renders neomodel ``StructuredNode`` classes with a Jinja2 template. + +Mapping to neomodel: + * a class with no ``is_a`` -> direct ``StructuredNode`` subclass + * a class with ``is_a: Parent`` -> subclasses the generated ``Parent`` class in + real Python (mirrors the LinkML hierarchy) — each class renders only its own + *directly declared* slots; inherited slots come down through Python + inheritance, not by being redeclared + * abstract class -> still generated, as ``__abstract_node__ = True`` + (neomodel's own polymorphic-node marker) — this is what makes a relationship + whose range is an abstract class resolve correctly: neomodel looks the target + up by name in its class registry, and the abstract base is now a real, + registered class with real, registered concrete subclasses, instead of a + name that was never defined at all + * identifier slot -> ``StringProperty(unique_index=True, required=True)`` — + renamed to ``id_`` when the schema's identifier slot is literally called + ``id`` (LinkML's own convention, used by every fixture this generator is + tested against), since neomodel reserves ``id``/``deleted``/``element_id`` + as Python attribute names and raises at class-definition time otherwise; + the actual Neo4j property key is preserved via ``db_property=...`` + * scalar slot -> typed ``*Property`` (``required=True`` if required) + * enum slot -> ``StringProperty(choices=...)`` + * multivalued scalar/enum -> ``ArrayProperty()`` + * object-valued slot -> ``RelationshipTo(target, 'REL_NAME', cardinality=...)`` + * ``any_of``-restricted range -> the relationship still points at the (now real) + abstract/base target; the restriction to specific subclasses is noted in a + trailing comment, not mechanically enforced (neomodel has no union-of- + subclasses relationship constraint, and this generator does not add + application-layer validation to fill that gap — see the parent EPIC's DEC-2) + +Divergence from the linkml-store mapping: neomodel has no inlining, so value objects +(e.g. GeoCoordinates, no id) also become StructuredNodes and are reached by a +relationship rather than embedded. Noted in the generated header. + +Usage: python gen_neomodel.py schema.yaml + or: gen-neomodel schema.yaml (once installed) +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +import click +from jinja2 import Template +from linkml.utils.generator import Generator, shared_arguments + +# LinkML scalar range -> neomodel property class. +NEOMODEL_PROP = { + "string": "StringProperty", + "uri": "StringProperty", + "uriorcurie": "StringProperty", + "curie": "StringProperty", + "ncname": "StringProperty", + "time": "StringProperty", # neomodel has no time-only property + "integer": "IntegerProperty", + "float": "FloatProperty", + "double": "FloatProperty", + "decimal": "FloatProperty", + "boolean": "BooleanProperty", + "date": "DateProperty", + "datetime": "DateTimeProperty", +} + +_TEMPLATE = Template( + '''"""neomodel OGM classes generated from {{ schema_name }}. + +DO NOT EDIT — regenerate with `make neomodel`. +Value objects (no identifier) are modelled as StructuredNodes reached by a +relationship: neomodel has no inlining. Abstract LinkML classes are generated as +`__abstract_node__ = True` bases with real Python inheritance to their concrete +subclasses, so relationships targeting an abstract class resolve correctly. +""" +from neomodel import ( + StructuredNode, + StringProperty, IntegerProperty, FloatProperty, BooleanProperty, + DateProperty, DateTimeProperty, ArrayProperty, + RelationshipTo, ZeroOrMore, ZeroOrOne, OneOrMore, One, +) + + +{% for c in classes %} +class {{ c.name }}({{ c.base }}): + """{{ c.doc }}""" +{% if c.abstract %} + __abstract_node__ = True +{% endif %} +{% for line in c.lines %} + {{ line }} +{% endfor %} +{% if not c.lines and not c.abstract %} + pass +{% endif %} + + +{% endfor %}''', + trim_blocks=True, + lstrip_blocks=True, +) + + +def _rel_name(slot_name: str) -> str: + """camelCase slot -> UPPER_SNAKE relationship type (providesService -> PROVIDES_SERVICE).""" + return re.sub(r"(? str: + if multivalued: + return "OneOrMore" if required else "ZeroOrMore" + return "One" if required else "ZeroOrOne" + + +def _any_of_targets(slot) -> list[str]: + """The concrete ranges an ``any_of``-restricted slot is actually limited to.""" + return [expr.range for expr in (slot.any_of or []) if expr.range] + + +# neomodel raises ValueError at class-definition time if a StructuredNode declares +# a Python attribute with any of these names — they collide with neomodel/Neo4j +# internals. `id` is also LinkML's own conventional identifier-slot name (used by +# both fixtures this generator is tested against), so this is not a hypothetical +# edge case — every schema following that convention hits it. +RESERVED_NEOMODEL_ATTRS = {"id", "deleted", "element_id"} + + +def _safe_attr_name(slot_name: str) -> str: + """A Python attribute name that avoids neomodel's reserved names. + + The underlying Neo4j property key is preserved via ``db_property=...`` (see + ``_property_line``) whenever the attribute name had to change — this is a + Python-attribute-only rename, so the graph property (and the Cypher + constraint generator's own property references) are unaffected. + """ + return f"{slot_name}_" if slot_name in RESERVED_NEOMODEL_ATTRS else slot_name + + +@dataclass +class NeomodelGenerator(Generator): + """Generate neomodel OGM classes for the LinkML entity/value-object classes.""" + + generatorname = "gen-neomodel" + generatorversion = "2.0.0" + valid_formats = ["python"] + file_extension = "py" + uses_schemaloader = False + + def _choices_arg(self, enum_name: str) -> str: + pv = self.schemaview.get_enum(enum_name).permissible_values + pairs = ", ".join(f"({v!r}, {v!r})" for v in pv) + return f"choices=({pairs},)" + + def _property_line(self, slot, id_name: str | None) -> str: + name = slot.name + attr = _safe_attr_name(name) + db_property_kw = f"db_property={name!r}" if attr != name else None + + if name == id_name: + args = ["unique_index=True", "required=True"] + if db_property_kw: + args.append(db_property_kw) + return f"{attr} = StringProperty({', '.join(args)})" + + is_enum = slot.range in self.schemaview.all_enums() + base = "StringProperty" if is_enum else NEOMODEL_PROP.get(slot.range, "StringProperty") + inner_args = [self._choices_arg(slot.range)] if is_enum else [] + + if slot.multivalued: + inner = f"{base}({', '.join(inner_args)})" + outer_args = [] + if slot.required: + outer_args.append("required=True") + if db_property_kw: + outer_args.append(db_property_kw) + outer = f", {', '.join(outer_args)}" if outer_args else "" + return f"{attr} = ArrayProperty({inner}{outer})" + + args = list(inner_args) + if slot.required: + args.append("required=True") + if db_property_kw: + args.append(db_property_kw) + return f"{attr} = {base}({', '.join(args)})" + + def _relationship_line(self, slot) -> str: + card = _cardinality(bool(slot.required), bool(slot.multivalued)) + line = ( + f"{slot.name} = RelationshipTo('{slot.range}', " + f"'{_rel_name(slot.name)}', cardinality={card})" + ) + targets = _any_of_targets(slot) + if targets: + line += f" # restricted to: {', '.join(targets)} (not mechanically enforced)" + return line + + def _direct_slots(self, class_name: str): + """Slots declared *directly* on this class — not inherited ones. + + Inherited slots come down through the generated Python inheritance + instead (see ``serialize``'s topological ordering), so re-emitting them + here would just redeclare what the parent class already provides. + """ + sv = self.schemaview + return [sv.induced_slot(name, class_name) for name in sv.class_slots(class_name, direct=True)] + + def _class_dict(self, class_name: str) -> dict: + sv = self.schemaview + class_names = set(sv.all_classes()) + cls = sv.get_class(class_name) + id_slot = sv.get_identifier_slot(class_name) + # Only treat the identifier specially if THIS class declares it directly + # (i.e. it isn't inherited from a base that already renders it). + own_slot_names = {s.name for s in self._direct_slots(class_name)} + id_name = id_slot.name if id_slot and id_slot.name in own_slot_names else None + + lines = [] + for slot in self._direct_slots(class_name): + if slot.range in class_names: + lines.append(self._relationship_line(slot)) + else: + lines.append(self._property_line(slot, id_name)) + + doc = " ".join((cls.description or class_name).split()) + return { + "name": class_name, + "doc": doc, + "lines": lines, + "abstract": bool(cls.abstract), + "base": cls.is_a if cls.is_a else "StructuredNode", + } + + def _parents_first_order(self, class_names: set[str]) -> list[str]: + """Topological order (parent before child) so Python class bodies compile.""" + sv = self.schemaview + ordered: list[str] = [] + visited: set[str] = set() + + def visit(name: str) -> None: + if name in visited or name not in class_names: + return + visited.add(name) + parent = sv.get_class(name).is_a + if parent: + visit(parent) + ordered.append(name) + + for name in sorted(class_names): + visit(name) + return ordered + + def serialize(self, **kwargs) -> str: + sv = self.schemaview + class_names = set(sv.all_classes()) + classes = [self._class_dict(cn) for cn in self._parents_first_order(class_names)] + return _TEMPLATE.render(schema_name=self.schema.name, classes=classes) + + +@shared_arguments(NeomodelGenerator) +@click.command(name="gen-neomodel") +@click.version_option(NeomodelGenerator.generatorversion, "-V", "--version") +def cli(yamlfile, **kwargs): + """Generate neomodel OGM classes from a LinkML schema.""" + gen = NeomodelGenerator(yamlfile, **kwargs) + print(gen.serialize()) + + +if __name__ == "__main__": + cli() diff --git a/tools/opencode_gen/gen.py b/tools/opencode_gen/gen.py index 5b83216..03d8050 100644 --- a/tools/opencode_gen/gen.py +++ b/tools/opencode_gen/gen.py @@ -161,7 +161,9 @@ def map_skill(repo: Path, name: str) -> tuple[dict[str, bytes], list[Gap]]: raise ValueError(f"skill '{name}': SKILL.md missing '{field}'") tree: dict[str, bytes] = {} for f in sorted(src.rglob("*")): - if f.is_file(): + if "__pycache__" in f.parts: + continue # a skill may ship importable Python assets; never mirror their bytecode cache + if f.is_file() and f.suffix not in (".pyc", ".pyo"): rel = f.relative_to(src).as_posix() tree[f"{OPENCODE_DIR}/skills/{name}/{rel}"] = f.read_bytes() return tree, [] diff --git a/tools/skill_inventory.py b/tools/skill_inventory.py new file mode 100644 index 0000000..76e0910 --- /dev/null +++ b/tools/skill_inventory.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""Generate the skill inventory (docs/skill-inventory.md): a Mermaid map plus +per-bundle tables — not a flat database dump. + +Deterministic — a pure function of `skills/*/SKILL.md` frontmatter/body, +`.claude-plugin/marketplace.json`, and two small hand-curated mappings +(`PURPOSE_OF` + `PURPOSE_BLURB`, below — the one piece of judgement this file +can't derive mechanically). DO NOT hand-edit the generated doc; regenerate via +`python -m tools.skill_inventory` (or `make skill-inventory`). +`tests/test_skill_inventory.py` fails the build on drift, the same +codegen-freshness pattern as `tools/opencode_gen`. + +The Mermaid map shows three relationships: + - containment (bundle -> skill) one subgraph per bundle, skills inside + - classification (skill -> purpose) solid line; a small, curated set of + purpose cards cutting across bundles + - dependency (skill -> skill) dashed line; mechanically parsed from + each skill's own "**Delegates:**" / + "**This skill DELEGATES:**" text — + not hand-curated, not the full + "Related" cross-reference graph + (that stays in the per-bundle tables; + at 20+ cross-bundle pairs alone it's + a hairball at this node count, and + "related" is weaker than "depends on") +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + +SKILLS_DIR = "skills" +MARKETPLACE = ".claude-plugin/marketplace.json" +OUTPUT = "docs/skill-inventory.md" + +_FRONTMATTER = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) +_RELATED = re.compile(r"\*\*Related:\*\*\s*(.+?)(?:\n\n|\Z)", re.DOTALL) +_DELEGATES = re.compile( + r"\*\*(?:This skill )?delegates:?\*\*\s*(.+?)(?=\n\*\*|\n\n|\n##|\Z)", re.DOTALL | re.IGNORECASE +) +_BACKTICKED = re.compile(r"`([a-z0-9][a-z0-9-]*)`") + +# The one hand-curated judgement call: which small, cross-cutting purpose +# category each skill serves, and a one-line explanation of each category. +# Everything else in this file is mechanically derived. Adding a skill without +# adding it here fails the build (see `category_gaps`) rather than silently +# rendering it uncategorised. +PURPOSE_OF: dict[str, str] = { + # Writing & Communication + "technical-writing": "Writing & Communication", + "explanatory-writing": "Writing & Communication", + "executive-communication": "Writing & Communication", + # Modelling & Architecture + "architecture": "Modelling & Architecture", + "conceptual-modelling": "Modelling & Architecture", + "modelling-conventions": "Modelling & Architecture", + "linkml-engineering": "Modelling & Architecture", + "cosmic-python": "Modelling & Architecture", + # Process & Governance (the spine build loop + agentic guardrails) + "epic-planning": "Process & Governance", + "spec-stewardship": "Process & Governance", + "clarity-gate": "Process & Governance", + "guardrails": "Process & Governance", + # Quality & Review + "bdd-gherkin": "Quality & Review", + "meaningfy-code-review": "Quality & Review", + # Delivery & Ops + "project-setup": "Delivery & Ops", + "ci-cd-delivery": "Delivery & Ops", + "meaningfy-release": "Delivery & Ops", + "meaningfy-git-workflow": "Delivery & Ops", + # Consulting & Business + "semantic-consulting-coach": "Consulting & Business", + "decision-package": "Consulting & Business", + "proposal-writing": "Consulting & Business", + "estimation": "Consulting & Business", +} + +PURPOSE_BLURB: dict[str, str] = { + "Writing & Communication": "clear prose, persuasion, teaching", + "Modelling & Architecture": "domain models, system design, LinkML", + "Process & Governance": "the spine build loop + agentic guardrails", + "Quality & Review": "tests, BDD, pre-PR review", + "Delivery & Ops": "scaffolding, CI/CD, releases", + "Consulting & Business": "front-of-funnel advisory work", +} + +# Light-but-distinct fills so each purpose reads as a card, not a container — +# deliberately whiter/less saturated than the bundle fades below. +_PURPOSE_FILLS = { + "Writing & Communication": "#fdf6e3", + "Modelling & Architecture": "#eafaf5", + "Process & Governance": "#eef1fb", + "Quality & Review": "#fdeef0", + "Delivery & Ops": "#f6f5ef", + "Consulting & Business": "#fdeef7", +} + +_SKILL_FILL = "#1e1e1e" +_SKILL_TEXT = "#ffffff" + +# Dark, hue-paired-with-_PURPOSE_FILLS variants for the Relations diagram's +# skill boxes — same white text, but colour-coded by purpose so a dense +# relation graph is still parseable without tracing every line back to a card. +_SKILL_FILLS_BY_CATEGORY = { + "Writing & Communication": "#7a5c00", + "Modelling & Architecture": "#0f5c48", + "Process & Governance": "#2b3a67", + "Quality & Review": "#7a2030", + "Delivery & Ops": "#4a4a3a", + "Consulting & Business": "#6b1f4d", +} + +# Pastel, distinct-enough fills for a fade/contained look — one per bundle, in +# marketplace.json's own order. +_BUNDLE_FILLS = ["#eaf3ff", "#fff2e0", "#eafaf0", "#f5eaff"] + +# Tighter than Mermaid's defaults (nodeSpacing 50 / rankSpacing 50) — the +# skill lists read as a compact index, not a spread-out chart. +_INIT_DIRECTIVE = "%%{init: {'flowchart': {'nodeSpacing': 12, 'rankSpacing': 45, 'curve': 'basis'}}}%%" + + +def _skill_dirs(repo: Path) -> dict[str, Path]: + root = repo / SKILLS_DIR + return { + p.name: p / "SKILL.md" + for p in sorted(root.iterdir()) + if p.is_dir() and (p / "SKILL.md").exists() + } + + +def _frontmatter(text: str) -> dict: + """Parse the YAML frontmatter tolerantly — some descriptions contain + unquoted colons that trip a strict YAML parser (the platform's own loader + is lenient), so fall back to a line-by-line `key: value` read. Mirrors + `tools/repo_lint/lint.py`'s `_frontmatter` (kept independent rather than + imported — a few lines, not worth coupling two separately-evolving tools).""" + m = _FRONTMATTER.match(text) + if not m: + return {} + block = m.group(1) + try: + data = yaml.safe_load(block) + if isinstance(data, dict): + return data + except yaml.YAMLError: + pass + out: dict = {} + for line in block.splitlines(): + mm = re.match(r"^([A-Za-z][\w-]*):\s?(.*)$", line) + if mm: + out.setdefault(mm.group(1), mm.group(2).strip()) + return out + + +def _first_sentence(description: str) -> str: + """Trim a dense, trigger-packed frontmatter description to its first + sentence — the full text is duplicated triggers/keywords, not prose meant + to read as a table cell.""" + description = " ".join(description.split()) + m = re.search(r"^(.*?[.!?])(\s|$)", description) + return m.group(1) if m else description + + +def _related(body: str) -> list[str]: + m = _RELATED.search(body) + if not m: + return [] + return _BACKTICKED.findall(m.group(1)) + + +def _depends_on(body: str, all_names: set[str], self_name: str) -> list[str]: + """Skills this one explicitly DELEGATES to (a stronger, narrower claim than + "Related") — parsed from its own text, filtered to first-party skills.""" + m = _DELEGATES.search(body) + if not m: + return [] + found = dict.fromkeys(_BACKTICKED.findall(m.group(1))) # dedupe, keep order + return [n for n in found if n in all_names and n != self_name] + + +def _bundles(repo: Path) -> list[dict]: + """Bundles in marketplace.json's own order, each with its blurb + skill list.""" + marketplace = json.loads((repo / MARKETPLACE).read_text(encoding="utf-8")) + out = [] + for plugin in marketplace.get("plugins", []): + skills = [s.replace("./skills/", "").strip("/").split("/")[-1] for s in plugin.get("skills", [])] + out.append({"name": plugin["name"], "description": plugin.get("description", ""), "skills": skills}) + return out + + +def collect(repo: Path) -> tuple[list[dict], dict[str, dict]]: + """Returns (bundles, skills_by_name) — skills_by_name carries + purpose/related/depends_on/description.""" + bundles = _bundles(repo) + paths = _skill_dirs(repo) + all_names = set(paths) + skills: dict[str, dict] = {} + for name, path in paths.items(): + text = path.read_text(encoding="utf-8") + fm = _frontmatter(text) + skills[name] = { + "purpose": _first_sentence(fm.get("description", "")), + "related": _related(text), + "depends_on": _depends_on(text, all_names, name), + "category": PURPOSE_OF.get(name), + } + return bundles, skills + + +def category_gaps(bundles: list[dict], skills: dict[str, dict]) -> list[str]: + """A skill with no PURPOSE_OF entry — fails generation instead of silently + rendering uncategorised (the same 'coverage' discipline as opencode_gen's + every-source-mapped-or-gapped gate).""" + named = {name for b in bundles for name in b["skills"]} + return sorted(name for name in named if skills.get(name, {}).get("category") is None) + + +def _node_id(name: str) -> str: + return "s_" + name.replace("-", "_") + + +def _category_id(category: str) -> str: + return "p_" + re.sub(r"[^a-z0-9]+", "_", category.lower()).strip("_") + + +def render_map(bundles: list[dict], skills: dict[str, dict]) -> str: + """Containment + classification only — one edge type, kept clean.""" + lines = ["```mermaid", _INIT_DIRECTIVE, "flowchart LR"] + for b in bundles: + bid = "b_" + b["name"].replace("-", "_") + lines.append(f' subgraph {bid}["{b["name"]}"]') + for name in b["skills"]: + if name in skills: # external skills (e.g. superpowers) aren't first-party rows + lines.append(f' {_node_id(name)}["{name}"]') + lines.append(" end") + lines.append("") + categories = sorted({s["category"] for s in skills.values() if s["category"]}) + for category in categories: + blurb = PURPOSE_BLURB.get(category, "") + label = f"{category}
{blurb}" if blurb else category + lines.append(f' {_category_id(category)}("{label}")') + lines.append("") + for name, data in skills.items(): + if data["category"]: + lines.append(f" {_node_id(name)} --> {_category_id(data['category'])}") + lines.append("") + for i, b in enumerate(bundles): + bid = "b_" + b["name"].replace("-", "_") + lines.append(f" style {bid} fill:{_BUNDLE_FILLS[i % len(_BUNDLE_FILLS)]},stroke:#999,stroke-width:1px") + for category in categories: + fill = _PURPOSE_FILLS.get(category, "#ffffff") + lines.append(f" style {_category_id(category)} fill:{fill},color:#000000,stroke:#333,stroke-width:1.5px") + for name in skills: + lines.append(f" style {_node_id(name)} fill:{_SKILL_FILL},color:{_SKILL_TEXT},stroke:#000") + lines.append("```") + return "\n".join(lines) + + +def _related_only(name: str, data: dict, skills: dict[str, dict]) -> list[str]: + """Related-but-not-a-hard-dependency targets: the weaker relation, first- + party only (filters out external names like `stream-coding`/`superpowers`, + which have no node in this diagram).""" + return [t for t in data["related"] if t in skills and t != name and t not in data["depends_on"]] + + +def render_relations(bundles: list[dict], skills: dict[str, dict]) -> str: + """Both skill<->skill relations in one diagram, grouped by bundle (same + containers/colours as the Map) and skill boxes colour-coded by purpose + category — a plain uniform-dark box graph at ~90 edges was unreadable; + grouping + colour-coding was the fix, confirmed by rendering both ways. + Two distinct line styles: a **thick solid** arrow is *depends on* + (stronger — mechanically parsed from "Delegates" text); a **thin dashed** + arrow is *related* (weaker — everything else in the "Related" list).""" + lines = ["```mermaid", _INIT_DIRECTIVE, "flowchart LR"] + for b in bundles: + bid = "b_" + b["name"].replace("-", "_") + lines.append(f' subgraph {bid}["{b["name"]}"]') + for name in b["skills"]: + if name in skills: + lines.append(f' {_node_id(name)}["{name}"]') + lines.append(" end") + lines.append("") + for name, data in skills.items(): + for target in data["depends_on"]: + lines.append(f" {_node_id(name)} ==> {_node_id(target)}") + lines.append("") + for name, data in skills.items(): + for target in _related_only(name, data, skills): + lines.append(f" {_node_id(name)} -.-> {_node_id(target)}") + lines.append("") + for i, b in enumerate(bundles): + bid = "b_" + b["name"].replace("-", "_") + lines.append(f" style {bid} fill:{_BUNDLE_FILLS[i % len(_BUNDLE_FILLS)]},stroke:#999,stroke-width:1px") + for name, data in skills.items(): + fill = _SKILL_FILLS_BY_CATEGORY.get(data["category"], _SKILL_FILL) + lines.append(f" style {_node_id(name)} fill:{fill},color:#ffffff,stroke:#000") + lines.append("```") + return "\n".join(lines) + + +def render(bundles: list[dict], skills: dict[str, dict]) -> str: + total = sum(len(b["skills"]) for b in bundles) + dependency_count = sum(len(s["depends_on"]) for s in skills.values()) + related_count = sum(len(_related_only(n, d, skills)) for n, d in skills.items()) + header = [ + "# Skill inventory", + "", + "", + "", + f"{total} skills across {len(bundles)} role bundles. Install `meaningfy-core` plus the " + "bundle(s) matching your role — see the root [`README.md`](../README.md).", + "", + "## Map", + "", + "**Containers** are bundles. **Dark rectangles** are skills. **Light rounded cards** are " + "the small set of purposes a skill's description was sorted into, cutting across bundles " + "— each with a one-line explanation of what it covers. The line is *classified as* " + "(skill → purpose).", + "", + render_map(bundles, skills), + "", + "## Relations", + "", + "Same bundle containers as the Map, but skill boxes are now colour-coded by **purpose** " + "(the same six categories, one dark shade each) instead of uniform dark — at this many " + "edges, colour is what makes the grouping legible without tracing every line. Two line " + f"types, not one: a **thick solid** arrow is *depends on* ({dependency_count} edges, " + 'mechanically parsed from each skill\'s own "Delegates" text — a skill explicitly handing ' + f"something off to another); a **thin dashed** arrow is *related* ({related_count} edges, " + 'everything else in the "Related" list — weaker, a "see also" rather than a hand-off). ' + f"Kept separate from the Map's classification edges — {dependency_count + related_count} " + "relation edges plus 22 classification edges in one diagram was tried and was a " + "long-crossing-line mess, confirmed by actually rendering it.", + "", + render_relations(bundles, skills), + "", + ] + body = [] + for b in bundles: + body.append(f"## {b['name']}") + body.append("") + body.append(b["description"] + ".") + body.append("") + body.append("| Skill | Purpose | Depends on | Related |") + body.append("|---|---|---|---|") + for name in b["skills"]: + data = skills.get(name) + if data is None: # external (e.g. superpowers) — no first-party SKILL.md to describe + continue + depends = ", ".join(f"`{x}`" for x in data["depends_on"]) if data["depends_on"] else "—" + related = ", ".join(f"`{x}`" for x in data["related"]) if data["related"] else "—" + body.append( + f"| [`{name}`](../skills/{name}/SKILL.md) | {data['purpose']} | {depends} | {related} |" + ) + body.append("") + return "\n".join(header + body) + + +def generate(repo: Path) -> str: + bundles, skills = collect(repo) + gaps = category_gaps(bundles, skills) + if gaps: + raise ValueError(f"skill(s) missing from PURPOSE_OF in tools/skill_inventory.py: {gaps}") + return render(bundles, skills) + + +def drift_errors(repo: Path) -> list[str]: + expected = generate(repo) + actual_path = repo / OUTPUT + if not actual_path.exists(): + return [f"missing generated file: {OUTPUT}"] + actual = actual_path.read_text(encoding="utf-8") + return [] if actual == expected else [f"{OUTPUT} is stale — regenerate with `make skill-inventory`"] + + +def main() -> None: + repo = Path(__file__).resolve().parents[1] + out = repo / OUTPUT + out.write_text(generate(repo), encoding="utf-8") + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + main() From 66e074962a57a06ec6382ae0edd3d7d30a01a18d Mon Sep 17 00:00:00 2001 From: Eugeniu Costetchi Date: Fri, 24 Jul 2026 01:24:52 +0300 Subject: [PATCH 2/2] fix(deps): declare click, linkml, neomodel, jinja2 in requirements-dev.txt CI's validate job installs only requirements-dev.txt, which never listed the packages tools/linkml_neo4j's generators actually import. Passed locally because these happened to already be present in the dev venv; failed in CI's clean environment with ModuleNotFoundError: No module named 'click' (the first missing import hit), 24 test errors. testcontainers is deliberately left out: its import is guarded inside the Docker-gated integration test, not at module level, so it isn't needed for the default (non-docker) test path. Verified against a genuinely clean venv, not the existing dev one. Co-Authored-By: Claude Sonnet 5 --- requirements-dev.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9dc255a..35a324a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,6 @@ pyyaml>=6.0 pytest>=8.0 +click>=8.0 +linkml>=1.11 +neomodel>=6.0 +jinja2>=3.1