From 0877cb69f06b09fa77c9d209ec9d6acc624dfaa2 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Fri, 9 Oct 2026 16:47:50 +0200 Subject: [PATCH 1/2] feat(openehr-specs): convert README.adoc to README.md and keep BMM work out of the ITS repos Scaffold template set revision 4. A root README.adoc no longer stands in for README.md: the plan reports it as convert (a rewrite, or a merge when both exist), and the skill renames it with git mv and rewrites the markup by references/readme-markdown.md; the script writes nothing for it. ITS-XML, ITS-BMM, ITS-JSON and ITS-REST hold no model of their own, so the scaffold never offers them a computable/BMM schema or asks Docker, and rejects a bmm_schema_id there; bmm-authoring edits no schema there, and class-generation and regen-classes run no bmm-publisher command. validate.py checks that those four skills name every excluded repo listed in the template set. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 7 +++ docs/testing.md | 2 +- plugins/openehr-specs/README.md | 4 +- .../skills/bmm-authoring/SKILL.md | 12 ++++- .../skills/class-generation/SKILL.md | 7 ++- .../skills/regen-classes/SKILL.md | 14 +++-- .../openehr-specs/skills/scaffold/SKILL.md | 23 ++++++-- .../skills/scaffold/assets/template-set.json | 22 ++++++-- .../skills/scaffold/migrations/0004.json | 5 ++ .../skills/scaffold/references/maintaining.md | 17 ++++-- .../scaffold/references/readme-markdown.md | 40 ++++++++++++++ .../skills/scaffold/scripts/scaffold.py | 36 ++++++++++++- scripts/test_scaffold.py | 54 ++++++++++++++++++- scripts/validate.py | 23 ++++++++ 15 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 plugins/openehr-specs/skills/scaffold/migrations/0004.json create mode 100644 plugins/openehr-specs/skills/scaffold/references/readme-markdown.md diff --git a/AGENTS.md b/AGENTS.md index 93fb479..69fdc63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Full details: [docs/skill-authoring.md](docs/skill-authoring.md) Pure-content repository (JSON manifests + markdown skills) — no build step or package manager. Two skills bundle a script, each with unit tests: `scaffold` (`scripts/scaffold.py`) and `bmm-authoring` (`scripts/check_bmm.py`). Run them with `python3 -m unittest discover -s scripts -p 'test_*.py'` (runs in CI). -- **Validation**: `python3 scripts/validate.py` (Claude + Cursor manifests, version sync, required skill/agent frontmatter fields, component paths, and the `scaffold` template set's integrity — runs in CI; it does not check description length, inventories, or the release tag) +- **Validation**: `python3 scripts/validate.py` (Claude + Cursor manifests, version sync, required skill/agent frontmatter fields, component paths, the `scaffold` template set's integrity, and that the BMM skills name every repo the template set excludes from BMM work — runs in CI; it does not check description length, inventories, or the release tag) - **Local testing**: [docs/testing.md](docs/testing.md); **versioning/releases**: [docs/versioning.md](docs/versioning.md) - **End-user installation**: [docs/install.md](docs/install.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 194df9f..f3cf605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ Notable changes to the plugins in this repository. The format follows [Keep a Ch ### Changed +- `scaffold` template set revision 4, README: a root `README.adoc` no longer stands in for `README.md`. The plan reports it as `convert` (a rewrite, or a merge when `README.md` exists too). The skill then runs `git mv README.adoc README.md` and rewrites the markup as GitHub Markdown, by the new reference `references/readme-markdown.md`; the script writes nothing for it. `README`, `README.rst` and `README.txt` are still accepted as they are, and a repo without a README still gets one. +- `scaffold` revision 4, ITS repositories: ITS-XML, ITS-BMM, ITS-JSON and ITS-REST are excluded from BMM work, because they hold no model of their own. + - The scaffold never offers them a `computable/BMM` schema, even in a new repository, and does not ask Docker. + - A non-empty `bmm_schema_id` or `base_bmm_schema_id` is reported as invalid there. + - `bmm-authoring` creates and edits no schema there and sends the change to the component repo. + - `class-generation` and `regen-classes` run no `bmm-publisher` command there. + - `scripts/validate.py` checks that these four skills name every repo that the template set excludes (`files[bmm].excluded_components`). - `class-generation` and `authoring` send changes to the class model itself to `bmm-authoring`, and `scaffold` points to it for filling a new repository's empty schema. - `docs/prompting-guide.md`: the class-change prompts name `bmm-authoring` and the checker, and a prompt for a new component's schema is added. diff --git a/docs/testing.md b/docs/testing.md index 39d1d46..100a0a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,7 +21,7 @@ For Cursor, copy `plugins/openehr-specs` to `~/.cursor/plugins/local/openehr-spe ## Validation -- **Manifest and frontmatter validation**: `python3 scripts/validate.py` (CI runs it on every PR) checks the Claude and Cursor JSON manifests, name and version sync, declared component paths, and the marketplace `owner.name`. For every skill and subagent it checks that `name` and `description` exist and that `name` matches the directory or file name. It also runs the `scaffold` template-set check (`scaffold.py check`: templates exist and render, revisions are contiguous, every revision has a migration file, and the template digest matches the latest revision). It does not check description length, `disable-model-invocation`, the README inventories, or the release tag. +- **Manifest and frontmatter validation**: `python3 scripts/validate.py` (CI runs it on every PR) checks the Claude and Cursor JSON manifests, name and version sync, declared component paths, and the marketplace `owner.name`. For every skill and subagent it checks that `name` and `description` exist and that `name` matches the directory or file name. It also runs the `scaffold` template-set check (`scaffold.py check`: templates exist and render, revisions are contiguous, every revision has a migration file, and the template digest matches the latest revision), and that the `bmm-authoring`, `class-generation`, `regen-classes` and `scaffold` skills each name every repo the template set excludes from BMM work. It does not check description length, `disable-model-invocation`, the README inventories, or the release tag. - **Script tests**: `python3 -m unittest discover -s scripts -p 'test_*.py'` (CI runs it on every PR) covers the `scaffold` script (the template engine, each file strategy, variable inference, and the upgrade path across revisions) and the `bmm-authoring` checker (each finding it reports, its exit codes, a clean result on the skill's worked example, and no traceback for a wrongly typed value anywhere in it), all in temporary directories. Run it after changing either script, the template set, or the worked example. - **Built-in manifest check**: `claude plugin validate plugins/openehr-specs` validates the plugin manifest; add `--strict` to treat warnings as errors. - **Structural validation**: after creating or modifying plugin components, run the `plugin-dev:plugin-validator` agent. It checks `plugin.json`, the marketplace entry, directory layout, and frontmatter. The agent comes from the `plugin-dev` plugin, which this repo enables in `.claude/settings.json`. diff --git a/plugins/openehr-specs/README.md b/plugins/openehr-specs/README.md index cf30f83..c73181d 100644 --- a/plugins/openehr-specs/README.md +++ b/plugins/openehr-specs/README.md @@ -36,8 +36,8 @@ Three skills run only when you type them: they do not auto-trigger, and their de | Skill | Argument | Action | |-------|----------|--------| -| `/openehr-specs:regen-classes` | ` [-d ...]` | Regenerate class tables and diagrams via `bmm-publisher` (needs Docker) | -| `/openehr-specs:scaffold` | `[component id]` | Initialise a specification repo, or upgrade an existing one, to the standard file set: `AGENTS.md` (plugin table, build, Jira-key commit conventions), `.claude/` settings, `manifest.json`, `.gitignore`, `.asciidoctorconfig`, `LICENSE`, `README.md`, and in a new repo `computable/BMM/.bmm.json` (the schema the `bmm-publisher` image bundles for the component, or an empty one). `.claude/settings.json` registers the `openehr` marketplace, enables four plugins and pre-approves `git add`, `git commit`, `git diff`, WebFetch on `specifications.openehr.org` and three read-only Atlassian MCP calls. Plans before it writes, and records a revision in `.claude/scaffold.json` so later runs can upgrade the repo. Needs `python3`, plus Docker for the new-repo BMM check; written for Claude Code (its script path comes from `${CLAUDE_SKILL_DIR}`) | +| `/openehr-specs:regen-classes` | ` [-d ...]` | Regenerate class tables and diagrams via `bmm-publisher` (needs Docker; stops in the ITS repos) | +| `/openehr-specs:scaffold` | `[component id]` | Initialise a specification repo, or upgrade an existing one, to the standard file set: `AGENTS.md` (plugin table, build, Jira-key commit conventions), `.claude/` settings, `manifest.json`, `.gitignore`, `.asciidoctorconfig`, `LICENSE`, `README.md` (a root `README.adoc` is rewritten as Markdown), and in a new repo `computable/BMM/.bmm.json` (the schema the `bmm-publisher` image bundles for the component, or an empty one; never in ITS-XML, ITS-BMM, ITS-JSON or ITS-REST). `.claude/settings.json` registers the `openehr` marketplace, enables four plugins and pre-approves `git add`, `git commit`, `git diff`, WebFetch on `specifications.openehr.org` and three read-only Atlassian MCP calls. Plans before it writes, and records a revision in `.claude/scaffold.json` so later runs can upgrade the repo. Needs `python3`, plus Docker for the new-repo BMM check; written for Claude Code (its script path comes from `${CLAUDE_SKILL_DIR}`) | | `/openehr-specs:publish` | `` | Build a local HTML preview with the published `ghcr.io/openehr/asciidoctor` image (needs Docker and a sibling `specifications-AA_GLOBAL` checkout; checks that the HTML was rebuilt and the class tables resolved) | ## Subagents diff --git a/plugins/openehr-specs/skills/bmm-authoring/SKILL.md b/plugins/openehr-specs/skills/bmm-authoring/SKILL.md index dab523a..d60af67 100644 --- a/plugins/openehr-specs/skills/bmm-authoring/SKILL.md +++ b/plugins/openehr-specs/skills/bmm-authoring/SKILL.md @@ -5,7 +5,8 @@ description: > `specifications-XX` repo. This skill should be used when the user asks to write a BMM schema from scratch, add, rename or remove a class, attribute, function, invariant or enumeration in a component's model, set the schema header or includes, or check a hand-edited `.bmm.json`. Not for - regenerating class tables (use class-generation) or spec prose (use content-patterns). + regenerating class tables (use class-generation), spec prose (use content-patterns), or the + ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repos, which hold no model of their own. allowed-tools: - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/check_bmm.py *)" --- @@ -23,6 +24,15 @@ ODIN and YAML. Change the model here, and only here. - **Worked example**: `${CLAUDE_SKILL_DIR}/assets/openehr_demo_0.1.0.bmm.json`, one of each construct. - **Checker**: `${CLAUDE_SKILL_DIR}/scripts/check_bmm.py` (Python 3.8+, standard library only, read-only). +## Where It Applies + +Only in a component repo. Check `manifest.json` `id` (or the directory name `specifications-`) +first. In the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repos, create and edit no BMM schema. Say that +the repo holds no model of its own, and make the change in the component's +`specifications-/computable/BMM/` instead. ITS-BMM copies its JSON from there and +generates its ODIN and YAML with its own `make generate`. Running the read-only checker on a file +there is fine. + ## Related Skills - **class-generation**: regenerate and place the class tables after a change diff --git a/plugins/openehr-specs/skills/class-generation/SKILL.md b/plugins/openehr-specs/skills/class-generation/SKILL.md index 034080e..9537386 100644 --- a/plugins/openehr-specs/skills/class-generation/SKILL.md +++ b/plugins/openehr-specs/skills/class-generation/SKILL.md @@ -96,7 +96,8 @@ image::ROOT:uml/classes/COMPOSITION.svg[] ## Typical Workflow -1. **Locate the schema files.** Work from the component repo's root. The schema is +1. **Locate the schema files.** Work from the component repo's root; in an ITS repo, stop (see + Guardrails). The schema is `computable/BMM/openehr__.bmm.json`; each dependency comes from its sibling clone (RM, AM, LANG and TERM classes refer to BASE types: `../specifications-BASE/computable/BMM/openehr_base_.bmm.json`). When a file is missing, say @@ -129,6 +130,10 @@ the user can run `/openehr-specs:publish `. ## Guardrails +- **Not in the ITS repos.** The ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repos (`manifest.json` `id`, + or the directory name `specifications-`) hold no model of their own. Run no `bmm-publisher` + command there, neither class tables nor any other format. Say so, and stop. ITS-BMM regenerates its + ODIN and YAML with its own `make generate`, which its `AGENTS.md` describes. - **Generated output is never hand-edited.** Fix the BMM schema (see `bmm-authoring`) and regenerate. A clean run does not prove the schema is right: `bmm-publisher` renders many modelling mistakes as type `Any` or drops them without a message, so run the `bmm-authoring` checker first. diff --git a/plugins/openehr-specs/skills/regen-classes/SKILL.md b/plugins/openehr-specs/skills/regen-classes/SKILL.md index a5c0637..14c0707 100644 --- a/plugins/openehr-specs/skills/regen-classes/SKILL.md +++ b/plugins/openehr-specs/skills/regen-classes/SKILL.md @@ -19,15 +19,19 @@ depends on BASE: `openehr_rm_1.2.0 -d openehr_base_1.3.0`). The layouts and the If `$ARGUMENTS` is empty, ask for the schema id and stop. -1. Run `docker --version`. If Docker is missing, say it is required (the image and its dev container +1. Read `manifest.json` `id` (or take the directory name `specifications-`). In the ITS-XML, + ITS-BMM, ITS-JSON and ITS-REST repos, say that they hold no model of their own and that + `bmm-publisher` is not run there, and stop. ITS-BMM regenerates its ODIN and YAML with its own + `make generate`. +2. Run `docker --version`. If Docker is missing, say it is required (the image and its dev container both run on it) and stop. -2. Map each id to its file and Glob that it exists: the schema to `computable/BMM/.bmm.json`, and a +3. Map each id to its file and Glob that it exists: the schema to `computable/BMM/.bmm.json`, and a dependency `openehr__` to the sibling clone's `../specifications-/computable/BMM/.bmm.json` (component upper-cased). If one is missing, name it and stop; never fall back to the bundled id. -3. Write to `./out` unless the user names another target. Ask before writing into a +4. Write to `./out` unless the user names another target. Ask before writing into a `specifications-XX` repo's `docs/UML/`. -4. Pick the command by layout (step 2 of the `class-generation` workflow): `legacy-adoc -o +5. Pick the command by layout (step 2 of the `class-generation` workflow): `legacy-adoc -o /app/output/UML/classes` when the spec's chapters contain `include::{uml_export_dir}/classes/` (the tables land in `./out/UML/classes`), otherwise `asciidoc`. Mount each file read-only under `/in/`, pass the paths, and map output ownership to the host user (RM with BASE shown): @@ -40,6 +44,6 @@ If `$ARGUMENTS` is empty, ask for the schema id and stop. ``` Add one mount and one `-d /in/.bmm.json` per dependency. The `-v` log lists each file read: check that every one is under `/in/`. -5. List what was produced (`classes/`, `effective/`, diagram SVGs). Do not commit. Generated files are +6. List what was produced (`classes/`, `effective/`, diagram SVGs). Do not commit. Generated files are never hand-edited: change the BMM and regenerate. To place the output, follow the `class-generation` skill. diff --git a/plugins/openehr-specs/skills/scaffold/SKILL.md b/plugins/openehr-specs/skills/scaffold/SKILL.md index b51b10c..9cd39ea 100644 --- a/plugins/openehr-specs/skills/scaffold/SKILL.md +++ b/plugins/openehr-specs/skills/scaffold/SKILL.md @@ -8,6 +8,7 @@ allowed-tools: - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py render *)" - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py diff *)" - "Bash(git init *)" + - "Bash(git mv README.adoc README.md)" - Read disable-model-invocation: true --- @@ -22,7 +23,12 @@ use the directory that holds this SKILL.md. The set covers AsciiDoc specification repos, with an optional BMM schema. In an OpenAPI repo such as `specifications-ITS-REST`, pin `agents`, `claude-md` and `asciidoctorconfig` (step 4): they are -AsciiDoc-specific. Pin `bmm` too when a new repo has no model. +AsciiDoc-specific. The script never offers a BMM schema to the ITS-XML, ITS-BMM, ITS-JSON and +ITS-REST repos, which hold no model of their own, and rejects a `bmm_schema_id` there. Pin `bmm` in +any other new repo that has no model. + +The root README is `README.md`. A `README.adoc` in its place is planned as `convert` and rewritten as +Markdown by hand (step 5), because the script cannot translate AsciiDoc. A new repository (plan `mode` `init`) also gets `computable/BMM/.bmm.json` (file id `bmm`). The script asks the local `ghcr.io/openehr/bmm-publisher` image for the schemas it bundles @@ -68,8 +74,12 @@ Existing repositories are not offered a schema. Docker, or to run `docker pull ghcr.io/openehr/bmm-publisher` (a large download; ask first), and plan again. Or create the empty schema the `detail` names with `--var bmm_schema_id=`. Applying anyway writes the other files without a schema; `--overwrite bmm` adds one later. - - `exists`, `exists-alternative` (for example `README.adoc`), `unchanged`, `pinned`, `skipped` (no BMM - schema is offered to an existing repository; `--overwrite bmm` asks for one): nothing to do. + - `convert` (only `readme`): the `convert_from` file (`README.adoc`) stands where `README.md` + belongs. Say that step 5 rewrites it as Markdown, or merges it into the existing `README.md` when + `detail` says so, and ask. + - `exists`, `exists-alternative` (for example `README.rst`), `unchanged`, `pinned`, `skipped` (no BMM + schema for an existing repository, where `--overwrite bmm` asks for one, nor for an ITS + repository): nothing to do. A `conflict` whose `detail` says the file is a symbolic link cannot be resolved with `--overwrite`: the user has to replace the link first. - `conflict`: `detail` says whether the file was edited since it was scaffolded or simply differs. @@ -100,7 +110,12 @@ Existing repositories are not offered a schema. `openehr-specs:authoring`). When an empty BMM schema was created, point out that classes go into it (skill `openehr-specs:bmm-authoring`, which also checks it) and the class tables are generated from it (skill `openehr-specs:class-generation`); until then - bmm-publisher warns about the empty package. Do not commit; if the user asks for a commit message, follow the + bmm-publisher warns about the empty package. + For a `convert` the user agreed to, run `git mv README.adoc README.md` (a plain rename outside git), + then rewrite the file as GitHub Markdown by `${CLAUDE_SKILL_DIR}/references/readme-markdown.md`, + keeping all of its content. When `README.md` already existed, merge the AsciiDoc file's content into + it instead, and delete the AsciiDoc file. Show `git diff -M`, then plan again: `README.md` is now + `exists`. Do not commit; if the user asks for a commit message, follow the Conventions section of the repo's `AGENTS.md`. ## Rules diff --git a/plugins/openehr-specs/skills/scaffold/assets/template-set.json b/plugins/openehr-specs/skills/scaffold/assets/template-set.json index 0016f9d..bc6576a 100644 --- a/plugins/openehr-specs/skills/scaffold/assets/template-set.json +++ b/plugins/openehr-specs/skills/scaffold/assets/template-set.json @@ -1,6 +1,6 @@ { "id": "openehr-specs/scaffold", - "revision": 3, + "revision": 4, "revisions": [ { "revision": 1, @@ -19,6 +19,12 @@ "released": "2026-10-09", "summary": "A new repository gets computable/BMM/.bmm.json: a copy of the schema the bmm-publisher image bundles for the component, or an empty schema (one root package, BASE included) when it bundles none. Existing repositories are not offered one.", "digest": "c78b965d50608d5937bd6dfcc0b0953cba4c91d51ad347bd46b647b7cdc7281f" + }, + { + "revision": 4, + "released": "2026-10-09", + "summary": "README.md: a root README.adoc is no longer accepted in its place; the plan reports it as convert (rewrite it as Markdown, or merge it into an existing README.md). The bmm file is never offered to the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repositories, and bmm_schema_id must be empty there.", + "digest": "1d37e98bd339a50792dcb5a9bad9a7090bc6269f1e3c5943db1f9679712be414" } ], "variables": { @@ -93,7 +99,7 @@ "bmm_schema_id": { "default": "", "pattern": "^$|^openehr_[a-z0-9_]+_[0-9]+\\.[0-9]+\\.[0-9]+$", - "description": "BMM schema id (openehr_base_1.3.0) of computable/BMM/.bmm.json, read from the repo. When a new repository is initialised without one: the highest schema the bmm-publisher image bundles for the component, else openehr__ for a new empty schema. Empty otherwise. Switches on the class-generation sections." + "description": "BMM schema id (openehr_base_1.3.0) of computable/BMM/.bmm.json, read from the repo. When a new repository is initialised without one: the highest schema the bmm-publisher image bundles for the component, else openehr__ for a new empty schema. Empty otherwise. Switches on the class-generation sections. Always empty in the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repositories." }, "base_bmm_schema_id": { "default": "", @@ -153,7 +159,13 @@ "strategy": "bmm-seed", "present_glob": "computable/BMM/*.bmm.json", "image": "ghcr.io/openehr/bmm-publisher", - "image_dir": "/app/resources" + "image_dir": "/app/resources", + "excluded_components": [ + "ITS-XML", + "ITS-BMM", + "ITS-JSON", + "ITS-REST" + ] }, { "id": "gitignore", @@ -190,10 +202,12 @@ "template": "readme-md.tmpl", "strategy": "seed", "alternatives": [ - "README.adoc", "README", "README.rst", "README.txt" + ], + "convert_from": [ + "README.adoc" ] } ] diff --git a/plugins/openehr-specs/skills/scaffold/migrations/0004.json b/plugins/openehr-specs/skills/scaffold/migrations/0004.json new file mode 100644 index 0000000..7fff933 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/migrations/0004.json @@ -0,0 +1,5 @@ +{ + "from": 3, + "to": 4, + "steps": [] +} diff --git a/plugins/openehr-specs/skills/scaffold/references/maintaining.md b/plugins/openehr-specs/skills/scaffold/references/maintaining.md index 2b3f637..4165b0f 100644 --- a/plugins/openehr-specs/skills/scaffold/references/maintaining.md +++ b/plugins/openehr-specs/skills/scaffold/references/maintaining.md @@ -30,12 +30,12 @@ Each entry of `files` names a strategy, chosen by who owns the file after it is | Strategy | Used for | Behaviour | |----------|----------|-----------| -| `seed` | `manifest.json`, `LICENSE`, `README.md` | Written once when absent, then the repo's own. Never changed. An `alternatives` list (`README.adoc`) counts as present. | +| `seed` | `manifest.json`, `LICENSE`, `README.md` | Written once when absent, then the repo's own. Never changed. An `alternatives` list (`README.rst`) counts as present. A `convert_from` file (`README.adoc`) does not: the plan reports `convert` (a rewrite, or a merge when the target exists too), and the skill rewrites it by hand, since the script cannot translate its markup. | | `whole` | `.claude/CLAUDE.md` | Re-rendered on upgrade only if its hash still equals the recorded one; otherwise a `conflict`. | | `json-merge` | `.claude/settings.json` | Adds keys and list items that are missing. Existing values always win, so a plugin set to `false` stays `false`. The descriptor remembers every template item it has offered, so an item the user removed afterwards is left out, and only items new in a later revision are added. Invalid JSON, or a top level that is not an object, is a `conflict`; `--overwrite` replaces the file with the template. | | `ensure-lines` | `.gitignore`, `.asciidoctorconfig` | Appends missing lines with their comment header, and, like `json-merge`, leaves out a line the user removed after an earlier run. With a `key` regex, a line counts as present whatever its value (`:component: X`, and an unset `:name!:`); a differing value only produces a warning. | | `regions` | `AGENTS.md` | Regions between `` and `...:end ID -->` are managed, with a hash per region. Text outside them is the repo's. A file with no markers is `upgrade-manual`. Regions are applied independently: an `update` or `add` is written even when another region conflicts. | -| `bmm-seed` | `computable/BMM/{{bmm_schema_id}}.bmm.json` | Like `seed`, but offered only in `init` mode (or with `--overwrite bmm`) and only while `present_glob` matches nothing; otherwise `exists` or `skipped`. The content is the schema that `image` bundles in `image_dir` for the component, copied verbatim, or else the rendered template (an empty schema). Without Docker or the image the file is `blocked`, unless the user gave `bmm_schema_id`. The decision is made before the other files are planned, so `AGENTS.md` names the schema that will be written. At most one file uses this strategy. | +| `bmm-seed` | `computable/BMM/{{bmm_schema_id}}.bmm.json` | Like `seed`, but offered only in `init` mode (or with `--overwrite bmm`) and only while `present_glob` matches nothing; otherwise `exists` or `skipped`. The content is the schema that `image` bundles in `image_dir` for the component, copied verbatim, or else the rendered template (an empty schema). Without Docker or the image the file is `blocked`, unless the user gave `bmm_schema_id`. The decision is made before the other files are planned, so `AGENTS.md` names the schema that will be written. A component in `excluded_components` (ITS-XML, ITS-BMM, ITS-JSON, ITS-REST) is never offered one, Docker is not asked, and a non-empty `bmm_schema_id` or `base_bmm_schema_id` is reported as invalid. `scripts/validate.py` checks that the `bmm-authoring`, `class-generation`, `regen-classes` and `scaffold` skills name every excluded id. At most one file uses this strategy. | `pinned` (in the descriptor, set with `--pin`) opts a file or `file:region` out for good. Regions the user deleted are reported as `removed`, not added back; a region the descriptor never recorded is added. @@ -116,8 +116,15 @@ Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git direc RM have identical `.claude/settings.json` and `.gitignore`. Five repos (AM, BASE, LANG, RM, TERM) have an identical `.asciidoctorconfig` apart from `:component:`; PROC adds `:diagrams_uri:`. - **README.** Every clone has one: `README.adoc` in 15, `README.md` in 3 (LANG, ITS, ITS-BMM). The seed - target is `README.md`, as requested; `alternatives` keeps it from duplicating an existing - `README.adoc`. + target is `README.md`, as requested. Since revision 4 a root `README.adoc` is converted to it + (`convert_from`) instead of standing in for it: on 2026-10-09 the 13 `README.adoc` files ran from 2 to + 59 lines, used only titles, links, listings, admonitions, bold text, one table and bullets + (`references/readme-markdown.md` maps each), and no publishing tooling in `specifications-AA_GLOBAL` + read a component's `README.adoc`. +- **ITS repositories.** ITS-XML, ITS-BMM, ITS-JSON and ITS-REST hold no model of their own. ITS-BMM + republishes the component repos' JSON and generates its ODIN and YAML with its own `Makefile`. The + others hold XSDs, JSON schemas and OpenAPI files, which bmm-publisher has no writer for. So none of + them gets a `computable/BMM` schema, and the BMM skills stop there. - **Licence.** Two families: CC BY-SA 3.0 (12 repos, byte-identical) and Apache 2.0 (the `ITS-*` repos). The texts in `assets/templates/` are copies of BASE's and ITS-REST's `LICENSE`. - **Default branch.** `master` in all 18 clones. @@ -142,6 +149,8 @@ Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git direc ## Known limits +- `README.adoc` conversion is done by hand, by the skill, following `references/readme-markdown.md`; the script + only plans it. - AsciiDoc specification repos only, with an optional BMM schema. An OpenAPI repo (`specifications-ITS-REST`, built with `make`) needs its own variant of `AGENTS.md`; until then, pin `agents`, `claude-md` and `asciidoctorconfig` there. diff --git a/plugins/openehr-specs/skills/scaffold/references/readme-markdown.md b/plugins/openehr-specs/skills/scaffold/references/readme-markdown.md new file mode 100644 index 0000000..33be2f2 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/references/readme-markdown.md @@ -0,0 +1,40 @@ +# Rewriting README.adoc as README.md + +For step 5 of the `scaffold` skill. Rewrite the AsciiDoc README as GitHub-flavoured Markdown after +`git mv README.adoc README.md`. The rewrite changes the markup only: keep every sentence, link, +listing and table, in the same order, and add nothing. When `README.md` already exists, merge the +AsciiDoc file's sections into it and remove what duplicates, without dropping content. + +| AsciiDoc | Markdown | +|----------|----------| +| `= Title`, `== Section`, `=== Subsection` | `# Title`, `## Section`, `### Subsection` (one `#` per `=`) | +| `https://host/path[text]`, `link:https://host/path[text]` | `[text](https://host/path)` | +| `link:docs/x.adoc[text]`, `xref:x.adoc[text]` | `[text](docs/x.adoc)`, keeping the path relative to the repo root | +| `<>` | `[text](#anchor)`, with the anchor as GitHub derives it from the heading | +| A bare URL | Unchanged (GitHub links it) | +| `*bold*`, `_italic_`, `` `code` `` | `**bold**`, `_italic_`, `` `code` `` | +| `* item`, `** nested` | `- item`, ` - nested` (two spaces per level) | +| `. step` | `1. step` | +| `----` … `----` listing, ```` ``` ```` … ```` ``` ```` | A fenced block (```` ``` ````) with the same lines | +| `[source,bash]` before a listing | The language on the fence: ```` ```bash ```` | +| `NOTE: text` (also `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`) | `> [!NOTE]` on one line, then `> text` | +| `\|===` table, `\|a \|b` rows | A pipe table, with the first row as the header row | +| `image::path[alt]` | `![alt](path)` | +| `:name: value` attributes, `{name}` uses | The value written in place of each use; the attribute lines are dropped | +| `include::file[]` | A link to the file, unless it is short enough to inline | +| `// comment` | `` | + +A construct without a Markdown form keeps its text as plain prose. Tell the user which lines it +affected. + +Before showing the diff, check: + +- Every heading, link target and code line is still there (`git diff -M README.md` shows the rename). +- Links that pointed at `.adoc` files in the repo still resolve. The published HTML lives at + `https://specifications.openehr.org/releases//`, not in the repository. +- Box-drawing directory trees inside listings keep their alignment. They stay in a fenced block. +- Other files in the repo that name `README.adoc` (`AGENTS.md`, `docs/`) now name `README.md`: search + the repo for the old name. + +Why Markdown: the standard file set's target is `README.md`. GitHub renders both formats, and none of +the publishing tooling in `specifications-AA_GLOBAL` reads a component's `README.adoc`. diff --git a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py index 100abc5..228d462 100644 --- a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py +++ b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py @@ -456,6 +456,16 @@ def rendered_for(ctx, spec): def plan_seed(ctx, spec, rendered): target = ctx.repo / spec["target"] + # a convert_from file (README.adoc) stands in for nothing: it is rewritten by hand, since the + # script cannot translate its markup, and merged into the target when both exist + for source in spec.get("convert_from", []): + if (ctx.repo / source).is_file() and not (ctx.repo / source).is_symlink(): + if target.is_file(): + return Act(spec, "convert", f"{source} is still there: merge what it says into {spec['target']} " + f"and delete it", convert_from=source) + return Act(spec, "convert", f"rewrite {source} as {spec['target']}: `git mv {source} {spec['target']}` " + "keeps its history, then convert the markup by hand (the script does not)", + convert_from=source) if target.is_file(): # a seed file is never changed, so it never needs decoding detail = None if "templates" in spec: @@ -702,6 +712,9 @@ def plan_bmm_seed(ctx, spec, rendered): present = sorted(p.name for p in ctx.repo.glob(spec["present_glob"])) if present: return Act(shown, "exists", ", ".join(present)) + if ctx.values.get("component") in spec.get("excluded_components", []): + return Act(shown, "skipped", f"{ctx.values['component']} holds no BMM schema of its own: schemas are " + "written in the component repositories, and bmm-publisher is not run here") new = ctx.new_bmm if new is None: return Act(shown, "skipped", "offered only when a repository is initialised; " @@ -861,13 +874,21 @@ def build_plan(repo, set_dir, explicit, overwrite=(), pin=()): # dependency are known before the other templates (AGENTS.md) are rendered plan.new_bmm = None bmm_spec = next((f for f in tset["files"] if f["strategy"] == "bmm-seed"), None) + excluded = bmm_spec.get("excluded_components", []) if bmm_spec else [] if (bmm_spec and bmm_spec["id"] not in plan.pinned and (mode == "init" or bmm_spec["id"] in overwrite) - and not present(repo, bmm_spec) and not resolved[2] and not resolved[3]): + and not present(repo, bmm_spec) and not resolved[2] and not resolved[3] + and resolved[0].get("component") not in excluded): plan.new_bmm = new_bmm(repo, bmm_spec, resolved[0], resolved[1], plan.notes) if plan.new_bmm.get("inferred"): inferred.update(plan.new_bmm["inferred"]) resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) plan.values, plan.sources, plan.missing, plan.invalid = resolved + if plan.values.get("component") in excluded: # the ITS repositories hold no model of their own + for name in ("bmm_schema_id", "base_bmm_schema_id"): + if plan.values.get(name): + plan.invalid.append({"name": name, "value": plan.values[name], + "problem": f"must be empty for {plan.values['component']}, which holds no " + "BMM schema of its own (pass --var " + name + "=)"}) plan.acts, plan.warnings = [], list(plan.notes) if not plan.missing and not plan.invalid: for spec in tset["files"]: @@ -1092,6 +1113,19 @@ def _check_template_set(set_dir): problems.append(f"file '{label}': a bmm-seed file needs {', '.join(lacking)}") if "{{bmm_schema_id}}" not in spec.get("target", ""): problems.append(f"file '{label}': a bmm-seed target must contain {{{{bmm_schema_id}}}}") + excluded = spec.get("excluded_components", []) + if not isinstance(excluded, list) or not all(isinstance(c, str) for c in excluded): + problems.append(f"file '{label}': excluded_components must be a list of component ids") + for key in ("alternatives", "convert_from"): + value = spec.get(key, []) + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + problems.append(f"file '{label}': {key} must be a list of paths") + else: + for path in value: + try: + require_relative(path, f"file '{label}' {key}") + except ScaffoldError as exc: + problems.append(str(exc)) names = list((spec.get("templates") or {}).values()) or [spec.get("template")] for name in names: if not name or not (set_dir / "assets" / "templates" / name).is_file(): diff --git a/scripts/test_scaffold.py b/scripts/test_scaffold.py index db0773a..e14a824 100644 --- a/scripts/test_scaffold.py +++ b/scripts/test_scaffold.py @@ -314,12 +314,33 @@ def test_seed_files_are_never_overwritten(self): self.assertIn('"custom"', self.text("manifest.json")) def test_an_existing_readme_in_another_format_blocks_readme_md(self): - self.put("README.adoc", "= Demo\n") + self.put("README.rst", "Demo\n====\n") plan = self.plan() self.assertEqual(self.actions(plan)["README.md"], "exists-alternative") self.apply() self.assertFalse((self.repo / "README.md").exists()) + def test_a_readme_adoc_is_planned_for_conversion_and_left_alone(self): + self.put("README.adoc", "= Demo\n") + act = next(a for a in self.plan().acts if a.id == "readme") + self.assertEqual((act.action, act.public()["convert_from"]), ("convert", "README.adoc")) + self.assertIn("`git mv README.adoc README.md`", act.detail) + result = self.apply() + self.assertNotIn("README.md", result["written"]) + self.assertFalse((self.repo / "README.md").exists()) + self.assertEqual(self.text("README.adoc"), "= Demo\n") + + def test_a_readme_adoc_next_to_readme_md_is_planned_for_merging(self): + self.put("README.adoc", "= Demo\n") + self.put("README.md", "# Demo\n") + act = next(a for a in self.plan().acts if a.id == "readme") + self.assertEqual(act.action, "convert") + self.assertIn("merge what it says into README.md and delete it", act.detail) + + def test_once_converted_the_readme_is_the_repos_own(self): + self.put("README.md", "# Demo\n") + self.assertEqual(self.actions(self.plan())["README.md"], "exists") + def test_licence_detail_says_whether_the_text_is_standard(self): standard = (self.set_dir / "assets/templates/license-cc-by-sa-3.0.txt").read_text(encoding="utf-8") self.put("LICENSE", standard) @@ -1205,6 +1226,32 @@ def test_a_hyphenated_component_gets_an_underscored_schema_name(self): self.assertEqual((plan.values["bmm_schema_id"], plan.values["bmm_schema_name"]), ("openehr_its_demo_0.1.0", "its_demo")) + def test_the_its_repositories_are_never_offered_a_schema_and_docker_is_not_asked(self): + self.docker.schemas = {"openehr_its_rest_1.0.0": BUNDLED} + for component in ("ITS-XML", "ITS-BMM", "ITS-JSON", "ITS-REST"): + plan = self.plan(component=component, jira_project="SPECITS") + act = self.bmm_act(plan) + self.assertEqual((plan.mode, act.action, act.target), ("init", "skipped", BMM_GLOB), component) + self.assertIn(f"{component} holds no BMM schema of its own", act.detail) + self.assertEqual(plan.values["bmm_schema_id"], "") + self.assertEqual(self.docker.calls, []) + self.apply(component="ITS-REST", jira_project="SPECITS") + self.assertFalse((self.repo / "computable").exists()) + self.assertNotIn("class-generation", self.text("AGENTS.md")) + + def test_a_schema_id_given_for_an_its_repository_is_rejected(self): + plan = self.plan(component="ITS-BMM", jira_project="SPECITS", bmm_schema_id="openehr_rm_1.2.0", + base_bmm_schema_id="openehr_base_1.3.0") + self.assertEqual({i["name"] for i in plan.invalid}, {"bmm_schema_id", "base_bmm_schema_id"}) + self.assertIn("must be empty for ITS-BMM", plan.invalid[0]["problem"]) + self.assertEqual(plan.acts, []) + self.assertEqual(self.plan(component="ITS-BMM", jira_project="SPECITS", bmm_schema_id="").invalid, []) + + def test_other_its_components_are_not_excluded(self): + self.docker.schemas = {} + plan = self.plan(component="ITS-DEMO", jira_project="SPECITS") + self.assertEqual(self.bmm_act(plan).action, "create") + def test_base_copies_its_own_schema_and_has_no_base_dependency(self): repo = self.root / "specifications-BASE" repo.mkdir() @@ -1220,8 +1267,13 @@ def test_check_flags_a_bmm_seed_file_without_its_keys_and_a_derived_without_a_ru del next(f for f in tset["files"] if f["id"] == "bmm")["image"] del tset["variables"]["bmm_rm_release"]["derived"]["match"] path.write_text(json.dumps(tset), encoding="utf-8") + next(f for f in tset["files"] if f["id"] == "readme")["convert_from"] = ["../README.adoc"] + next(f for f in tset["files"] if f["id"] == "bmm")["excluded_components"] = "ITS-REST" + path.write_text(json.dumps(tset), encoding="utf-8") problems = scaffold.check_template_set(dst) self.assertTrue(any("a bmm-seed file needs image" in p for p in problems), problems) + self.assertTrue(any("convert_from: '../README.adoc' must be a relative path" in p for p in problems), problems) + self.assertTrue(any("excluded_components must be a list" in p for p in problems), problems) self.assertTrue(any("'bmm_rm_release': derived needs either map or match" in p for p in problems), problems) diff --git a/scripts/validate.py b/scripts/validate.py index 155974c..4b192e9 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parent.parent errors = [] +BMM_SKILLS = ("bmm-authoring", "class-generation", "regen-classes", "scaffold") PLUGIN_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") MARKETPLACE_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") MANIFEST_FIELDS = ("logo", "rules", "skills", "agents", "commands", "hooks", "mcpServers") @@ -77,6 +78,27 @@ def validate_template_sets(plugin_dir: Path): err(f"{rel}: template set: {problem}") +def validate_bmm_exclusions(plugin_dir: Path): + """Every skill that writes or renders a BMM schema names each repo the scaffold excludes from that + work (the excluded_components of its bmm-seed file), so the rule cannot drift between them.""" + tset_path = plugin_dir / "skills" / "scaffold" / "assets" / "template-set.json" + if not tset_path.is_file(): + return + tset = load_json(tset_path, "template set") + if tset is None: + return + excluded = next((f.get("excluded_components", []) for f in tset.get("files", []) + if f.get("strategy") == "bmm-seed"), []) + for skill in BMM_SKILLS: + skill_md = plugin_dir / "skills" / skill / "SKILL.md" + if not skill_md.is_file(): + continue + missing = [c for c in excluded if c not in skill_md.read_text()] + if missing: + err(f"{skill_md.relative_to(ROOT)}: does not name {', '.join(missing)}, which the scaffold " + "template set excludes from BMM work (files[bmm].excluded_components)") + + def validate_md_components(plugin_dir: Path, subdir: str): """Validate flat .md components (agents/): frontmatter present with the required fields, and any `name` matches the filename stem.""" @@ -209,6 +231,7 @@ def main(): validate_cross_manifest_versions() for plugin_dir in sorted(d for d in (ROOT / "plugins").iterdir() if d.is_dir()): validate_template_sets(plugin_dir) # once per plugin, not once per marketplace + validate_bmm_exclusions(plugin_dir) if __name__ == "__main__": From bef16739048d4d36d4348124f3f5e73d3436f8c7 Mon Sep 17 00:00:00 2001 From: Sebastian Iancu Date: Fri, 9 Oct 2026 17:46:08 +0200 Subject: [PATCH 2/2] fix(openehr-specs): load every schema the BMM includes in the class-table command The AGENTS.md class-table command loaded only BASE with -d, so AM 2.4.0, which also includes LANG, rendered its LANG types as dangling /classes/ links. The new variable bmm_dependencies lists what to load: BASE first for every component but BASE (TERM includes nothing yet needs it for String), then every other schema the BMM's includes name, each from the sibling clone of its component; a missing clone is left out with a warning. It is read from the repo on every run (prefer_inferred), and base_bmm_schema_id is now derived from it. The template engine gains {{#each}} over a comma-separated value and a component filter. Folded into the unreleased template set revision 4. Fixes #10 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- .../openehr-specs/skills/scaffold/SKILL.md | 2 +- .../skills/scaffold/assets/template-set.json | 19 ++- .../scaffold/assets/templates/agents-md.tmpl | 16 +-- .../skills/scaffold/references/maintaining.md | 26 ++-- .../skills/scaffold/scripts/scaffold.py | 134 +++++++++++++++--- scripts/test_scaffold.py | 134 +++++++++++++++++- 7 files changed, 293 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a07c4c5..3a9d3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,13 +25,23 @@ Notable changes to the plugins in this repository. The format follows [Keep a Ch - `scaffold` template set revision 4, README: a root `README.adoc` no longer stands in for `README.md`. The plan reports it as `convert` (a rewrite, or a merge when `README.md` exists too). The skill then runs `git mv README.adoc README.md` and rewrites the markup as GitHub Markdown, by the new reference `references/readme-markdown.md`; the script writes nothing for it. `README`, `README.rst` and `README.txt` are still accepted as they are, and a repo without a README still gets one. - `scaffold` revision 4, ITS repositories: ITS-XML, ITS-BMM, ITS-JSON and ITS-REST are excluded from BMM work, because they hold no model of their own. - The scaffold never offers them a `computable/BMM` schema, even in a new repository, and does not ask Docker. - - A non-empty `bmm_schema_id` or `base_bmm_schema_id` is reported as invalid there. + - A non-empty `bmm_schema_id` or `bmm_dependencies` is reported as invalid there. - `bmm-authoring` creates and edits no schema there and sends the change to the component repo. - `class-generation` and `regen-classes` run no `bmm-publisher` command there. - `scripts/validate.py` checks that these four skills name every repo that the template set excludes (`files[bmm].excluded_components`). - `class-generation`, `authoring` and `scaffold` send BMM schema edits to `bmm-authoring`. - `docs/prompting-guide.md` sends the class-change prompts to `bmm-authoring` and adds a section for a new component's schema. +### Fixed + +- `scaffold` ([#10](https://github.com/openEHR/ai-plugins/issues/10)): the class-table command in `AGENTS.md` loaded only BASE with `-d`, so AM 2.4.0, which also includes LANG, rendered its LANG types (`STATEMENT_SET`, `ASSERTION`, ...) as dangling `/classes/` links. + - It now mounts and loads every schema the BMM includes, from the sibling clone of that schema's component. The list is the new variable `bmm_dependencies`: BASE first for every component but BASE, then the other includes. + - BASE stays first even when a schema does not include it. TERM includes nothing, yet needs it for its `String` links. + - The list is read from the schema on every run (`prefer_inferred`), so a later include is picked up. + - An include whose sibling clone is missing is left out, with a warning. + - `base_bmm_schema_id` is now derived from that list and can no longer be passed with `--var`. + - The template engine gains `{{#each name}}…{{/each}}` over a comma-separated value, and a `component` filter (`openehr_lang_1.1.0` → `LANG`). + ## openehr-specs 0.5.0 (2026-10-08) ### Changed diff --git a/plugins/openehr-specs/skills/scaffold/SKILL.md b/plugins/openehr-specs/skills/scaffold/SKILL.md index 9cd39ea..c26c93c 100644 --- a/plugins/openehr-specs/skills/scaffold/SKILL.md +++ b/plugins/openehr-specs/skills/scaffold/SKILL.md @@ -51,7 +51,7 @@ Existing repositories are not offered a schema. 2. **Variables.** When `status` is `needs_input`, ask for each missing or invalid variable, then plan again with `--var name=value`. A variable whose source is `default` or starts with `inferred` is a guess. Ask the user to confirm the ones that matter (`license`, `jira_project`, `bmm_schema_id`, - `base_bmm_schema_id`, `default_branch`, `published_url`, and `first_release` only when the plan creates + `bmm_dependencies`, `default_branch`, `published_url`, and `first_release` only when the plan creates `manifest.json` or a new empty BMM schema, the files that use it) and list the rest in one line. In a new repository, `bmm_schema_id` comes from `inferred: bmm-publisher image` or `inferred: new empty schema`. Never invent a diff --git a/plugins/openehr-specs/skills/scaffold/assets/template-set.json b/plugins/openehr-specs/skills/scaffold/assets/template-set.json index 8a2249f..831f9a3 100644 --- a/plugins/openehr-specs/skills/scaffold/assets/template-set.json +++ b/plugins/openehr-specs/skills/scaffold/assets/template-set.json @@ -23,8 +23,8 @@ { "revision": 4, "released": "2026-10-09", - "summary": "README.md: a root README.adoc is no longer accepted in its place; the plan reports it as convert (rewrite it as Markdown, or merge it into an existing README.md). The bmm file is never offered to the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repositories, and bmm_schema_id must be empty there.", - "digest": "35c1f9ba27c7a23ad8994f184dd04e4b702419af575a393ced8120a107b15521" + "summary": "README.md: a root README.adoc is no longer accepted in its place; the plan reports it as convert (rewrite it as Markdown, or merge it into an existing README.md). The bmm file is never offered to the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repositories, and bmm_schema_id must be empty there. AGENTS.md loads every schema the BMM includes with -d (bmm_dependencies, read from its includes), not BASE alone; base_bmm_schema_id is now derived from it.", + "digest": "ac2e9e8e3b4ab498e4c5e337e0ce79a79ed15cf45f410cc3b1fd62e2f059eac8" } ], "variables": { @@ -101,10 +101,19 @@ "pattern": "^$|^openehr_[a-z0-9_]+_[0-9]+\\.[0-9]+\\.[0-9]+$", "description": "BMM schema id (openehr_base_1.3.0) of computable/BMM/.bmm.json, read from the repo. When a new repository is initialised without one: the highest schema the bmm-publisher image bundles for the component, else openehr__ for a new empty schema. Empty otherwise. Switches on the class-generation sections. Always empty in the ITS-XML, ITS-BMM, ITS-JSON and ITS-REST repositories." }, - "base_bmm_schema_id": { + "bmm_dependencies": { "default": "", - "pattern": "^$|^openehr_base_[0-9]+\\.[0-9]+\\.[0-9]+$", - "description": "BASE BMM schema id (openehr_base_1.3.0) that the component's classes refer to, read from the sibling specifications-BASE clone; loaded with -d when the class tables are regenerated. Empty for BASE itself and for repos without a BMM." + "pattern": "^$|^openehr_[a-z0-9_]+_[0-9]+\\.[0-9]+\\.[0-9]+(\\s*,\\s*openehr_[a-z0-9_]+_[0-9]+\\.[0-9]+\\.[0-9]+)*$", + "prefer_inferred": true, + "description": "Schema ids loaded with -d when the class tables are regenerated, comma-separated: BASE first for every component but BASE (the version the BMM includes, else the highest in the sibling specifications-BASE clone), then each other schema its includes name, from the sibling clone of that component (openehr_lang_1.1.0 from specifications-LANG). Read from the repo on every run. Empty for BASE and without a BMM." + }, + "base_bmm_schema_id": { + "derived": { + "from": "bmm_dependencies", + "match": "(?:^|,)\\s*(openehr_base_[0-9]+\\.[0-9]+\\.[0-9]+)\\s*(?:,|$)", + "group": 1 + }, + "description": "The BASE schema among bmm_dependencies, which a new empty schema includes; empty without one." }, "bmm_schema_name": { "derived": { diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl index bd646c1..b79a0d0 100644 --- a/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl @@ -60,24 +60,24 @@ The build prints `generated ` and exits 0 even when includes are missing, # regenerate class tables (NEVER hand-edit docs/UML/classes/*.adoc) — run from this repo's root. # Pass the repo BMM by PATH: a bare schema id ({{bmm_schema_id}}) uses the image's bundled copy, which lags this repo. {{/if}} -{{#if base_bmm_schema_id}} -# {{component}} classes refer to BASE types: load the sibling BASE BMM with -d, or their links come out as /classes/. +{{#if bmm_dependencies}} +# {{component}} classes refer to types of other schemas: load each from its sibling clone with -d, or their links come out as /classes/. {{/if}} {{#if bmm_schema_id}} OUT=$(mktemp -d) docker run --rm --user $(id -u):$(id -g) \ -v "$PWD/computable/BMM/{{bmm_schema_id}}.bmm.json":/in/{{bmm_schema_id}}.bmm.json:ro \ {{/if}} -{{#if base_bmm_schema_id}} - -v "$PWD/../specifications-BASE/computable/BMM/{{base_bmm_schema_id}}.bmm.json":/in/{{base_bmm_schema_id}}.bmm.json:ro \ -{{/if}} +{{#each bmm_dependencies}} + -v "$PWD/../specifications-{{item|component}}/computable/BMM/{{item}}.bmm.json":/in/{{item}}.bmm.json:ro \ +{{/each}} {{#if bmm_schema_id}} -v "$OUT":/out \ ghcr.io/openehr/bmm-publisher legacy-adoc \ {{/if}} -{{#if base_bmm_schema_id}} - -d /in/{{base_bmm_schema_id}}.bmm.json \ -{{/if}} +{{#each bmm_dependencies}} + -d /in/{{item}}.bmm.json \ +{{/each}} {{#if bmm_schema_id}} /in/{{bmm_schema_id}}.bmm.json -o /out # then diff "$OUT" against docs/UML/classes and copy over the tables you changed diff --git a/plugins/openehr-specs/skills/scaffold/references/maintaining.md b/plugins/openehr-specs/skills/scaffold/references/maintaining.md index 4165b0f..8c5771f 100644 --- a/plugins/openehr-specs/skills/scaffold/references/maintaining.md +++ b/plugins/openehr-specs/skills/scaffold/references/maintaining.md @@ -35,7 +35,7 @@ Each entry of `files` names a strategy, chosen by who owns the file after it is | `json-merge` | `.claude/settings.json` | Adds keys and list items that are missing. Existing values always win, so a plugin set to `false` stays `false`. The descriptor remembers every template item it has offered, so an item the user removed afterwards is left out, and only items new in a later revision are added. Invalid JSON, or a top level that is not an object, is a `conflict`; `--overwrite` replaces the file with the template. | | `ensure-lines` | `.gitignore`, `.asciidoctorconfig` | Appends missing lines with their comment header, and, like `json-merge`, leaves out a line the user removed after an earlier run. With a `key` regex, a line counts as present whatever its value (`:component: X`, and an unset `:name!:`); a differing value only produces a warning. | | `regions` | `AGENTS.md` | Regions between `` and `...:end ID -->` are managed, with a hash per region. Text outside them is the repo's. A file with no markers is `upgrade-manual`. Regions are applied independently: an `update` or `add` is written even when another region conflicts. | -| `bmm-seed` | `computable/BMM/{{bmm_schema_id}}.bmm.json` | Like `seed`, but offered only in `init` mode (or with `--overwrite bmm`) and only while `present_glob` matches nothing; otherwise `exists` or `skipped`. The content is the schema that `image` bundles in `image_dir` for the component, copied verbatim, or else the rendered template (an empty schema). Without Docker or the image the file is `blocked`, unless the user gave `bmm_schema_id`. The decision is made before the other files are planned, so `AGENTS.md` names the schema that will be written. A component in `excluded_components` (ITS-XML, ITS-BMM, ITS-JSON, ITS-REST) is never offered one, Docker is not asked, and a non-empty `bmm_schema_id` or `base_bmm_schema_id` is reported as invalid. `scripts/validate.py` checks that the `bmm-authoring`, `class-generation`, `regen-classes` and `scaffold` skills name every excluded id. At most one file uses this strategy. | +| `bmm-seed` | `computable/BMM/{{bmm_schema_id}}.bmm.json` | Like `seed`, but offered only in `init` mode (or with `--overwrite bmm`) and only while `present_glob` matches nothing; otherwise `exists` or `skipped`. The content is the schema that `image` bundles in `image_dir` for the component, copied verbatim, or else the rendered template (an empty schema). Without Docker or the image the file is `blocked`, unless the user gave `bmm_schema_id`. The decision is made before the other files are planned, so `AGENTS.md` names the schema that will be written. A component in `excluded_components` (ITS-XML, ITS-BMM, ITS-JSON, ITS-REST) is never offered one, Docker is not asked, and a non-empty `bmm_schema_id` or `bmm_dependencies` is reported as invalid. `scripts/validate.py` checks that the `bmm-authoring`, `class-generation`, `regen-classes` and `scaffold` skills name every excluded id. At most one file uses this strategy. | `pinned` (in the descriptor, set with `--pin`) opts a file or `file:region` out for good. Regions the user deleted are reported as `removed`, not added back; a region the descriptor never recorded is added. @@ -54,13 +54,23 @@ For every strategy, line endings (CRLF or LF) and the file mode are kept when a `variables` in `template-set.json`: `required`, `default` (itself a template, may use earlier variables), `default_if` (rules by regex on another variable), `pattern`, `choices`, `derived` (`from` + `map`, or `from` + `match` + `group`: a regex group of the other variable, empty when it does -not match; `bmm_schema_name` and `bmm_rm_release` split `bmm_schema_id` this way). Resolution order is explicit `--var`, then a value recorded in the repo's -descriptor as confirmed, then inference, then a recorded default, then the default. Unknown `--var` +not match; `bmm_schema_name` and `bmm_rm_release` split `bmm_schema_id` this way, and +`base_bmm_schema_id` picks the BASE id out of `bmm_dependencies`), and `prefer_inferred`. Resolution order +is explicit `--var`, then a value recorded in the repo's descriptor as confirmed, then inference, then a +recorded default, then the default. A `prefer_inferred` variable takes the inferred value ahead of a +recorded one, because the repo is its source of truth: `bmm_dependencies` follows the schema's `includes` +on every run. Unknown `--var` names and empty required values are rejected. - Inference reads `manifest.json`, `.asciidoctorconfig`, the git remote, the directory name, - `computable/BMM`, `origin/HEAD` (for `default_branch`), the `LICENSE` text, and the sibling - `specifications-BASE/computable/BMM` (for `base_bmm_schema_id`, the highest version; never for BASE itself). + `computable/BMM`, `origin/HEAD` (for `default_branch`), the `LICENSE` text, and the sibling clones. + `bmm_dependencies` is worked out once `bmm_schema_id` is known, from that schema's `includes` (or the + bundled copy's, for a new repository). BASE comes first for every component but BASE, even when the schema + does not include it: TERM includes nothing, yet without `-d` BASE its `String` links come out as + `link:/classes/String`. That is the version the schema includes when `../specifications-BASE` holds it, else + the highest there. Then come the other included ids, each from `../specifications-/computable/BMM/` + (`openehr_lang_1.1.0` → `LANG`, `openehr_its_rest_…` → `ITS-REST`). An include whose clone is missing is + left out, with a warning. When a new repository is offered a BMM schema, `bmm_schema_id` comes from the bmm-publisher image (`docker run --rm --pull never --entrypoint ls|cat`): the highest `openehr__`, with the component lower-cased and `-` turned into `_`. Without one it is `openehr__`. @@ -154,9 +164,9 @@ Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git direc - AsciiDoc specification repos only, with an optional BMM schema. An OpenAPI repo (`specifications-ITS-REST`, built with `make`) needs its own variant of `AGENTS.md`; until then, pin `agents`, `claude-md` and `asciidoctorconfig` there. -- One BMM dependency. The class-table command in `AGENTS.md` loads only BASE with `-d`, from the sibling - clone. A component whose classes also refer to another component (AM 2.x to LANG expression classes, for - example) needs further `-d` options, added by hand. +- Dependencies are not followed transitively: each schema's own `includes` is read, not its dependencies'. + That is enough today, because every schema an include names is BASE or names BASE itself (AM 2.4.0 + includes BASE and LANG, and LANG includes BASE). - One BMM schema per repo. When several are found (AM has 1.4.0 and 2.4.0, LANG has three) the highest version is used and a warning lists the others; pass `--var bmm_schema_id=` to choose. - A BMM schema is offered only in `init` mode, that is when none of the standard files exists yet. A new diff --git a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py index 228d462..ed4be3a 100644 --- a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py +++ b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py @@ -163,21 +163,57 @@ def require_relative(path, where): # --------------------------------------------------------------------------- template engine -# {{name}}, {{name|lower}}, {{name|upper}}, and non-nested {{#if name}}..{{/if}} and -# {{#unless name}}..{{/unless}}. A block tag alone on a line disappears with its line. +# {{name}}, {{name|lower}}, {{name|upper}}, {{name|component}}, and non-nested {{#if name}}..{{/if}}, +# {{#unless name}}..{{/unless}} and {{#each name}}..{{/each}}, which repeats its body for each +# comma-separated item of the value, as {{item}}. A block tag alone on a line disappears with its line. -_STANDALONE = re.compile(r"^[ \t]*(\{\{\s*[#/](?:if|unless)\b[^}]*\}\})[ \t]*\n", re.M) +_STANDALONE = re.compile(r"^[ \t]*(\{\{\s*[#/](?:if|unless|each)\b[^}]*\}\})[ \t]*\n", re.M) _COND = re.compile(r"\{\{\s*#(if|unless)\s+([\w-]+)\s*\}\}(.*?)\{\{\s*/\1\s*\}\}", re.S) -_VAR = re.compile(r"\{\{\s*([\w-]+)(?:\|(lower|upper))?\s*\}\}") +_EACH = re.compile(r"\{\{\s*#each\s+([\w-]+)\s*\}\}(.*?)\{\{\s*/each\s*\}\}", re.S) +_FILTERS = "lower|upper|component" +_VAR = re.compile(r"\{\{\s*([\w-]+)(?:\|(" + _FILTERS + r"))?\s*\}\}") +_ITEM = re.compile(r"\{\{\s*item(?:\|(" + _FILTERS + r"))?\s*\}\}") +EACH_ITEM = "item" def truthy(value): return bool(value) and str(value).strip().lower() not in ("false", "0", "no") +def items_of(value): + return [item.strip() for item in str(value).split(",") if item.strip()] + + +def component_of(schema_id): + """openehr_lang_1.1.0 -> LANG, openehr_its_rest_1.0.0 -> ITS-REST: the specifications- clone.""" + match = re.match(r"^openehr_(.+?)_[0-9]+\.[0-9]+\.[0-9]+", schema_id) + return (match.group(1) if match else schema_id).upper().replace("_", "-") + + +def apply_filter(value, flt): + if flt == "lower": + return value.lower() + if flt == "upper": + return value.upper() + if flt == "component": + return component_of(value) + return value + + def render(text, values, escape=None): text = _STANDALONE.sub(r"\1", text) + def each(match): + name, body = match.groups() + if name not in values: + raise ScaffoldError(f"template uses unknown variable '{name}'") + if re.search(r"\{\{\s*[#/]", body): + raise ScaffoldError("nested block inside {{#each}} in a template") + return "".join(_ITEM.sub(lambda m, i=item: (escape or str)(apply_filter(i, m.group(1))), body) + for item in items_of(values[name])) + + text = _EACH.sub(each, text) + def cond(match): kind, name, body = match.groups() if name not in values: @@ -185,18 +221,14 @@ def cond(match): return body if truthy(values[name]) == (kind == "if") else "" text = _COND.sub(cond, text) - if re.search(r"\{\{\s*[#/](?:if|unless)\b", text): - raise ScaffoldError("unbalanced or nested {{#if}} / {{#unless}} block in a template") + if re.search(r"\{\{\s*[#/](?:if|unless|each)\b", text): + raise ScaffoldError("unbalanced or nested {{#if}} / {{#unless}} / {{#each}} block in a template") def var(match): name, flt = match.groups() if name not in values: raise ScaffoldError(f"template uses unknown variable '{name}'") - value = str(values[name]) - if flt == "lower": - value = value.lower() - elif flt == "upper": - value = value.upper() + value = apply_filter(str(values[name]), flt) return escape(value) if escape else value return _VAR.sub(var, text) @@ -283,7 +315,7 @@ def sibling_base(repo, notes): return base[-1] notes.append("no sibling specifications-BASE clone with a BMM schema: the class-table command in " "AGENTS.md will not load BASE with -d, so links to BASE types break; clone it, or " - "pass --var base_bmm_schema_id=") + "pass --var bmm_dependencies=") return None @@ -339,8 +371,6 @@ def put(name, value, where): if len(bmm) > 1: notes.append(f"several BMM schemas in computable/BMM ({', '.join(bmm)}): using {bmm[-1]}; " "pass --var bmm_schema_id= to choose another") - if not bmm[-1].startswith("openehr_base_"): - put("base_bmm_schema_id", sibling_base(repo, notes), "sibling specifications-BASE") head = git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") if head: @@ -357,7 +387,8 @@ def put(name, value, where): def resolve_variables(tset, explicit, saved, inferred, defaulted=()): - """Merge by priority: explicit, recorded as confirmed, inferred, recorded as a default, default.""" + """Merge by priority: explicit, recorded as confirmed, inferred, recorded as a default, default. + A variable with prefer_inferred takes an inferred value ahead of a recorded one.""" schema = tset["variables"] values, sources = {}, {} missing, invalid = [], [] @@ -365,10 +396,11 @@ def resolve_variables(tset, explicit, saved, inferred, defaulted=()): unconfirmed = {k: v for k, v in saved.items() if k in defaulted} pools = (("explicit", explicit), ("descriptor", confirmed), ("inferred", inferred), ("descriptor (default)", unconfirmed)) + repo_first = (pools[0], pools[2], pools[1], pools[3]) for name, spec in schema.items(): if "derived" in spec: continue - for label, pool in pools: + for label, pool in (repo_first if spec.get("prefer_inferred") else pools): if name in pool: entry = pool[name] value, where = entry if isinstance(entry, tuple) else (entry, None) @@ -830,14 +862,55 @@ def new_bmm(repo, spec, values, sources, notes): if not given: inferred["bmm_schema_id"] = (result["schema_id"], "bmm-publisher image" if "text" in result else "new empty schema") - if not result["schema_id"].startswith("openehr_base_"): - base = sibling_base(repo, notes) - if base: - inferred["base_bmm_schema_id"] = (base, "sibling specifications-BASE") result["inferred"] = inferred return result +def included_ids(text): + """The schema ids a P_BMM schema's `includes` names, in order (empty when it cannot be read).""" + try: + includes = json.loads(text).get("includes") if text else None + except (ValueError, AttributeError): + return [] + if not isinstance(includes, dict): + return [] + return [str(v.get("id") if isinstance(v, dict) and v.get("id") else k) for k, v in includes.items()] + + +def dependencies(repo, schema_id, text, notes): + """The schemas to load with -d when the class tables of `schema_id` are regenerated. + + BASE comes first for every component but BASE, even when the schema does not include it (TERM + does not, yet its String links need BASE); the version the schema includes when the sibling clone + has it, else the clone's highest. Then each other schema the `includes` name, from the sibling + clone of its component (openehr_lang_1.1.0 -> ../specifications-LANG/computable/BMM/).""" + includes = [i for i in included_ids(text) if i != schema_id] + deps = [] + if not schema_id.startswith("openehr_base_"): + base_dir = repo.resolve().parent / "specifications-BASE" / "computable" / "BMM" + wanted = next((i for i in includes if i.startswith("openehr_base_")), None) + if wanted and (base_dir / f"{wanted}.bmm.json").is_file(): + deps.append(wanted) + else: + base = sibling_base(repo, notes) + if base: + if wanted: + notes.append(f"{schema_id} includes {wanted}, which the sibling specifications-BASE clone " + f"does not hold: loading {base} instead") + deps.append(base) + for inc in includes: + if inc.startswith("openehr_base_") or inc in deps: + continue + clone = f"specifications-{component_of(inc)}" + if (repo.resolve().parent / clone / "computable" / "BMM" / f"{inc}.bmm.json").is_file(): + deps.append(inc) + else: + notes.append(f"{schema_id} includes {inc}, but no sibling {clone} clone holds " + f"computable/BMM/{inc}.bmm.json: the class-table command in AGENTS.md leaves it " + "out, so links to its types break; clone it and run again") + return deps + + def build_plan(repo, set_dir, explicit, overwrite=(), pin=()): tset = load_set(set_dir) unknown = [k for k in explicit if k not in tset["variables"] or "derived" in tset["variables"][k]] @@ -882,9 +955,22 @@ def build_plan(repo, set_dir, explicit, overwrite=(), pin=()): if plan.new_bmm.get("inferred"): inferred.update(plan.new_bmm["inferred"]) resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) + schema_id = resolved[0].get("bmm_schema_id", "") + if (schema_id and "bmm_dependencies" in tset["variables"] and not resolved[2] + and resolved[0].get("component") not in excluded): + if plan.new_bmm and "schema_id" in plan.new_bmm: + text = plan.new_bmm.get("text") # the bundled copy, or None for the empty template + else: + text = read_quiet(repo / "computable" / "BMM" / f"{schema_id}.bmm.json") + deps = dependencies(repo, schema_id, text, plan.notes) + inferred["bmm_dependencies"] = (", ".join(deps), f"includes of {schema_id}") if deps else ("", None) + resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) + elif "bmm_dependencies" in tset["variables"] and not resolved[2]: + inferred["bmm_dependencies"] = ("", None) # no schema, no dependencies: a recorded list is stale + resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) plan.values, plan.sources, plan.missing, plan.invalid = resolved if plan.values.get("component") in excluded: # the ITS repositories hold no model of their own - for name in ("bmm_schema_id", "base_bmm_schema_id"): + for name in ("bmm_schema_id", "bmm_dependencies"): if plan.values.get(name): plan.invalid.append({"name": name, "value": plan.values[name], "problem": f"must be empty for {plan.values['component']}, which holds no " @@ -1062,7 +1148,7 @@ def sample_values(tset, bmm): if spec.get("required"): explicit[name] = "EXAMPLE" if name == "component" else "Example Title" explicit["bmm_schema_id"] = "openehr_example_1.0.0" if bmm else "" - explicit["base_bmm_schema_id"] = "openehr_base_1.0.0" if bmm else "" + explicit["bmm_dependencies"] = "openehr_base_1.0.0, openehr_lang_1.0.0" if bmm else "" return resolve_variables(tset, explicit, {}, {}) @@ -1146,6 +1232,10 @@ def _check_template_set(set_dir): problems.append(f"variable '{name}': match is not a valid regex ({exc})") if not derived and "default" not in spec and not spec.get("required"): problems.append(f"variable '{name}' needs a default or required: true") + if name == EACH_ITEM: + problems.append(f"variable '{name}': the name is reserved for the items of {{{{#each}}}}") + if not isinstance(spec.get("prefer_inferred", False), bool): + problems.append(f"variable '{name}': prefer_inferred must be true or false") for bmm in (False, True): values, _, missing, invalid = sample_values(tset, bmm) diff --git a/scripts/test_scaffold.py b/scripts/test_scaffold.py index 5664613..9c55de0 100644 --- a/scripts/test_scaffold.py +++ b/scripts/test_scaffold.py @@ -82,6 +82,18 @@ def put(self, name, text): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") + def clone(self, component, *schemas): + """A sibling specifications- clone holding the given BMM schemas ({id: includes} or ids).""" + bmm = self.root / f"specifications-{component}" / "computable" / "BMM" + bmm.mkdir(parents=True, exist_ok=True) + for schema in schemas: + (bmm / f"{schema}.bmm.json").write_text("{}", encoding="utf-8") + + def schema(self, schema_id, *includes): + """A BMM schema in this repo's computable/BMM that includes the given schema ids.""" + body = {"includes": {i: {"id": i} for i in includes}} if includes else {} + self.put(f"computable/BMM/{schema_id}.bmm.json", json.dumps(body)) + def base_clone(self, *schema_ids): """A sibling specifications-BASE clone holding the given BMM schema ids.""" bmm = self.root / "specifications-BASE" / "computable" / "BMM" @@ -177,7 +189,7 @@ def test_bmm_switches_on_the_class_generation_sections(self): self.assertIn("and BMM sources", self.text(".claude/CLAUDE.md")) def test_a_base_dependency_is_loaded_with_d_in_the_class_table_command(self): - self.apply(bmm_schema_id="openehr_demo_1.0.0", base_bmm_schema_id="openehr_base_1.3.0") + self.apply(bmm_schema_id="openehr_demo_1.0.0", bmm_dependencies="openehr_base_1.3.0") agents = self.text("AGENTS.md") self.assertIn(' -v "$PWD/../specifications-BASE/computable/BMM/openehr_base_1.3.0.bmm.json"' ':/in/openehr_base_1.3.0.bmm.json:ro \\\n', agents) @@ -263,8 +275,9 @@ def test_base_bmm_comes_from_the_sibling_base_clone_highest_version_first(self): self.put("computable/BMM/openehr_demo_1.0.0.bmm.json", "{}") self.base_clone("openehr_base_1.2.0", "openehr_base_1.10.0", "openehr_base_1.3.0") plan = self.plan() - self.assertEqual(plan.values["base_bmm_schema_id"], "openehr_base_1.10.0") - self.assertTrue(plan.sources["base_bmm_schema_id"].startswith("inferred")) + self.assertEqual((plan.values["bmm_dependencies"], plan.values["base_bmm_schema_id"]), + ("openehr_base_1.10.0", "openehr_base_1.10.0")) + self.assertEqual(plan.sources["bmm_dependencies"], "inferred: includes of openehr_demo_1.0.0") def test_base_itself_gets_no_base_dependency(self): repo = self.root / "specifications-BASE" @@ -1243,8 +1256,8 @@ def test_the_its_repositories_are_never_offered_a_schema_and_docker_is_not_asked def test_a_schema_id_given_for_an_its_repository_is_rejected(self): plan = self.plan(component="ITS-BMM", jira_project="SPECITS", bmm_schema_id="openehr_rm_1.2.0", - base_bmm_schema_id="openehr_base_1.3.0") - self.assertEqual({i["name"] for i in plan.invalid}, {"bmm_schema_id", "base_bmm_schema_id"}) + bmm_dependencies="openehr_base_1.3.0") + self.assertEqual({i["name"] for i in plan.invalid}, {"bmm_schema_id", "bmm_dependencies"}) self.assertIn("must be empty for ITS-BMM", plan.invalid[0]["problem"]) self.assertEqual(plan.acts, []) self.assertEqual(self.plan(component="ITS-BMM", jira_project="SPECITS", bmm_schema_id="").invalid, []) @@ -1279,6 +1292,117 @@ def test_check_flags_a_bmm_seed_file_without_its_keys_and_a_derived_without_a_ru self.assertTrue(any("'bmm_rm_release': derived needs either map or match" in p for p in problems), problems) +class DependencyTests(Base): + """bmm_dependencies: what the class-table command loads with -d, read from the schema's includes.""" + + def test_every_included_schema_is_mounted_and_loaded_in_order(self): + self.schema("openehr_demo_2.4.0", "openehr_base_1.3.0", "openehr_lang_1.1.0") + self.clone("BASE", "openehr_base_1.3.0") + self.clone("LANG", "openehr_lang_1.1.0") + plan = self.plan() + self.assertEqual(plan.values["bmm_dependencies"], "openehr_base_1.3.0, openehr_lang_1.1.0") + self.apply() + agents = self.text("AGENTS.md") + self.assertIn(' -v "$PWD/../specifications-BASE/computable/BMM/openehr_base_1.3.0.bmm.json"' + ':/in/openehr_base_1.3.0.bmm.json:ro \\\n' + ' -v "$PWD/../specifications-LANG/computable/BMM/openehr_lang_1.1.0.bmm.json"' + ':/in/openehr_lang_1.1.0.bmm.json:ro \\\n', agents) + self.assertIn(" ghcr.io/openehr/bmm-publisher legacy-adoc \\\n" + " -d /in/openehr_base_1.3.0.bmm.json \\\n" + " -d /in/openehr_lang_1.1.0.bmm.json \\\n" + " /in/openehr_demo_2.4.0.bmm.json -o /out\n", agents) + + def test_a_schema_without_includes_still_loads_base(self): + self.schema("openehr_demo_3.1.0") # TERM includes nothing, yet its String links need BASE + self.clone("BASE", "openehr_base_1.3.0") + self.assertEqual(self.plan().values["bmm_dependencies"], "openehr_base_1.3.0") + + def test_the_included_base_version_is_preferred_when_the_clone_holds_it(self): + self.schema("openehr_demo_1.0.0", "openehr_base_1.2.0") + self.clone("BASE", "openehr_base_1.2.0", "openehr_base_1.3.0") + self.assertEqual(self.plan().values["bmm_dependencies"], "openehr_base_1.2.0") + + def test_an_included_base_version_the_clone_lacks_falls_back_to_the_highest(self): + self.schema("openehr_demo_1.0.0", "openehr_base_1.1.0") + self.clone("BASE", "openehr_base_1.3.0") + plan = self.plan() + self.assertEqual(plan.values["bmm_dependencies"], "openehr_base_1.3.0") + self.assertTrue(any("includes openehr_base_1.1.0" in w and "loading openehr_base_1.3.0" in w + for w in plan.warnings), plan.warnings) + + def test_an_include_without_its_sibling_clone_is_left_out_and_reported(self): + self.schema("openehr_demo_2.4.0", "openehr_base_1.3.0", "openehr_lang_1.1.0") + self.clone("BASE", "openehr_base_1.3.0") + plan = self.plan() + self.assertEqual(plan.values["bmm_dependencies"], "openehr_base_1.3.0") + self.assertTrue(any("includes openehr_lang_1.1.0, but no sibling specifications-LANG clone" in w + for w in plan.warnings), plan.warnings) + + def test_a_new_include_is_picked_up_although_the_old_list_was_recorded(self): + self.schema("openehr_demo_2.4.0", "openehr_base_1.3.0") + self.clone("BASE", "openehr_base_1.3.0") + self.apply() + self.schema("openehr_demo_2.4.0", "openehr_base_1.3.0", "openehr_lang_1.1.0") + self.clone("LANG", "openehr_lang_1.1.0") + self.assertEqual(self.plan().values["bmm_dependencies"], "openehr_base_1.3.0, openehr_lang_1.1.0") + + def test_an_explicit_empty_list_loads_nothing(self): + self.schema("openehr_demo_1.0.0", "openehr_base_1.3.0") + self.clone("BASE", "openehr_base_1.3.0") + self.apply(bmm_dependencies="") + self.assertNotIn("-d /in/", self.text("AGENTS.md")) + + def test_a_hyphenated_component_maps_to_its_clone(self): + self.schema("openehr_demo_1.0.0", "openehr_base_1.3.0", "openehr_its_demo_1.0.0") + self.clone("BASE", "openehr_base_1.3.0") + self.clone("ITS-DEMO", "openehr_its_demo_1.0.0") + self.apply() + self.assertIn("../specifications-ITS-DEMO/computable/BMM/openehr_its_demo_1.0.0.bmm.json", + self.text("AGENTS.md")) + + def test_base_bmm_schema_id_can_no_longer_be_given(self): + with self.assertRaisesRegex(scaffold.ScaffoldError, "unknown variable.*base_bmm_schema_id"): + self.plan(base_bmm_schema_id="openehr_base_1.3.0") + + def test_a_bundled_copy_brings_its_own_includes(self): + self.docker.schemas = {"openehr_demo_2.4.0": json.dumps( + {"includes": {"openehr_base_1.3.0": {"id": "openehr_base_1.3.0"}, + "openehr_lang_1.1.0": {"id": "openehr_lang_1.1.0"}}})} + self.clone("BASE", "openehr_base_1.3.0") + self.clone("LANG", "openehr_lang_1.1.0") + self.assertEqual(self.plan().values["bmm_dependencies"], "openehr_base_1.3.0, openehr_lang_1.1.0") + + def test_without_a_schema_a_recorded_list_is_dropped(self): + self.schema("openehr_demo_1.0.0", "openehr_base_1.3.0") + self.clone("BASE", "openehr_base_1.3.0") + self.apply() + (self.repo / "computable/BMM/openehr_demo_1.0.0.bmm.json").unlink() + plan = self.plan(bmm_schema_id="") + self.assertEqual(plan.values["bmm_dependencies"], "") + + +class EachTests(unittest.TestCase): + def test_each_repeats_its_body_per_item_with_filters(self): + out = scaffold.render("{{#each deps}}\n{{item|component}}={{item}};\n{{/each}}\nend {{x}}\n", + {"deps": "openehr_base_1.3.0, openehr_its_rest_1.0.0", "x": "y"}) + self.assertEqual(out, "BASE=openehr_base_1.3.0;\nITS-REST=openehr_its_rest_1.0.0;\nend y\n") + + def test_an_empty_list_renders_nothing(self): + self.assertEqual(scaffold.render("a\n{{#each deps}}\nx {{item}}\n{{/each}}\nb\n", {"deps": ""}), "a\nb\n") + + def test_a_block_nested_in_each_is_rejected(self): + with self.assertRaisesRegex(scaffold.ScaffoldError, "nested block inside"): + scaffold.render("{{#each d}}{{#if x}}y{{/if}}{{/each}}", {"d": "a", "x": "1"}) + + def test_items_are_escaped_like_any_value(self): + self.assertEqual(scaffold.render('{{#each d}}"{{item}}"{{/each}}', {"d": 'a"b'}, scaffold.json_escape), + '"a\\"b"') + + def test_item_outside_each_is_an_unknown_variable(self): + with self.assertRaisesRegex(scaffold.ScaffoldError, "unknown variable 'item'"): + scaffold.render("{{item}}", {}) + + class DockerHelperTests(unittest.TestCase): def test_a_missing_docker_binary_is_reported_not_raised(self): with mock.patch.dict(os.environ, {"PATH": ""}):