diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 17e4c8b..ed8ed54 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ ## Checklist -- [ ] `python3 scripts/validate.py` passes +- [ ] `python3 scripts/validate.py` and `python3 -m unittest discover -s scripts -p 'test_*.py'` pass - [ ] Skill triggering tested locally in Claude Code (see [docs/testing.md](../docs/testing.md)) - [ ] Cursor install tested locally when plugin or manifest content changed (see [docs/install.md](../docs/install.md#cursor)) - [ ] Both marketplaces updated when plugin metadata changed: `.claude-plugin/marketplace.json` and `.cursor-plugin/marketplace.json` (plus matching `plugin.json` files under `plugins//`) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9cd83f3..876193f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,3 +11,4 @@ jobs: steps: - uses: actions/checkout@v4 - run: python3 scripts/validate.py + - run: python3 -m unittest discover -s scripts -p 'test_*.py' diff --git a/.gitignore b/.gitignore index aefd7a3..0366a74 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ .DS_Store Thumbs.db +# Python bytecode (from the scaffold script and its tests) +__pycache__/ + # Claude Code local settings .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index caa263b..7ab11ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ ai-plugins/ ├── .claude-plugin/marketplace.json # Claude Code marketplace manifest ├── .cursor-plugin/marketplace.json # Cursor marketplace manifest ├── docs/ # Contributor and user documentation -├── scripts/validate.py # Manifest and frontmatter validation (CI) +├── scripts/validate.py # Manifest, frontmatter and template-set validation (CI) +├── scripts/test_scaffold.py # Unit tests for the scaffold skill's script (CI) ├── CHANGELOG.md # Release notes per plugin └── plugins// # One directory per plugin ├── .claude-plugin/plugin.json # Claude Code plugin manifest @@ -21,7 +22,8 @@ ai-plugins/ ├── README.md # Plugin purpose, install, and component inventory ├── skills// # Knowledge/workflow skills; user-only actions set disable-model-invocation: true │ ├── SKILL.md # Skill definition (YAML frontmatter + body) - │ └── references/ # Optional supplementary content + │ ├── references/ # Optional supplementary content + │ └── assets/, scripts/ # Optional bundled templates and scripts (see `scaffold`) └── agents/.md # Autonomous subagents (context-isolated, multi-file) ``` @@ -30,7 +32,7 @@ Current plugins: `openehr-specs` (skills, including user-only action skills, and ## Key Conventions - Plugin names: `openehr-` (mandatory prefix — flat global namespace); skill names: terse activity nouns with no prefix (auto-namespaced as `:`). User-only action skills and subagents follow the same no-prefix rule (`spec-reviewer` is the one exception). -- Component choice follows the *nature of the work*: **skills** = knowledge/workflows (model- or user-invoked); **user-only action skills** (`publish`, `regen-classes`) = user-initiated actions, also in `skills/`, marked `disable-model-invocation: true` so their descriptions stay out of context and they don't compete with knowledge-skill triggering (Claude Code treats `commands/` as the older format, so new actions are skills); **subagents** (`agents/`) = context-heavy, multi-file, or adversarial work that would otherwise pollute the main context. Action skills and agents reference their sibling knowledge skill rather than duplicating it. +- Component choice follows the *nature of the work*: **skills** = knowledge/workflows (model- or user-invoked); **user-only action skills** (`publish`, `regen-classes`, `scaffold`) = user-initiated actions, also in `skills/`, marked `disable-model-invocation: true` so their descriptions stay out of context and they don't compete with knowledge-skill triggering (Claude Code treats `commands/` as the older format, so new actions are skills); **subagents** (`agents/`) = context-heavy, multi-file, or adversarial work that would otherwise pollute the main context. Action skills and agents reference their sibling knowledge skill rather than duplicating it. - Skill `description` frontmatter is lean (~50–75 words): one what+scope sentence → a few representative triggers → short "Not for …" anti-triggers routing to the right sibling/plugin. - Keep each `SKILL.md` body a lean overview (quick-reference + pointers); push bulky detail (tables, templates, format specs) into `references/` so it loads only on demand. - Versions must stay in sync across both plugin manifests (`.claude-plugin/` and `.cursor-plugin/`), both marketplace entries, and the release tag (`{name}--v{version}`). @@ -42,21 +44,22 @@ Full details: [docs/skill-authoring.md](docs/skill-authoring.md) ## Development -Pure-content repository (JSON manifests + markdown skills) — no build step, package manager, or test suite. +Pure-content repository (JSON manifests + markdown skills) — no build step or package manager. The one script, the `scaffold` skill's `scripts/scaffold.py`, has unit tests: `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 — 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, and the `scaffold` template set's integrity — 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) - **Generating/editing AsciiDoc for `specifications-XX` repos**: follow [docs/spec-style-guide.md](docs/spec-style-guide.md); skill bodies must agree with it — when changing one, check the other. ## Component Dependencies -The skills are pure content; the subagents and the `publish` and `regen-classes` action skills rely on external tools and degrade +The skills are pure content; the subagents and the `publish`, `regen-classes` and `scaffold` action skills rely on external tools and degrade gracefully when one is absent (each says so in its prompt): - **`spec-reviewer`, `xref-auditor`** — need a `specifications-XX` checkout and (for attribute resolution) the sibling `specifications-AA_GLOBAL`. - **`xref-auditor`, `identifier-grounding`** — use **WebFetch** to read spec Markdown twins (`.html` → `.md`); `identifier-grounding` additionally prefers the **`openehr-assistant` MCP** (`type_specification_get`) when connected, falling back to the twin. - **`regen-classes`** — needs **Docker** (`ghcr.io/openehr/bmm-publisher`). +- **`scaffold`** — needs **`python3`** (standard library only) to run its bundled `scripts/scaffold.py`, and says so and stops when it is missing. It writes into the repository it is run in, so it plans first and never overwrites a hand-edited file without being told to. - **`publish`** — needs **Docker** and sibling `specifications-AA_GLOBAL` and component checkouts. It runs the published `ghcr.io/openehr/asciidoctor` image, which bundles the toolchain and `spec_publish.sh`; the boilerplate and references are still read from the AA_GLOBAL checkout. It checks that each HTML output was rebuilt and that the log has no missing includes, because the script reports success even when it built nothing or dropped the class tables. None are bundled (the `openehr-assistant` MCP is interactively authenticated, not redistributable); document them, don't assume them. diff --git a/CHANGELOG.md b/CHANGELOG.md index 162c992..d30c317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ Notable changes to the plugins in this repository. The format follows [Keep a Ch ## Unreleased +### Added + +- `scaffold` skill (user-only, `/openehr-specs:scaffold`): initialises a specification repository, or brings an existing one up to the standard file set. It installs `AGENTS.md` (the `openehr-specs@openehr` plugin table, the Docker build invocation, and commit and branch conventions built around the Jira ticket key, `Changes for SPECXX-NN - `), `.claude/CLAUDE.md`, `.claude/settings.json` (registers the `openehr` marketplace, enables `openehr-specs` and three other plugins, and pre-approves `git add`, `git commit`, `git diff`, WebFetch on `specifications.openehr.org` and three read-only Atlassian MCP calls, as BASE, RM and ITS-REST do today), `manifest.json`, `.gitignore`, `.asciidoctorconfig`, `LICENSE` (CC BY-SA 3.0, or Apache 2.0 for `ITS-*`) and `README.md`. It plans before it writes, reads the component, title, Jira key, BMM schema and licence from the repo, and never overwrites a hand-edited file without being told to. +- The file set is versioned: `assets/template-set.json` holds a revision, the variables and a strategy per file (seed, whole, JSON merge, ensure-lines, managed regions in `AGENTS.md`), and each repo records its revision and file hashes in `.claude/scaffold.json`, so a later run calculates the migration path from the recorded revision to the latest. `scripts/scaffold.py` needs `python3` (standard library only). + ### Changed - `publish` and `regen-classes` moved from `commands/` to `skills/` as user-only skills (`disable-model-invocation: true`). Claude Code treats `commands/` as the older format, so the plugin no longer ships that directory. The `/openehr-specs:publish` and `/openehr-specs:regen-classes` names are unchanged. +- `scripts/validate.py` also checks the `scaffold` template set (templates exist and render, revisions have migration files, the digest matches), and CI runs the unit tests in `scripts/test_scaffold.py`. - `scripts/validate.py` no longer checks a `commands/` directory; the user-only skills are validated as skills. - `publish`, `authoring`, and the release checklist run the published `ghcr.io/openehr/asciidoctor` image instead of `openehr/asciidoctor`, which is only the tag of a local `docker build`. - `publish` runs only the published image; the local-script branch is gone. That branch ran `spec_publish.sh -f -v` without `-q`, so every `{pkg}` class-table include failed (70 errors on BASE, with about 80% of the table blocks missing from the HTML) while the script still printed "generated" and exited 0. The image's entrypoint passes `-q`; on BASE the same image builds with no errors. The AA_GLOBAL checkout is still required, because the boilerplate and reference definitions are read from it. `authoring` shows the image first and says to pass `-q` with a local toolchain. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b81f322..45ad739 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Use [Conventional Commits](https://www.conventionalcommits.org): `type(scope): s ## Pull Request Process 1. Branch from `main`. -2. Run the validation script locally: `python3 scripts/validate.py` +2. Run the checks locally: `python3 scripts/validate.py` and `python3 -m unittest discover -s scripts -p 'test_*.py'` 3. If you changed plugin content, add a line under `## Unreleased` in [CHANGELOG.md](CHANGELOG.md). Do not bump the version; maintainers do that at release ([docs/versioning.md](docs/versioning.md)). If you added, removed, or renamed a skill or subagent, also update the inventories in the plugin's `README.md` and in the root [README.md](README.md). 4. Test skill triggering locally ([docs/testing.md](docs/testing.md)). 5. Open the PR and complete the checklist in the template. CI must pass. diff --git a/README.md b/README.md index b7d8d66..a974108 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ In Claude Code, skills are namespaced by the plugin and invoked as `/openehr-spe Type `/openehr` in the slash menu to list the available skills, or describe the task (for example, "review this openEHR spec before release") and Claude loads the matching skill when the request fits its description. -The plugin also ships **subagents** (`spec-reviewer`, `xref-auditor`, `identifier-grounding`) that Claude dispatches for heavy, multi-file, or verification work, and two **user-only action skills** (`/openehr-specs:regen-classes`, `/openehr-specs:publish`) that run only when you type them. The [plugin README](plugins/openehr-specs/README.md) has the full inventory and invocation details. +The plugin also ships **subagents** (`spec-reviewer`, `xref-auditor`, `identifier-grounding`) that Claude dispatches for heavy, multi-file, or verification work, and three **user-only action skills** (`/openehr-specs:regen-classes`, `/openehr-specs:publish`, `/openehr-specs:scaffold`) that run only when you type them. The [plugin README](plugins/openehr-specs/README.md) has the full inventory and invocation details. ## Documentation diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index fb17f66..702b3da 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -16,7 +16,7 @@ Each plugin lives under `plugins//` and must contain: - `.claude-plugin/plugin.json`: Claude Code plugin manifest (name, version, description, author, license, keywords) - `.cursor-plugin/plugin.json`: Cursor plugin manifest (keep name, version, and metadata in sync with the Claude manifest; declare `skills` as `./skills/`) - `README.md`: purpose, installation, and component inventory (skills, subagents) -- `skills/`: one subdirectory per skill, each with a `SKILL.md` (YAML frontmatter and markdown body) and an optional `references/` subdirectory for supplementary content +- `skills/`: one subdirectory per skill, each with a `SKILL.md` (YAML frontmatter and markdown body) and optional `references/`, `assets/` and `scripts/` subdirectories for supplementary content, templates and bundled scripts A plugin may also contain: @@ -39,6 +39,7 @@ The plugin manifests, the marketplace entries, and the release tag must all carr 3. **Anti-triggers** — a short "Not for …" that routes each overlapping case to the sibling skill or external plugin that owns it (e.g. archetype work → the `openehr-assistant` plugin). - Keep skill content factual and grounded in openEHR specifications; do not invent identifiers, paths, or conventions. - Reference files go in `references/` next to the SKILL.md; keep `SKILL.md` bodies focused (none of the current ones exceeds 300 lines) and push bulky supporting material to `references/`. +- A skill may also bundle `scripts/` and `assets/` (the `scaffold` skill does). Call a script as `${CLAUDE_SKILL_DIR}/scripts/` in the body and in `allowed-tools`, keep it to the standard library, say in the skill what it does when the interpreter is missing, and test it under `scripts/test_*.py` in this repo (CI runs them). A skill that installs versioned content states a revision and a migration path, as `scaffold` does in `assets/template-set.json`. - Skill bodies that generate or review AsciiDoc must agree with the [spec style guide](spec-style-guide.md) — when changing one, check the other. ## Subagent and Action-Skill Authoring diff --git a/docs/testing.md b/docs/testing.md index 46a25d2..0d5d837 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,6 +1,6 @@ # Testing and Validation -This is a pure-content repository: JSON manifests plus markdown skills (including user-only action skills) and subagents. There is no build step, package manager, or automated test suite. Testing means installing the marketplace locally and validating structure and skill quality. +This is a pure-content repository: JSON manifests plus markdown skills (including user-only action skills) and subagents. There is no build step or package manager, and the only automated tests cover the `scaffold` skill's script. Testing means installing the marketplace locally and validating structure and skill quality. ## Local Testing @@ -13,7 +13,7 @@ Install the marketplace from your working copy (inside a Claude Code session): Then exercise the skills against a checkout of a `specifications-XX` repo: confirm each skill triggers on its documented phrases (see the `description` frontmatter) and does **not** trigger on its anti-trigger cases. -The user-only action skills (`publish`, `regen-classes`) run only when you type them (`/openehr-specs:`): confirm that a plain request does not start one. Subagents are dispatched when a request matches their `description`, and you can also ask for one by name: confirm both. Both need Docker, and `publish` also needs a sibling `specifications-AA_GLOBAL` checkout (see the [plugin README](../plugins/openehr-specs/README.md)). +The user-only action skills (`publish`, `regen-classes`, `scaffold`) run only when you type them (`/openehr-specs:`): confirm that a plain request does not start one. Subagents are dispatched when a request matches their `description`, and you can also ask for one by name: confirm both. `publish` and `regen-classes` need Docker, and `publish` also needs a sibling `specifications-AA_GLOBAL` checkout; `scaffold` needs `python3` and writes files into the repository it runs in, so try it in an empty or scratch directory first (see the [plugin README](../plugins/openehr-specs/README.md)). After editing skill content, reinstall (or restart the session) to pick up changes. @@ -21,7 +21,8 @@ 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 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). 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, all in temporary directories. Run it after changing the script or the template set. - **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`. - **Skill quality review**: run the `plugin-dev:skill-reviewer` agent (also from `plugin-dev`). It checks description triggering quality, progressive disclosure, and content structure. diff --git a/plugins/openehr-specs/README.md b/plugins/openehr-specs/README.md index aaac2a5..4cae2a5 100644 --- a/plugins/openehr-specs/README.md +++ b/plugins/openehr-specs/README.md @@ -31,11 +31,12 @@ Add the marketplace from this repository (or a fork), then install `openehr-spec ### User-only action skills -Two skills run only when you type them: they do not auto-trigger, and their descriptions stay out of the session context. Each wraps a knowledge skill: `regen-classes` uses `class-generation`, and `publish` follows the preview step in `authoring`. +Three skills run only when you type them: they do not auto-trigger, and their descriptions stay out of the session context. `regen-classes` wraps `class-generation`, and `publish` follows the preview step in `authoring`; `scaffold` carries its own template set. | 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`. `.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`; 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 @@ -61,7 +62,7 @@ In Claude Code, each skill is namespaced by the plugin, so it is invoked as **`/ /openehr-specs:class-generation ``` 2. **Browse the group**: type `/openehr` in the slash menu to list the plugin's skills together, or fuzzy-type a name (e.g. `review`) to jump straight to it. -3. **Let it trigger automatically**: Claude loads the right skill (or dispatches the right subagent) when your request matches its description. For example, "review this openEHR spec before release" activates `review` / `spec-reviewer`, and "regenerate the class tables for specifications-BASE" activates `class-generation`, with no slash command needed. (The action skills `/openehr-specs:publish` and `/openehr-specs:regen-classes` are user-only and never auto-trigger.) +3. **Let it trigger automatically**: Claude loads the right skill (or dispatches the right subagent) when your request matches its description. For example, "review this openEHR spec before release" activates `review` / `spec-reviewer`, and "regenerate the class tables for specifications-BASE" activates `class-generation`, with no slash command needed. (The action skills `/openehr-specs:publish`, `/openehr-specs:regen-classes` and `/openehr-specs:scaffold` are user-only and never auto-trigger.) Most skills act on the spec you are working in, so run them from a checkout of a `specifications-XX` repo (or name the target spec/file in your request). diff --git a/plugins/openehr-specs/skills/scaffold/SKILL.md b/plugins/openehr-specs/skills/scaffold/SKILL.md new file mode 100644 index 0000000..b814bbb --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/SKILL.md @@ -0,0 +1,97 @@ +--- +name: scaffold +description: Initialise a specification repository, or bring an existing one up to the current standard file set (AGENTS.md, .claude settings, manifest.json, .gitignore, LICENSE, README) +argument-hint: "[component id, e.g. BASE]" +allowed-tools: + - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py plan *)" + - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py apply *)" + - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py render *)" + - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py diff *)" + - "Bash(git init *)" + - Read +disable-model-invocation: true +--- + +Install or upgrade the standard file set in the repository at the current directory: a +`specifications-XX` repo, or a new empty one. `$ARGUMENTS` may name the component. The file set, its +variables and its revision history are in `${CLAUDE_SKILL_DIR}/assets/template-set.json`; +`${CLAUDE_SKILL_DIR}/scripts/scaffold.py` (Python 3.8+, standard library only) performs the file +operations. Let the script write the managed files; edit by hand only in the cases that steps 3 and 5 +name. If `python3` is missing, say so and stop. In a tool that does not expand `${CLAUDE_SKILL_DIR}`, +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. + +## Steps + +1. **Plan.** Run `python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py plan --repo .`, adding + `--var component=` when `$ARGUMENTS` names one. It writes nothing and prints JSON: `mode`, + `repo`, `variables` (each with its source), `missing_variables`, `invalid_variables`, `files`, + `migration_steps`, `warnings`, `todo_markers`. Confirm that `repo` is the repository root the user + means. The modes are `init` (none of the files exist yet), `upgrade` (some do: `recorded_revision` is + null when the repo was never scaffolded, otherwise it is older than `latest_revision` and + `migration_path` lists the revisions to apply, in order) and `current`. If the output is + `{"error": ...}`, show the message and stop; do not edit `.claude/scaffold.json` to get past it + (for example "update the openehr-specs plugin" means a newer plugin scaffolded this repo). +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`, + `default_branch`, `first_release`, `published_url`) and list the rest in one line. Never invent a + title, a Jira key or a filter id. Values the user confirmed earlier are recorded in + `.claude/scaffold.json` and kept; a recorded default is not, so a BMM schema added later shows up + as a new guess to confirm. `--overwrite` and `--pin` accept only the file ids and `agents:` + ids that `files` lists; anything else is an error. +3. **Review.** Summarise the `files` by action and ask before applying. Show the `detail` of every + `merge`, `conflict` and `exists` entry (a `LICENSE` that differs from the standard text) and every + `warnings` entry. + - `create`, `merge`, `update`: the script writes. For `.claude/settings.json`, say what it grants: + the `openehr` marketplace, four enabled plugins, pre-approved `git add`, `git commit` and + `git diff`, WebFetch on `specifications.openehr.org`, and three read-only Atlassian MCP calls. + If the user wants the `git add` and `git commit` rules gone, remove them from the file after + applying: later runs leave out what the user removed, and add only what a newer revision + introduces. + - `exists`, `exists-alternative` (for example `README.adoc`), `unchanged`, `pinned`: 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. + Show the difference with `diff --repo . --file ` (repeat the `--var` options), then ask: + keep it, take the template (`--overwrite `), or leave it alone for good (`--pin `). In + `AGENTS.md`, each entry of `files[].regions` has its own action and id: use it as + `--overwrite agents:` or `--pin agents:`. Regions marked `update` or `add` are + written even when another region conflicts; `removed` means the user deleted the region, and + nothing is done. + - `upgrade-manual`: `AGENTS.md` exists without scaffold regions. Print the template with + `render --repo . --file agents` (repeat the `--var` options), compare it with the file, and offer + to edit the file so the shared sections sit between the + `` markers, or to pin it. + - `migration_steps`: show them, with each `note` as a manual step. The file actions shown are for + the tree as it is now; `apply` re-plans after running the steps and returns each step's `result`. + - Nothing but `unchanged`, `exists`, `exists-alternative` and `pinned`, and no `migration_steps`: + report "up to date" and stop. + - A directory that is not a git repository: offer `git init -b `; if git itself is + missing, say so. +4. **Apply.** Run the same command with `apply`, repeating the `--var` options and adding the + `--overwrite` and `--pin` options the user chose. Check that the output has `applied: true`; + `status: needs_input` means nothing was written. The script never changes an existing seed file + (`manifest.json`, `LICENSE`, `README.md`), never rewrites a hand-edited file or region without + `--overwrite`, and records the revision, variables and file hashes in `.claude/scaffold.json`. +5. **Finish.** List `written`. For each `todo_markers` entry (a `TODO(scaffold)` comment in + `AGENTS.md`), offer to fill it from the repo's content and delete the comment once it is filled. + When `manifest.json` was created, point out that its `specifications` list starts empty (skill + `openehr-specs:authoring`). Do not commit; if the user asks for a commit message, follow the + Conventions section of the repo's `AGENTS.md`. + +## Rules + +- Text between `` and `` belongs + to the template and is replaced on upgrade while it is unmodified. Text outside the markers belongs + to the repo and is never touched. +- To keep a region different in one repo, pin it (`--pin agents:`; to unpin, remove it from + `pinned` in `.claude/scaffold.json`). Changing what a region says for every repo is a change to the + plugin's template set. +- Run the skill again later to upgrade: the recorded revision is compared with the latest. + +To change the template set, add a revision, or understand a file strategy, read +`${CLAUDE_SKILL_DIR}/references/maintaining.md`. diff --git a/plugins/openehr-specs/skills/scaffold/assets/template-set.json b/plugins/openehr-specs/skills/scaffold/assets/template-set.json new file mode 100644 index 0000000..01250ec --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/template-set.json @@ -0,0 +1,158 @@ +{ + "id": "openehr-specs/scaffold", + "revision": 1, + "revisions": [ + { + "revision": 1, + "released": "2026-10-07", + "summary": "Initial set: AGENTS.md (plugin, build, conventions regions), .claude/CLAUDE.md and settings.json, manifest.json, .gitignore, .asciidoctorconfig, LICENSE, README.md.", + "digest": "27be97e72c5c80329ed51091f36c7db81bae89732bcfa133ea7203f0b2a697ae" + } + ], + "variables": { + "component": { + "required": true, + "pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$", + "description": "Component id, as in manifest.json `id` (BASE, RM, ITS-REST)." + }, + "repo_name": { + "default": "specifications-{{component}}", + "description": "Repository name." + }, + "component_title": { + "required": true, + "description": "Formal component name, for example 'Base Model'." + }, + "component_description": { + "default": "openEHR {{component_title}} Component", + "description": "One-line description used by manifest.json and README.md." + }, + "keywords": { + "default": "{{component_title|lower}}, openehr", + "description": "Component keywords for manifest.json." + }, + "jira_project": { + "default": "SPEC{{component}}", + "pattern": "^SPEC[A-Z]+$", + "description": "Jira project key of the component's change requests (SPECBASE, SPECRM). Not derivable for hyphenated ids: ITS-REST uses SPECITS." + }, + "jira_open_issues": { + "default": "", + "description": "Jira filter id of the open-issues list (manifest.json jira.open_issues); empty until it exists." + }, + "first_release": { + "default": "0.1.0", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Id of the first release entry in manifest.json. Planning and Development use 0.y.z (see skill governance)." + }, + "published_url": { + "default": "https://specifications.openehr.org/releases/{{component}}/", + "description": "Where the rendered specifications are published." + }, + "default_branch": { + "default": "master", + "description": "Default branch of the repository (master in every existing specifications-* repo)." + }, + "license": { + "default": "cc-by-sa-3.0", + "default_if": [ + { + "var": "component", + "matches": "^ITS(-|$)", + "value": "apache-2.0" + } + ], + "choices": [ + "cc-by-sa-3.0", + "apache-2.0" + ], + "description": "Licence: cc-by-sa-3.0 for the AsciiDoc specifications, apache-2.0 for the ITS-* technical artefacts." + }, + "license_name": { + "derived": { + "from": "license", + "map": { + "cc-by-sa-3.0": "Creative Commons Attribution-ShareAlike 3.0 Unported", + "apache-2.0": "Apache License 2.0" + } + }, + "description": "Display name of the licence." + }, + "bmm_schema_id": { + "default": "", + "pattern": "^$|^openehr_[a-z0-9_]+_[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "BMM schema id (openehr_base_1.3.0) when the repo holds computable/BMM/.bmm.json; empty otherwise. Switches on the class-generation sections." + }, + "documents": { + "default": "none yet (add them to `manifest.json`)", + "description": "Published document ids, read from manifest.json when present." + } + }, + "files": [ + { + "id": "agents", + "target": "AGENTS.md", + "template": "agents-md.tmpl", + "strategy": "regions" + }, + { + "id": "claude-md", + "target": ".claude/CLAUDE.md", + "template": "claude-md.tmpl", + "strategy": "whole" + }, + { + "id": "claude-settings", + "target": ".claude/settings.json", + "template": "claude-settings.json.tmpl", + "strategy": "json-merge" + }, + { + "id": "manifest", + "target": "manifest.json", + "template": "manifest-json.tmpl", + "strategy": "seed" + }, + { + "id": "gitignore", + "target": ".gitignore", + "template": "gitignore.tmpl", + "strategy": "ensure-lines" + }, + { + "id": "asciidoctorconfig", + "target": ".asciidoctorconfig", + "template": "asciidoctorconfig.tmpl", + "strategy": "ensure-lines", + "key": "^:!?([A-Za-z0-9_-]+)!?:" + }, + { + "id": "license", + "target": "LICENSE", + "template_var": "license", + "templates": { + "cc-by-sa-3.0": "license-cc-by-sa-3.0.txt", + "apache-2.0": "license-apache-2.0.txt" + }, + "strategy": "seed", + "alternatives": [ + "LICENSE.md", + "LICENSE.txt", + "LICENCE", + "COPYING" + ] + }, + { + "id": "readme", + "target": "README.md", + "template": "readme-md.tmpl", + "strategy": "seed", + "alternatives": [ + "README.adoc", + "README", + "README.rst", + "README.txt" + ] + } + ] +} diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl new file mode 100644 index 0000000..2951124 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/agents-md.tmpl @@ -0,0 +1,96 @@ +# AGENTS.md + +Guidance for AI coding agents working in **{{repo_name}}**. + +## What this repo is + +`{{repo_name}}` is the **document {{#if bmm_schema_id}}+ model {{/if}}source** for the openEHR **{{component}}** ({{component_title}}) component. It is a *specification* repo, not software: the deliverables are the HTML specs at {{published_url}}. Sources are **AsciiDoc** prose{{#if bmm_schema_id}} plus the **BMM** schema `computable/BMM/{{bmm_schema_id}}.bmm.json`{{/if}}; the committed `docs/*.html` files are build artefacts. + +`manifest.json` is the source of truth for which documents exist, their `spec_status`, releases, and the `{{jira_project}}` Jira roadmap. Published documents: {{documents}}. + + + +## Layout + +- `docs//master.adoc` plus `masterNN-*.adoc` chapters (`master00` = amendment record); `manifest_vars.adoc` is generated from `manifest.json` on publish. +{{#if bmm_schema_id}} +- `computable/BMM/{{bmm_schema_id}}.bmm.json` — the BMM; source of truth for all classes. +- `docs/UML/classes/` — **generated** class tables, named `org.openehr....adoc` (hence `{pkg}` in chapter includes, and `-q` when publishing). +{{/if}} +- `.asciidoctorconfig` — attributes for editor previews (`:component:`, `:imagesdir:`). + + +## Use the `openehr-specs@openehr` plugin + +The plugin carries the spec-authoring know-how — **prefer its skills/agents over ad-hoc edits.** Don't re-derive their workflows here. `.claude/settings.json` registers the `openehr` marketplace and enables the plugin; Claude Code asks you to trust the folder first. To install it by hand: `/plugin marketplace add openEHR/ai-plugins`, then `/plugin install openehr-specs@openehr`. + +| Task | Use | +|------|-----| +| Create/edit a spec, chapter, `master.adoc`, or `manifest.json` | skill `openehr-specs:authoring` | +| Spec prose style — overviews, semantics, design rationale | skill `openehr-specs:content-patterns` | +{{#if bmm_schema_id}} +| Regenerate class tables/diagrams from BMM (`bmm-publisher`) | skill `openehr-specs:class-generation` | +{{/if}} +| Amendment record (`master00-amendment_record.adoc`) | skill `openehr-specs:amendment-record` | +| Releases, CR/PR, lifecycle status, Jira workflow | skill `openehr-specs:governance` | +| Quality / convention review of a document | skill `openehr-specs:review` | +| Whole-document convention review (all chapters) | agent `openehr-specs:spec-reviewer` | +| Fact-check class/attribute/function names in prose | agent `openehr-specs:identifier-grounding` | +| Audit `{openehr_*}` attributes + `<>` cross-refs | agent `openehr-specs:xref-auditor` | +| Local HTML preview of this component (you run it) | `/openehr-specs:publish {{component}}` | +| Check this repo against the standard file set | `/openehr-specs:scaffold` | + + + +## Build tool invocation + +No build tooling lives in this repo. All `specifications-XX` repos, including `specifications-AA_GLOBAL` (boilerplate, references), are cloned as siblings under one parent directory; Docker is the only prerequisite. Run from that parent directory: + +```bash +# render HTML. The image is published from specifications-AA_GLOBAL; its entrypoint passes -q +# (package-qualified class files). Use `Release-X.Y.Z` instead of `development` for a release build. +docker run --rm -u $(id -u):$(id -g) -v "$PWD:/documents/" ghcr.io/openehr/asciidoctor development {{component}} +``` + +The build prints `generated ` and exits 0 even when includes are missing, so read the log: any `ERROR` or `include file not found` line means incomplete output. It also rewrites the tracked `docs/*.html` artefacts. +{{#if bmm_schema_id}} + +```bash +# 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. +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 \ + -v "$OUT":/out \ + ghcr.io/openehr/bmm-publisher legacy-adoc /in/{{bmm_schema_id}}.bmm.json -o /out +# then diff "$OUT" against docs/UML/classes and copy over the tables you changed +``` + +To change a class/attribute/function/invariant, edit the BMM schema and regenerate — never touch the generated tables (see skill `openehr-specs:class-generation`). +{{/if}} + +{{#if bmm_schema_id}} + +## Gotchas + +- BMM `documentation` strings pass through bmm-publisher's `formatText()`: `{attr}` is escaped, so write literal values (e.g. `latest`, not `{base_release}`); same-document `<<_x_class,X>>` xrefs work; use `×`, not `*`. +{{/if}} + + +## Conventions + +### Commit messages + +- **Format:** `Changes for - `, one line, for example `Changes for {{jira_project}}-42 - fix typos in the overview chapter`. Say what changed in the specification, not which file. +- **Several tickets:** join the numbers, `Changes for {{jira_project}}-49/50 - `. +- **Every commit that has a Jira ticket carries its key.** Take it from, in order: the user, the branch name (`feat/{{jira_project}}-42-`), or the Jira issue the task links to (the `atlassian-openehr` MCP server can look an issue up). Never invent or guess a key. +- **Which key:** `{{jira_project}}` for this component (change requests), `SPECPR` for problem reports, `SPECPUB` for publishing and tooling issues, or the owning component's key (`SPECAM`, `SPECRM`, ...) when the change belongs there. See skill `openehr-specs:governance`. +- **No ticket:** write a plain summary without a key and say so in the pull request; do not use a placeholder key. +- Some history, mostly in other components, puts the key last, ` ({{jira_project}}-42)`. It is understood, but use the leading form here. +- A change to published specification text also needs an amendment-record entry with the same ticket (skill `openehr-specs:amendment-record`). +- Do not stage regenerated `docs/*.html` or generated class tables together with source changes unless the task is to refresh them. + +### Branches + +- Branch from `{{default_branch}}` as `feat/-` or `fix/-`, for example `feat/{{jira_project}}-42-template-id`, and merge to `{{default_branch}}` by pull request. + diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/asciidoctorconfig.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/asciidoctorconfig.tmpl new file mode 100644 index 0000000..4ab21ba --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/asciidoctorconfig.tmpl @@ -0,0 +1,5 @@ +:component: {{component}} + +:package_qualifiers: + +:imagesdir: ../ diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/claude-md.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/claude-md.tmpl new file mode 100644 index 0000000..5e68034 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/claude-md.tmpl @@ -0,0 +1,7 @@ +# CLAUDE.md + +Guidance for Claude Code in this repository (a specification repo: AsciiDoc{{#if bmm_schema_id}} and BMM{{/if}} sources, not software). + +All repository guidance for AI assistants is maintained in the root-level `AGENTS.md`: + +@../AGENTS.md diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/claude-settings.json.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/claude-settings.json.tmpl new file mode 100644 index 0000000..409d879 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/claude-settings.json.tmpl @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "Bash(git diff:*)", + "Bash(git commit *)", + "Bash(git add *)", + "WebFetch(domain:specifications.openehr.org)", + "mcp__atlassian-openehr__atlassianUserInfo", + "mcp__atlassian-openehr__getAccessibleAtlassianResources", + "mcp__atlassian-openehr__getVisibleJiraProjects" + ] + }, + "extraKnownMarketplaces": { + "openehr": { + "source": { + "source": "github", + "repo": "openEHR/ai-plugins" + } + } + }, + "enabledPlugins": { + "claude-md-management@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true, + "openehr-specs@openehr": true, + "superpowers@claude-plugins-official": true + } +} diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/gitignore.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/gitignore.tmpl new file mode 100644 index 0000000..0422647 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/gitignore.tmpl @@ -0,0 +1,18 @@ +# Backup files +*.bak + +# temp files +temp/ + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + +# Git worktrees (isolated feature checkouts) +.worktrees/ + +# Local AI agent settings (per-machine permissions, etc.) +.claude/settings.local.json diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/license-apache-2.0.txt b/plugins/openehr-specs/skills/scaffold/assets/templates/license-apache-2.0.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/license-apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/license-cc-by-sa-3.0.txt b/plugins/openehr-specs/skills/scaffold/assets/templates/license-cc-by-sa-3.0.txt new file mode 100644 index 0000000..604209a --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/license-cc-by-sa-3.0.txt @@ -0,0 +1,359 @@ +Creative Commons Legal Code + +Attribution-ShareAlike 3.0 Unported + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR + DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE +COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY +COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS +AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE +TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY +BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS +CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND +CONDITIONS. + +1. Definitions + + a. "Adaptation" means a work based upon the Work, or upon the Work and + other pre-existing works, such as a translation, adaptation, + derivative work, arrangement of music or other alterations of a + literary or artistic work, or phonogram or performance and includes + cinematographic adaptations or any other form in which the Work may be + recast, transformed, or adapted including in any form recognizably + derived from the original, except that a work that constitutes a + Collection will not be considered an Adaptation for the purpose of + this License. For the avoidance of doubt, where the Work is a musical + work, performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be considered an + Adaptation for the purpose of this License. + b. "Collection" means a collection of literary or artistic works, such as + encyclopedias and anthologies, or performances, phonograms or + broadcasts, or other works or subject matter other than works listed + in Section 1(f) below, which, by reason of the selection and + arrangement of their contents, constitute intellectual creations, in + which the Work is included in its entirety in unmodified form along + with one or more other contributions, each constituting separate and + independent works in themselves, which together are assembled into a + collective whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined below) for the purposes of this + License. + c. "Creative Commons Compatible License" means a license that is listed + at https://creativecommons.org/compatiblelicenses that has been + approved by Creative Commons as being essentially equivalent to this + License, including, at a minimum, because that license: (i) contains + terms that have the same purpose, meaning and effect as the License + Elements of this License; and, (ii) explicitly permits the relicensing + of adaptations of works made available under that license under this + License or a Creative Commons jurisdiction license with the same + License Elements as this License. + d. "Distribute" means to make available to the public the original and + copies of the Work or Adaptation, as appropriate, through sale or + other transfer of ownership. + e. "License Elements" means the following high-level license attributes + as selected by Licensor and indicated in the title of this License: + Attribution, ShareAlike. + f. "Licensor" means the individual, individuals, entity or entities that + offer(s) the Work under the terms of this License. + g. "Original Author" means, in the case of a literary or artistic work, + the individual, individuals, entity or entities who created the Work + or if no individual or entity can be identified, the publisher; and in + addition (i) in the case of a performance the actors, singers, + musicians, dancers, and other persons who act, sing, deliver, declaim, + play in, interpret or otherwise perform literary or artistic works or + expressions of folklore; (ii) in the case of a phonogram the producer + being the person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of broadcasts, the + organization that transmits the broadcast. + h. "Work" means the literary and/or artistic work offered under the terms + of this License including without limitation any production in the + literary, scientific and artistic domain, whatever may be the mode or + form of its expression including digital form, such as a book, + pamphlet and other writing; a lecture, address, sermon or other work + of the same nature; a dramatic or dramatico-musical work; a + choreographic work or entertainment in dumb show; a musical + composition with or without words; a cinematographic work to which are + assimilated works expressed by a process analogous to cinematography; + a work of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of applied + art; an illustration, map, plan, sketch or three-dimensional work + relative to geography, topography, architecture or science; a + performance; a broadcast; a phonogram; a compilation of data to the + extent it is protected as a copyrightable work; or a work performed by + a variety or circus performer to the extent it is not otherwise + considered a literary or artistic work. + i. "You" means an individual or entity exercising rights under this + License who has not previously violated the terms of this License with + respect to the Work, or who has received express permission from the + Licensor to exercise rights under this License despite a previous + violation. + j. "Publicly Perform" means to perform public recitations of the Work and + to communicate to the public those public recitations, by any means or + process, including by wire or wireless means or public digital + performances; to make available to the public Works in such a way that + members of the public may access these Works from a place and at a + place individually chosen by them; to perform the Work to the public + by any means or process and the communication to the public of the + performances of the Work, including by public digital performance; to + broadcast and rebroadcast the Work by any means including signs, + sounds or images. + k. "Reproduce" means to make copies of the Work by any means including + without limitation by sound or visual recordings and the right of + fixation and reproducing fixations of the Work, including storage of a + protected performance or phonogram in digital form or other electronic + medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, +limit, or restrict any uses free from copyright or rights arising from +limitations or exceptions that are provided for in connection with the +copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, +Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +perpetual (for the duration of the applicable copyright) license to +exercise the rights in the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or more + Collections, and to Reproduce the Work as incorporated in the + Collections; + b. to create and Reproduce Adaptations provided that any such Adaptation, + including any translation in any medium, takes reasonable steps to + clearly label, demarcate or otherwise identify that changes were made + to the original Work. For example, a translation could be marked "The + original work was translated from English to Spanish," or a + modification could indicate "The original work has been modified."; + c. to Distribute and Publicly Perform the Work including as incorporated + in Collections; and, + d. to Distribute and Publicly Perform Adaptations. + e. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme cannot be waived, the Licensor + reserves the exclusive right to collect such royalties for any + exercise by You of the rights granted under this License; + ii. Waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme can be waived, the Licensor waives the + exclusive right to collect such royalties for any exercise by You + of the rights granted under this License; and, + iii. Voluntary License Schemes. The Licensor waives the right to + collect royalties, whether individually or, in the event that the + Licensor is a member of a collecting society that administers + voluntary licensing schemes, via that society, from any exercise + by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now +known or hereafter devised. The above rights include the right to make +such modifications as are technically necessary to exercise the rights in +other media and formats. Subject to Section 8(f), all rights not expressly +granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made +subject to and limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under the terms + of this License. You must include a copy of, or the Uniform Resource + Identifier (URI) for, this License with every copy of the Work You + Distribute or Publicly Perform. You may not offer or impose any terms + on the Work that restrict the terms of this License or the ability of + the recipient of the Work to exercise the rights granted to that + recipient under the terms of the License. You may not sublicense the + Work. You must keep intact all notices that refer to this License and + to the disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of the + Work from You to exercise the rights granted to that recipient under + the terms of the License. This Section 4(a) applies to the Work as + incorporated in a Collection, but this does not require the Collection + apart from the Work itself to be made subject to the terms of this + License. If You create a Collection, upon notice from any Licensor You + must, to the extent practicable, remove from the Collection any credit + as required by Section 4(c), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the extent + practicable, remove from the Adaptation any credit as required by + Section 4(c), as requested. + b. You may Distribute or Publicly Perform an Adaptation only under the + terms of: (i) this License; (ii) a later version of this License with + the same License Elements as this License; (iii) a Creative Commons + jurisdiction license (either this or a later license version) that + contains the same License Elements as this License (e.g., + Attribution-ShareAlike 3.0 US)); (iv) a Creative Commons Compatible + License. If you license the Adaptation under one of the licenses + mentioned in (iv), you must comply with the terms of that license. If + you license the Adaptation under the terms of any of the licenses + mentioned in (i), (ii) or (iii) (the "Applicable License"), you must + comply with the terms of the Applicable License generally and the + following provisions: (I) You must include a copy of, or the URI for, + the Applicable License with every copy of each Adaptation You + Distribute or Publicly Perform; (II) You may not offer or impose any + terms on the Adaptation that restrict the terms of the Applicable + License or the ability of the recipient of the Adaptation to exercise + the rights granted to that recipient under the terms of the Applicable + License; (III) You must keep intact all notices that refer to the + Applicable License and to the disclaimer of warranties with every copy + of the Work as included in the Adaptation You Distribute or Publicly + Perform; (IV) when You Distribute or Publicly Perform the Adaptation, + You may not impose any effective technological measures on the + Adaptation that restrict the ability of a recipient of the Adaptation + from You to exercise the rights granted to that recipient under the + terms of the Applicable License. This Section 4(b) applies to the + Adaptation as incorporated in a Collection, but this does not require + the Collection apart from the Adaptation itself to be made subject to + the terms of the Applicable License. + c. If You Distribute, or Publicly Perform the Work or any Adaptations or + Collections, You must, unless a request has been made pursuant to + Section 4(a), keep intact all copyright notices for the Work and + provide, reasonable to the medium or means You are utilizing: (i) the + name of the Original Author (or pseudonym, if applicable) if supplied, + and/or if the Original Author and/or Licensor designate another party + or parties (e.g., a sponsor institute, publishing entity, journal) for + attribution ("Attribution Parties") in Licensor's copyright notice, + terms of service or by other reasonable means, the name of such party + or parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + specifies to be associated with the Work, unless such URI does not + refer to the copyright notice or licensing information for the Work; + and (iv) , consistent with Ssection 3(b), in the case of an + Adaptation, a credit identifying the use of the Work in the Adaptation + (e.g., "French translation of the Work by Original Author," or + "Screenplay based on original Work by Original Author"). The credit + required by this Section 4(c) may be implemented in any reasonable + manner; provided, however, that in the case of a Adaptation or + Collection, at a minimum such credit will appear, if a credit for all + contributing authors of the Adaptation or Collection appears, then as + part of these credits and in a manner at least as prominent as the + credits for the other contributing authors. For the avoidance of + doubt, You may only use the credit required by this Section for the + purpose of attribution in the manner set out above and, by exercising + Your rights under this License, You may not implicitly or explicitly + assert or imply any connection with, sponsorship or endorsement by the + Original Author, Licensor and/or Attribution Parties, as appropriate, + of You or Your use of the Work, without the separate, express prior + written permission of the Original Author, Licensor and/or Attribution + Parties. + d. Except as otherwise agreed in writing by the Licensor or as may be + otherwise permitted by applicable law, if You Reproduce, Distribute or + Publicly Perform the Work either by itself or as part of any + Adaptations or Collections, You must not distort, mutilate, modify or + take other derogatory action in relation to the Work which would be + prejudicial to the Original Author's honor or reputation. Licensor + agrees that in those jurisdictions (e.g. Japan), in which any exercise + of the right granted in Section 3(b) of this License (the right to + make Adaptations) would be deemed to be a distortion, mutilation, + modification or other derogatory action prejudicial to the Original + Author's honor and reputation, the Licensor will waive or not assert, + as appropriate, this Section, to the fullest extent permitted by the + applicable national law, to enable You to reasonably exercise Your + right under Section 3(b) of this License (right to make Adaptations) + but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR +OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY +KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, +INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF +LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, +WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION +OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE +LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR +ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES +ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this License. + Individuals or entities who have received Adaptations or Collections + from You under this License, however, will not have their licenses + terminated provided such individuals or entities remain in full + compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will + survive any termination of this License. + b. Subject to the above terms and conditions, the license granted here is + perpetual (for the duration of the applicable copyright in the Work). + Notwithstanding the above, Licensor reserves the right to release the + Work under different license terms or to stop distributing the Work at + any time; provided, however that any such election will not serve to + withdraw this License (or any other license that has been, or is + required to be, granted under the terms of this License), and this + License will continue in full force and effect unless terminated as + stated above. + +8. Miscellaneous + + a. Each time You Distribute or Publicly Perform the Work or a Collection, + the Licensor offers to the recipient a license to the Work on the same + terms and conditions as the license granted to You under this License. + b. Each time You Distribute or Publicly Perform an Adaptation, Licensor + offers to the recipient a license to the original Work on the same + terms and conditions as the license granted to You under this License. + c. If any provision of this License is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this License, and without further action + by the parties to this agreement, such provision shall be reformed to + the minimum extent necessary to make such provision valid and + enforceable. + d. No term or provision of this License shall be deemed waived and no + breach consented to unless such waiver or consent shall be in writing + and signed by the party to be charged with such waiver or consent. + e. This License constitutes the entire agreement between the parties with + respect to the Work licensed here. There are no understandings, + agreements or representations with respect to the Work not specified + here. Licensor shall not be bound by any additional provisions that + may appear in any communication from You. This License may not be + modified without the mutual written agreement of the Licensor and You. + f. The rights granted under, and the subject matter referenced, in this + License were drafted utilizing the terminology of the Berne Convention + for the Protection of Literary and Artistic Works (as amended on + September 28, 1979), the Rome Convention of 1961, the WIPO Copyright + Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 + and the Universal Copyright Convention (as revised on July 24, 1971). + These rights and subject matter take effect in the relevant + jurisdiction in which the License terms are sought to be enforced + according to the corresponding provisions of the implementation of + those treaty provisions in the applicable national law. If the + standard suite of rights granted under applicable copyright law + includes additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights under + applicable law. + + +Creative Commons Notice + + Creative Commons is not a party to this License, and makes no warranty + whatsoever in connection with the Work. Creative Commons will not be + liable to You or any party on any legal theory for any damages + whatsoever, including without limitation any general, special, + incidental or consequential damages arising in connection to this + license. Notwithstanding the foregoing two (2) sentences, if Creative + Commons has expressly identified itself as the Licensor hereunder, it + shall have all rights and obligations of Licensor. + + Except for the limited purpose of indicating to the public that the + Work is licensed under the CCPL, Creative Commons does not authorize + the use by either party of the trademark "Creative Commons" or any + related trademark or logo of Creative Commons without the prior + written consent of Creative Commons. Any permitted use will be in + compliance with Creative Commons' then-current trademark usage + guidelines, as may be published on its website or otherwise made + available upon request from time to time. For the avoidance of doubt, + this trademark restriction does not form part of the License. + + Creative Commons may be contacted at https://creativecommons.org/. diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/manifest-json.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/manifest-json.tmpl new file mode 100644 index 0000000..08a930f --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/manifest-json.tmpl @@ -0,0 +1,22 @@ +{ + "id": "{{component}}", + "title": "{{component_title}}", + "description": "{{component_description}}", + "keywords": "{{keywords}}", + "specifications": [], + "expressions": [], + "jira": { + "open_issues": "{{jira_open_issues}}", + "roadmap": "{{jira_project}}" + }, + "releases": [ + { + "id": "{{first_release}}", + "date": "", + "jira": { + "crs": "", + "prs": "" + } + } + ] +} diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/readme-md.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/readme-md.tmpl new file mode 100644 index 0000000..f8a18ad --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/readme-md.tmpl @@ -0,0 +1,13 @@ +# {{repo_name}} + +{{component_description}}: the openEHR specification sources for the **{{component}}** component. + +- Published specifications: {{published_url}} +- Layout, build and commit conventions: [AGENTS.md](AGENTS.md) +- Licence: {{license_name}}, see [LICENSE](LICENSE) + +To render a local HTML preview, run this from the directory that holds the `specifications-*` clones (Docker is the only prerequisite): + +```bash +docker run --rm -u $(id -u):$(id -g) -v "$PWD:/documents/" ghcr.io/openehr/asciidoctor development {{component}} +``` diff --git a/plugins/openehr-specs/skills/scaffold/references/maintaining.md b/plugins/openehr-specs/skills/scaffold/references/maintaining.md new file mode 100644 index 0000000..d228839 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/references/maintaining.md @@ -0,0 +1,142 @@ +# Maintaining the scaffold template set + +For maintainers of this plugin. Users run the `scaffold` skill; this file explains what it installs and +how to change that. Paths are relative to the skill directory +(`plugins/openehr-specs/skills/scaffold/`) unless they start with `scripts/` at the repo root. + +## Layout + +``` +scaffold/ +├── SKILL.md +├── assets/ +│ ├── template-set.json # the schema: revision, revisions, variables, files +│ └── templates/ # one template per file (and the two licence texts) +├── migrations/NNNN.json # how to go from revision NNNN-1 to NNNN; created with revision 2 +└── scripts/scaffold.py # plan, apply, render, diff, check, seal +``` + +`migrations/` does not exist yet, because git does not track an empty directory. Create it with the first +migration file. + +From the repo root: + +- `python3 plugins/openehr-specs/skills/scaffold/scripts/scaffold.py check` validates the template set. + `python3 scripts/validate.py` runs the same check in CI. +- `python3 -m unittest discover -s scripts -p 'test_*.py'` runs the tests (`scripts/test_scaffold.py`). + +## File strategies + +Each entry of `files` names a strategy, chosen by who owns the file after it is installed. + +| 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. | +| `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. | + +`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. + +**How a template change reaches repos.** Only `whole` and `regions` follow the hashes: an unmodified file +or region is updated, an edited one is reported as a `conflict`. `json-merge` and `ensure-lines` only add, +so a changed value in `claude-settings.json.tmpl`, `gitignore.tmpl` or `asciidoctorconfig.tmpl` +never propagates by itself, and neither does removing an item (a new item does, once); add a `note` step +to the migration for those. `seed` files +never change. + +For every strategy, line endings (CRLF or LF) and the file mode are kept when a file is rewritten, a write goes to a temporary file that then replaces the target, and nothing is written through a symbolic link or outside the repo. + +## Variables + +`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`). 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` +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`) and the `LICENSE` text. +- The descriptor lists which recorded values were only defaults (`defaulted`). Inference replaces those, so a + BMM schema added to the repo later is picked up as a new guess; a value the user confirmed is kept. +- A value that fails its `pattern` is reported, not guessed: `SPEC{{component}}` is invalid for + `ITS-REST`, so it must come from the manifest or the user. + +## The descriptor in a scaffolded repo + +`.claude/scaffold.json`: `template_set`, `revision`, `variables` (everything resolved at install time, +so an upgrade does not ask again), `files` (`sha256` for `whole`, per-region hashes for `regions`) and +`pinned`. The hashes are what tell "unmodified since scaffolded" from "edited by hand". + +It lives in `.claude/` because `manifest.json` is the repo's own file, read by the publisher, and a merge would reformat it and put hashes into every diff; a root-level dot file would add clutter. It is committed. + +## Changing the set + +1. **Before the first release that contains this skill**, edit revision 1 and run `scaffold.py seal`. +2. **After it ships**, any change to a template, a variable or a `files` entry needs a new revision: + - raise `revision`, append an entry to `revisions` (`revision`, `released`, `summary`, empty `digest`); + - create `migrations/NNNN.json` for it, even when it has no steps; + - run `scaffold.py seal`. + + `check` fails when the digest of the templates, variables and files does not match the latest + revision, when revisions have gaps, or when a migration file is missing or malformed. That is what + keeps old repos upgradable. + +Migration file: + +```json +{ + "from": 1, + "to": 2, + "steps": [ + { "op": "rename", "from": ".old/path", "to": ".new/path" }, + { "op": "delete", "path": "docs/obsolete.adoc", "file_id": "obsolete" }, + { "op": "note", "text": "Add the new `foo` key to manifest.json by hand." } + ] +} +``` + +`rename` moves a file when the source exists and the target does not. `delete` removes a file only when +its hash equals the `sha256` recorded for `file_id`, which only `whole` files have; for any other file it +is skipped. `note` is shown to the user as a manual step. A new required variable needs no step: the next +plan asks for it. + +## Where the conventions come from + +Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git directory). + +- **Standard files.** `AGENTS.md`, `.claude/*` and `.asciidoctorconfig` exist only in some repos. BASE and + 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`. +- **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. +- **Commit messages.** Of 2,988 non-merge commits (up to 300 per repo), 968 name a `SPEC*` ticket. In BASE, + RM and AM, the models for the template, the leading `Changes for SPECXX-NN - ` form is by far the + most common (154 commits against 24 with the key last), and BASE's `AGENTS.md` documents it. Across the + nine key-heavy repos (those three plus QUERY, ITS-REST, ITS-XML, PROC, LANG, TERM) the trailing + ` (SPECXX-NN)` form is more common (333 against 221) and newer (median December 2022 against + December 2019), driven by QUERY and the ITS repos. The template makes the leading form primary and says + the trailing form is understood. About 2,000 commits carry no key at all. +- **Not verified for every repo:** "do not stage regenerated `docs/*.html`" comes from BASE's practice (its + commits are source-only) and the note in its `AGENTS.md` that the HTML is a build artefact. + +## Known limits + +- 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 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. +- `json-merge` rewrites the file with two-space indentation when it adds something, and any rewrite drops + a byte-order mark. `.gitignore` is matched line by line: negations (`!x`) and anchoring are not + understood, so a user who negates a pattern the template adds should pin the file. +- Region markers must start a line. A marker shown inside a fenced code block is still read as a marker, + so do not quote them in `AGENTS.md`. +- Migration steps run before files are planned, so a dry `plan` shows file actions for the tree as it is + now; `apply` re-plans after the steps. diff --git a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py new file mode 100644 index 0000000..b2fbce1 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py @@ -0,0 +1,1048 @@ +#!/usr/bin/env python3 +"""Install or upgrade the standard openEHR specification-repo file set. + +Python 3.8+, standard library only. The file set (templates, variables, strategies and +revisions) is described by ../assets/template-set.json; see ../SKILL.md for how the skill +drives this script and ../references/maintaining.md for how to change the set. + + scaffold.py plan --repo DIR [--var NAME=VALUE ...] what would change; writes nothing + scaffold.py apply --repo DIR [--var ...] [--overwrite ID ...] [--pin ID ...] + scaffold.py render --repo DIR --file ID [--var ...] print one rendered template + scaffold.py diff --repo DIR --file ID [--var ...] show how the repo's file differs from it + scaffold.py check validate the template set (CI) + scaffold.py seal record the template digest + +plan and apply print JSON (render and diff print text); errors are {"error": ...}, exit 2. +Nothing is written outside --repo, through a symbolic link, or without the file being complete +first (each write goes to a temporary file that then replaces the target). +""" +import argparse +import copy +import difflib +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +SET_DIR = Path(__file__).resolve().parent.parent +DESCRIPTOR = ".claude/scaffold.json" +STRATEGIES = ("seed", "whole", "json-merge", "ensure-lines", "regions") +MIGRATION_OPS = ("rename", "delete", "note") +STEP_FIELDS = {"rename": ("from", "to"), "delete": ("path", "file_id"), "note": ("text",)} +COMMENT_PREFIXES = ("#", "//") +TODO_MARK = "TODO(scaffold)" +BEGIN_RE = re.compile(r"^", re.M) +REGION_RE = re.compile( + r"^\n(?P.*?)^", + re.S | re.M, +) + + +class ScaffoldError(Exception): + pass + + +# --------------------------------------------------------------------------- files + +def sha(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def read_raw(path): + try: + with open(path, encoding="utf-8", newline="") as handle: + return handle.read() + except UnicodeDecodeError: + raise ScaffoldError(f"{path} is not UTF-8 text: convert it to UTF-8 first") + + +def normalise(text): + """Line endings and a byte-order mark never matter for comparing or hashing.""" + return text.lstrip("").replace("\r\n", "\n").replace("\r", "\n") + + +def read(path): + return normalise(read_raw(path)) if path.is_file() else None + + +def read_quiet(path): + try: + return read(path) + except (ScaffoldError, OSError): + return None + + +def eol_of(path): + raw = read_raw(path) + crlf = raw.count("\r\n") + return "\r\n" if crlf and crlf * 2 > raw.count("\n") else "\n" + + +def inside(repo, path): + try: + Path(os.path.realpath(path)).relative_to(Path(os.path.realpath(repo))) + return True + except ValueError: + return False + + +def write(repo, path, text, eol="\n"): + """Replace `path` atomically, keeping its line endings and file mode.""" + if not inside(repo, path): + raise ScaffoldError(f"refusing to write outside the repository: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".scaffold-tmp") + try: + with open(tmp, "w", encoding="utf-8", newline="") as handle: + handle.write(text.replace("\n", eol)) + if path.exists(): + shutil.copymode(path, tmp) + os.replace(tmp, path) + except BaseException: + if tmp.exists(): + tmp.unlink() + raise + + +def dump_json(obj): + return json.dumps(obj, indent=2, ensure_ascii=False) + "\n" + + +def load_json(path, what): + try: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError) as exc: + raise ScaffoldError(f"cannot read {what} ({path}): {exc}") + + +def git(repo, *args): + try: + done = subprocess.run(["git", *args], cwd=str(repo), capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError): + return None + return done.stdout.strip() if done.returncode == 0 else None + + +def git_installed(): + try: + return subprocess.run(["git", "--version"], capture_output=True, timeout=10).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def require_relative(path, where): + """Paths in the template set and in migrations must stay inside the repository.""" + parts = Path(str(path)).parts + if not str(path) or Path(str(path)).is_absolute() or ".." in parts: + raise ScaffoldError(f"{where}: '{path}' must be a relative path inside the repository") + + +# --------------------------------------------------------------------------- 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. + +_STANDALONE = re.compile(r"^[ \t]*(\{\{\s*[#/](?:if|unless)\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*\}\}") + + +def truthy(value): + return bool(value) and str(value).strip().lower() not in ("false", "0", "no") + + +def render(text, values, escape=None): + text = _STANDALONE.sub(r"\1", text) + + def cond(match): + kind, name, body = match.groups() + if name not in values: + raise ScaffoldError(f"template uses unknown variable '{name}'") + 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") + + 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() + return escape(value) if escape else value + + return _VAR.sub(var, text) + + +def json_escape(value): + return json.dumps(value, ensure_ascii=False)[1:-1] + + +# --------------------------------------------------------------------------- template set + +def load_set(set_dir): + tset = load_json(set_dir / "assets" / "template-set.json", "template set") + for key in ("id", "revision", "revisions", "variables", "files"): + if key not in tset: + raise ScaffoldError(f"template-set.json lacks '{key}'") + return tset + + +def strip_descriptions(obj): + if isinstance(obj, dict): + return {k: strip_descriptions(v) for k, v in obj.items() if k != "description"} + if isinstance(obj, list): + return [strip_descriptions(v) for v in obj] + return obj + + +def template_digest(set_dir, tset): + """Digest of everything that decides what gets written (templates, variables, files).""" + digest = hashlib.sha256() + tdir = set_dir / "assets" / "templates" + for path in sorted(p for p in tdir.rglob("*") if p.is_file() and p.name not in (".DS_Store", "Thumbs.db")): + digest.update(path.relative_to(tdir).as_posix().encode("utf-8") + b"\0") + digest.update(path.read_bytes().replace(b"\r\n", b"\n") + b"\0") + spec = strip_descriptions({"variables": tset["variables"], "files": tset["files"]}) + digest.update(json.dumps(spec, sort_keys=True).encode("utf-8")) + return digest.hexdigest() + + +def load_migrations(set_dir, path): + steps = [] + for rev in path: + where = f"migrations/{rev:04d}.json" + mfile = set_dir / "migrations" / f"{rev:04d}.json" + if not mfile.is_file(): + raise ScaffoldError(f"template set has no migration file {where}") + migration = load_json(mfile, f"migration {rev}") + if not isinstance(migration, dict) or migration.get("from") != rev - 1 or migration.get("to") != rev: + raise ScaffoldError(f"{where} must go from {rev - 1} to {rev}") + for step in migration.get("steps", []): + if not isinstance(step, dict) or step.get("op") not in MIGRATION_OPS: + raise ScaffoldError(f"{where}: unknown op '{step.get('op') if isinstance(step, dict) else step}'") + for field in STEP_FIELDS[step["op"]]: + if field not in step: + raise ScaffoldError(f"{where}: a {step['op']} step lacks '{field}'") + for field in ("from", "to", "path"): + if field in step: + require_relative(step[field], where) + steps.append(dict(step, revision=rev)) + return steps + + +# --------------------------------------------------------------------------- variables + +def version_key(name): + return (tuple(int(n) for n in re.findall(r"\d+", name)), name) + + +def infer(repo, tset, set_dir, notes): + """Variable values read from the repository itself: name -> (value, where from).""" + found = {} + + def put(name, value, where): + if isinstance(value, (str, int, float)) and str(value) != "" and name not in found: + found[name] = (str(value), where) + + manifest = None + mtext = read_quiet(repo / "manifest.json") + if mtext: + try: + manifest = json.loads(mtext) + except ValueError: + manifest = None + if isinstance(manifest, dict): + jira = manifest.get("jira") if isinstance(manifest.get("jira"), dict) else {} + put("component", manifest.get("id"), "manifest.json") + put("component_title", manifest.get("title"), "manifest.json") + put("component_description", manifest.get("description"), "manifest.json") + put("keywords", manifest.get("keywords"), "manifest.json") + put("jira_project", jira.get("roadmap"), "manifest.json") + put("jira_open_issues", jira.get("open_issues"), "manifest.json") + specs = manifest.get("specifications") if isinstance(manifest.get("specifications"), list) else [] + ids = [s["id"] for s in specs if isinstance(s, dict) and isinstance(s.get("id"), str) and s["id"]] + if ids: + put("documents", ", ".join(f"`{i}`" for i in ids), "manifest.json") + + config = read_quiet(repo / ".asciidoctorconfig") + if config: + match = re.search(r"^:component:\s*(\S+)", config, re.M) + if match: + put("component", match.group(1), ".asciidoctorconfig") + + remote = git(repo, "remote", "get-url", "origin") + if remote: + match = re.search(r"/(specifications-[\w-]+?)(?:\.git)?/?$", remote) + if match: + put("repo_name", match.group(1), "git remote") + put("component", match.group(1)[len("specifications-"):].upper(), "git remote") + + match = re.fullmatch(r"specifications-(.+)", repo.name) + if match: + put("repo_name", repo.name, "directory name") + put("component", match.group(1).upper(), "directory name") + + bmm_dir = repo / "computable" / "BMM" + bmm = sorted((p.name[: -len(".bmm.json")] for p in bmm_dir.glob("*.bmm.json")), key=version_key) if bmm_dir.is_dir() else [] + if bmm: + put("bmm_schema_id", bmm[-1], "computable/BMM") + 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") + + head = git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") + if head: + put("default_branch", head.split("/", 1)[-1], "git origin/HEAD") + + lic = read_quiet(repo / "LICENSE") + if lic is not None: + for file_spec in tset["files"]: + for key, name in (file_spec.get("templates") or {}).items(): + text = read_quiet(set_dir / "assets" / "templates" / name) + if text is not None and sha(text) == sha(lic): + put(file_spec["template_var"], key, "LICENSE text") + return found + + +def resolve_variables(tset, explicit, saved, inferred, defaulted=()): + """Merge by priority: explicit, recorded as confirmed, inferred, recorded as a default, default.""" + schema = tset["variables"] + values, sources = {}, {} + missing, invalid = [], [] + confirmed = {k: v for k, v in saved.items() if k not in defaulted} + unconfirmed = {k: v for k, v in saved.items() if k in defaulted} + pools = (("explicit", explicit), ("descriptor", confirmed), ("inferred", inferred), + ("descriptor (default)", unconfirmed)) + for name, spec in schema.items(): + if "derived" in spec: + continue + for label, pool in pools: + if name in pool: + entry = pool[name] + value, where = entry if isinstance(entry, tuple) else (entry, None) + if spec.get("required") and not str(value).strip(): + continue # an empty required value is no value + values[name] = str(value) + sources[name] = label if where is None else f"{label}: {where}" + break + else: + default = spec.get("default") + for rule in spec.get("default_if", []): + if re.search(rule["matches"], values.get(rule["var"], "")): + default = rule["value"] + if default is not None: + try: + values[name] = render(default, values) + sources[name] = "default" + except ScaffoldError: + pass # depends on a variable that is itself missing + if name not in values and spec.get("required"): + missing.append({"name": name, "description": spec.get("description", "")}) + + for name, spec in schema.items(): + derived = spec.get("derived") + if derived and derived["from"] in values: + values[name] = derived["map"].get(values[derived["from"]], values[derived["from"]]) + sources[name] = "derived" + + for name, value in values.items(): + spec = schema[name] + if spec.get("pattern") and not re.search(spec["pattern"], value): + invalid.append({"name": name, "value": value, "problem": f"does not match {spec['pattern']}"}) + if spec.get("choices") and value not in spec["choices"]: + invalid.append({"name": name, "value": value, "problem": f"must be one of {spec['choices']}"}) + return values, sources, missing, invalid + + +# --------------------------------------------------------------------------- file strategies + +class Act: + """What to do about one managed file. `content` is written when it is not None.""" + + def __init__(self, spec, action, detail=None, content=None, record=None, **extra): + self.id, self.target, self.strategy = spec["id"], spec["target"], spec["strategy"] + self.action, self.detail, self.content, self.record, self.extra = action, detail, content, record, extra + self.eol = "\n" + + def public(self): + out = {"id": self.id, "target": self.target, "strategy": self.strategy, "action": self.action} + if self.detail: + out["detail"] = self.detail + out.update({k: v for k, v in self.extra.items() if v not in (None, [], "")}) + return out + + +class Ctx: + def __init__(self, repo, set_dir, tset, values, desc, overwrite, pinned): + self.repo, self.set_dir, self.tset, self.values = repo, set_dir, tset, values + self.desc_files = (desc or {}).get("files", {}) + self.overwrite, self.pinned = set(overwrite), set(pinned) + self.warnings = [] + + +def template_name(ctx, spec): + if "templates" in spec: + return spec["templates"][ctx.values[spec["template_var"]]] + return spec["template"] + + +def rendered_for(ctx, spec): + name = template_name(ctx, spec) + text = normalise((ctx.set_dir / "assets" / "templates" / name).read_text(encoding="utf-8")) + if name.endswith(".txt"): + return text # licence texts are verbatim + escape = json_escape if spec["target"].endswith(".json") else None + out = render(text, ctx.values, escape) + return out if out.endswith("\n") else out + "\n" + + +def plan_seed(ctx, spec, rendered): + target = ctx.repo / spec["target"] + if target.is_file(): # a seed file is never changed, so it never needs decoding + detail = None + if "templates" in spec: + current = read_quiet(target) + wanted = ctx.values[spec["template_var"]] + family = None + for key, name in spec["templates"].items(): + text = read_quiet(ctx.set_dir / "assets" / "templates" / name) + if text is not None and current is not None and sha(text) == sha(current): + family = key + if current is None: + detail = "could not be read as UTF-8 text, so it was not compared with the standard texts" + elif family is None: + detail = "differs from the standard licence texts" + elif family != wanted: + detail = f"has the standard {family} text, but license is {wanted}" + else: + detail = f"matches the standard {family} text" + return Act(spec, "exists", detail) + for alt in spec.get("alternatives", []): + if (ctx.repo / alt).exists(): + return Act(spec, "exists-alternative", alt) + return Act(spec, "create", content=rendered) + + +def plan_whole(ctx, spec, rendered): + current = read(ctx.repo / spec["target"]) + new_sha = sha(rendered) + recorded = ctx.desc_files.get(spec["id"], {}).get("sha256") + if current is None: + return Act(spec, "create", content=rendered, record={"sha256": new_sha}) + if sha(current) == new_sha: + return Act(spec, "unchanged", record={"sha256": new_sha}) + if recorded == sha(current) or spec["id"] in ctx.overwrite: + return Act(spec, "update", content=rendered, record={"sha256": new_sha}) + why = "edited since it was scaffolded" if recorded else "exists and differs from the template" + return Act(spec, "conflict", why) + + +def json_items(obj, path=""): + """Ids of the leaves and list items of a JSON document: what a merge has already offered.""" + if isinstance(obj, dict): + for key, value in obj.items(): + yield from json_items(value, f"{path}.{key}" if path else key) + elif isinstance(obj, list): + for item in obj: + yield f"{path}[]={json.dumps(item, sort_keys=True, ensure_ascii=False)}" + else: + yield path + + +def merge_list(base, extra, here, added, removed, known): + for item in extra: + item_id = f"{here}[]={json.dumps(item, sort_keys=True, ensure_ascii=False)}" + if item in base: + continue + if item_id in known: + removed.append(item_id) + continue + base.append(copy.deepcopy(item)) + added.append(f"{here}[] {item if not isinstance(item, (dict, list)) else '...'}") + + +def deep_merge(base, extra, path, added, removed, known): + """Add what `extra` has and `base` lacks; existing values win, and items the user removed + after an earlier run (they are in `known` but absent) are not added back.""" + for key, value in extra.items(): + here = f"{path}.{key}" if path else key + if key in base: + if isinstance(base[key], dict) and isinstance(value, dict): + deep_merge(base[key], value, here, added, removed, known) + elif isinstance(base[key], list) and isinstance(value, list): + merge_list(base[key], value, here, added, removed, known) + elif isinstance(value, dict): + sub = {} + deep_merge(sub, value, here, added, removed, known) + if sub or not value: + base[key] = sub + if not value: + added.append(here) + elif isinstance(value, list): + sub = [] + merge_list(sub, value, here, added, removed, known) + if sub or not value: + base[key] = sub + if not value: + added.append(here) + elif here in known: + removed.append(here) + else: + base[key] = copy.deepcopy(value) + added.append(here) + + +def plan_json_merge(ctx, spec, rendered): + current = read(ctx.repo / spec["target"]) + template = json.loads(rendered) + known = set(ctx.desc_files.get(spec["id"], {}).get("items", [])) + record = {"items": sorted(known | set(json_items(template)))} + if current is None: + return Act(spec, "create", content=rendered, record=record) + try: + existing = json.loads(current) + problem = None if isinstance(existing, dict) else "its top level is not a JSON object" + except ValueError as exc: + problem = f"not valid JSON ({exc})" + if problem: + if spec["id"] in ctx.overwrite: + return Act(spec, "update", f"replaced the file ({problem}) with the template", content=rendered, record=record) + return Act(spec, "conflict", f"{problem}; fix it, or take the template with --overwrite {spec['id']}") + added, removed = [], [] + deep_merge(existing, template, "", added, removed, known) + detail = f"{len(removed)} item(s) you removed earlier are left out" if removed else None + if not added: + return Act(spec, "unchanged", detail, record=record) + detail = f"adds {len(added)}: " + "; ".join(added) + (f"; {detail}" if detail else "") + return Act(spec, "merge", detail, content=dump_json(existing), record=record, added=added) + + +def parse_groups(text): + """Blocks of consecutive non-blank lines: comment lines first, then the required lines.""" + groups, block = [], [] + for line in text.splitlines() + [""]: + if line.strip(): + block.append(line) + elif block: + comments = [l for l in block if l.startswith(COMMENT_PREFIXES)] + groups.append({"comments": comments, "lines": [l for l in block if l not in comments]}) + block = [] + return groups + + +def plan_ensure_lines(ctx, spec, rendered): + current = read(ctx.repo / spec["target"]) + key = re.compile(spec["key"]) if spec.get("key") else None + + def keyof(line): + match = key.match(line) if key else None + return match.group(1) if match else line.strip() + + groups = parse_groups(rendered) + known = set(ctx.desc_files.get(spec["id"], {}).get("items", [])) + record = {"items": sorted(known | {keyof(l) for g in groups for l in g["lines"]})} + if current is None: + return Act(spec, "create", content=rendered, record=record) + present = {} + for line in current.splitlines(): + if line.strip() and not line.startswith(COMMENT_PREFIXES): + present.setdefault(keyof(line), line.strip()) + blocks, added, removed, notes = [], [], [], [] + for group in groups: + missing = [] + for line in group["lines"]: + if keyof(line) in present: + if present[keyof(line)] != line.strip(): + notes.append(f"{spec['target']} has `{present[keyof(line)]}` where the standard has `{line.strip()}`") + elif keyof(line) in known: + removed.append(line) + else: + missing.append(line) + if missing: + blocks.append(group["comments"] + missing) + added.extend(missing) + ctx.warnings.extend(notes) + left_out = f"{len(removed)} line(s) you removed earlier are left out" if removed else None + if not added: + return Act(spec, "unchanged", left_out, record=record) + out = current if current.endswith("\n") or not current else current + "\n" + for block in blocks: + out += ("\n" if out else "") + "\n".join(block) + "\n" + detail = f"adds {len(added)} line(s): " + ", ".join(added) + (f"; {left_out}" if left_out else "") + return Act(spec, "merge", detail, content=out, record=record, added=added) + + +def parse_regions(text, where="the template"): + """Regions of a file, by id. Every begin marker must close, or a replace could swallow user text.""" + regions = {} + for match in REGION_RE.finditer(text): + if match.group("id") in regions: + raise ScaffoldError(f"{where}: region '{match.group('id')}' appears twice") + regions[match.group("id")] = match.group("body") + begins = BEGIN_RE.findall(text) + if len(begins) != len(regions): + unclosed = [b for b in begins if b not in regions] or begins + raise ScaffoldError(f"{where}: scaffold markers do not pair up (no matching end marker for " + f"{', '.join(unclosed)}); fix the markers by hand and run again") + return regions + + +def wrap_region(rid, body): + body = body if body.endswith("\n") else body + "\n" + return f"\n{body}" + + +def plan_regions(ctx, spec, rendered): + current = read(ctx.repo / spec["target"]) + template = parse_regions(rendered) + recorded = ctx.desc_files.get(spec["id"], {}).get("regions", {}) + if current is None: + return Act(spec, "create", content=rendered, + record={"regions": {rid: sha(body.strip()) for rid, body in template.items()}}) + existing = parse_regions(current, spec["target"]) + if not existing: + return Act(spec, "upgrade-manual", + "exists without scaffold regions; compare it with `render --file " + f"{spec['id']}` and merge by hand, or pin it") + text, new_record, results = current, dict(recorded), [] + for rid, body in template.items(): + tsha, here = sha(body.strip()), existing.get(rid) + key = f"{spec['id']}:{rid}" + forced = spec["id"] in ctx.overwrite or key in ctx.overwrite + if here is None: + if key in ctx.pinned: + results.append({"region": rid, "action": "pinned"}) + elif rid in recorded and not forced: + results.append({"region": rid, "action": "removed"}) + else: + text = text.rstrip("\n") + "\n\n" + wrap_region(rid, body) + "\n" + new_record[rid] = tsha + results.append({"region": rid, "action": "add"}) + elif sha(here.strip()) == tsha: + new_record[rid] = tsha + results.append({"region": rid, "action": "unchanged"}) + elif key in ctx.pinned: + results.append({"region": rid, "action": "pinned"}) + elif recorded.get(rid) == sha(here.strip()) or forced: + text = REGION_RE.sub(lambda m, r=rid, b=body: wrap_region(r, b) if m.group("id") == r else m.group(0), text) + new_record[rid] = tsha + results.append({"region": rid, "action": "update"}) + else: + results.append({"region": rid, "action": "conflict"}) + actions = {r["action"] for r in results} + overall = ("conflict" if "conflict" in actions + else "update" if actions & {"add", "update"} + else "unchanged") + return Act(spec, overall, content=text if text != current else None, + record={"regions": new_record}, regions=results) + + +STRATEGY_FUNCS = { + "seed": plan_seed, + "whole": plan_whole, + "json-merge": plan_json_merge, + "ensure-lines": plan_ensure_lines, + "regions": plan_regions, +} + + +def plan_file(ctx, spec): + if spec["id"] in ctx.pinned: + return Act(spec, "pinned") + target = ctx.repo / spec["target"] + if target.is_symlink(): + return Act(spec, "conflict", "is a symbolic link; scaffold never writes through links") + act = STRATEGY_FUNCS[spec["strategy"]](ctx, spec, rendered_for(ctx, spec)) + if act.content is not None and target.is_file(): + act.eol = eol_of(target) + return act + + +# --------------------------------------------------------------------------- plan / apply + +class Plan: + pass + + +def validate_descriptor(desc): + if not isinstance(desc, dict) or not isinstance(desc.get("revision"), int) or desc["revision"] < 1: + raise ScaffoldError(f"{DESCRIPTOR} is malformed: 'revision' must be a positive integer") + for key in ("variables", "files"): + if not isinstance(desc.setdefault(key, {}), dict): + raise ScaffoldError(f"{DESCRIPTOR} is malformed: '{key}' must be an object") + if not all(isinstance(rec, dict) for rec in desc["files"].values()): + raise ScaffoldError(f"{DESCRIPTOR} is malformed: every entry of 'files' must be an object") + for key in ("pinned", "defaulted"): + if not isinstance(desc.setdefault(key, []), list): + raise ScaffoldError(f"{DESCRIPTOR} is malformed: '{key}' must be a list") + + +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]] + if unknown: + known = [k for k, v in tset["variables"].items() if "derived" not in v] + raise ScaffoldError(f"unknown variable(s) {', '.join(unknown)}; known: {', '.join(known)}") + desc_path = repo / DESCRIPTOR + desc = load_json(desc_path, "scaffold descriptor") if desc_path.is_file() else None + latest = tset["revision"] + if desc is not None: + validate_descriptor(desc) + if desc.get("template_set") != tset["id"]: + raise ScaffoldError(f"{DESCRIPTOR} belongs to template set '{desc.get('template_set')}', not '{tset['id']}'") + if desc["revision"] > latest: + raise ScaffoldError(f"{DESCRIPTOR} is revision {desc['revision']}, newer than this plugin's {latest}: update the openehr-specs plugin") + recorded = desc["revision"] + mode = "current" if recorded == latest else "upgrade" + path = list(range(recorded + 1, latest + 1)) + else: + recorded, path = None, [] + mode = "upgrade" if any((repo / f["target"]).exists() or (repo / f["target"]).is_symlink() + for f in tset["files"]) else "init" + + plan = Plan() + plan.repo, plan.set_dir, plan.tset, plan.desc = repo, set_dir, tset, desc + plan.mode, plan.recorded, plan.latest, plan.path = mode, recorded, latest, path + plan.steps = load_migrations(set_dir, path) + plan.pinned = set((desc or {}).get("pinned", [])) | set(pin) + + plan.notes = [] + plan.values, plan.sources, plan.missing, plan.invalid = resolve_variables( + tset, explicit, (desc or {}).get("variables", {}), infer(repo, tset, set_dir, plan.notes), + (desc or {}).get("defaulted", [])) + plan.acts, plan.warnings = [], list(plan.notes) + if not plan.missing and not plan.invalid: + for spec in tset["files"]: + require_relative(spec["target"], f"file '{spec['id']}' target") + ctx = Ctx(repo, set_dir, tset, plan.values, desc, overwrite, plan.pinned) + valid = {spec["id"] for spec in tset["files"]} + for spec in tset["files"]: + if spec["strategy"] == "regions": + valid |= {f"{spec['id']}:{rid}" for rid in parse_regions(rendered_for(ctx, spec))} + wrong = [x for x in list(overwrite) + list(pin) if x not in valid] + if wrong: + raise ScaffoldError(f"unknown id(s) {', '.join(wrong)} for --overwrite/--pin; valid: {', '.join(sorted(valid))}") + plan.acts = [plan_file(ctx, spec) for spec in tset["files"]] + plan.warnings = plan.notes + ctx.warnings + return plan + + +def todo_markers(repo, tset): + found = [] + for spec in tset["files"]: + text = read_quiet(repo / spec["target"]) + for number, line in enumerate((text or "").splitlines(), 1): + if TODO_MARK in line: + found.append({"file": spec["target"], "line": number, "text": line.strip()[:160]}) + return found + + +def public(plan): + repo = plan.repo + warnings = list(plan.warnings) + installed = git_installed() + top = git(repo, "rev-parse", "--show-toplevel") if installed else None + inside_repo = top is not None and Path(top).resolve() == repo.resolve() + if not installed: + warnings.append("git is not installed: install it before this directory can become a repository") + elif top is None: + warnings.append("not a git repository: run `git init -b ` first if this is a new repository") + elif not inside_repo: + warnings.append(f"{repo} is inside the git repository at {top}: run from the repository root") + elif git(repo, "status", "--porcelain"): + warnings.append("the working tree has uncommitted changes: review the result with `git diff`") + return { + "status": "needs_input" if (plan.missing or plan.invalid) else "ok", + "repo": str(repo), + "mode": plan.mode, + "template_set": plan.tset["id"], + "recorded_revision": plan.recorded, + "latest_revision": plan.latest, + "migration_path": plan.path, + "variables": {k: {"value": v, "source": plan.sources.get(k)} for k, v in plan.values.items()}, + "missing_variables": plan.missing, + "invalid_variables": plan.invalid, + "files": [a.public() for a in plan.acts], + "migration_steps": plan.steps, + "warnings": warnings, + "todo_markers": todo_markers(repo, plan.tset), + "git_repository": inside_repo, + } + + +def run_migration_steps(plan): + repo, files = plan.repo, (plan.desc or {}).get("files", {}) + results = [] + for step in plan.steps: + op, entry = step["op"], dict(step) + fid = step.get("file_id") + if op == "note": + entry["result"] = "manual: see note" + elif fid and fid in plan.pinned: + entry["result"] = "skipped: that file is pinned" + elif op == "rename": + src, dst = repo / step["from"], repo / step["to"] + if not src.exists(): + entry["result"] = "skipped: source is missing" + elif dst.exists(): + entry["result"] = "skipped: target exists" + elif not (inside(repo, src) and inside(repo, dst)): + entry["result"] = "skipped: outside the repository" + else: + dst.parent.mkdir(parents=True, exist_ok=True) + src.rename(dst) + entry["result"] = "done" + else: # delete + path = repo / step["path"] + recorded = files.get(fid, {}).get("sha256") + current = read_quiet(path) + if not path.exists(): + entry["result"] = "skipped: already gone" + elif path.is_symlink() or not inside(repo, path): + entry["result"] = "skipped: a link or outside the repository" + elif recorded and current is not None and sha(current) == recorded: + path.unlink() + entry["result"] = "done" + else: + entry["result"] = "skipped: modified or not recorded as scaffolded, delete by hand if wanted" + results.append(entry) + return results + + +def apply_plan(repo, set_dir, explicit, overwrite, pin): + first = build_plan(repo, set_dir, explicit, overwrite, pin) + if first.missing or first.invalid: + return public(first) + migration_results = run_migration_steps(first) if first.steps else [] + plan = build_plan(repo, set_dir, explicit, overwrite, pin) if first.steps else first + plan.steps = migration_results or plan.steps + + written = [] + for act in plan.acts: + if act.content is not None: + write(repo, repo / act.target, act.content, act.eol) + written.append(act.target) + + files = copy.deepcopy((plan.desc or {}).get("files", {})) + for act in plan.acts: + if act.record: + merged = files.setdefault(act.id, {}) + for key, value in act.record.items(): + if isinstance(value, dict): + merged.setdefault(key, {}).update(value) + else: + merged[key] = value + desc = { + "template_set": plan.tset["id"], + "revision": plan.latest, + "variables": {k: v for k, v in plan.values.items() if "derived" not in plan.tset["variables"][k]}, + "defaulted": sorted(k for k, s in plan.sources.items() if s in ("default", "descriptor (default)")), + "files": files, + } + if plan.pinned: + desc["pinned"] = sorted(plan.pinned) + write(repo, repo / DESCRIPTOR, dump_json(desc)) + + out = public(plan) + out["applied"] = True + out["written"] = written + [DESCRIPTOR] + return out + + +def diff_file(repo, set_dir, explicit, file_id): + """Unified diff of the repo's file against what the template would write.""" + plan = build_plan(repo, set_dir, explicit) + if plan.missing or plan.invalid: + raise ScaffoldError(f"variables missing or invalid: {plan.missing or plan.invalid}") + spec = next((f for f in plan.tset["files"] if f["id"] == file_id), None) + if spec is None: + raise ScaffoldError(f"unknown file id '{file_id}'") + rendered = rendered_for(Ctx(repo, set_dir, plan.tset, plan.values, plan.desc, (), ()), spec) + current = read(repo / spec["target"]) + if current is None: + return f"{spec['target']} does not exist yet\n" + pairs = [(spec["target"], current, rendered)] + if spec["strategy"] == "regions" and parse_regions(current, spec["target"]): + here = parse_regions(current, spec["target"]) + pairs = [(f"{spec['target']} [region {rid}]", here[rid], body) + for rid, body in parse_regions(rendered).items() + if rid in here and here[rid].strip() != body.strip()] + out = "".join( + "".join(difflib.unified_diff(a.splitlines(True), b.splitlines(True), + fromfile=f"{label} (repo)", tofile=f"{label} (template)")) + for label, a, b in pairs) + return out or "no differences\n" + + +# --------------------------------------------------------------------------- check / seal + +def sample_values(tset, bmm): + explicit = {} + for name, spec in tset["variables"].items(): + 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 "" + return resolve_variables(tset, explicit, {}, {}) + + +def check_template_set(set_dir): + """Problems with the template set itself (an empty list means it is sound).""" + try: + return _check_template_set(set_dir) + except Exception as exc: # a malformed set must be reported, not crash the checker + return [f"the template set could not be checked: {type(exc).__name__}: {exc}"] + + +def _check_template_set(set_dir): + problems = [] + try: + tset = load_set(set_dir) + except ScaffoldError as exc: + return [str(exc)] + revisions = [r.get("revision") for r in tset["revisions"]] + if not revisions: + return ["revisions is empty: the first revision needs an entry"] + if revisions != list(range(1, len(revisions) + 1)): + problems.append(f"revisions must run 1..N without gaps, found {revisions}") + elif tset["revision"] != revisions[-1]: + problems.append(f"revision {tset['revision']} is not the last entry of revisions ({revisions[-1]})") + for rev in range(2, revisions[-1] + 1): + try: + load_migrations(set_dir, [rev]) + except ScaffoldError as exc: + problems.append(str(exc)) + if tset["revisions"][-1].get("digest") != template_digest(set_dir, tset): + problems.append("the templates or the variables/files changed without a new revision: " + "add a revision and a migration file, then run `scaffold.py seal`") + + ids = [f.get("id") for f in tset["files"]] + if len(ids) != len(set(ids)): + problems.append("file ids must be unique") + for spec in tset["files"]: + label = spec.get("id", "?") + try: + require_relative(spec.get("target", ""), f"file '{label}' target") + except ScaffoldError as exc: + problems.append(str(exc)) + if spec.get("strategy") not in STRATEGIES: + problems.append(f"file '{label}': unknown strategy '{spec.get('strategy')}'") + 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(): + problems.append(f"file '{label}': template '{name}' is missing") + for name, spec in tset["variables"].items(): + derived = spec.get("derived") + if derived and derived["from"] not in tset["variables"]: + problems.append(f"variable '{name}' derives from unknown '{derived['from']}'") + if not derived and "default" not in spec and not spec.get("required"): + problems.append(f"variable '{name}' needs a default or required: true") + + for bmm in (False, True): + values, _, missing, invalid = sample_values(tset, bmm) + if missing or invalid: + problems.append(f"sample variables do not resolve: {missing or invalid}") + continue + ctx = Ctx(Path("/nonexistent"), set_dir, tset, values, None, (), ()) + for spec in tset["files"]: + names = list((spec.get("templates") or {}).values()) or [spec.get("template")] + if not all(n and (set_dir / "assets" / "templates" / n).is_file() for n in names): + continue # already reported as a missing template + try: + text = rendered_for(ctx, spec) + if spec["target"].endswith(".json"): + json.loads(text) + if spec["strategy"] == "regions": + parse_regions(text) + except (ScaffoldError, ValueError, KeyError) as exc: + problems.append(f"file '{spec['id']}' (bmm={bmm}): {exc}") + return problems + + +def seal(set_dir): + path = set_dir / "assets" / "template-set.json" + tset = load_set(set_dir) + if not tset["revisions"]: + raise ScaffoldError("revisions is empty: add the first revision before sealing") + tset["revisions"][-1]["digest"] = template_digest(set_dir, tset) + path.write_text(dump_json(tset), encoding="utf-8") + return {"sealed": tset["revisions"][-1]["revision"], "digest": tset["revisions"][-1]["digest"]} + + +# --------------------------------------------------------------------------- CLI + +def parse_vars(pairs): + out = {} + for pair in pairs or []: + if "=" not in pair: + raise ScaffoldError(f"--var expects NAME=VALUE, got '{pair}'") + name, value = pair.split("=", 1) + out[name.strip()] = value + return out + + +def main(argv=None): + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--set-dir", default=str(SET_DIR), help=argparse.SUPPRESS) + sub = parser.add_subparsers(dest="cmd", required=True) + for name in ("plan", "apply", "render", "diff"): + p = sub.add_parser(name) + p.add_argument("--repo", required=True) + p.add_argument("--var", action="append", default=[]) + if name in ("plan", "apply"): + p.add_argument("--overwrite", action="append", default=[], help="file id or file:region to take from the template") + p.add_argument("--pin", action="append", default=[], help="file id or file:region to leave alone from now on") + if name in ("render", "diff"): + p.add_argument("--file", required=True) + sub.add_parser("check") + sub.add_parser("seal") + args = parser.parse_args(argv) + set_dir = Path(args.set_dir) + + try: + if args.cmd == "check": + problems = check_template_set(set_dir) + print(json.dumps({"ok": not problems, "problems": problems}, indent=2)) + return 1 if problems else 0 + if args.cmd == "seal": + print(json.dumps(seal(set_dir), indent=2)) + return 0 + repo = Path(args.repo).resolve() + if not repo.is_dir(): + raise ScaffoldError(f"{repo} is not a directory") + explicit = parse_vars(args.var) + if args.cmd == "plan": + print(json.dumps(public(build_plan(repo, set_dir, explicit, args.overwrite, args.pin)), indent=2, ensure_ascii=False)) + elif args.cmd == "apply": + print(json.dumps(apply_plan(repo, set_dir, explicit, args.overwrite, args.pin), indent=2, ensure_ascii=False)) + elif args.cmd == "diff": + sys.stdout.write(diff_file(repo, set_dir, explicit, args.file)) + else: + plan = build_plan(repo, set_dir, explicit) + if plan.missing or plan.invalid: + raise ScaffoldError(f"variables missing or invalid: {plan.missing or plan.invalid}") + spec = next((f for f in plan.tset["files"] if f["id"] == args.file), None) + if spec is None: + raise ScaffoldError(f"unknown file id '{args.file}'") + sys.stdout.write(rendered_for(Ctx(repo, set_dir, plan.tset, plan.values, plan.desc, (), ()), spec)) + return 0 + except ScaffoldError as exc: + print(json.dumps({"error": str(exc)})) + return 2 + except Exception as exc: # the contract is JSON on stdout, never a traceback + print(json.dumps({"error": f"unexpected {type(exc).__name__}: {exc}"})) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_scaffold.py b/scripts/test_scaffold.py new file mode 100644 index 0000000..144256d --- /dev/null +++ b/scripts/test_scaffold.py @@ -0,0 +1,1010 @@ +#!/usr/bin/env python3 +"""Tests for the scaffold skill's script (plugins/openehr-specs/skills/scaffold/scripts/scaffold.py). + +Run from the repo root: python3 -m unittest discover -s scripts -p 'test_*.py' +Standard library only; every test works in a temporary directory. +""" +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "plugins/openehr-specs/skills/scaffold/scripts/scaffold.py" +_spec = importlib.util.spec_from_file_location("scaffold", SCRIPT) +scaffold = importlib.util.module_from_spec(_spec) +sys.modules["scaffold"] = scaffold +_spec.loader.exec_module(scaffold) + +TITLE = {"component_title": "Demo Model"} + + +class Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + # no stray GIT_* variable and no repository above the temporary directory may leak in + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env["GIT_CEILING_DIRECTORIES"] = str(self.root.parent) + patcher = mock.patch.dict(os.environ, env, clear=True) + patcher.start() + self.addCleanup(patcher.stop) + self.repo = self.root / "specifications-DEMO" + self.repo.mkdir() + self.set_dir = scaffold.SET_DIR + + def plan(self, repo=None, overwrite=(), pin=(), **variables): + return scaffold.build_plan(repo or self.repo, self.set_dir, dict(TITLE, **variables), overwrite, pin) + + def actions(self, plan): + return {a.target: a.action for a in plan.acts} + + def apply(self, repo=None, overwrite=(), pin=(), **variables): + return scaffold.apply_plan(repo or self.repo, self.set_dir, dict(TITLE, **variables), overwrite, pin) + + def text(self, name): + return (self.repo / name).read_text(encoding="utf-8") + + def put(self, name, text): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def copy_set(self): + dst = self.root / f"set{len(list(self.root.glob('set*')))}" + shutil.copytree(scaffold.SET_DIR, dst, ignore=shutil.ignore_patterns("__pycache__")) + return dst + + def bump(self, dst, steps=None, edit=None): + """Add the next revision (with its migration file) to a copied template set.""" + path = dst / "assets" / "template-set.json" + tset = json.loads(path.read_text(encoding="utf-8")) + rev = tset["revision"] + 1 + tset["revision"] = rev + tset["revisions"].append({"revision": rev, "released": "2027-01-01", "summary": "test", "digest": ""}) + path.write_text(json.dumps(tset, indent=2), encoding="utf-8") + (dst / "migrations").mkdir(exist_ok=True) + (dst / "migrations" / f"{rev:04d}.json").write_text( + json.dumps({"from": rev - 1, "to": rev, "steps": steps or []}), encoding="utf-8") + if edit: + edit(dst) + scaffold.seal(dst) + return rev + + +class EngineTests(unittest.TestCase): + def test_conditionals_and_standalone_tag_lines(self): + text = "a\n{{#if x}}\nb\n{{/if}}\nc\n{{#unless x}}\nd\n{{/unless}}\ne\n" + self.assertEqual(scaffold.render(text, {"x": "1"}), "a\nb\nc\ne\n") + self.assertEqual(scaffold.render(text, {"x": ""}), "a\nc\nd\ne\n") + + def test_inline_conditional_keeps_the_line(self): + self.assertEqual(scaffold.render("v{{#if x}} + m{{/if}}.", {"x": "yes"}), "v + m.") + self.assertEqual(scaffold.render("v{{#if x}} + m{{/if}}.", {"x": ""}), "v.") + + def test_false_like_values_are_falsy(self): + for value in ("", "false", "0", "no"): + self.assertEqual(scaffold.render("{{#if x}}y{{/if}}", {"x": value}), "") + + def test_filters(self): + self.assertEqual(scaffold.render("{{a|lower}} {{a|upper}}", {"a": "Base Model"}), "base model BASE MODEL") + + def test_unknown_variable_is_an_error(self): + with self.assertRaises(scaffold.ScaffoldError): + scaffold.render("{{nope}}", {}) + with self.assertRaises(scaffold.ScaffoldError): + scaffold.render("{{#if nope}}x{{/if}}", {}) + + def test_nested_blocks_are_rejected(self): + with self.assertRaises(scaffold.ScaffoldError): + scaffold.render("{{#if a}}{{#if b}}x{{/if}}{{/if}}", {"a": "1", "b": "1"}) + + def test_json_escape(self): + out = scaffold.render('{"t": "{{t}}"}', {"t": 'Say "hi"\\'}, scaffold.json_escape) + self.assertEqual(json.loads(out), {"t": 'Say "hi"\\'}) + + +class InitTests(Base): + def test_init_creates_the_standard_set_and_the_second_run_is_a_noop(self): + result = self.apply() + self.assertTrue(result["applied"]) + for name in (".claude/scaffold.json", "AGENTS.md", ".claude/CLAUDE.md", ".claude/settings.json", + "manifest.json", ".gitignore", ".asciidoctorconfig", "LICENSE", "README.md"): + self.assertTrue((self.repo / name).is_file(), name) + self.assertEqual(self.plan().mode, "current") + self.assertTrue(all(a in ("unchanged", "exists") for a in self.actions(self.plan()).values())) + self.assertEqual(json.loads(self.text("manifest.json"))["id"], "DEMO") + self.assertIn(":component: DEMO", self.text(".asciidoctorconfig")) + self.assertIn("Changes for SPECDEMO-", self.text("AGENTS.md")) + + def test_descriptor_records_revision_variables_and_hashes(self): + self.apply() + desc = json.loads(self.text(".claude/scaffold.json")) + self.assertEqual(list(desc)[:2], ["template_set", "revision"]) + self.assertEqual(desc["revision"], 1) + self.assertEqual(desc["variables"]["component"], "DEMO") + self.assertNotIn("license_name", desc["variables"]) # derived, not stored + self.assertEqual(set(desc["files"]["agents"]["regions"]), {"plugin", "build", "conventions"}) + self.assertIn("sha256", desc["files"]["claude-md"]) + + def test_bmm_switches_on_the_class_generation_sections(self): + self.apply(bmm_schema_id="openehr_demo_1.0.0") + agents = self.text("AGENTS.md") + self.assertIn("computable/BMM/openehr_demo_1.0.0.bmm.json", agents) + self.assertIn("openehr-specs:class-generation", agents) + self.assertIn("## Gotchas", agents) + self.assertIn("and BMM sources", self.text(".claude/CLAUDE.md")) + + def test_without_bmm_those_sections_are_absent(self): + self.apply() + agents = self.text("AGENTS.md") + for needle in ("class-generation", "## Gotchas", "bmm-publisher", "BMM"): + self.assertNotIn(needle, agents) + + def test_missing_required_variables_stop_the_plan(self): + plan = scaffold.build_plan(self.repo, self.set_dir, {}) + self.assertEqual([m["name"] for m in plan.missing], ["component_title"]) + self.assertEqual(plan.acts, []) + self.assertEqual(scaffold.public(plan)["status"], "needs_input") + + def test_invalid_values_are_reported(self): + plan = self.plan(jira_project="jira-ish") + self.assertEqual([i["name"] for i in plan.invalid], ["jira_project"]) + + def test_hyphenated_component_cannot_default_its_jira_key(self): + repo = self.root / "specifications-ITS-REST" + repo.mkdir() + plan = self.plan(repo) + self.assertEqual(plan.values["component"], "ITS-REST") + self.assertEqual([i["name"] for i in plan.invalid], ["jira_project"]) # SPECITS-REST is wrong: ask + self.assertEqual(self.plan(repo, jira_project="SPECITS").invalid, []) + + def test_its_components_default_to_apache(self): + repo = self.root / "specifications-ITS-JSON" + repo.mkdir() + self.assertEqual(self.plan(repo, jira_project="SPECITS").values["license"], "apache-2.0") + self.assertEqual(self.plan().values["license"], "cc-by-sa-3.0") + + def test_quotes_in_the_title_do_not_break_manifest_json(self): + self.apply(component_title='The "Demo" Model') + self.assertEqual(json.loads(self.text("manifest.json"))["title"], 'The "Demo" Model') + + def test_explicit_beats_descriptor_beats_inferred(self): + self.apply(default_branch="main") + self.assertEqual(self.plan().values["default_branch"], "main") # recorded + self.assertEqual(self.plan(default_branch="trunk").values["default_branch"], "trunk") # explicit + + +class InferTests(Base): + def test_variables_come_from_an_existing_manifest(self): + self.put("manifest.json", json.dumps({ + "id": "DEMO", "title": "Demo Model", "description": "d", "keywords": "k", + "jira": {"roadmap": "SPECDEMO", "open_issues": "11999"}, + "specifications": [{"id": "alpha"}, {"id": "beta"}]})) + plan = scaffold.build_plan(self.repo, self.set_dir, {}) + self.assertEqual(plan.missing, []) + self.assertEqual(plan.values["jira_open_issues"], "11999") + self.assertEqual(plan.values["documents"], "`alpha`, `beta`") + self.assertTrue(plan.sources["component_title"].startswith("inferred")) + + def test_bmm_schema_is_found(self): + self.put("computable/BMM/openehr_demo_1.2.0.bmm.json", "{}") + self.assertEqual(self.plan().values["bmm_schema_id"], "openehr_demo_1.2.0") + + def test_licence_family_is_recognised_from_the_text(self): + text = (self.set_dir / "assets/templates/license-apache-2.0.txt").read_text(encoding="utf-8") + self.put("LICENSE", text) + self.assertEqual(self.plan().values["license"], "apache-2.0") + + def test_component_from_git_remote_when_the_directory_name_says_nothing(self): + repo = self.root / "checkout" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", + "https://github.com/openEHR/specifications-QUERY.git"], check=True) + plan = self.plan(repo) + self.assertEqual(plan.values["component"], "QUERY") + self.assertEqual(plan.values["repo_name"], "specifications-QUERY") + + def test_component_from_asciidoctorconfig(self): + repo = self.root / "somewhere" + repo.mkdir() + (repo / ".asciidoctorconfig").write_text(":component: PROC\n", encoding="utf-8") + self.assertEqual(self.plan(repo).values["component"], "PROC") + + +class StrategyTests(Base): + def test_seed_files_are_never_overwritten(self): + self.put("README.md", "mine\n") + self.put("manifest.json", '{"id": "DEMO", "custom": true}\n') + self.apply() + self.assertEqual(self.text("README.md"), "mine\n") + 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") + plan = self.plan() + self.assertEqual(self.actions(plan)["README.md"], "exists-alternative") + self.apply() + self.assertFalse((self.repo / "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) + detail = {a.target: a.detail for a in self.plan().acts}["LICENSE"] + self.assertIn("matches the standard cc-by-sa-3.0", detail) + self.put("LICENSE", standard + "\nextra\n") + detail = {a.target: a.detail for a in self.plan().acts}["LICENSE"] + self.assertIn("differs", detail) + + def test_json_merge_adds_missing_keys_and_keeps_existing_values(self): + self.put(".claude/settings.json", json.dumps({ + "permissions": {"allow": ["Bash(make *)"], "deny": ["Bash(rm *)"]}, + "enabledPlugins": {"superpowers@claude-plugins-official": False}})) + plan = self.plan() + self.assertEqual(self.actions(plan)[".claude/settings.json"], "merge") + self.apply() + merged = json.loads(self.text(".claude/settings.json")) + self.assertFalse(merged["enabledPlugins"]["superpowers@claude-plugins-official"]) # existing value wins + self.assertTrue(merged["enabledPlugins"]["openehr-specs@openehr"]) + self.assertIn("Bash(make *)", merged["permissions"]["allow"]) + self.assertIn("WebFetch(domain:specifications.openehr.org)", merged["permissions"]["allow"]) + self.assertEqual(merged["permissions"]["deny"], ["Bash(rm *)"]) + self.assertEqual(merged["extraKnownMarketplaces"]["openehr"]["source"]["repo"], "openEHR/ai-plugins") + self.assertEqual(self.actions(self.plan())[".claude/settings.json"], "unchanged") + + def test_json_merge_reports_invalid_json_instead_of_overwriting(self): + self.put(".claude/settings.json", "{ not json") + self.assertEqual(self.actions(self.plan())[".claude/settings.json"], "conflict") + self.apply() + self.assertEqual(self.text(".claude/settings.json"), "{ not json") + + def test_ensure_lines_appends_only_what_is_missing(self): + self.put(".gitignore", "*.bak\n\nbuild/\n") + plan = self.plan() + self.assertEqual(self.actions(plan)[".gitignore"], "merge") + self.apply() + lines = self.text(".gitignore").splitlines() + self.assertEqual(lines.count("*.bak"), 1) + self.assertIn("build/", lines) + self.assertIn(".claude/settings.local.json", lines) + self.assertIn("# Git worktrees (isolated feature checkouts)", lines) + self.assertEqual(self.actions(self.plan())[".gitignore"], "unchanged") + + def test_ensure_lines_by_key_does_not_duplicate_a_differing_attribute(self): + self.put(".asciidoctorconfig", ":component: OTHER\n\n:diagrams_uri: ./diagrams") + self.assertEqual(self.plan().values["component"], "OTHER") # inferred from the file itself + plan = self.plan(component="DEMO") # an explicit value disagrees with the file + self.assertEqual(self.actions(plan)[".asciidoctorconfig"], "merge") + self.assertTrue(any("OTHER" in w for w in plan.warnings)) + self.apply(component="DEMO") + text = self.text(".asciidoctorconfig") + self.assertEqual(text.count(":component:"), 1) + self.assertIn(":imagesdir: ../", text) + self.assertIn(":diagrams_uri: ./diagrams", text) + + def test_whole_file_edited_locally_is_a_conflict_until_overwritten(self): + self.apply() + self.put(".claude/CLAUDE.md", "my own\n") + self.assertEqual(self.actions(self.plan())[".claude/CLAUDE.md"], "conflict") + self.apply() + self.assertEqual(self.text(".claude/CLAUDE.md"), "my own\n") + self.apply(overwrite=["claude-md"]) + self.assertIn("@../AGENTS.md", self.text(".claude/CLAUDE.md")) + + def test_pinned_files_are_left_alone_and_the_pin_is_remembered(self): + self.apply() + self.put(".claude/CLAUDE.md", "my own\n") + self.apply(pin=["claude-md"]) + self.assertEqual(self.actions(self.plan())[".claude/CLAUDE.md"], "pinned") + self.assertEqual(json.loads(self.text(".claude/scaffold.json"))["pinned"], ["claude-md"]) + + +class RegionTests(Base): + def region_actions(self, plan): + agents = next(a for a in plan.acts if a.id == "agents") + return {r["region"]: r["action"] for r in agents.extra.get("regions", [])} + + def test_text_outside_regions_is_never_touched(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("# AGENTS.md", "# AGENTS.md\n\nMy intro.")) + plan = self.plan() + self.assertEqual(self.actions(plan)["AGENTS.md"], "unchanged") + self.apply() + self.assertIn("My intro.", self.text("AGENTS.md")) + + def test_an_edited_region_is_a_conflict_and_is_kept(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("understood, but use the leading form", "tolerated")) + plan = self.plan() + self.assertEqual(self.region_actions(plan)["conventions"], "conflict") + self.assertEqual(self.actions(plan)["AGENTS.md"], "conflict") + self.apply() + self.assertIn("tolerated", self.text("AGENTS.md")) + self.apply(overwrite=["agents:conventions"]) + self.assertNotIn("tolerated", self.text("AGENTS.md")) + + def test_a_region_pinned_by_id_stays_quiet(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("understood, but use the leading form", "tolerated")) + self.assertEqual(self.region_actions(self.plan(pin=["agents:conventions"]))["conventions"], "pinned") + + def test_a_region_the_user_deleted_is_not_added_back(self): + self.apply() + text = self.text("AGENTS.md") + start = text.index("") + end = text.index("") + len("") + self.put("AGENTS.md", text[:start] + text[end:]) + self.assertEqual(self.region_actions(self.plan())["build"], "removed") + + def test_a_region_that_was_never_recorded_is_added(self): + self.apply() + text = self.text("AGENTS.md") + start = text.index("") + end = text.index("") + len("") + self.put("AGENTS.md", text[:start] + text[end:]) + desc = json.loads(self.text(".claude/scaffold.json")) + del desc["files"]["agents"]["regions"]["build"] + self.put(".claude/scaffold.json", json.dumps(desc)) + self.assertEqual(self.region_actions(self.plan())["build"], "add") + self.apply() + self.assertIn("", self.text("AGENTS.md")) + + def test_an_agents_file_without_markers_is_left_for_a_manual_merge(self): + self.put("AGENTS.md", "# AGENTS.md\n\nhand written\n") + plan = self.plan() + self.assertEqual(self.actions(plan)["AGENTS.md"], "upgrade-manual") + self.apply() + self.assertEqual(self.text("AGENTS.md"), "# AGENTS.md\n\nhand written\n") + self.assertEqual(scaffold.public(self.plan(pin=["agents"]))["files"][0]["action"], "pinned") + + +class UpgradeTests(Base): + def edit_conventions(self, dst): + path = dst / "assets/templates/agents-md.tmpl" + path.write_text(path.read_text(encoding="utf-8").replace("one line, for example", "a single line, for example"), + encoding="utf-8") + + def test_a_template_change_updates_untouched_regions_and_reports_edited_ones(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("# AGENTS.md", "# AGENTS.md\n\nMy intro.")) + new_set = self.copy_set() + self.bump(new_set, edit=self.edit_conventions) + self.set_dir = new_set + plan = self.plan() + self.assertEqual((plan.mode, plan.path), ("upgrade", [2])) + self.assertEqual(self.actions(plan)["AGENTS.md"], "update") + self.apply() + agents = self.text("AGENTS.md") + self.assertIn("a single line, for example", agents) + self.assertIn("My intro.", agents) + self.assertEqual(json.loads(self.text(".claude/scaffold.json"))["revision"], 2) + self.assertEqual(self.plan().mode, "current") + + def test_a_template_change_conflicts_with_a_locally_edited_region(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("understood, but use the leading form", "tolerated")) + new_set = self.copy_set() + self.bump(new_set, edit=self.edit_conventions) + self.set_dir = new_set + agents = next(a for a in self.plan().acts if a.id == "agents") + self.assertEqual(agents.action, "conflict") + self.apply() + self.assertIn("tolerated", self.text("AGENTS.md")) # kept + self.assertEqual(self.plan().recorded, 2) + + def test_the_path_chains_every_revision_between_recorded_and_latest(self): + self.apply() + new_set = self.copy_set() + self.bump(new_set, steps=[{"op": "note", "text": "two"}]) + self.bump(new_set, steps=[{"op": "note", "text": "three"}]) + self.set_dir = new_set + plan = self.plan() + self.assertEqual(plan.path, [2, 3]) + self.assertEqual([(s["revision"], s["text"]) for s in plan.steps], [(2, "two"), (3, "three")]) + + def test_rename_moves_the_file_and_keeps_its_recorded_hash(self): + self.apply() + new_set = self.copy_set() + + def move_target(dst): + path = dst / "assets/template-set.json" + tset = json.loads(path.read_text(encoding="utf-8")) + for spec in tset["files"]: + if spec["id"] == "claude-md": + spec["target"] = ".claude/CLAUDE-OLD.md" + path.write_text(json.dumps(tset, indent=2), encoding="utf-8") + + self.bump(new_set, steps=[{"op": "rename", "from": ".claude/CLAUDE.md", "to": ".claude/CLAUDE-OLD.md"}], + edit=move_target) + self.set_dir = new_set + result = self.apply() + self.assertFalse((self.repo / ".claude/CLAUDE.md").exists()) + self.assertTrue((self.repo / ".claude/CLAUDE-OLD.md").exists()) + self.assertEqual(result["migration_steps"][0]["result"], "done") + self.assertEqual(self.actions(self.plan())[".claude/CLAUDE-OLD.md"], "unchanged") + + def test_delete_only_removes_a_file_that_is_unmodified(self): + self.apply() + new_set = self.copy_set() + self.bump(new_set, steps=[{"op": "delete", "path": ".claude/CLAUDE.md", "file_id": "claude-md"}]) + self.set_dir = new_set + self.put(".claude/CLAUDE.md", "edited\n") + result = self.apply(overwrite=[]) + step = result["migration_steps"][0] + self.assertTrue(step["result"].startswith("skipped")) + self.assertTrue((self.repo / ".claude/CLAUDE.md").exists()) + + def test_a_missing_migration_file_is_an_error(self): + self.apply() + new_set = self.copy_set() + self.bump(new_set) + (new_set / "migrations/0002.json").unlink() + self.set_dir = new_set + with self.assertRaises(scaffold.ScaffoldError): + self.plan() + + def test_a_descriptor_newer_than_the_plugin_is_an_error(self): + self.apply() + desc = json.loads(self.text(".claude/scaffold.json")) + desc["revision"] = 9 + self.put(".claude/scaffold.json", json.dumps(desc)) + with self.assertRaises(scaffold.ScaffoldError): + self.plan() + + def test_a_descriptor_from_another_template_set_is_an_error(self): + self.apply() + desc = json.loads(self.text(".claude/scaffold.json")) + desc["template_set"] = "someone/else" + self.put(".claude/scaffold.json", json.dumps(desc)) + with self.assertRaises(scaffold.ScaffoldError): + self.plan() + + def test_upgrade_mode_when_files_exist_but_there_is_no_descriptor(self): + self.put(".gitignore", "*.bak\n") + plan = self.plan() + self.assertEqual((plan.mode, plan.recorded, plan.path), ("upgrade", None, [])) + + +class CheckTests(Base): + def test_the_shipped_template_set_is_sound(self): + self.assertEqual(scaffold.check_template_set(scaffold.SET_DIR), []) + + def test_a_template_edit_without_a_new_revision_is_flagged(self): + dst = self.copy_set() + path = dst / "assets/templates/gitignore.tmpl" + path.write_text(path.read_text(encoding="utf-8") + "\nextra/\n", encoding="utf-8") + problems = scaffold.check_template_set(dst) + self.assertTrue(any("without a new revision" in p for p in problems)) + + def test_sealing_after_a_revision_bump_makes_it_sound_again(self): + dst = self.copy_set() + self.bump(dst) + self.assertEqual(scaffold.check_template_set(dst), []) + + def test_a_revision_without_its_migration_file_is_flagged(self): + dst = self.copy_set() + self.bump(dst) + (dst / "migrations/0002.json").unlink() + self.assertTrue(any("0002.json" in p for p in scaffold.check_template_set(dst))) + + def test_a_missing_template_and_a_broken_template_are_flagged(self): + dst = self.copy_set() + (dst / "assets/templates/readme-md.tmpl").unlink() + self.assertTrue(any("readme-md.tmpl" in p for p in scaffold.check_template_set(dst))) + dst = self.copy_set() + path = dst / "assets/templates/claude-md.tmpl" + path.write_text("{{#if a}}{{#if b}}x{{/if}}{{/if}}", encoding="utf-8") + self.assertTrue(any("claude-md" in p for p in scaffold.check_template_set(dst))) + + def test_unbalanced_region_markers_are_flagged(self): + dst = self.copy_set() + path = dst / "assets/templates/agents-md.tmpl" + path.write_text(path.read_text(encoding="utf-8").replace("", ""), + encoding="utf-8") + self.assertTrue(any("agents" in p for p in scaffold.check_template_set(dst))) + + +class DiffTests(Base): + def run_diff(self, file_id): + return scaffold.diff_file(self.repo, self.set_dir, dict(TITLE), file_id) + + def test_a_missing_file_is_reported(self): + self.assertIn("does not exist yet", self.run_diff("claude-md")) + + def test_identical_content_has_no_differences(self): + self.apply() + self.assertEqual(self.run_diff("claude-md"), "no differences\n") + self.assertEqual(self.run_diff("agents"), "no differences\n") + + def test_a_whole_file_diff_labels_both_sides(self): + self.apply() + self.put(".claude/CLAUDE.md", "my own\n") + out = self.run_diff("claude-md") + self.assertIn("(repo)", out) + self.assertIn("(template)", out) + self.assertIn("-my own", out) + + def test_a_regions_diff_shows_only_the_regions_that_differ(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("understood, but use the leading form", "tolerated")) + out = self.run_diff("agents") + self.assertIn("[region conventions]", out) + self.assertNotIn("[region plugin]", out) + self.assertIn("+- Some history", out) + + def test_an_unmarked_agents_file_is_compared_as_a_whole(self): + self.put("AGENTS.md", "# AGENTS.md\n\nhand written\n") + out = self.run_diff("agents") + self.assertIn("-hand written", out) + + def test_unknown_ids_and_missing_variables_are_errors(self): + with self.assertRaises(scaffold.ScaffoldError): + self.run_diff("nope") + with self.assertRaises(scaffold.ScaffoldError): + scaffold.diff_file(self.repo, self.set_dir, {}, "agents") + + +class HardeningTests(Base): + def test_a_conflicting_region_does_not_block_a_clean_one_in_the_same_file(self): + self.apply() + self.put("AGENTS.md", self.text("AGENTS.md").replace("understood, but use the leading form", "tolerated")) + + def edit(dst): + path = dst / "assets/templates/agents-md.tmpl" + text = path.read_text(encoding="utf-8") + text = text.replace("Docker is the only prerequisite", "Docker is the sole prerequisite") + text = text.replace("one line, for example", "a single line, for example") + path.write_text(text, encoding="utf-8") + + new_set = self.copy_set() + self.bump(new_set, edit=edit) + self.set_dir = new_set + plan = self.plan() + agents = next(a for a in plan.acts if a.id == "agents") + regions = {r["region"]: r["action"] for r in agents.extra["regions"]} + self.assertEqual((agents.action, regions["build"], regions["conventions"]), ("conflict", "update", "conflict")) + self.apply() + text = self.text("AGENTS.md") + self.assertIn("sole prerequisite", text) # clean region written + self.assertIn("tolerated", text) # conflicting region kept + self.assertNotIn("a single line, for example", text) + + def test_invalid_settings_json_can_be_replaced_with_overwrite(self): + self.put(".claude/settings.json", "{ not json") + self.assertEqual(self.actions(self.plan())[".claude/settings.json"], "conflict") + self.assertIn("--overwrite claude-settings", {a.target: a.detail for a in self.plan().acts}[".claude/settings.json"]) + self.apply(overwrite=["claude-settings"]) + self.assertTrue(json.loads(self.text(".claude/settings.json"))["enabledPlugins"]["openehr-specs@openehr"]) + + def test_bmm_schemas_are_ordered_by_version_not_by_text(self): + for name in ("openehr_am_1.4.0", "openehr_am_1.9.0", "openehr_am_1.10.0"): + self.put(f"computable/BMM/{name}.bmm.json", "{}") + plan = self.plan() + self.assertEqual(plan.values["bmm_schema_id"], "openehr_am_1.10.0") + self.assertTrue(any("several BMM schemas" in w and "1.9.0" in w for w in plan.warnings)) + self.assertEqual(self.plan(bmm_schema_id="openehr_am_1.4.0").values["bmm_schema_id"], "openehr_am_1.4.0") + + def test_a_single_bmm_schema_gives_no_warning(self): + self.put("computable/BMM/openehr_demo_1.0.0.bmm.json", "{}") + self.assertFalse(any("BMM" in w for w in self.plan().warnings)) + + def test_git_warnings_distinguish_the_cases(self): + self.assertTrue(any("not a git repository" in w for w in scaffold.public(self.plan())["warnings"])) + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + warnings = scaffold.public(self.plan())["warnings"] + self.assertTrue(any("inside the git repository" in w and "repository root" in w for w in warnings)) + subprocess.run(["git", "init", "-q", str(self.repo)], check=True) + warnings = scaffold.public(self.plan())["warnings"] + self.assertFalse(any("not a git repository" in w or "inside the git repository" in w for w in warnings)) + + def test_a_dirty_tree_is_mentioned(self): + subprocess.run(["git", "init", "-q", str(self.repo)], check=True) + self.put("notes.txt", "x") + self.assertTrue(any("uncommitted" in w for w in scaffold.public(self.plan())["warnings"])) + + def test_malformed_migration_steps_are_rejected_with_a_clean_error(self): + self.apply() + for step in ({"op": "rename", "to": "x"}, {"op": "delete"}, {"op": "note"}, {"op": "explode"}): + new_set = self.copy_set() + self.bump(new_set, steps=[step]) + self.set_dir = new_set + with self.assertRaises(scaffold.ScaffoldError, msg=step): + self.plan() + self.assertTrue(scaffold.check_template_set(new_set), step) # check reports it too + + def test_migration_paths_cannot_leave_the_repository(self): + self.apply() + for step in ({"op": "rename", "from": "a", "to": "../outside"}, + {"op": "delete", "path": "/etc/passwd"}): + new_set = self.copy_set() + self.bump(new_set, steps=[step]) + self.set_dir = new_set + with self.assertRaises(scaffold.ScaffoldError, msg=step): + self.plan() + + def test_check_rejects_a_target_outside_the_repository(self): + dst = self.copy_set() + path = dst / "assets/template-set.json" + tset = json.loads(path.read_text(encoding="utf-8")) + tset["files"][0]["target"] = "../escape.md" + path.write_text(json.dumps(tset, indent=2), encoding="utf-8") + self.assertTrue(any("relative path" in p for p in scaffold.check_template_set(dst))) + + +class FidelityTests(Base): + def put_bytes(self, name, data): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def test_crlf_files_keep_their_line_endings_when_merged(self): + self.put_bytes(".gitignore", b"*.bak\r\nbuild/\r\n") + self.apply() + data = (self.repo / ".gitignore").read_bytes() + self.assertTrue(data.startswith(b"*.bak\r\nbuild/\r\n")) + self.assertNotIn(b"\n", data.replace(b"\r\n", b"")) # every newline is CRLF + self.assertIn(b".claude/settings.local.json", data) + + def test_lf_files_stay_lf_and_new_files_are_lf(self): + self.put_bytes(".gitignore", b"*.bak\n") + self.apply() + self.assertNotIn(b"\r", (self.repo / ".gitignore").read_bytes()) + self.assertNotIn(b"\r", (self.repo / "AGENTS.md").read_bytes()) + + def test_a_crlf_region_file_gets_the_region_update_in_crlf(self): + self.apply() + text = self.text("AGENTS.md").replace("\n", "\r\n") + self.put_bytes("AGENTS.md", text.encode("utf-8")) + self.assertEqual(self.actions(self.plan())["AGENTS.md"], "unchanged") # CRLF never causes a conflict + new_set = self.copy_set() + self.bump(new_set, edit=lambda dst: (dst / "assets/templates/agents-md.tmpl").write_text( + (dst / "assets/templates/agents-md.tmpl").read_text(encoding="utf-8").replace( + "one line, for example", "a single line, for example"), encoding="utf-8")) + self.set_dir = new_set + self.apply() + data = (self.repo / "AGENTS.md").read_bytes() + self.assertIn(b"a single line, for example", data) + self.assertNotIn(b"\n", data.replace(b"\r\n", b"")) + + def test_the_file_mode_survives_a_rewrite(self): + self.put(".gitignore", "*.bak\n") + os.chmod(self.repo / ".gitignore", 0o755) + self.apply() + self.assertEqual((self.repo / ".gitignore").stat().st_mode & 0o777, 0o755) + + def test_a_byte_order_mark_does_not_hide_a_line(self): + self.put_bytes(".gitignore", "\ufeff*.bak\nbuild/\n".encode("utf-8")) + self.apply() + self.assertEqual(self.text(".gitignore").count("*.bak"), 1) + + def test_no_temporary_files_are_left_behind(self): + self.apply() + self.assertEqual(list(self.repo.rglob("*.scaffold-tmp")), []) + + def test_symbolic_links_are_never_written_through(self): + self.put("AGENTS.md", "# keep me\n") + (self.repo / ".claude").mkdir() + (self.repo / ".claude" / "CLAUDE.md").symlink_to("../AGENTS.md") + plan = self.plan() + self.assertEqual(self.actions(plan)[".claude/CLAUDE.md"], "conflict") + self.apply(overwrite=["claude-md"], pin=["agents"]) + self.assertEqual(self.text("AGENTS.md"), "# keep me\n") + self.assertTrue((self.repo / ".claude" / "CLAUDE.md").is_symlink()) + + +class MarkerTests(Base): + def test_a_begin_marker_without_an_end_stops_the_run_and_names_the_file(self): + self.apply() + text = self.text("AGENTS.md").replace("", "") + self.put("AGENTS.md", text) + with self.assertRaises(scaffold.ScaffoldError) as caught: + self.plan() + self.assertIn("AGENTS.md", str(caught.exception)) + self.assertIn("conventions", str(caught.exception)) + with self.assertRaises(scaffold.ScaffoldError): + self.apply(overwrite=["agents:conventions"]) + self.assertEqual(self.text("AGENTS.md"), text) # nothing was touched + + def test_a_duplicated_region_in_the_repo_names_the_file(self): + self.apply() + text = self.text("AGENTS.md") + start = text.index("") + end = text.index("") + len("") + self.put("AGENTS.md", text + "\n" + text[start:end] + "\n") + with self.assertRaises(scaffold.ScaffoldError) as caught: + self.plan() + self.assertIn("AGENTS.md", str(caught.exception)) + + def test_markers_that_are_not_at_the_start_of_a_line_are_ignored(self): + self.put("AGENTS.md", "# AGENTS.md\n\nSee `` in the docs.\n") + self.assertEqual(self.actions(self.plan())["AGENTS.md"], "upgrade-manual") + + def test_pinning_a_missing_region_stops_it_being_added(self): + self.apply() + text = self.text("AGENTS.md") + start = text.index("") + end = text.index("") + len("") + desc = json.loads(self.text(".claude/scaffold.json")) + del desc["files"]["agents"]["regions"]["build"] + self.put(".claude/scaffold.json", json.dumps(desc)) + self.put("AGENTS.md", text[:start] + text[end:]) + plan = self.plan(pin=["agents:build"]) + agents = next(a for a in plan.acts if a.id == "agents") + self.assertEqual({r["region"]: r["action"] for r in agents.extra["regions"]}["build"], "pinned") + self.assertIsNone(agents.content) + + def test_overwrite_brings_back_a_region_the_user_deleted(self): + self.apply() + text = self.text("AGENTS.md") + start = text.index("") + end = text.index("") + len("") + self.put("AGENTS.md", text[:start] + text[end:]) + self.assertIsNone(next(a for a in self.plan().acts if a.id == "agents").content) # removed: left out + self.apply(overwrite=["agents:build"]) + self.assertIn("", self.text("AGENTS.md")) + + +class RemovalTests(Base): + def test_items_the_user_removed_from_settings_are_not_added_back(self): + self.apply() + settings = json.loads(self.text(".claude/settings.json")) + settings["permissions"]["allow"].remove("Bash(git commit *)") + del settings["enabledPlugins"]["superpowers@claude-plugins-official"] + self.put(".claude/settings.json", json.dumps(settings, indent=2)) + plan = self.plan() + act = next(a for a in plan.acts if a.id == "claude-settings") + self.assertEqual(act.action, "unchanged") + self.assertIn("removed earlier", act.detail) + self.apply() + after = json.loads(self.text(".claude/settings.json")) + self.assertNotIn("Bash(git commit *)", after["permissions"]["allow"]) + self.assertNotIn("superpowers@claude-plugins-official", after["enabledPlugins"]) + + def test_a_new_template_item_is_added_even_after_other_items_were_removed(self): + self.apply() + settings = json.loads(self.text(".claude/settings.json")) + settings["permissions"]["allow"].remove("Bash(git commit *)") + self.put(".claude/settings.json", json.dumps(settings)) + + def edit(dst): + path = dst / "assets/templates/claude-settings.json.tmpl" + path.write_text(path.read_text(encoding="utf-8").replace( + '"Bash(git add *)",', '"Bash(git add *)",\n "Bash(git status *)",'), encoding="utf-8") + + new_set = self.copy_set() + self.bump(new_set, edit=edit) + self.set_dir = new_set + self.apply() + allow = json.loads(self.text(".claude/settings.json"))["permissions"]["allow"] + self.assertIn("Bash(git status *)", allow) + self.assertNotIn("Bash(git commit *)", allow) + + def test_a_removed_gitignore_line_stays_removed_but_a_new_one_is_added(self): + self.apply() + self.put(".gitignore", self.text(".gitignore").replace(".idea/\n", "")) + act = next(a for a in self.plan().acts if a.id == "gitignore") + self.assertEqual(act.action, "unchanged") + self.assertIn("removed earlier", act.detail) + + def edit(dst): + path = dst / "assets/templates/gitignore.tmpl" + path.write_text(path.read_text(encoding="utf-8") + "\n# Build output\nout/\n", encoding="utf-8") + + new_set = self.copy_set() + self.bump(new_set, edit=edit) + self.set_dir = new_set + self.apply() + lines = self.text(".gitignore").splitlines() + self.assertIn("out/", lines) + self.assertNotIn(".idea/", lines) + + def test_an_unset_attribute_counts_as_present(self): + self.put(".asciidoctorconfig", ":component: DEMO\n\n:package_qualifiers!:\n") + self.apply(component="DEMO") + self.assertNotIn("\n:package_qualifiers:", self.text(".asciidoctorconfig")) + + def test_an_existing_licence_in_another_file_name_blocks_a_second_one(self): + self.put("LICENSE.md", "my licence\n") + self.assertEqual(self.actions(self.plan())["LICENSE"], "exists-alternative") + + +class VariableTests(Base): + def test_a_bmm_schema_added_after_scaffolding_is_picked_up_as_a_guess(self): + self.apply() + self.put("computable/BMM/openehr_demo_1.0.0.bmm.json", "{}") + plan = self.plan() + self.assertEqual(plan.values["bmm_schema_id"], "openehr_demo_1.0.0") + self.assertTrue(plan.sources["bmm_schema_id"].startswith("inferred")) + + def test_confirmed_values_are_not_overridden_by_inference(self): + self.apply(default_branch="main") + self.assertEqual(self.plan().sources["default_branch"], "descriptor") + + def test_unknown_variable_names_are_an_error(self): + with self.assertRaises(scaffold.ScaffoldError) as caught: + self.plan(colour="red") + self.assertIn("colour", str(caught.exception)) + with self.assertRaises(scaffold.ScaffoldError): + self.plan(license_name="x") # derived: cannot be set + + def test_an_empty_required_value_counts_as_missing(self): + plan = scaffold.build_plan(self.repo, self.set_dir, {"component_title": " "}) + self.assertEqual([m["name"] for m in plan.missing], ["component_title"]) + + def test_unknown_overwrite_and_pin_ids_are_an_error(self): + with self.assertRaises(scaffold.ScaffoldError): + self.plan(pin=["agnts"]) + with self.assertRaises(scaffold.ScaffoldError): + self.plan(overwrite=["agents:nope"]) + self.assertTrue(self.plan(pin=["agents:plugin"])) + + def test_apply_writes_nothing_when_input_is_missing(self): + result = scaffold.apply_plan(self.repo, self.set_dir, {}, (), ()) + self.assertEqual(result["status"], "needs_input") + self.assertNotIn("applied", result) + self.assertEqual(list(self.repo.iterdir()), []) + + def test_plan_writes_nothing_in_upgrade_mode(self): + self.put(".gitignore", "*.bak\n") + before = sorted(p.name for p in self.repo.rglob("*")) + self.plan() + self.assertEqual(sorted(p.name for p in self.repo.rglob("*")), before) + + def test_a_conflict_is_still_a_conflict_after_apply(self): + self.apply() + self.put(".claude/CLAUDE.md", "my own\n") + self.apply() + self.assertEqual(self.actions(self.plan())[".claude/CLAUDE.md"], "conflict") + + +class MalformedInputTests(Base): + def test_settings_json_that_is_not_an_object_is_a_conflict_not_a_crash(self): + for body in ("[]", "null", '"text"', "3"): + self.put(".claude/settings.json", body) + self.assertEqual(self.actions(self.plan())[".claude/settings.json"], "conflict", body) + self.apply(overwrite=["claude-settings"]) + self.assertIsInstance(json.loads(self.text(".claude/settings.json")), dict) + + def test_a_file_that_is_not_utf8_names_the_file(self): + (self.repo / ".gitignore").write_bytes("*.bak\n".encode("utf-16")) + with self.assertRaises(scaffold.ScaffoldError) as caught: + self.plan() + self.assertIn(".gitignore", str(caught.exception)) + self.assertIn("UTF-8", str(caught.exception)) + + def test_a_pinned_file_is_never_read(self): + (self.repo / ".gitignore").write_bytes("*.bak\n".encode("utf-16")) + self.assertEqual(self.actions(self.plan(pin=["gitignore"]))[".gitignore"], "pinned") + + def test_a_licence_that_is_not_utf8_does_not_stop_inference(self): + (self.repo / "LICENSE").write_bytes(b"caf\xe9 licence") + self.assertEqual(self.plan().values["license"], "cc-by-sa-3.0") + + def test_odd_manifest_shapes_do_not_crash_inference(self): + self.put("manifest.json", json.dumps({"id": "DEMO", "title": "Demo Model", "jira": [], + "specifications": ["x", {"id": 7}, {"id": "ok"}]})) + plan = scaffold.build_plan(self.repo, self.set_dir, {}) + self.assertEqual(plan.values["documents"], "`ok`") + + def test_a_hand_edited_descriptor_is_reported_cleanly(self): + self.apply() + for edit in ({"revision": "1"}, {"revision": 0}, {"files": []}, {"variables": "x"}, {"pinned": "agents"}): + desc = json.loads(self.text(".claude/scaffold.json")) + desc.update(edit) + self.put(".claude/scaffold.json", json.dumps(desc)) + with self.assertRaises(scaffold.ScaffoldError, msg=edit): + self.plan() + desc = json.loads(json.dumps(desc)) + self.put(".claude/scaffold.json", "[1, 2]") + with self.assertRaises(scaffold.ScaffoldError): + self.plan() + + def test_the_cli_turns_any_failure_into_json_with_exit_code_2(self): + out = io.StringIO() + with redirect_stdout(out): + code = scaffold.main(["plan", "--repo", str(self.repo), "--var", "component_title=T", "--var", "nope=1"]) + self.assertEqual(code, 2) + self.assertIn("nope", json.loads(out.getvalue())["error"]) + with mock.patch.object(scaffold, "build_plan", side_effect=RuntimeError("boom")): + out = io.StringIO() + with redirect_stdout(out): + code = scaffold.main(["plan", "--repo", str(self.repo)]) + self.assertEqual(code, 2) + self.assertIn("boom", json.loads(out.getvalue())["error"]) + + def test_check_reports_a_malformed_set_instead_of_crashing(self): + dst = self.copy_set() + path = dst / "assets/template-set.json" + tset = json.loads(path.read_text(encoding="utf-8")) + tset["revisions"] = [] + path.write_text(json.dumps(tset), encoding="utf-8") + self.assertTrue(scaffold.check_template_set(dst)) + dst = self.copy_set() + tset = json.loads((dst / "assets/template-set.json").read_text(encoding="utf-8")) + tset["variables"]["license_name"]["derived"] = {} + (dst / "assets/template-set.json").write_text(json.dumps(tset), encoding="utf-8") + problems = scaffold.check_template_set(dst) + self.assertTrue(problems and all(isinstance(p, str) for p in problems)) + + +class MigrationStepTests(Base): + def upgrade_with(self, steps): + self.apply() + new_set = self.copy_set() + self.bump(new_set, steps=steps) + self.set_dir = new_set + + def test_delete_removes_an_unmodified_scaffolded_file(self): + self.upgrade_with([{"op": "delete", "path": ".claude/CLAUDE.md", "file_id": "claude-md"}]) + result = self.apply() + self.assertEqual(result["migration_steps"][0]["result"], "done") + + def test_delete_and_rename_leave_a_pinned_file_alone(self): + self.upgrade_with([{"op": "delete", "path": ".claude/CLAUDE.md", "file_id": "claude-md"}, + {"op": "rename", "from": ".claude/CLAUDE.md", "to": ".claude/OLD.md", "file_id": "claude-md"}]) + result = self.apply(pin=["claude-md"]) + self.assertEqual([s["result"] for s in result["migration_steps"]], ["skipped: that file is pinned"] * 2) + self.assertTrue((self.repo / ".claude/CLAUDE.md").exists()) + + def test_rename_is_skipped_when_the_source_is_missing_or_the_target_exists(self): + self.upgrade_with([{"op": "rename", "from": "nope.txt", "to": "x.txt"}, + {"op": "rename", "from": "manifest.json", "to": "README.md"}]) + results = [s["result"] for s in self.apply()["migration_steps"]] + self.assertEqual(results, ["skipped: source is missing", "skipped: target exists"]) + + def test_a_note_is_reported_as_manual_and_a_delete_needs_a_file_id(self): + self.upgrade_with([{"op": "note", "text": "do it by hand"}]) + self.assertEqual(self.apply()["migration_steps"][0]["result"], "manual: see note") + new_set = self.copy_set() + self.bump(new_set, steps=[{"op": "delete", "path": "x"}]) + self.assertTrue(scaffold.check_template_set(new_set)) + + +class CliTests(Base): + def run_cli(self, *args): + out = io.StringIO() + with redirect_stdout(out): + code = scaffold.main(list(args)) + return code, out.getvalue() + + def test_plan_prints_json_and_writes_nothing(self): + code, out = self.run_cli("plan", "--repo", str(self.repo), "--var", "component_title=Demo Model") + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["status"], "ok") + self.assertEqual(list(self.repo.iterdir()), []) + + def test_errors_are_json_with_exit_code_2(self): + code, out = self.run_cli("plan", "--repo", str(self.repo / "nope")) + self.assertEqual(code, 2) + self.assertIn("error", json.loads(out)) + code, out = self.run_cli("plan", "--repo", str(self.repo), "--var", "broken") + self.assertEqual(code, 2) + + def test_render_prints_one_file(self): + code, out = self.run_cli("render", "--repo", str(self.repo), "--file", "claude-md", + "--var", "component_title=Demo Model") + self.assertEqual(code, 0) + self.assertIn("@../AGENTS.md", out) + + def test_diff_prints_text(self): + self.apply() + code, out = self.run_cli("diff", "--repo", str(self.repo), "--file", "claude-md", "--var", "component_title=Demo Model") + self.assertEqual((code, out), (0, "no differences\n")) + + def test_check_exit_code(self): + code, out = self.run_cli("check") + self.assertEqual((code, json.loads(out)["ok"]), (0, True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate.py b/scripts/validate.py index df4a230..155974c 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Validate marketplace and plugin manifests, and skill/agent frontmatter. +"""Validate marketplace and plugin manifests, skill/agent frontmatter, and bundled template sets. Checks both Claude Code (``.claude-plugin/``) and Cursor (``.cursor-plugin/``) layouts. Usage: python3 scripts/validate.py (from the repo root) """ +import importlib.util import json import re import sys @@ -55,6 +56,27 @@ def validate_skills(plugin_dir: Path): err(f"{rel}: frontmatter name '{fm_name.group(1)}' != directory '{skill_dir.name}'") +def validate_template_sets(plugin_dir: Path): + """A skill that ships assets/template-set.json carries its own checker (scripts/scaffold.py).""" + for tset in sorted(plugin_dir.glob("skills/*/assets/template-set.json")): + skill_dir = tset.parent.parent + rel = skill_dir.relative_to(ROOT) + script = skill_dir / "scripts" / "scaffold.py" + if not script.is_file(): + err(f"{rel}: template-set.json without scripts/scaffold.py") + continue + try: + spec = importlib.util.spec_from_file_location("scaffold_check", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + problems = module.check_template_set(skill_dir) + except Exception as exc: # a broken script must fail validation, not crash it + err(f"{rel}: cannot run scripts/scaffold.py check: {exc!r}") + continue + for problem in problems: + err(f"{rel}: template set: {problem}") + + 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.""" @@ -185,6 +207,8 @@ def main(): validate_marketplace(mp_path, subdir, label) 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 if __name__ == "__main__": @@ -194,4 +218,4 @@ def main(): for e in errors: print(f" - {e}") sys.exit(1) - print("OK: Claude and Cursor manifests, plugin metadata, skills, and agents are valid") + print("OK: Claude and Cursor manifests, plugin metadata, skills, template sets, and agents are valid")