diff --git a/AGENTS.md b/AGENTS.md index 48a5e24..1c5c0f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ 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. +- **`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. For a new repository's BMM schema it asks **Docker** for the schemas the local `ghcr.io/openehr/bmm-publisher` image bundles, and never pulls the image. Without Docker or the image that one file is reported as blocked, and the rest are still written. - **`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 a469e6e..c737ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Notable changes to the plugins in this repository. The format follows [Keep a Ch ## Unreleased +### Added + +- `scaffold` template set revision 3: a new repository also gets `computable/BMM/.bmm.json` (file id `bmm`, strategy `bmm-seed`). + - The script lists the schemas the local `ghcr.io/openehr/bmm-publisher` image bundles (`docker run --pull never`, so nothing is downloaded) and copies the highest version for the component. + - When the image bundles none, it writes an empty schema `openehr__`. The schema has one root package `org.openehr.`, no classes, and BASE in `includes` for other components. bmm-publisher refuses a schema without a package. + - Without Docker or the image the file is reported as `blocked` and the other files are still written; `--var bmm_schema_id=` creates the empty schema anyway. + - Existing repositories are not offered a schema; `--overwrite bmm` asks for one. + - Derived variables can take a regex group (`match` + `group`), which gives the new `bmm_schema_name` and `bmm_rm_release`. + ## openehr-specs 0.5.0 (2026-10-08) ### Changed diff --git a/plugins/openehr-specs/README.md b/plugins/openehr-specs/README.md index 07013f9..015fbb8 100644 --- a/plugins/openehr-specs/README.md +++ b/plugins/openehr-specs/README.md @@ -36,7 +36,7 @@ Three skills run only when you type them: they do not auto-trigger, and their de | Skill | Argument | Action | |-------|----------|--------| | `/openehr-specs:regen-classes` | ` [-d ...]` | Regenerate class tables and diagrams via `bmm-publisher` (needs Docker) | -| `/openehr-specs:scaffold` | `[component id]` | Initialise a specification repo, or upgrade an existing one, to the standard file set: `AGENTS.md` (plugin table, build, Jira-key commit conventions), `.claude/` settings, `manifest.json`, `.gitignore`, `.asciidoctorconfig`, `LICENSE`, `README.md`. `.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:scaffold` | `[component id]` | Initialise a specification repo, or upgrade an existing one, to the standard file set: `AGENTS.md` (plugin table, build, Jira-key commit conventions), `.claude/` settings, `manifest.json`, `.gitignore`, `.asciidoctorconfig`, `LICENSE`, `README.md`, and in a new repo `computable/BMM/.bmm.json` (the schema the `bmm-publisher` image bundles for the component, or an empty one). `.claude/settings.json` registers the `openehr` marketplace, enables four plugins and pre-approves `git add`, `git commit`, `git diff`, WebFetch on `specifications.openehr.org` and three read-only Atlassian MCP calls. Plans before it writes, and records a revision in `.claude/scaffold.json` so later runs can upgrade the repo. Needs `python3`, plus Docker for the new-repo BMM check; written for Claude Code (its script path comes from `${CLAUDE_SKILL_DIR}`) | | `/openehr-specs:publish` | `` | Build a local HTML preview with the published `ghcr.io/openehr/asciidoctor` image (needs Docker and a sibling `specifications-AA_GLOBAL` checkout; checks that the HTML was rebuilt and the class tables resolved) | ## Subagents diff --git a/plugins/openehr-specs/skills/scaffold/SKILL.md b/plugins/openehr-specs/skills/scaffold/SKILL.md index ece446d..4f04c92 100644 --- a/plugins/openehr-specs/skills/scaffold/SKILL.md +++ b/plugins/openehr-specs/skills/scaffold/SKILL.md @@ -1,6 +1,6 @@ --- 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) +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, and in a new repository a computable/BMM schema) argument-hint: "[component id, e.g. BASE]" allowed-tools: - "Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/scaffold.py plan *)" @@ -22,7 +22,14 @@ 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. +AsciiDoc-specific. Pin `bmm` too when a new repo has no model. + +A new repository (plan `mode` `init`) also gets `computable/BMM/.bmm.json` (file id +`bmm`). The script asks the local `ghcr.io/openehr/bmm-publisher` image for the schemas it bundles +and copies the highest version for the component. When the image bundles none, it writes an empty +schema: one root package `org.openehr.`, no classes, BASE included for other components, and +`rm_release` equal to `first_release`. The check needs Docker and the image, and never pulls it. +Existing repositories are not offered a schema. ## Steps @@ -39,7 +46,9 @@ AsciiDoc-specific. again with `--var name=value`. A variable whose source is `default` or starts with `inferred` is a guess. Ask the user to confirm the ones that matter (`license`, `jira_project`, `bmm_schema_id`, `base_bmm_schema_id`, `default_branch`, `published_url`, and `first_release` only when the plan creates - `manifest.json`, the one file that uses it) and list the rest in one line. Never invent a + `manifest.json` or a new empty BMM schema, the files that use it) and list the rest in one line. In a + new repository, `bmm_schema_id` comes from `inferred: bmm-publisher image` or `inferred: new empty + schema`. Never invent a 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:` @@ -53,7 +62,14 @@ AsciiDoc-specific. 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. + - `create` for `bmm`: say from its `detail` whether the schema is copied from the image or new and + empty. + - `blocked` (only `bmm`): the image could not be checked, and `detail` says why. Offer to start + Docker, or to run `docker pull ghcr.io/openehr/bmm-publisher` (a large download; ask first), and + plan again. Or create the empty schema the `detail` names with `--var bmm_schema_id=`. Applying + anyway writes the other files without a schema; `--overwrite bmm` adds one later. + - `exists`, `exists-alternative` (for example `README.adoc`), `unchanged`, `pinned`, `skipped` (no BMM + schema is offered to an existing repository; `--overwrite bmm` asks for one): nothing to do. 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. @@ -69,19 +85,21 @@ AsciiDoc-specific. `` 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. + - Nothing but `unchanged`, `exists`, `exists-alternative`, `pinned` and `skipped`, 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 + (`manifest.json`, `LICENSE`, `README.md`, a BMM schema), 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 + `openehr-specs:authoring`). When an empty BMM schema was created, point out that classes go into it + and the class tables are generated from it (skill `openehr-specs:class-generation`); until then + bmm-publisher warns about the empty package. Do not commit; if the user asks for a commit message, follow the Conventions section of the repo's `AGENTS.md`. ## Rules diff --git a/plugins/openehr-specs/skills/scaffold/assets/template-set.json b/plugins/openehr-specs/skills/scaffold/assets/template-set.json index c295953..0016f9d 100644 --- a/plugins/openehr-specs/skills/scaffold/assets/template-set.json +++ b/plugins/openehr-specs/skills/scaffold/assets/template-set.json @@ -1,6 +1,6 @@ { "id": "openehr-specs/scaffold", - "revision": 2, + "revision": 3, "revisions": [ { "revision": 1, @@ -13,6 +13,12 @@ "released": "2026-10-08", "summary": "AGENTS.md build region: no longer claims the repo has no build tooling, and the class-table command loads the sibling BASE BMM with -d (new variable base_bmm_schema_id); a TODO(scaffold) marker asks new repos to describe their own tooling. .claude/CLAUDE.md says where repo guidance goes.", "digest": "e973f4d599169188dcef2ae965e6f642ad58f3cea9d09ef930e923a4ca54e5b7" + }, + { + "revision": 3, + "released": "2026-10-09", + "summary": "A new repository gets computable/BMM/.bmm.json: a copy of the schema the bmm-publisher image bundles for the component, or an empty schema (one root package, BASE included) when it bundles none. Existing repositories are not offered one.", + "digest": "c78b965d50608d5937bd6dfcc0b0953cba4c91d51ad347bd46b647b7cdc7281f" } ], "variables": { @@ -87,13 +93,29 @@ "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." + "description": "BMM schema id (openehr_base_1.3.0) of computable/BMM/.bmm.json, read from the repo. When a new repository is initialised without one: the highest schema the bmm-publisher image bundles for the component, else openehr__ for a new empty schema. Empty otherwise. Switches on the class-generation sections." }, "base_bmm_schema_id": { "default": "", "pattern": "^$|^openehr_base_[0-9]+\\.[0-9]+\\.[0-9]+$", "description": "BASE BMM schema id (openehr_base_1.3.0) that the component's classes refer to, read from the sibling specifications-BASE clone; loaded with -d when the class tables are regenerated. Empty for BASE itself and for repos without a BMM." }, + "bmm_schema_name": { + "derived": { + "from": "bmm_schema_id", + "match": "^openehr_(.+)_[0-9]+\\.[0-9]+\\.[0-9]+$", + "group": 1 + }, + "description": "schema_name of the BMM schema (base in openehr_base_1.3.0); empty without one." + }, + "bmm_rm_release": { + "derived": { + "from": "bmm_schema_id", + "match": "^openehr_.+_([0-9]+\\.[0-9]+\\.[0-9]+)$", + "group": 1 + }, + "description": "rm_release of the BMM schema (1.3.0 in openehr_base_1.3.0); empty without one." + }, "documents": { "default": "none yet (add them to `manifest.json`)", "description": "Published document ids, read from manifest.json when present." @@ -124,6 +146,15 @@ "template": "manifest-json.tmpl", "strategy": "seed" }, + { + "id": "bmm", + "target": "computable/BMM/{{bmm_schema_id}}.bmm.json", + "template": "bmm-json.tmpl", + "strategy": "bmm-seed", + "present_glob": "computable/BMM/*.bmm.json", + "image": "ghcr.io/openehr/bmm-publisher", + "image_dir": "/app/resources" + }, { "id": "gitignore", "target": ".gitignore", diff --git a/plugins/openehr-specs/skills/scaffold/assets/templates/bmm-json.tmpl b/plugins/openehr-specs/skills/scaffold/assets/templates/bmm-json.tmpl new file mode 100644 index 0000000..eed723b --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/assets/templates/bmm-json.tmpl @@ -0,0 +1,24 @@ +{ + "bmm_version": "2.4", + "rm_publisher": "openehr", + "schema_name": "{{bmm_schema_name}}", + "rm_release": "{{bmm_rm_release}}", + "schema_revision": "{{bmm_rm_release}}.1", + "schema_lifecycle_state": "development", + "schema_description": "{{component_description}}", + "schema_author": "openEHR Foundation", + {{#if base_bmm_schema_id}} + "includes": { + "{{base_bmm_schema_id}}": { + "id": "{{base_bmm_schema_id}}" + } + }, + {{/if}} + "packages": { + "org.openehr.{{bmm_schema_name}}": { + "name": "org.openehr.{{bmm_schema_name}}", + "classes": [] + } + }, + "class_definitions": {} +} diff --git a/plugins/openehr-specs/skills/scaffold/migrations/0003.json b/plugins/openehr-specs/skills/scaffold/migrations/0003.json new file mode 100644 index 0000000..fea3de0 --- /dev/null +++ b/plugins/openehr-specs/skills/scaffold/migrations/0003.json @@ -0,0 +1,5 @@ +{ + "from": 2, + "to": 3, + "steps": [] +} diff --git a/plugins/openehr-specs/skills/scaffold/references/maintaining.md b/plugins/openehr-specs/skills/scaffold/references/maintaining.md index 250434c..2b3f637 100644 --- a/plugins/openehr-specs/skills/scaffold/references/maintaining.md +++ b/plugins/openehr-specs/skills/scaffold/references/maintaining.md @@ -35,6 +35,7 @@ Each entry of `files` names a strategy, chosen by who owns the file after it is | `json-merge` | `.claude/settings.json` | Adds keys and list items that are missing. Existing values always win, so a plugin set to `false` stays `false`. The descriptor remembers every template item it has offered, so an item the user removed afterwards is left out, and only items new in a later revision are added. Invalid JSON, or a top level that is not an object, is a `conflict`; `--overwrite` replaces the file with the template. | | `ensure-lines` | `.gitignore`, `.asciidoctorconfig` | Appends missing lines with their comment header, and, like `json-merge`, leaves out a line the user removed after an earlier run. With a `key` regex, a line counts as present whatever its value (`:component: X`, and an unset `:name!:`); a differing value only produces a warning. | | `regions` | `AGENTS.md` | Regions between `` and `...:end ID -->` are managed, with a hash per region. Text outside them is the repo's. A file with no markers is `upgrade-manual`. Regions are applied independently: an `update` or `add` is written even when another region conflicts. | +| `bmm-seed` | `computable/BMM/{{bmm_schema_id}}.bmm.json` | Like `seed`, but offered only in `init` mode (or with `--overwrite bmm`) and only while `present_glob` matches nothing; otherwise `exists` or `skipped`. The content is the schema that `image` bundles in `image_dir` for the component, copied verbatim, or else the rendered template (an empty schema). Without Docker or the image the file is `blocked`, unless the user gave `bmm_schema_id`. The decision is made before the other files are planned, so `AGENTS.md` names the schema that will be written. At most one file uses this strategy. | `pinned` (in the descriptor, set with `--pin`) opts a file or `file:region` out for good. Regions the user deleted are reported as `removed`, not added back; a region the descriptor never recorded is added. @@ -43,7 +44,7 @@ user deleted are reported as `removed`, not added back; a region the descriptor 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 +to the migration for those. `seed` and `bmm-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. @@ -52,13 +53,17 @@ For every strategy, line endings (CRLF or LF) and the file mode are kept when a `variables` in `template-set.json`: `required`, `default` (itself a template, may use earlier variables), `default_if` (rules by regex on another variable), `pattern`, `choices`, `derived` -(`from` + `map`). Resolution order is explicit `--var`, then a value recorded in the repo's +(`from` + `map`, or `from` + `match` + `group`: a regex group of the other variable, empty when it does +not match; `bmm_schema_name` and `bmm_rm_release` split `bmm_schema_id` this way). Resolution order is explicit `--var`, then a value recorded in the repo's descriptor as confirmed, then inference, then a recorded default, then the default. Unknown `--var` names and empty required values are rejected. - Inference reads `manifest.json`, `.asciidoctorconfig`, the git remote, the directory name, `computable/BMM`, `origin/HEAD` (for `default_branch`), the `LICENSE` text, and the sibling `specifications-BASE/computable/BMM` (for `base_bmm_schema_id`, the highest version; never for BASE itself). + When a new repository is offered a BMM schema, `bmm_schema_id` comes from the bmm-publisher image + (`docker run --rm --pull never --entrypoint ls|cat`): the highest `openehr__`, with the + component lower-cased and `-` turned into `_`. Without one it is `openehr__`. - 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 @@ -125,6 +130,15 @@ Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git direc 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. +- **BMM schema** (checked on 2026-10-09). AM, BASE, LANG, RM and TERM keep theirs in `computable/BMM/`. The + bmm-publisher image (0.12.0) bundles 18 schemas for exactly those five components in `/app/resources`. + The empty template has the header items of `P_BMM_SCHEMA` and `BMM_SCHEMA_CORE` (LANG `bmm_persistence`), + with `bmm_version` 2.4 like every existing schema. Every existing schema has the revision + `.2` and the state `stable`. A new one starts at `.1` and `development`: not yet `stable`, and + one of the two lifecycle states that use 0.y.z releases (Planning and Development, skill `governance`). It names the Foundation as author rather than a person, and + includes BASE by id, as RM and LANG do. bmm-publisher refuses a schema without a package ("Schema must + contain at least one package"), so the template has one root package. With no classes it only warns + about an empty package, and `legacy-adoc` exits 0. ## Known limits @@ -136,6 +150,11 @@ Surveyed on 2026-10-07 across the `specifications-*` clones (18 with a git direc example) needs further `-d` options, added by hand. - One BMM schema per repo. When several are found (AM has 1.4.0 and 2.4.0, LANG has three) the highest version is used and a warning lists the others; pass `--var bmm_schema_id=` to choose. +- A BMM schema is offered only in `init` mode, that is when none of the standard files exists yet. A new + repository that already has, say, a `README.md` is in `upgrade` mode and needs `--overwrite bmm`. +- The bundled copy comes from whatever `ghcr.io/openehr/bmm-publisher` (`latest`) image is present + locally. Nothing is pulled, so a stale local image gives an older schema; `docker pull` it first. + Docker before 20.10 has no `--pull` option, and the file is reported as `blocked`. - `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. diff --git a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py index 56497bf..100abc5 100644 --- a/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py +++ b/plugins/openehr-specs/skills/scaffold/scripts/scaffold.py @@ -14,7 +14,9 @@ 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). +first (each write goes to a temporary file that then replaces the target). When a new repository +gets a BMM schema, plan and apply ask the local bmm-publisher image for a bundled one +(docker run --pull never: nothing is downloaded). """ import argparse import copy @@ -30,7 +32,8 @@ SET_DIR = Path(__file__).resolve().parent.parent DESCRIPTOR = ".claude/scaffold.json" -STRATEGIES = ("seed", "whole", "json-merge", "ensure-lines", "regions") +STRATEGIES = ("seed", "whole", "json-merge", "ensure-lines", "regions", "bmm-seed") +BMM_SEED_KEYS = ("present_glob", "image", "image_dir") MIGRATION_OPS = ("rename", "delete", "note") STEP_FIELDS = {"rename": ("from", "to"), "delete": ("path", "file_id"), "note": ("text",)} COMMENT_PREFIXES = ("#", "//") @@ -135,6 +138,23 @@ def git_installed(): return False +def docker(*args): + """(stdout, None) of a docker command, or (None, why it did not work).""" + try: + done = subprocess.run(["docker", *args], capture_output=True, timeout=60) + except FileNotFoundError: + return None, "docker is not installed" + except (OSError, subprocess.SubprocessError) as exc: + return None, f"docker did not run ({type(exc).__name__})" + if done.returncode != 0: + lines = [l.strip() for l in done.stderr.decode("utf-8", "replace").splitlines() if l.strip()] + return None, (lines[0] if lines else f"docker exited with {done.returncode}")[:200] + try: + return done.stdout.decode("utf-8"), None + except UnicodeDecodeError: + return None, "docker printed something that is not UTF-8 text" + + def require_relative(path, where): """Paths in the template set and in migrations must stay inside the repository.""" parts = Path(str(path)).parts @@ -245,6 +265,28 @@ def version_key(name): return (tuple(int(n) for n in re.findall(r"\d+", name)), name) +def repo_bmm_ids(repo): + bmm_dir = repo / "computable" / "BMM" + return sorted((p.name[: -len(".bmm.json")] for p in bmm_dir.glob("*.bmm.json")), + key=version_key) if bmm_dir.is_dir() else [] + + +def sibling_base(repo, notes): + """The highest BASE schema id in the sibling specifications-BASE clone, or None (with a note). + + The classes of every component but BASE refer to BASE types, which bmm-publisher only links when + the BASE BMM is loaded as a dependency (-d).""" + base_dir = repo.resolve().parent / "specifications-BASE" / "computable" / "BMM" + base = sorted((p.name[: -len(".bmm.json")] for p in base_dir.glob("openehr_base_*.bmm.json")), + key=version_key) if base_dir.is_dir() else [] + if base: + return base[-1] + notes.append("no sibling specifications-BASE clone with a BMM schema: the class-table command in " + "AGENTS.md will not load BASE with -d, so links to BASE types break; clone it, or " + "pass --var base_bmm_schema_id=") + return None + + def infer(repo, tset, set_dir, notes): """Variable values read from the repository itself: name -> (value, where from).""" found = {} @@ -291,25 +333,14 @@ def put(name, value, where): 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 [] + bmm = repo_bmm_ids(repo) 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") - # the classes of every component but BASE refer to BASE types, which bmm-publisher only - # links when the BASE BMM is loaded as a dependency (-d); read it from the sibling clone if not bmm[-1].startswith("openehr_base_"): - base_dir = repo.resolve().parent / "specifications-BASE" / "computable" / "BMM" - base = sorted((p.name[: -len(".bmm.json")] for p in base_dir.glob("openehr_base_*.bmm.json")), - key=version_key) if base_dir.is_dir() else [] - if base: - put("base_bmm_schema_id", base[-1], "sibling specifications-BASE") - else: - notes.append("no sibling specifications-BASE clone with a BMM schema: the class-table command in " - "AGENTS.md will not load BASE with -d, so links to BASE types break; clone it, or " - "pass --var base_bmm_schema_id=") + put("base_bmm_schema_id", sibling_base(repo, notes), "sibling specifications-BASE") head = git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") if head: @@ -363,7 +394,12 @@ def resolve_variables(tset, explicit, saved, inferred, defaulted=()): 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"]]) + source = values[derived["from"]] + if "match" in derived: # a regex group of the source, empty when it does not match + match = re.search(derived["match"], source) + values[name] = match.group(derived.get("group", 1)) if match else "" + else: + values[name] = derived.get("map", {}).get(source, source) sources[name] = "derived" for name, value in values.items(): @@ -394,10 +430,11 @@ def public(self): class Ctx: - def __init__(self, repo, set_dir, tset, values, desc, overwrite, pinned): + def __init__(self, repo, set_dir, tset, values, desc, overwrite, pinned, new_bmm=None): 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.new_bmm = new_bmm # what new_bmm() decided, when this run offers a BMM schema self.warnings = [] @@ -658,18 +695,46 @@ def plan_regions(ctx, spec, rendered): record={"regions": new_record}, regions=results) +def plan_bmm_seed(ctx, spec, rendered): + """Like seed, but only for a new repository and only while computable/BMM holds no schema; + the content is the image's bundled schema when there is one, else the empty-schema template.""" + shown = dict(spec, target=spec["present_glob"]) + present = sorted(p.name for p in ctx.repo.glob(spec["present_glob"])) + if present: + return Act(shown, "exists", ", ".join(present)) + new = ctx.new_bmm + if new is None: + return Act(shown, "skipped", "offered only when a repository is initialised; " + f"pass --overwrite {spec['id']} to create one here") + if "blocked" in new: + return Act(shown, "blocked", new["blocked"]) + if "text" in new: + return Act(spec, "create", f"copied from {spec['image']} ({new['schema_id']})", content=new["text"]) + return Act(spec, "create", f"new empty schema {new['schema_id']}: one root package, no classes yet" + + (f"; {new['note']}" if new.get("note") else ""), content=rendered) + + STRATEGY_FUNCS = { "seed": plan_seed, "whole": plan_whole, "json-merge": plan_json_merge, "ensure-lines": plan_ensure_lines, "regions": plan_regions, + "bmm-seed": plan_bmm_seed, } +def target_of(spec, values): + """A target may use variables (computable/BMM/{{bmm_schema_id}}.bmm.json).""" + target = render(spec["target"], values) if "{{" in spec["target"] else spec["target"] + require_relative(target, f"file '{spec['id']}' target") + return target + + def plan_file(ctx, spec): if spec["id"] in ctx.pinned: - return Act(spec, "pinned") + return Act(dict(spec, target=spec.get("present_glob", spec["target"])), "pinned") + spec = dict(spec, target=target_of(spec, ctx.values)) target = ctx.repo / spec["target"] if target.is_symlink(): return Act(spec, "conflict", "is a symbolic link; scaffold never writes through links") @@ -698,6 +763,68 @@ def validate_descriptor(desc): raise ScaffoldError(f"{DESCRIPTOR} is malformed: '{key}' must be a list") +def present(repo, spec): + if "present_glob" in spec: + return any(repo.glob(spec["present_glob"])) + return (repo / spec["target"]).exists() or (repo / spec["target"]).is_symlink() + + +def bundled_bmm(spec, name): + """Ids of the schemas the bmm-publisher image bundles for schema `name`, highest version last, + or (None, why) when the image could not be listed.""" + out, problem = docker("run", "--rm", "--pull", "never", "--entrypoint", "ls", spec["image"], spec["image_dir"]) + if problem: + return None, problem + pattern = re.compile(rf"openehr_{re.escape(name)}_[0-9]+\.[0-9]+\.[0-9]+") + ids = [line.strip()[: -len(".bmm.json")] for line in out.splitlines() if line.strip().endswith(".bmm.json")] + return sorted((i for i in ids if pattern.fullmatch(i)), key=version_key), None + + +def new_bmm(repo, spec, values, sources, notes): + """Decide the BMM schema of a new repository: the image's bundled copy, or the empty template. + + Returns {"schema_id", "inferred"} plus "text" (the bundled copy) or "note", or {"blocked": why} + when the image could not be checked and the user gave no schema id.""" + component = values["component"] + name = component.lower().replace("-", "_") + given = values.get("bmm_schema_id", "") if sources.get("bmm_schema_id") in ("explicit", "descriptor") else "" + fresh = given or f"openehr_{name}_{values['first_release']}" + ids, problem = bundled_bmm(spec, name) + if problem: + if not given: + return {"blocked": f"could not check whether {spec['image']} bundles a {component} schema ({problem}); " + f"start Docker (`docker pull {spec['image']}` if the image is missing) and plan " + f"again, or create an empty schema with --var bmm_schema_id={fresh}"} + result = {"schema_id": given, "note": f"{spec['image']} was not checked for a bundled copy ({problem})"} + else: + chosen = given or (ids[-1] if ids else fresh) + result = {"schema_id": chosen} + if chosen in ids: + text, problem = docker("run", "--rm", "--pull", "never", "--entrypoint", "cat", spec["image"], + f"{spec['image_dir']}/{chosen}.bmm.json") + if problem is None: + try: + json.loads(text) + except ValueError: + problem = "it is not valid JSON" + if problem: + return {"blocked": f"could not copy {chosen} from {spec['image']} ({problem}); plan again, or " + "create an empty schema with --var bmm_schema_id="} + result["text"] = normalise(text) + elif ids: + result["note"] = f"{spec['image']} bundles {', '.join(ids)}, not {chosen}" + inferred = {} + if not given: + inferred["bmm_schema_id"] = (result["schema_id"], + "bmm-publisher image" if "text" in result else "new empty schema") + if not result["schema_id"].startswith("openehr_base_"): + base = sibling_base(repo, notes) + if base: + inferred["base_bmm_schema_id"] = (base, "sibling specifications-BASE") + result["inferred"] = inferred + return result + + def 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]] @@ -718,8 +845,7 @@ def build_plan(repo, set_dir, explicit, overwrite=(), pin=()): 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" + mode = "upgrade" if any(present(repo, f) for f in tset["files"]) else "init" plan = Plan() plan.repo, plan.set_dir, plan.tset, plan.desc = repo, set_dir, tset, desc @@ -728,14 +854,25 @@ def build_plan(repo, set_dir, explicit, overwrite=(), pin=()): 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", [])) + saved, defaulted = (desc or {}).get("variables", {}), (desc or {}).get("defaulted", []) + inferred = infer(repo, tset, set_dir, plan.notes) + resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) + # a BMM schema is offered to a new repository only (or on request), so its id and the BASE + # dependency are known before the other templates (AGENTS.md) are rendered + plan.new_bmm = None + bmm_spec = next((f for f in tset["files"] if f["strategy"] == "bmm-seed"), None) + if (bmm_spec and bmm_spec["id"] not in plan.pinned and (mode == "init" or bmm_spec["id"] in overwrite) + and not present(repo, bmm_spec) and not resolved[2] and not resolved[3]): + plan.new_bmm = new_bmm(repo, bmm_spec, resolved[0], resolved[1], plan.notes) + if plan.new_bmm.get("inferred"): + inferred.update(plan.new_bmm["inferred"]) + resolved = resolve_variables(tset, explicit, saved, inferred, defaulted) + plan.values, plan.sources, plan.missing, plan.invalid = resolved 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) + ctx = Ctx(repo, set_dir, tset, plan.values, desc, overwrite, plan.pinned, plan.new_bmm) valid = {spec["id"] for spec in tset["files"]} for spec in tset["files"]: if spec["strategy"] == "regions": @@ -878,6 +1015,7 @@ def diff_file(repo, set_dir, explicit, file_id): 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}'") + spec = dict(spec, target=target_of(spec, plan.values)) rendered = rendered_for(Ctx(repo, set_dir, plan.tset, plan.values, plan.desc, (), ()), spec) current = read(repo / spec["target"]) if current is None: @@ -948,14 +1086,30 @@ def _check_template_set(set_dir): problems.append(str(exc)) if spec.get("strategy") not in STRATEGIES: problems.append(f"file '{label}': unknown strategy '{spec.get('strategy')}'") + if spec.get("strategy") == "bmm-seed": + lacking = [k for k in BMM_SEED_KEYS if not spec.get(k)] + if lacking: + problems.append(f"file '{label}': a bmm-seed file needs {', '.join(lacking)}") + if "{{bmm_schema_id}}" not in spec.get("target", ""): + problems.append(f"file '{label}': a bmm-seed target must contain {{{{bmm_schema_id}}}}") 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") + if sum(1 for f in tset["files"] if f.get("strategy") == "bmm-seed") > 1: + problems.append("only one file may use the bmm-seed strategy") 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 derived and ("map" in derived) == ("match" in derived): + problems.append(f"variable '{name}': derived needs either map or match") + elif derived and "match" in derived: + try: + if re.compile(derived["match"]).groups < derived.get("group", 1): + problems.append(f"variable '{name}': match has no group {derived.get('group', 1)}") + except re.error as exc: + problems.append(f"variable '{name}': match is not a valid regex ({exc})") if not derived and "default" not in spec and not spec.get("required"): problems.append(f"variable '{name}' needs a default or required: true") @@ -970,6 +1124,7 @@ def _check_template_set(set_dir): if not all(n and (set_dir / "assets" / "templates" / n).is_file() for n in names): continue # already reported as a missing template try: + target_of(spec, values) text = rendered_for(ctx, spec) if spec["target"].endswith(".json"): json.loads(text) diff --git a/scripts/test_scaffold.py b/scripts/test_scaffold.py index 813b455..db0773a 100644 --- a/scripts/test_scaffold.py +++ b/scripts/test_scaffold.py @@ -27,6 +27,24 @@ TITLE = {"component_title": "Demo Model"} +class FakeDocker: + """Stands in for scaffold.docker: an image bundling `schemas` ({id: text}), or none at all (None).""" + + def __init__(self, schemas, problem="docker is not installed"): + self.schemas, self.problem, self.calls = schemas, problem, [] + + def __call__(self, *args): + self.calls.append(args) + if self.schemas is None: + return None, self.problem + if args[-2:] == ("ghcr.io/openehr/bmm-publisher", "/app/resources"): + return "".join(f"{i}.bmm.json\n" for i in self.schemas), None + name = args[-1].rsplit("/", 1)[-1][: -len(".bmm.json")] + if name in self.schemas: + return self.schemas[name], None + return None, f"cat: can't open '{args[-1]}': No such file or directory" + + class Base(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -38,6 +56,11 @@ def setUp(self): patcher = mock.patch.dict(os.environ, env, clear=True) patcher.start() self.addCleanup(patcher.stop) + # the host's Docker must not leak in either: by default the bmm-publisher image is unavailable + self.docker = FakeDocker(None) + patcher = mock.patch.object(scaffold, "docker", self.docker) + patcher.start() + self.addCleanup(patcher.stop) self.repo = self.root / "specifications-DEMO" self.repo.mkdir() self.set_dir = scaffold.SET_DIR @@ -128,7 +151,7 @@ def test_init_creates_the_standard_set_and_the_second_run_is_a_noop(self): "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.assertTrue(all(a in ("unchanged", "exists", "skipped") 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")) @@ -1067,5 +1090,146 @@ def test_check_exit_code(self): self.assertEqual((code, json.loads(out)["ok"]), (0, True)) +BUNDLED = '{\n "bmm_version": "2.4",\n "rm_publisher": "openehr",\n "schema_name": "demo"\n}\n' +BMM_GLOB = "computable/BMM/*.bmm.json" + + +class BmmTests(Base): + """computable/BMM in a new repository: the schema the image bundles, else an empty one.""" + + def bmm_act(self, plan): + return next(a for a in plan.acts if a.id == "bmm") + + def test_the_highest_bundled_schema_of_the_component_is_copied(self): + self.docker.schemas = {"openehr_demo_1.0.0": "{}", "openehr_demo_1.2.0": BUNDLED, + "openehr_demo_1.10.0-bmm3": "{}", "openehr_demox_9.0.0": "{}", "openehr_rm_1.2.0": "{}"} + plan = self.plan() + self.assertEqual((plan.values["bmm_schema_id"], plan.sources["bmm_schema_id"]), + ("openehr_demo_1.2.0", "inferred: bmm-publisher image")) + self.assertIn("copied from ghcr.io/openehr/bmm-publisher", self.bmm_act(plan).detail) + self.apply() + self.assertEqual(self.text("computable/BMM/openehr_demo_1.2.0.bmm.json"), BUNDLED) + self.assertIn("computable/BMM/openehr_demo_1.2.0.bmm.json", self.text("AGENTS.md")) + self.assertIn(("run", "--rm", "--pull", "never", "--entrypoint", "ls", + "ghcr.io/openehr/bmm-publisher", "/app/resources"), self.docker.calls) + + def test_without_a_bundled_schema_an_empty_one_is_rendered(self): + self.docker.schemas = {"openehr_rm_1.2.0": "{}"} + self.base_clone("openehr_base_1.3.0") + result = self.apply(first_release="1.0.0", component_title='Demo "Quoted" Model') + self.assertIn("computable/BMM/openehr_demo_1.0.0.bmm.json", result["written"]) + schema = json.loads(self.text("computable/BMM/openehr_demo_1.0.0.bmm.json")) + self.assertEqual({k: schema[k] for k in ("bmm_version", "rm_publisher", "schema_name", "rm_release", + "schema_revision", "schema_description")}, + {"bmm_version": "2.4", "rm_publisher": "openehr", "schema_name": "demo", + "rm_release": "1.0.0", "schema_revision": "1.0.0.1", + "schema_description": 'openEHR Demo "Quoted" Model Component'}) + self.assertEqual(schema["includes"], {"openehr_base_1.3.0": {"id": "openehr_base_1.3.0"}}) + # bmm-publisher refuses a schema without a package + self.assertEqual(schema["packages"], {"org.openehr.demo": {"name": "org.openehr.demo", "classes": []}}) + self.assertEqual(schema["class_definitions"], {}) + self.assertIn(" -d /in/openehr_base_1.3.0.bmm.json \\\n", self.text("AGENTS.md")) + self.assertEqual(self.bmm_act(self.plan()).action, "exists") + + def test_without_a_base_clone_the_empty_schema_includes_nothing(self): + self.docker.schemas = {} + plan = self.plan() + self.assertTrue(any("no sibling specifications-BASE" in w for w in plan.warnings)) + self.assertNotIn("includes", json.loads(self.bmm_act(plan).content)) + + def test_without_docker_the_schema_is_blocked_and_the_rest_is_written(self): + plan = self.plan() + act = self.bmm_act(plan) + self.assertEqual((act.action, act.target), ("blocked", BMM_GLOB)) + self.assertIn("(docker is not installed)", act.detail) + self.assertIn("docker pull ghcr.io/openehr/bmm-publisher", act.detail) + self.assertIn("--var bmm_schema_id=openehr_demo_0.1.0", act.detail) + self.assertEqual(plan.values["bmm_schema_id"], "") + result = self.apply() + self.assertFalse((self.repo / "computable").exists()) + self.assertIn("AGENTS.md", result["written"]) + self.assertNotIn("class-generation", self.text("AGENTS.md")) + + def test_a_given_schema_id_creates_an_empty_schema_without_docker(self): + act = self.bmm_act(self.plan(bmm_schema_id="openehr_demo_2.0.0")) + self.assertEqual((act.action, act.target), ("create", "computable/BMM/openehr_demo_2.0.0.bmm.json")) + self.assertIn("was not checked for a bundled copy (docker is not installed)", act.detail) + self.assertEqual(json.loads(act.content)["rm_release"], "2.0.0") + + def test_a_given_schema_id_is_copied_when_the_image_bundles_it_and_rendered_when_not(self): + self.docker.schemas = {"openehr_demo_1.0.0": BUNDLED, "openehr_demo_1.2.0": "{}"} + act = self.bmm_act(self.plan(bmm_schema_id="openehr_demo_1.0.0")) + self.assertEqual((act.action, act.content), ("create", BUNDLED)) + act = self.bmm_act(self.plan(bmm_schema_id="openehr_demo_3.0.0")) + self.assertEqual(json.loads(act.content)["schema_revision"], "3.0.0.1") + self.assertIn("bundles openehr_demo_1.0.0, openehr_demo_1.2.0, not openehr_demo_3.0.0", act.detail) + + def test_an_unreadable_bundled_copy_blocks_instead_of_being_written(self): + self.docker.schemas = {"openehr_demo_1.0.0": "not json"} + act = self.bmm_act(self.plan()) + self.assertEqual(act.action, "blocked") + self.assertIn("not valid JSON", act.detail) + + def test_an_existing_repository_is_not_offered_one_and_docker_is_not_asked(self): + self.put("README.md", "# demo\n") + self.docker.schemas = {} + plan = self.plan() + self.assertEqual(plan.mode, "upgrade") + act = self.bmm_act(plan) + self.assertEqual((act.action, act.target), ("skipped", BMM_GLOB)) + self.assertIn("--overwrite bmm", act.detail) + self.assertEqual((plan.values["bmm_schema_id"], self.docker.calls), ("", [])) + + def test_overwrite_offers_one_to_an_existing_repository(self): + self.put("README.md", "# demo\n") + self.docker.schemas = {} + self.apply(overwrite=("bmm",)) + self.assertTrue((self.repo / "computable/BMM/openehr_demo_0.1.0.bmm.json").is_file()) + + def test_a_schema_already_in_computable_bmm_is_left_alone(self): + self.put("computable/BMM/openehr_demo_1.0.0.bmm.json", "{}") + self.docker.schemas = {"openehr_demo_1.2.0": BUNDLED} + act = self.bmm_act(self.plan()) + self.assertEqual((act.action, act.detail), ("exists", "openehr_demo_1.0.0.bmm.json")) + self.assertEqual(self.docker.calls, []) + + def test_a_pinned_schema_is_not_created_and_docker_is_not_asked(self): + self.docker.schemas = {} + plan = self.plan(pin=("bmm",)) + self.assertEqual((self.bmm_act(plan).action, self.bmm_act(plan).target), ("pinned", BMM_GLOB)) + self.assertEqual((plan.values["bmm_schema_id"], self.docker.calls), ("", [])) + + def test_a_hyphenated_component_gets_an_underscored_schema_name(self): + self.docker.schemas = {} + plan = self.plan(component="ITS-DEMO", jira_project="SPECITS") + self.assertEqual((plan.values["bmm_schema_id"], plan.values["bmm_schema_name"]), + ("openehr_its_demo_0.1.0", "its_demo")) + + def test_base_copies_its_own_schema_and_has_no_base_dependency(self): + repo = self.root / "specifications-BASE" + repo.mkdir() + self.docker.schemas = {"openehr_base_1.3.0": BUNDLED} + plan = self.plan(repo=repo, component_title="Base Model") + self.assertEqual((plan.values["bmm_schema_id"], plan.values["base_bmm_schema_id"]), + ("openehr_base_1.3.0", "")) + + def test_check_flags_a_bmm_seed_file_without_its_keys_and_a_derived_without_a_rule(self): + dst = self.copy_set() + path = dst / "assets" / "template-set.json" + tset = json.loads(path.read_text(encoding="utf-8")) + del next(f for f in tset["files"] if f["id"] == "bmm")["image"] + del tset["variables"]["bmm_rm_release"]["derived"]["match"] + path.write_text(json.dumps(tset), encoding="utf-8") + problems = scaffold.check_template_set(dst) + self.assertTrue(any("a bmm-seed file needs image" in p for p in problems), problems) + self.assertTrue(any("'bmm_rm_release': derived needs either map or match" in p for p in problems), problems) + + +class DockerHelperTests(unittest.TestCase): + def test_a_missing_docker_binary_is_reported_not_raised(self): + with mock.patch.dict(os.environ, {"PATH": ""}): + self.assertEqual(scaffold.docker("--version"), (None, "docker is not installed")) + + if __name__ == "__main__": unittest.main()