diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0138cc3..a2f5f9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Phase-0 manifest contract gate + # Asserts dist/repo.meta.json parses, carries all required fields, + # and every exposes.* path resolves on disk. Cross-repo tier-2 + # contract per .github/docs/AI-discoverability-plan.md §3.4. + # Runs before the Docker build so a broken manifest fails fast. + run: make check-manifest + + - name: docs/ prose-only gate + # Cross-repo guardrail: docs/ holds only human-readable prose. + run: make check-docs-prose + - name: Build image working-directory: docker run: docker compose build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f6f9d1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,134 @@ +--- +# Machine-readable project descriptor. +name: m-test-engine +kind: [infrastructure, container] +status: active +languages: [dockerfile] + +distribution: + github: m-dev-tools/m-test-engine + +location: ~/m-dev-tools/m-test-engine + +exposes: + container_name: "m-test-engine" + image_base: "yottadb/yottadb-base:latest-master" + mount_point: "/work" + lifecycle: "make up | down | smoke | shell | clean | logs" + +consumes: + formats: [] + services: [] + +companions: + - project: m-cli + relation: "consumer — DockerEngine transport drives `docker exec m-test-engine` for `m test` / `m coverage`" + - project: m-stdlib + relation: "consumer — its CI uses this container's image base (yottadb-base:latest-master); test runner is engine-agnostic and works against this container too" + +incompatibilities: + - "Not a VistA runtime. No FileMan, no RPC broker, no Kernel. Use vista-meta for VistA work." + - "No SSH server in the default container. m-cli's SSHEngine transport requires the optional compose.ssh.yml overlay (not shipped)." + +docs: + primary: README.md +--- + +# m-test-engine — Claude project context + +Minimal YottaDB Docker container for `m-cli` and `m-stdlib` testing. +Long-running container exposing a YottaDB engine via `docker exec`. +Consumer projects bind-mount their source as `/work` and dispatch +`docker exec m-test-engine $ydb_dist/mumps -run ...` commands. + +The full design rationale and lifecycle table is in `README.md`. + +## What this is + +- A `Dockerfile` (atop `yottadb/yottadb-base:latest-master`) plus a + `compose.yml` plus thin Makefile wrappers (`up`, `down`, `smoke`, + `shell`, `clean`, `logs`). +- A container name (`m-test-engine`) and a bind-mount contract + (`/work` in container = consumer's `$PWD` on host). +- A `dist/lifecycle.json` describing those facts in + machine-readable form for the org-level AI-discoverability catalog. + +## What this is NOT + +- A test framework. The tests live in the consumer repos. +- A VistA system. No FileMan, no Kernel, no RPC. Use vista-meta. +- A long-lived data store. The globals volume + (`m-test-engine-globals`) persists between runs as a convenience + but is destructively cleared by `make clean`. + +## Setup + +```bash +git clone https://github.com/m-dev-tools/m-test-engine +``` + +Requires Docker + `docker compose`. No Python or Node. + +## Test + +```bash +make smoke # one-shot: `docker exec ... $ydb_dist/mumps -run %XCMD 'write "ok",!'` +``` + +The smoke test is also the CI gate — it asserts the container starts +clean on a fresh checkout, the YDB environment loads via +`/etc/profile.d/ydb-env.sh`, and `mumps -run %XCMD` produces the +expected output. If smoke fails, the container is unusable; consumer +projects (m-cli, m-stdlib) will fail too. + +## Build / generate + +```bash +make up # build + start the container detached +make down # stop + remove the container (globals volume preserved) +make logs # tail container stdout (debugging startup) +make shell # interactive bash inside the container +make clean # DESTRUCTIVE — stop + remove + drop globals volume +``` + +The `dist/lifecycle.json` published by this repo for AI-discoverability +is hand-authored. When the Dockerfile or `compose.yml` changes in a way +that affects an external claim (image base, container name, mount +point, exposed make targets), update `dist/lifecycle.json` in the same +commit. + +## Verify + +The `verification_commands` declared in `dist/repo.meta.json`: + +```bash +make smoke # container starts + mumps runs +make check-manifest # dist/repo.meta.json valid + exposes.* paths exist +``` + +Plus the cross-repo guardrail: + +```bash +make check-docs-prose # docs/ holds only prose (this repo has no docs/ at all) +``` + +## Guardrails + +- **Do not add VistA-specific extras.** No FileMan, no RPC broker, no + Kernel routines, no Octo SQL. m-test-engine is deliberately minimal; + VistA work belongs in vista-meta. +- **Do not add an SSH server to the default image.** If a consumer + needs `m-cli`'s `SSHEngine` transport, ship an opt-in overlay + (`compose.ssh.yml`) rather than expanding the default container. +- **Do not hand-edit `dist/repo.meta.json` `verified_on` to a future + date.** The org smoke test rejects manifests older than 90 days; bump + the date only when the manifest changes materially (image bump, + container rename, mount-point change). +- **Container name and mount point are public contract.** + `m-test-engine` (name) and `/work` (mount) are referenced by + consumer code paths in m-cli and m-stdlib. Renaming either is a + breaking change requiring coordinated updates in those repos. +- **Image base is pinned for a reason.** The Dockerfile pin + (`yottadb-base:latest-master`) matches what `m-stdlib`'s CI uses. + Bumping requires confirming both consumers still pass against the + new base. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Makefile b/Makefile index 1491616..6956ad0 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ # But run-from-here works too: cd into m-test-engine and `make smoke` # for a quick check. -.PHONY: up down logs shell smoke clean +.PHONY: up down logs shell smoke clean manifest check-manifest check-docs-prose COMPOSE := docker compose -f docker/compose.yml @@ -48,3 +48,42 @@ smoke: # left ^STDLIB or similar in a bad state and you want a clean slate. clean: $(COMPOSE) down -v + +# ── Phase-0 AI-discoverability contract ─────────────────────────────── +# +# Tier-2 entry to the org catalog. See +# https://github.com/m-dev-tools/.github/blob/main/docs/AI-discoverability-plan.md +# +# `dist/lifecycle.json` is hand-authored, not regenerated — the source +# of truth for the container's name, image base, mount point, and the +# make/compose lifecycle targets is the Dockerfile + compose.yml + this +# Makefile, all already committed. When any of those change in a way +# that affects an external claim, update lifecycle.json in the same +# commit (this is captured in AGENTS.md § Guardrails). +# +# `make manifest` is therefore a pointer no-op — it exists so +# verification_commands in dist/repo.meta.json line up with other org +# repos. + +manifest: + @echo "m-test-engine: dist/lifecycle.json is hand-authored alongside Dockerfile + compose.yml." + @echo " see AGENTS.md § Build / generate for the rebuild-when-it-changes guardrail." + +check-manifest: + python3 tools/check-manifest.py + +# Guardrail: docs/ holds only human-readable prose. Same target name +# as the tier-1 repos so cross-repo muscle memory works. +check-docs-prose: + @if [ ! -d docs ]; then echo "check-docs-prose: no docs/ directory ✓"; exit 0; fi; \ + violations=$$(find docs -type f \ + ! -name '*.md' ! -name '*.markdown' \ + ! -name '*.png' ! -name '*.jpg' ! -name '*.jpeg' \ + ! -name '*.gif' ! -name '*.svg' ! -name '*.webp' \ + ! -name '.gitkeep'); \ + if [ -n "$$violations" ]; then \ + echo "ERROR: non-prose files under docs/ — move to a top-level domain dir:" >&2; \ + echo "$$violations" >&2; \ + exit 1; \ + fi; \ + echo "check-docs-prose: docs/ is prose-only ✓" diff --git a/dist/lifecycle.json b/dist/lifecycle.json new file mode 100644 index 0000000..9812113 --- /dev/null +++ b/dist/lifecycle.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1", + "container_name": "m-test-engine", + "hostname": "m-test-engine", + "image": "m-test-engine:latest", + "image_base": "yottadb/yottadb-base:latest-master", + "mount_point": "/work", + "mount_source_env": "M_TEST_ENGINE_BIND", + "mount_source_default": "$PWD", + "globals_volume": "m-test-engine-globals", + "ports": [], + "ssh_server": false, + "lifecycle": { + "up": { "make": "make up", "compose": "docker compose -f docker/compose.yml up -d --build" }, + "down": { "make": "make down", "compose": "docker compose -f docker/compose.yml down" }, + "logs": { "make": "make logs", "compose": "docker compose -f docker/compose.yml logs -f" }, + "shell": { "make": "make shell", "compose": "docker exec -it m-test-engine bash" }, + "smoke": { "make": "make smoke", "compose": "docker exec m-test-engine bash -lc '$ydb_dist/mumps -run %XCMD '\\''write \"smoke ok\",!'\\'''" }, + "clean": { "make": "make clean", "compose": "docker compose -f docker/compose.yml down -v" } + }, + "exec_convention": { + "shape": "docker exec m-test-engine bash -lc ''", + "ydb_env_loaded_via": "/etc/profile.d/ydb-env.sh (sources /opt/yottadb/current/ydb_env_set)", + "available_env_vars": ["ydb_dist", "ydb_routines"], + "notes": "Use bash -lc so the YDB env loads. Single-quote M commands so the outer bash does not expand $ZVERSION etc. (those are YDB special variables, not shell vars)." + }, + "consumers": [ + { "repo": "m-cli", "transport": "DockerEngine (src/m_cli/engine.py)" }, + { "repo": "m-stdlib", "transport": "test runner; reuses m-cli's DockerEngine when m-cli is installed" } + ] +} diff --git a/dist/repo.meta.json b/dist/repo.meta.json new file mode 100644 index 0000000..50ba023 --- /dev/null +++ b/dist/repo.meta.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/m-dev-tools/.github/main/profile/repo.meta.schema.json", + "id": "tool:m-test-engine", + "repo": "https://github.com/m-dev-tools/m-test-engine", + "role": "Minimal YottaDB Docker container — lightweight default test substrate for m-cli + m-stdlib", + "language": ["dockerfile"], + "license": "AGPL-3.0", + "agent_instructions": "AGENTS.md", + "verified_on": "2026-05-10", + "exposes": { + "lifecycle": "dist/lifecycle.json", + "dockerfile": "docker/Dockerfile", + "compose": "docker/compose.yml" + }, + "verification_commands": ["make smoke", "make check-manifest"], + "status": "active", + "notes": "Container name 'm-test-engine' and mount point '/work' are public contract — m-cli's DockerEngine and m-stdlib's test runner reference them. Image base 'yottadb-base:latest-master' is pinned to match m-stdlib's CI." +} diff --git a/tools/check-manifest.py b/tools/check-manifest.py new file mode 100755 index 0000000..f01d59a --- /dev/null +++ b/tools/check-manifest.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Phase-0 contract gate for dist/repo.meta.json. + +Validates that: + 1. dist/repo.meta.json parses as JSON. + 2. Required fields from the org-level repo.meta.schema.json contract + are present. + 3. Each path under `exposes.*` resolves on disk. + 4. (Best-effort) full schema validation if jsonschema is available + and the canonical schema URL is reachable. + +Exits 0 on success; non-zero with structured stderr on failure. + +Engine-free, no Node, no Python deps beyond the standard library +unless jsonschema happens to be installed. +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +from pathlib import Path + +MANIFEST = Path("dist/repo.meta.json") + +REQUIRED_FIELDS = ( + "id", + "repo", + "role", + "language", + "license", + "agent_instructions", + "verified_on", + "exposes", + "verification_commands", +) + + +def main() -> int: + if not MANIFEST.exists(): + print(f"ERROR: {MANIFEST} not found", file=sys.stderr) + return 1 + + try: + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"ERROR: {MANIFEST} is invalid JSON: {exc}", file=sys.stderr) + return 1 + + missing = [f for f in REQUIRED_FIELDS if f not in data] + if missing: + print(f"ERROR: missing required fields: {missing}", file=sys.stderr) + return 1 + + fail = False + for key, rel_path in data["exposes"].items(): + if rel_path.startswith(("http://", "https://")): + continue + if not Path(rel_path).exists(): + print( + f"ERROR: exposes.{key} payload missing on disk: {rel_path}", + file=sys.stderr, + ) + fail = True + if fail: + return 1 + + # Best-effort full schema validation. Skipped silently if jsonschema + # isn't available (the canonical Track-A validator runs in the org + # smoke test against the same manifest). + try: + from jsonschema import Draft202012Validator # type: ignore + except ImportError: + print( + "check-manifest: dist/repo.meta.json valid; " + "all exposes.* present ✓ (jsonschema not installed — " + "skipping full schema validation)" + ) + return 0 + + schema_uri = data.get("$schema", "") + try: + with urllib.request.urlopen(schema_uri, timeout=5) as resp: + schema = json.load(resp) + except Exception as exc: # noqa: BLE001 + print( + f"check-manifest: dist/repo.meta.json valid; all exposes.* " + f"present ✓ (skipped live schema fetch: {exc})" + ) + return 0 + + errors = list(Draft202012Validator(schema).iter_errors(data)) + if errors: + for err in errors: + path = "/".join(str(p) for p in err.absolute_path) or "" + print(f"SCHEMA ERROR at {path}: {err.message}", file=sys.stderr) + return 1 + + print( + "check-manifest: dist/repo.meta.json valid against org schema; " + "all exposes.* present ✓" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())