diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74e2377..9df75fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,3 +8,5 @@ on: jobs: ci: uses: vista-cloud-dev/.github/.github/workflows/go-ci.yml@main + arch: + uses: vista-cloud-dev/.github/.github/workflows/arch-waterline.yml@main diff --git a/.gitignore b/.gitignore index c73816d..ee6eaab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -# Build output -/dist/ +# Build output — ignore everything in dist/ EXCEPT the generated, committed +# contract artifact (drift-gated *.json). +/dist/* +!/dist/*.json /bin/ *.test # coverage report artifacts — specific/anchored so they never match a source diff --git a/Makefile b/Makefile index 5e4ec87..ecaabea 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -# m-kids — KIDS round-trip CLI. Build conventions inherited from +# v-pkg — the `v pkg` KIDS domain. Build conventions inherited from # go-cli-template: static (CGO_ENABLED=0), -trimpath, version stamped via -# -ldflags, cross-compile matrix, lint, test, schema. +# -ldflags, cross-compile matrix, lint, test, schema, contract. -BIN ?= m-kids # KIDS round-trip CLI -PKG := github.com/vista-cloud-dev/m-kids +BIN ?= v-pkg # the v pkg domain CLI (standalone) +PKG := github.com/vista-cloud-dev/v-pkg LDPKG := $(PKG)/clikit VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) @@ -16,7 +16,7 @@ export CGO_ENABLED := 0 PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 windows/amd64 -.PHONY: all build run lint test tidy schema dist clean +.PHONY: all build run lint test tidy schema contract check-contract dist clean all: lint test build @@ -39,6 +39,14 @@ tidy: schema: build ./dist/$(BIN) schema +# Regenerate the v-cli domain contract (v-cli-platform.md §4) → dist/v-contract.json. +contract: + UPDATE_GOLDEN=1 go test ./pkgcli/ -run Contract + +# Drift gate: fail if dist/v-contract.json is stale vs the live command tree. +check-contract: + go test ./pkgcli/ -run Contract + # Cross-compile the pinned matrix into dist/. dist: @mkdir -p dist @@ -50,4 +58,5 @@ dist: done clean: - rm -rf dist + rm -f dist/$(BIN) dist/$(BIN)-* *.test + @# keep dist/*.json — committed, drift-gated contract artifacts diff --git a/clikit/version.go b/clikit/version.go index b76ae92..58db528 100644 --- a/clikit/version.go +++ b/clikit/version.go @@ -4,7 +4,7 @@ import "runtime" // Build metadata, injected at link time: // -// go build -ldflags "-X github.com/vista-cloud-dev/m-kids/clikit.Version=$VER \ +// go build -ldflags "-X github.com/vista-cloud-dev/v-pkg/clikit.Version=$VER \ // -X …/clikit.Commit=$SHA -X …/clikit.Date=$DATE" var ( Version = "dev" diff --git a/dist/v-contract.json b/dist/v-contract.json new file mode 100644 index 0000000..520ee96 --- /dev/null +++ b/dist/v-contract.json @@ -0,0 +1,275 @@ +{ + "domain": "pkg", + "version": "0.1.0", + "contractVersion": "1.0", + "exits": [ + { + "code": 0, + "meaning": "ok" + }, + { + "code": 1, + "meaning": "runtime error" + }, + { + "code": 2, + "meaning": "usage error" + }, + { + "code": 3, + "meaning": "check / gate failed" + }, + { + "code": 4, + "meaning": "refused / substrate unavailable" + } + ], + "commands": [ + { + "path": [ + "parse" + ], + "help": "Parse a .KID file and summarize its builds and sections.", + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID file." + } + ] + }, + { + "path": [ + "decompose" + ], + "help": "Split a .KID into a per-component KIDComponents/ tree.", + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID file." + }, + { + "name": "output-dir", + "type": "string", + "required": true, + "help": "Output directory for the component tree (replaced if it exists)." + } + ] + }, + { + "path": [ + "assemble" + ], + "help": "Reassemble a component tree back into a .KID.", + "args": [ + { + "name": "input-dir", + "type": "string", + "required": true, + "help": "Component tree (a directory of \u003cbuild\u003e/KIDComponents/)." + }, + { + "name": "output-kid", + "type": "string", + "required": true, + "help": "Output .KID path." + } + ] + }, + { + "path": [ + "roundtrip" + ], + "help": "Verify decompose→assemble reproduces the build (exit 3 on drift).", + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID file." + } + ] + }, + { + "path": [ + "canonicalize" + ], + "help": "Substitute install-time IENs with \"IEN\" in a tree (LOSSY; review-only).", + "args": [ + { + "name": "decomp-dir", + "type": "string", + "required": true, + "help": "A decomposed component tree to rewrite in place (LOSSY)." + } + ] + }, + { + "path": [ + "lint" + ], + "help": "Run the PIKS data-class gate over a .KID (exit 3 on a blocked file).", + "flags": [ + { + "name": "piks", + "type": "string", + "help": "Path to an authoritative PIKS classification table (TSV: filenumber\u003cTAB\u003eclass)." + }, + { + "name": "strict", + "type": "bool", + "help": "Treat unclassified data files as gate failures (fail-closed)." + } + ], + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID file." + } + ] + }, + { + "path": [ + "build" + ], + "help": "Build a KIDS transport global from a declarative build spec (deterministic, normalized export).", + "flags": [ + { + "name": "src", + "type": "string", + "default": "src", + "help": "Directory holding the routine source (\u003croutine\u003e.m)." + }, + { + "name": "out", + "type": "string", + "help": "Output .KID path (default: dist/kids/\u003cpkg\u003e.kids)." + } + ], + "args": [ + { + "name": "spec", + "type": "string", + "required": true, + "help": "Path to the kids/\u003cpkg\u003e.build.json build spec." + } + ] + }, + { + "path": [ + "install" + ], + "help": "Install a built .KID on a live engine over the driver (non-interactive KIDS load+install).", + "flags": [ + { + "name": "engine", + "type": "enum", + "enum": [ + "ydb", + "iris" + ], + "required": true, + "help": "Engine to reach: ydb or iris." + }, + { + "name": "transport", + "type": "enum", + "enum": [ + "local", + "docker", + "remote" + ], + "default": "remote", + "help": "Driver transport: local | docker | remote." + } + ], + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the built .KID transport file to install on the live engine." + } + ] + }, + { + "path": [ + "verify" + ], + "help": "Verify a .KID's install on a live engine (#9.7 status + per-routine presence).", + "flags": [ + { + "name": "engine", + "type": "enum", + "enum": [ + "ydb", + "iris" + ], + "required": true, + "help": "Engine to reach: ydb or iris." + }, + { + "name": "transport", + "type": "enum", + "enum": [ + "local", + "docker", + "remote" + ], + "default": "remote", + "help": "Driver transport: local | docker | remote." + } + ], + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID whose install to verify (its name + routines)." + } + ] + }, + { + "path": [ + "uninstall" + ], + "help": "Uninstall a .KID from a live engine (routine-only back-out: routines + #9.7/#9.6).", + "flags": [ + { + "name": "engine", + "type": "enum", + "enum": [ + "ydb", + "iris" + ], + "required": true, + "help": "Engine to reach: ydb or iris." + }, + { + "name": "transport", + "type": "enum", + "enum": [ + "local", + "docker", + "remote" + ], + "default": "remote", + "help": "Driver transport: local | docker | remote." + } + ], + "args": [ + { + "name": "kid-file", + "type": "string", + "required": true, + "help": "Path to the .KID whose install to reverse (routine-only back-out)." + } + ] + } + ] +} diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 900dcc7..6a470fb 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -23,7 +23,7 @@ append any new `§ Lessons learned`, and log directional decisions in `§ Q&A`. | P4.1 | — Phase 1: inventory only (`#9.4`/`#9.6`/`#9.7` → `inventory.json`, zero writes, no PHI) | ☐ | — | [§P4](#p4) | | P4.2 | — Phase 2: definition extraction (S2/S3) behind the PIKS airlock | ☐ | — | [§P4](#p4) | | P4.3 | — Phase 3: S1 re-export → real `.KID` for `m-kids` round-trip | 🔒 | needs gold doc (P6) | [§P4](#p4) | -| **P5** | KIDS install automation (silent/non-interactive install of a `.KID`) | ☐ design only | `docs/kids-installation-automation.md` | [§P5](#p5) | +| **P5** | KIDS install automation (silent/non-interactive install of a `.KID`) | ◑ built — `v pkg install/verify/uninstall` over the driver; **install now streams the transport global in size-bounded chunks → staging global → MERGE + `EN^XPDIJ`** (2026-06-12), fixing a silent partial-install of large packages (one-mega-routine staging truncated). YDB live-proven incl. the full 15-routine MSL base (test-in-place 15/15 suites); IRIS live-validation of the chunked path owed (T0b.2 IRIS leg) | `pkgcli/lifecycle.go`, `internal/installspec/*`, `docs/kids-installation-automation.md §7` | [§P5](#p5) | | **P6** | Gold-doc gap — *Kernel 8.0 Developer's Guide: KIDS Developer Tools User Guide* (silent-install APIs + `XPD*` answer vars + re-export entry points) | ☐ | VDL fetch pending | [§P6](#p6) | | **P7** | Engine parity for extraction + install (`^XTMP`/`XINDEX`/KIDS identical under YottaDB & IRIS) | 🔒 | depends on `m-ydb`/`m-iris` real-engine spikes | [§P7](#p7) | @@ -122,7 +122,7 @@ definition walk / S3 component dump); recommended architecture runs over the engine-neutral driver contract (P7). **Phase 1 (inventory-only) is zero-write, no-PHI — ship first.** Open items tracked in `§ Q&A` (Q2, Q5). -### P5 — KIDS install automation (design only) {#p5} +### P5 — KIDS install automation (built; IRIS-live pending) {#p5} Per `docs/kids-installation-automation.md`: drive the authoritative 3-phase KIDS install (load distribution → install build → post-install) non-interactively. Two tiers: **Tier A** = native silent-install APIs + `XPD*` answer variables @@ -131,6 +131,21 @@ interactive menus (the safe interim default). Transport globals live in `^XTMP`; menus `[XPD MAIN]`/`[XPD INSTALLATION MENU]`; keys `XUPROG`/`XUPROGMODE`. Runs over the driver contract (P7). Open items in `§ Q&A` (Q3, Q4). +**Built (2026-06-12):** the chosen automation route is the **direct-`^XTMP` +populate** path (§7.1) — `internal/installspec` generates the proven M (create the +`#9.7` entry via `$$INST^XPDIL1` → populate `^XTMP("XPDI",XPDA,…)` from the parsed +`.KID` pairs → synchronous `EN^XPDIJ`; `<>key=value` result markers), and +`pkgcli/lifecycle.go` mounts **`v pkg install` / `verify` / `uninstall`** on it. +The waterline split holds: the KIDS knowledge stays here, while reaching a live +engine goes through the shared `mdriver.Client` (m-driver-sdk v0.3.0) — each verb +stages its script as a scratch routine (`ZVPKG*`) via `exec load` and runs `EN^…` +via `exec run` (one process = one symbol table, so `XPDA` survives the SETs). +Markers are read off captured device output. **Live-proven on the YDB FOIA engine +(T0a.3/T0a.4).** Remaining: **T0a.5** — the same lifecycle live on the **IRIS +FOIA** engine (`foia` container) is the M0a exit gate (three invariants green on +both engines). `v pkg ` takes the built `.KID` + `--engine ydb|iris` +`--transport local|docker|remote`. + ### P6 — Gold-doc gap {#p6} The *Kernel 8.0 Developer's Guide: KIDS Developer Tools User Guide* is **not** in the `~/data/vdocs` gold corpus and is required for the exact silent-install APIs, diff --git a/docs/kids-installation-automation.md b/docs/kids-installation-automation.md index 9bc4994..9391d0b 100644 --- a/docs/kids-installation-automation.md +++ b/docs/kids-installation-automation.md @@ -162,9 +162,9 @@ flowchart LR SPEC --> ORCH["installer orchestrator"] subgraph tierA["Tier A — API-driven (preferred)"] - A1["$$LOAD^XPDID / silent load API"] - A2["set XPD* answer variables in symbol table"] - A3["D INSTALL / silent install entry point"] + A1["EN1^XPDIL — load distribution from HFS → ^XTMP + INSTALL #9.7"] + A2["pre-seed symbol table: XPDDIQ(\"XPZ1/XPO1/XPI1\")=0, Delay=0, null device"] + A3["EN^XPDIJ(xpda) — task install of the loaded build by #9.7 IEN (ICR 2243)"] end subgraph tierB["Tier B — expect-driven (fallback)"] B1["spawn terminal session"] @@ -178,13 +178,29 @@ flowchart LR ENG --> RES["structured result
(read from INSTALL #9.7 + #9.4)"] ``` -- **Tier A — API/silent install (preferred).** KIDS exposes silent entry points - used by automated patching: a build is loaded with a load API, the standard - answers are pre-seeded as `XPD*` variables in the M symbol table, and the - install is driven without prompting. This is how FORUM/Patchman-style - automatic patching (`[XPD AUTOMATIC PATCHING MENU]`) operates. Exact entry - points must be confirmed against the KIDS Developer Tools guide (gap — § - References [4]). +- **Tier A — API/silent install (preferred). Entry points confirmed from the + GOLD corpus (2026-06-12), closing the prior gap:** + - **Load:** `EN1^XPDIL` — the routine behind `[XPD LOAD DISTRIBUTION]`; loads + the HFS `.KID` into `^XTMP` and creates an INSTALL (#9.7) entry. It is the + *interactive option routine* (prompts "Enter a Host File:") — there is **no** + documented param-list silent API (`$$LOAD^XPDID` etc. do **not** exist), so it + must be driven with the file/device answers pre-seeded. + - **Install:** `EN^XPDIJ(xpda)` — the one documented *programmatic* install call + (ICR **#2243**, Controlled Subscription): tasks off the install of an + **already-loaded** build, where `xpda` = the build's INSTALL (#9.7) IEN. (The + interactive option routine is `EN^XPDI`.) + - **Suppress the standard questions** from the env-check via the `XPDDIQ` array: + `XPDDIQ("XPZ1")=0` (disable options/protocols → NO) is the documented case; + `XPDDIQ("XPO1")=0` (rebuild menu trees) / `XPDDIQ("XPI1")=0` (inhibit logons) + follow the same answer-code pattern. Delay = `0`; device = null/non-queued via + the Kernel IO context (no single documented `XPD*` device var). + - **Phase:** `XPDENV` = `1` during the install-phase env-check, `0` during the + load-phase one — guard install-only setup (e.g. `XPDDIQ`) on `XPDENV=1`. + - **Caveat:** the exact param signatures + non-interactive driving of these + routines are **not confirmable from docs alone** (the corpus has no XPD* + routine source) — confirm against a real Kernel routine listing (`%RO`/`ZL`) + on a live engine before relying on them. So a live FOIA VistA is still + required to finalize + validate the driver (M0a's "deepest unknown"). - **Tier B — expect-driven (fallback).** When silent APIs are unavailable, drive the menu in a pseudo-terminal: send `D ^XUP`/`EVE` → KIDS → `XPD INSTALL BUILD`, then a prompt→answer state machine sourced from `install-spec.yaml`. @@ -264,6 +280,99 @@ with long background post-installs (e.g. cross-reference rebuilds that pause every N records), the orchestrator must poll the INSTALL `#9.7` status and any build-specific completion tag rather than assuming synchronous completion. [5] +### 7.1 Live-proven minimal sequence (ZZSKEL, YottaDB FOIA — 2026-06-12) + +The full load → install → verify → uninstall lifecycle was driven end-to-end on +a live FOIA `worldvista/vehu` engine (`GT.M V7.0-005`), installing the throwaway +`ZZSKEL` routine-only package built by `v pkg build`. This is the ground truth +the §11 entry-point research was missing; every claim below was observed live. + +**Load** — `EN1^XPDIL` (the `ST→GI` path) reads the host file interactively. Its +`GI` parser expects exactly the layout `v pkg build` emits: line 1 banner, line 2 +comment, a `**KIDS**:^` line, a blank terminator, then `**INSTALL NAME**` + +name, then `node)` / `value` pairs to `**END**`. Driving it needs **two** answers +on stdin — the host-file path and accept-default at *"Want to Continue with Load? +YES//"*. On success it populates `^XTMP("XPDI",XPDA,…)` and a `#9.7` INSTALL +entry, and prints *"Use INSTALL NAME: to install this Distribution."* + +```m +S DUZ=1,DUZ(0)="@",DT=$$DT^XLFDT,U="^" +D EN1^XPDIL ; reads the next two stdin lines: +; /tmp/ZZSKEL.kids (host file) +; YES (continue-with-load default) +S XPDA=$O(^XPD(9.7,"B","ZZSKEL*1.0*1",0)) ; recover the loaded build's #9.7 IEN +``` + +**Install** — call `EN^XPDIJ` **directly** (synchronous; it is the job TaskMan +would otherwise queue). It self-runs `INIT^XPDID` (builds the `"ASP"` xref), +installs the routines via `IN^XPDIJ1`, and sets `#9.7` status. It does **not** +prompt: the interactive questions live in the *preceding* `XPDIA`/`XPDIP` phase, +and `$$ANSWER^XPDIQ` returns `""` (= default NO) for a routine-only build with no +stored answers — exactly what automation wants. Full FM priv (`DUZ(0)="@"`) is +required. + +```m +S XPDA=$O(^XPD(9.7,"B","ZZSKEL*1.0*1",0)) D EN^XPDIJ +``` + +**Verify** — success markers, all confirmed live: +- `$P(^XPD(9.7,XPDA,0),U,9) = 3` — INSTALL `#9.7` status **"Install Completed"** + (piece 9 of the 0-node; **set `U="^"` before slicing** — a missing `U` silently + yields an empty/garbage piece and was the one false-alarm this session). +- `$T(^ZZSKEL)]""` — routine installed and `$$PING^ZZSKEL()` returns `"pong"` + (the routine actually executes, not merely files). +- `#9.6` BUILD entry created (`^XPD(9.6,"B","")`). + +**Uninstall (reversibility, T0a.4)** — KIDS ships **no** generic uninstall; back-out +is the tool's job. For a routine-only package three deletions fully reverse it: + +```m +S X="ZZSKEL" X ^%ZOSF("DEL") ; delete routine (.m + .o) +S DA=$O(^XPD(9.7,"B","ZZSKEL*1.0*1",0)),DIK="^XPD(9.7," D ^DIK ; delete #9.7 INSTALL +S DA=$O(^XPD(9.6,"B","ZZSKEL*1.0*1",0)),DIK="^XPD(9.6," D ^DIK ; delete #9.6 BUILD +``` + +A snapshot→install→uninstall→diff cycle proved **reversible**: the routine is +absent (and `ZZSKEL.m`/`.o` gone from disk) and both `#9.6`/`#9.7` B-xrefs return +to empty, identical to the pre-install snapshot. The only residual divergence is +the monotonic `#9.6`/`#9.7` IEN counters (`^XPD(9.x,0)` piece 3) — inherent to +FileMan, not a leak. + +**Gotcha — corrupt half-installs.** A prior aborted install can leave a `#9.7` +entry with the `"ASP"`/`"INI"`/`"INIT"` xrefs (written by `INIT^XPDID` at install +start) but **no `0`-node** — `EN^XPDIJ`'s first line `Q:'$D(^XPD(9.7,+$G(XPDA),0))` +silently bails on it, and `EN1^XPDIU` (the KIDS unload) crashes reading the missing +`0`-node. Automation must purge by IEN (`K ^XPD(9.7,ien),^XPD(9.7,"ASP",ien)` + +the `"B"` xref + `^XTMP("XPDI",ien)`) before a clean reinstall. + +**Non-interactive load is the remaining design point** for `v pkg install` over +the driver `Exec` (subprocess + JSON, no interactive stdin): either drive the two +`EN1^XPDIL` prompts through a stdin-capable transport, or populate +`^XTMP("XPDI",XPDA,…)` + the `#9.7` entry directly from the parsed `.KID` (whose +node/value pairs are exactly the transport-global contents) and call `EN^XPDIJ`. +The direct-populate path sidesteps the interactive prompt entirely and is the +chosen automation route. + +**Streamed populate (2026-06-12) — the transport global is too big for one +routine.** The first cut embedded every `^XTMP("XPDI",XPDA,…)` SET in one +generated routine (`ZVPKGINS`) and ran it. That works for a one-routine fixture +(ZZSKEL) but **silently partial-installs a real package**: the MSL base +(15 routines / ~6100 nodes / ~560 KB) produced a ~560 KB install routine, which +the driver's routine staging **truncates without error** (T0b.2 discoveries P1), +so only the first ~3 routines landed while `EN^XPDIJ` still reported `#9.7` +status 3. The fix (`internal/installspec`, `pkgcli/lifecycle.go`): **stream the +pairs into a staging global `^XTMP("VPKGI",…)` in size-bounded chunks** +(`StageChunks`, ≤40 KB each — each stages reliably), then a **constant-size +finalize routine** (`FinalInstallScript`) counts the staged nodes (refusing with +`error=stage-incomplete` on any mismatch — a silent truncation now fails loudly), +creates the `#9.7` entry, `MERGE`s the staged tree into `^XTMP("XPDI",XPDA)`, and +runs `EN^XPDIJ` — all in one process so XPDA survives. Live-proven on the YDB FOIA +`vehu`: the full 15-routine MSL base installs (all `$T(^STD*)=1`), the m-stdlib +suites pass **test-in-place** (15/15 suites, 1403 assertions), and uninstall is +reversible. (A native driver `SetGlobal` would let the host populate `^XTMP` +without staging routines at all — cleaner, but it is not on the reference +`mdriver.Client` yet; the chunked-routine path needs no SDK change.) + --- ## 8. Verification & idempotency @@ -316,10 +425,22 @@ Back-out, Rollback guide). Automation should: ## 11. Open questions -1. **Exact silent-install entry points.** The precise KIDS load/install APIs and - the full `XPD*` answer-variable names for fully non-interactive installs need - confirmation from the *KIDS Developer Tools User Guide* (§References [4], a - gold-corpus gap). Until then, Tier B (expect) is the safe default. +1. **Exact silent-install entry points. ~~Gap~~ — RESOLVED 2026-06-12 from the + GOLD corpus** (Kernel 8.0 DG/SM KIDS UG + TM): load = `EN1^XPDIL`, install = + `EN^XPDIJ(xpda)` (ICR 2243) / `EN^XPDI`, answer-suppression via + `XPDDIQ("XPZ1"/"XPO1"/"XPI1")`, phase via `XPDENV`, result via INSTALL #9.7 + `STATUS` (#.02) = "Install Completed" (global `^XPD(9.7,…)`) + PACKAGE #9.4 + patch history (`^DIC(9.4,ien,22,v,1105,…)`). **No** clean public "silent load + from HFS / silent install by name" API exists — `EN1^XPDIL`/`EN^XPDI` are the + interactive option routines, driven under a pre-seeded symbol table; `EN^XPDIJ` + tasks an already-loaded build. **CLOSED 2026-06-12 (live, YDB FOIA):** the + exact sequence is proven end-to-end in [§7.1](#71-live-proven-minimal-sequence-zzskel-yottadb-foia--2026-06-12) + — `EN1^XPDIL` load (two stdin answers), `EN^XPDIJ` synchronous install (no + prompt; `$$ANSWER^XPDIQ`→`""` default-NO), `#9.7` status piece 9 = 3, routine + installed + executes. Reversible uninstall via `^%ZOSF("DEL")` + `DIK` on + `#9.7`/`#9.6`. The one remaining design point is **non-interactive load over + the driver `Exec`** (direct `^XTMP` populate vs stdin transport), not the entry + points. Tier B (expect) stays the cross-engine fallback. 2. **Queued-install polling contract.** Standardize how the orchestrator polls TaskMan + `#9.7` for queued/background completion. 3. **Engine parity.** Confirm `^XTMP`, `XINDEX`, and KIDS behave identically diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md new file mode 100644 index 0000000..7b95b33 --- /dev/null +++ b/docs/memory/MEMORY.md @@ -0,0 +1,5 @@ +# v-pkg — per-repo memory index + +One line per memory file. Content lives in the files, not here. + +- [streamed-install](streamed-install.md) — `v pkg install` now streams the transport global in size-bounded chunks → staging global `^XTMP("VPKGI")` → MERGE + `EN^XPDIJ` in one process, fixing a silent partial-install of large packages (the one-mega-routine staging truncated at ~3 routines). YDB live-proven on the full 15-routine MSL base (test-in-place 15/15 suites). IRIS validation of the chunked path owed. diff --git a/docs/memory/streamed-install.md b/docs/memory/streamed-install.md new file mode 100644 index 0000000..a64cfdf --- /dev/null +++ b/docs/memory/streamed-install.md @@ -0,0 +1,55 @@ +--- +name: streamed-install +description: v pkg install now STREAMS the transport global in size-bounded chunks into a staging global, then MERGE + EN^XPDIJ in one process — fixing a silent partial-install of large packages (the one-mega-routine staging truncated at ~3 routines). YDB live-proven on the full 15-routine MSL base. +metadata: + type: project +--- + +# Streamed KIDS install (fixes silent partial-install of large packages) + +## The bug (T0b.2 discoveries P1, m-stdlib) +`v pkg install` built ONE scratch routine `ZVPKGINS` embedding every +`S ^XTMP("XPDI",XPDA,…)=…` pair and ran it. Fine for ZZSKEL (1 routine), but a +real package's transport global is large — the m-stdlib MSL base is **15 routines +/ ~6100 nodes / ~560 KB**, giving a ~560 KB install routine. The **driver's +routine staging silently truncates** a routine that big (m-ydb docker `exec load` +fails/partial-stages ≳60 KB; the eval path truncates a single command at ~21 KB), +so only the first ~3 routines (STDSTR/STDMATH/STDB64) landed — while `EN^XPDIJ` +still reported `#9.7` status 3. **v-pkg generated the correct full 6156-line +script** (`ParseKID().Pairs()` = all 6148 nodes); the loss was entirely in +staging the giant routine. + +## The fix (this branch, `refile-v-pkg`) +Two phases, no SDK change: +1. **`installspec.StageChunks(pairs, maxBytes)`** — renders the pairs as several + small routine bodies (≤40 KB each, `stageChunkBytes` in `lifecycle.go`) that + `S ^XTMP("VPKGI",)=`. The first body `K ^XTMP("VPKGI")`. `runInstall` + runs each via `runMScript` (load+run); the staging global persists across the + stateless driver processes, accumulating the whole tree. +2. **`installspec.FinalInstallScript(name, header, nPairs)`** — constant-size + routine: counts the staged nodes ($QUERY loop) and **refuses with + `error=stage-incomplete` unless the count == nPairs** (so a silent truncation + now fails loudly, never installs a partial package); then `$$INST^XPDIL1` → + `M ^XTMP("XPDI",XPDA)=^XTMP("VPKGI")` → `EN^XPDIJ` → `K ^XTMP("VPKGI")` → status + marker. INST + MERGE + EN^XPDIJ stay in ONE process so XPDA survives (the MERGE + makes the install routine size-independent of the package). + +`runInstall` returns a Go error (not `already-installed`) on `stage-incomplete`. + +## Proof +- Unit (TDD): `TestStageChunks_CoversAllPairsBounded`, `TestFinalInstallScript`, + `TestRunInstall_MultiChunkStages` (asserts loads/runs == chunks+1). `go test + ./... -race`, `go vet`, `gofmt`, contract no-drift — all green. +- **Live on YDB FOIA `vehu`** (m-ydb docker, after the gbldir fix `e5dcf85`): the + full 15-routine MSL base installs (all `$T(^STD*)=1`, status 3, ~3 s); + `scripts/kids-test-in-place.sh ydb` (m-stdlib) → **15/15 suites pass in place, + 1403 assertions, 0 fail**; uninstall reversible, verify-clean. + +## Owed +- **IRIS live-validation of the chunked path** (m-iris/Atelier stages each chunk + via PutDoc; ^XTMP persists across runner invocations — expected to work, not yet + run). Will be exercised by the **T0b.2 IRIS leg** (m-stdlib session). +- A native `mdriver.Client.SetGlobal` would let the host populate `^XTMP` directly + (no staging routines) — cleaner, but an SDK/coordinator change; not needed now. + +See `docs/kids-installation-automation.md §7` and the m-stdlib T0b.2 tracker. diff --git a/go.mod b/go.mod index 8d88156..5b95efa 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module github.com/vista-cloud-dev/m-kids +module github.com/vista-cloud-dev/v-pkg go 1.26.3 require ( github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/vista-cloud-dev/m-driver-sdk v0.3.0 github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 ) diff --git a/go.sum b/go.sum index 84ce838..8764fd4 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vista-cloud-dev/m-driver-sdk v0.3.0 h1:RudBmVTutjVPur0mF9sFxv7tBpCa2L78DD5GZuB8KGI= +github.com/vista-cloud-dev/m-driver-sdk v0.3.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/internal/buildspec/buildspec.go b/internal/buildspec/buildspec.go new file mode 100644 index 0000000..076c132 --- /dev/null +++ b/internal/buildspec/buildspec.go @@ -0,0 +1,169 @@ +// Package buildspec is the KIDS build-spec schema + validating loader +// (VSL T0a.1, CQ9): the declarative, diffable git source of a KIDS BUILD (#9.6), +// kids/.build.json. It is the human-readable form of a package definition — +// component list, Required Builds, the environment-check routine, and the ICR +// list — that `v pkg build` consumes to produce a transport global / .KID. +// See msl-vsl-coordination-implementation-plan.md §7.2. +package buildspec + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" +) + +// Required-build install actions (KIDS BUILD #9.6 REQUIRED BUILD #11 multiple; +// architecture §3.1). +const ( + ActionWarn = "WARNING ONLY" // warn, continue + ActionLeaveGlobal = "DON'T INSTALL, LEAVE GLOBAL" // block unless prerequisite present; keep its global + ActionRemoveGlobal = "DON'T INSTALL, REMOVE GLOBAL" // block unless prerequisite present; remove its global +) + +var validActions = map[string]bool{ActionWarn: true, ActionLeaveGlobal: true, ActionRemoveGlobal: true} + +// Spec is one KIDS build definition (kids/.build.json). +type Spec struct { + Package string `json:"package"` // NAMESPACE (e.g. ZZSKEL) + Version string `json:"version"` // e.g. 1.0 + Patch string `json:"patch,omitempty"` // e.g. 1 (optional) + Components Components `json:"components"` + RequiredBuilds []RequiredBuild `json:"requiredBuilds,omitempty"` + EnvCheck string `json:"envCheck,omitempty"` // environment-check routine name (XPDENV) + ICRs []ICR `json:"icrs,omitempty"` // DBIA/ICR agreements the package relies on +} + +// Components is the BUILD component list (#9.6). Each slice is omitempty so a +// spec lists only what it ships. +type Components struct { + Routines []string `json:"routines,omitempty"` + Files []FileComp `json:"files,omitempty"` + Options []string `json:"options,omitempty"` + Keys []string `json:"keys,omitempty"` // security keys + Parameters []string `json:"parameters,omitempty"` // XPAR parameters + Protocols []string `json:"protocols,omitempty"` + Templates []string `json:"templates,omitempty"` + RPCs []string `json:"rpcs,omitempty"` + MailGroups []string `json:"mailGroups,omitempty"` + HL7 []string `json:"hl7,omitempty"` +} + +func (c Components) empty() bool { + return len(c.Routines) == 0 && len(c.Files) == 0 && len(c.Options) == 0 && + len(c.Keys) == 0 && len(c.Parameters) == 0 && len(c.Protocols) == 0 && + len(c.Templates) == 0 && len(c.RPCs) == 0 && len(c.MailGroups) == 0 && len(c.HL7) == 0 +} + +// FileComp is a FileMan file component (number may be fractional, e.g. 8989.51). +type FileComp struct { + Number float64 `json:"number"` + Name string `json:"name,omitempty"` +} + +// RequiredBuild is a KIDS Required Build (#11) — a prerequisite build + the +// action KIDS takes when it is absent. +type RequiredBuild struct { + Name string `json:"name"` // NAMESPACE*VERSION[*PATCH] + Action string `json:"action"` // one of the Action* constants +} + +// ICR is a DBIA/Integration Control Registration the package depends on. +type ICR struct { + Number int `json:"number"` + Name string `json:"name,omitempty"` + Custodian string `json:"custodian,omitempty"` +} + +// InstallName is the KIDS install name NAMESPACE*VERSION[*PATCH]. +func (s *Spec) InstallName() string { + n := s.Package + "*" + s.Version + if s.Patch != "" { + n += "*" + s.Patch + } + return n +} + +var ( + reNamespace = regexp.MustCompile(`^[A-Z%][A-Z0-9]*$`) + reVersion = regexp.MustCompile(`^\d+\.\d+$`) + rePatch = regexp.MustCompile(`^\d+$`) + reRoutine = regexp.MustCompile(`^%?[A-Z][A-Z0-9]*$`) // M routine name + reReqBuild = regexp.MustCompile(`^[A-Z%][A-Z0-9]*\*\d+\.\d+(\*\d+)?$`) // NS*VER[*PATCH] +) + +// Load reads and validates a build spec from a kids/.build.json file. +func Load(path string) (*Spec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("buildspec: read %s: %w", path, err) + } + return Parse(data) +} + +// Parse decodes a build spec from JSON (rejecting unknown fields — a typo'd key +// is an error, not a silent drop) and validates it. +func Parse(data []byte) (*Spec, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var s Spec + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("buildspec: parse: %w", err) + } + if err := s.Validate(); err != nil { + return nil, err + } + return &s, nil +} + +// Validate checks the spec is a usable KIDS build definition. +func (s *Spec) Validate() error { + if !reNamespace.MatchString(s.Package) { + return fmt.Errorf("buildspec: package %q is not a valid VistA namespace", s.Package) + } + if !reVersion.MatchString(s.Version) { + return fmt.Errorf("buildspec: version %q must be MAJOR.MINOR (e.g. 1.0)", s.Version) + } + if s.Patch != "" && !rePatch.MatchString(s.Patch) { + return fmt.Errorf("buildspec: patch %q must be numeric", s.Patch) + } + if s.Components.empty() { + return fmt.Errorf("buildspec: %s has no components — a build must ship at least one", s.InstallName()) + } + if err := validateRoutines(s.Components.Routines); err != nil { + return err + } + for _, rb := range s.RequiredBuilds { + if !reReqBuild.MatchString(rb.Name) { + return fmt.Errorf("buildspec: required build name %q must be NAMESPACE*VERSION[*PATCH]", rb.Name) + } + if !validActions[rb.Action] { + return fmt.Errorf("buildspec: required build %s action %q is not a KIDS install action", rb.Name, rb.Action) + } + } + if s.EnvCheck != "" && !isRoutineName(s.EnvCheck) { + return fmt.Errorf("buildspec: envCheck %q is not a valid routine name (≤8 chars)", s.EnvCheck) + } + for _, icr := range s.ICRs { + if icr.Number <= 0 { + return fmt.Errorf("buildspec: ICR number must be positive, got %d", icr.Number) + } + } + return nil +} + +func validateRoutines(rtns []string) error { + for _, r := range rtns { + if !isRoutineName(r) { + return fmt.Errorf("buildspec: %q is not a valid routine name (≤8 chars, M naming)", r) + } + } + return nil +} + +// isRoutineName reports whether s is a valid VistA/M routine name (≤8 chars). +func isRoutineName(s string) bool { + return len(s) >= 1 && len(s) <= 8 && reRoutine.MatchString(s) && !strings.ContainsAny(s, " \t") +} diff --git a/internal/buildspec/buildspec_test.go b/internal/buildspec/buildspec_test.go new file mode 100644 index 0000000..e25da2c --- /dev/null +++ b/internal/buildspec/buildspec_test.go @@ -0,0 +1,88 @@ +package buildspec + +import ( + "strings" + "testing" +) + +const zzskel = `{ + "package": "ZZSKEL", + "version": "1.0", + "patch": "1", + "components": { "routines": ["ZZSKEL"] }, + "requiredBuilds": [ + { "name": "STD*1.0*5", "action": "DON'T INSTALL, LEAVE GLOBAL" } + ], + "envCheck": "ZZSKELEN", + "icrs": [ { "number": 10063, "name": "$$PSET^%ZTLOAD", "custodian": "KERNEL" } ] +}` + +func TestParse_Valid(t *testing.T) { + s, err := Parse([]byte(zzskel)) + if err != nil { + t.Fatalf("Parse valid spec: %v", err) + } + if got := s.InstallName(); got != "ZZSKEL*1.0*1" { + t.Errorf("InstallName = %q, want ZZSKEL*1.0*1", got) + } + if len(s.Components.Routines) != 1 || s.Components.Routines[0] != "ZZSKEL" { + t.Errorf("routines = %v", s.Components.Routines) + } + if len(s.RequiredBuilds) != 1 || s.RequiredBuilds[0].Action != ActionLeaveGlobal { + t.Errorf("requiredBuilds = %+v", s.RequiredBuilds) + } + if s.EnvCheck != "ZZSKELEN" { + t.Errorf("envCheck = %q", s.EnvCheck) + } +} + +func TestInstallName_NoPatch(t *testing.T) { + s, err := Parse([]byte(`{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := s.InstallName(); got != "ZZSKEL*1.0" { + t.Errorf("InstallName = %q, want ZZSKEL*1.0", got) + } +} + +func TestParse_Invalid(t *testing.T) { + cases := map[string]string{ + "missing package": `{"version":"1.0","components":{"routines":["ZZSKEL"]}}`, + "bad version": `{"package":"ZZSKEL","version":"1","components":{"routines":["ZZSKEL"]}}`, + "no components": `{"package":"ZZSKEL","version":"1.0","components":{}}`, + "bad action": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]},"requiredBuilds":[{"name":"STD*1.0*5","action":"MAYBE"}]}`, + "bad routine name": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["not a routine"]}}`, + "bad env-check": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]},"envCheck":"toolongname9"}`, + "required no name": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]},"requiredBuilds":[{"action":"WARNING ONLY"}]}`, + "unknown field": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]},"oops":1}`, + "bad icr number": `{"package":"ZZSKEL","version":"1.0","components":{"routines":["ZZSKEL"]},"icrs":[{"number":0}]}`, + } + for name, js := range cases { + if _, err := Parse([]byte(js)); err == nil { + t.Errorf("%s: expected an error, got nil", name) + } + } +} + +func TestParse_NotJSON(t *testing.T) { + if _, err := Parse([]byte("nope")); err == nil || !strings.Contains(err.Error(), "buildspec") { + t.Errorf("want a buildspec parse error, got %v", err) + } +} + +func TestLoad_File(t *testing.T) { + s, err := Load("testdata/zzskel.build.json") + if err != nil { + t.Fatalf("Load: %v", err) + } + if s.InstallName() != "ZZSKEL*1.0*1" { + t.Errorf("InstallName = %q", s.InstallName()) + } +} + +func TestLoad_Missing(t *testing.T) { + if _, err := Load("testdata/nope.build.json"); err == nil { + t.Error("missing file must error") + } +} diff --git a/internal/buildspec/testdata/zzskel.build.json b/internal/buildspec/testdata/zzskel.build.json new file mode 100644 index 0000000..b0d2f5f --- /dev/null +++ b/internal/buildspec/testdata/zzskel.build.json @@ -0,0 +1,15 @@ +{ + "package": "ZZSKEL", + "version": "1.0", + "patch": "1", + "components": { + "routines": ["ZZSKEL"] + }, + "requiredBuilds": [ + { "name": "STD*1.0*5", "action": "DON'T INSTALL, LEAVE GLOBAL" } + ], + "envCheck": "ZZSKELEN", + "icrs": [ + { "number": 10063, "name": "$$PSET^%ZTLOAD", "custodian": "KERNEL" } + ] +} diff --git a/internal/installspec/installspec.go b/internal/installspec/installspec.go new file mode 100644 index 0000000..78ec722 --- /dev/null +++ b/internal/installspec/installspec.go @@ -0,0 +1,124 @@ +// Package installspec is the declarative KIDS install spec + validating loader +// (VSL T0a.3): the answers `v pkg install` feeds to a non-interactive KIDS +// install — the source distribution, the environment-check choice, the standard +// KIDS questions, and the device/queue. It is the JSON form of the +// install-spec.yaml in docs/kids-installation-automation.md §5. The standard +// answers map onto the KIDS XPDDIQ answer codes (XPO1/XPI1/XPZ1) used to suppress +// the prompts (see that doc, Tier A). +package installspec + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "regexp" +) + +// Spec is one install spec (kids/.install.json or passed inline). +type Spec struct { + Name string `json:"name"` // INSTALL NAME, e.g. ZZSKEL*1.0*1 + Source Source `json:"source"` // where the distribution comes from + EnvCheck string `json:"environmentCheck,omitempty"` // "run" (default) | "skip" + Backup bool `json:"backupTransportGlobal,omitempty"` + Answers Answers `json:"answers"` + Device Device `json:"device"` + ExtraAnswers []ExtraAnswer `json:"extraAnswers,omitempty"` // build-specific pre/post questions +} + +// Source is the distribution location. +type Source struct { + Kind string `json:"kind"` // "hfs" (Host File) | "packman" (MailMan message) + Path string `json:"path"` +} + +// Answers are the four standard KIDS install questions. +type Answers struct { + RebuildMenuTrees bool `json:"rebuildMenuTrees"` + InhibitLogons bool `json:"inhibitLogons"` + DisableOptionsProtocols bool `json:"disableOptionsProtocols"` + DelayInstallMinutes int `json:"delayInstallMinutes"` +} + +// XPDDIQ maps the standard answers onto the KIDS XPDDIQ answer codes (set in the +// environment-check to suppress the prompts): XPO1 = rebuild menu trees, XPI1 = +// inhibit logons, XPZ1 = disable options/protocols. "0" = NO, "1" = YES. +func (a Answers) XPDDIQ() map[string]string { + yn := func(b bool) string { + if b { + return "1" + } + return "0" + } + return map[string]string{ + "XPO1": yn(a.RebuildMenuTrees), + "XPI1": yn(a.InhibitLogons), + "XPZ1": yn(a.DisableOptionsProtocols), + } +} + +// Device selects the install output device / queueing. +type Device struct { + Queue bool `json:"queue"` + At string `json:"at,omitempty"` // ISO time when queued +} + +// ExtraAnswer answers a build-specific pre/post-install question by prompt match. +type ExtraAnswer struct { + PromptContains string `json:"promptContains"` + Answer string `json:"answer"` +} + +var ( + reInstallName = regexp.MustCompile(`^[A-Z%][A-Z0-9]*\*\d+\.\d+(\*\d+)?$`) + validSource = map[string]bool{"hfs": true, "packman": true} + validEnvCheck = map[string]bool{"": true, "run": true, "skip": true} +) + +// Load reads + validates an install spec from a file. +func Load(path string) (*Spec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("installspec: read %s: %w", path, err) + } + return Parse(data) +} + +// Parse decodes an install spec from JSON (rejecting unknown fields) + validates. +func Parse(data []byte) (*Spec, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var s Spec + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("installspec: parse: %w", err) + } + if err := s.Validate(); err != nil { + return nil, err + } + return &s, nil +} + +// Validate checks the spec is usable for a non-interactive install. +func (s *Spec) Validate() error { + if !reInstallName.MatchString(s.Name) { + return fmt.Errorf("installspec: name %q must be NAMESPACE*VERSION[*PATCH]", s.Name) + } + if !validSource[s.Source.Kind] { + return fmt.Errorf("installspec: source.kind %q must be hfs or packman", s.Source.Kind) + } + if s.Source.Path == "" { + return fmt.Errorf("installspec: source.path is required") + } + if !validEnvCheck[s.EnvCheck] { + return fmt.Errorf("installspec: environmentCheck %q must be run or skip", s.EnvCheck) + } + if d := s.Answers.DelayInstallMinutes; d < 0 || d > 60 { + return fmt.Errorf("installspec: delayInstallMinutes %d out of range (0-60)", d) + } + for i, ea := range s.ExtraAnswers { + if ea.PromptContains == "" { + return fmt.Errorf("installspec: extraAnswers[%d] needs promptContains", i) + } + } + return nil +} diff --git a/internal/installspec/installspec_test.go b/internal/installspec/installspec_test.go new file mode 100644 index 0000000..c6ddc11 --- /dev/null +++ b/internal/installspec/installspec_test.go @@ -0,0 +1,55 @@ +package installspec + +import "testing" + +const zzskel = `{ + "name": "ZZSKEL*1.0*1", + "source": { "kind": "hfs", "path": "dist/kids/ZZSKEL.kids" }, + "environmentCheck": "run", + "answers": { + "rebuildMenuTrees": false, + "inhibitLogons": false, + "disableOptionsProtocols": false, + "delayInstallMinutes": 0 + }, + "device": { "queue": false } +}` + +func TestParse_Valid(t *testing.T) { + s, err := Parse([]byte(zzskel)) + if err != nil { + t.Fatalf("Parse valid: %v", err) + } + if s.Name != "ZZSKEL*1.0*1" || s.Source.Kind != "hfs" { + t.Errorf("spec = %+v", s) + } + // The standard answers map onto the KIDS XPDDIQ answer codes. + if got := s.Answers.XPDDIQ(); got["XPZ1"] != "0" || got["XPO1"] != "0" || got["XPI1"] != "0" { + t.Errorf("XPDDIQ = %v, want all 0 (NO)", got) + } +} + +func TestAnswers_XPDDIQ_Yes(t *testing.T) { + s, _ := Parse([]byte(`{"name":"ZZSKEL*1.0*1","source":{"kind":"hfs","path":"x"}, + "answers":{"disableOptionsProtocols":true},"device":{"queue":false}}`)) + if s.Answers.XPDDIQ()["XPZ1"] != "1" { + t.Errorf("disableOptionsProtocols=true must map XPZ1=1") + } +} + +func TestParse_Invalid(t *testing.T) { + cases := map[string]string{ + "missing name": `{"source":{"kind":"hfs","path":"x"},"device":{}}`, + "bad name": `{"name":"zzskel","source":{"kind":"hfs","path":"x"},"device":{}}`, + "bad source kind": `{"name":"ZZSKEL*1.0*1","source":{"kind":"ftp","path":"x"},"device":{}}`, + "no source path": `{"name":"ZZSKEL*1.0*1","source":{"kind":"hfs"},"device":{}}`, + "bad envcheck": `{"name":"ZZSKEL*1.0*1","source":{"kind":"hfs","path":"x"},"environmentCheck":"maybe","device":{}}`, + "delay too high": `{"name":"ZZSKEL*1.0*1","source":{"kind":"hfs","path":"x"},"answers":{"delayInstallMinutes":99},"device":{}}`, + "unknown field": `{"name":"ZZSKEL*1.0*1","source":{"kind":"hfs","path":"x"},"device":{},"oops":1}`, + } + for name, js := range cases { + if _, err := Parse([]byte(js)); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} diff --git a/internal/installspec/script.go b/internal/installspec/script.go new file mode 100644 index 0000000..e343b5f --- /dev/null +++ b/internal/installspec/script.go @@ -0,0 +1,136 @@ +package installspec + +import ( + "strconv" + "strings" + + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// This file generates the M install script for the non-interactive +// direct-populate path (T0a.3, live-proven on YDB FOIA — see +// kids-installation-automation.md §7.1). Rather than driving the interactive +// EN1^XPDIL host-file load (which prompts and cannot be fed over the driver's +// subprocess+JSON Exec), it creates the KIDS #9.7 INSTALL entry via the real +// $$INST^XPDIL1, populates ^XTMP("XPDI",XPDA,…) directly from the parsed .KID +// pairs (those pairs ARE the transport-global contents), then runs the +// synchronous EN^XPDIJ install. The script is one execution unit: the driver +// must run it with a persistent symbol table so XPDA survives across the SETs. + +// ResultMarker prefixes the script's machine-readable result lines on the +// principal device — the driver layer scans stdout for it. Format: +// `<>key=value`. +const ResultMarker = "<>" + +// The install is delivered in two phases so it never embeds the whole transport +// global in one routine. A real package's transport global is large (the MSL +// base is ~6100 nodes / ~560 KB); one routine that big silently truncates when +// the driver stages it (T0b.2 discoveries P1), installing only a prefix. Instead: +// +// 1. StageChunks streams the pairs into a staging global ^XTMP("VPKGI",…) as a +// sequence of small, size-bounded routine bodies (each stages reliably). +// 2. FinalInstallScript verifies the staged node count, then — in ONE process, +// so XPDA survives — creates the #9.7 entry, MERGEs the staged tree into +// ^XTMP("XPDI",XPDA), and runs EN^XPDIJ. +// +// The MERGE makes the install routine constant-size regardless of package size. + +// stageOpen is the open global reference the staging SETs hang off (MRef closes +// the paren); stageGbl is the same global, used to clear/merge/count it whole. +const ( + stageOpen = `^XTMP("VPKGI",` + stageGbl = `^XTMP("VPKGI")` +) + +// StageChunks renders the transport-global pairs as M routine bodies that +// populate the staging global ^XTMP("VPKGI",…). Each body is kept at or below +// maxBytes (a lone over-long SET is its own chunk), so no single staged routine +// is large enough to hit the driver's silent-truncation limit. The first body +// clears any stale staging global. Run each body in order; the global persists +// across the (stateless) driver processes, accumulating the whole tree. +func StageChunks(pairs []kids.Pair, maxBytes int) []string { + var chunks []string + var b strings.Builder + b.WriteString("K " + stageGbl + "\n") // clear stale staging before the first batch + for _, p := range pairs { + line := "S " + p.Subs.MRef(stageOpen) + "=" + kids.MString(p.Value) + "\n" + if b.Len() > 0 && b.Len()+len(line) > maxBytes { + chunks = append(chunks, b.String()) + b.Reset() + } + b.WriteString(line) + } + if b.Len() > 0 { + chunks = append(chunks, b.String()) + } + return chunks +} + +// FinalInstallScript returns the constant-size install routine run after +// StageChunks has populated ^XTMP("VPKGI",…). nPairs is the expected staged-node +// count: the routine counts the staging global and refuses to install if it does +// not match (so a silently-truncated stage fails loudly instead of installing a +// partial package). header is the #9.7 install header (cosmetic). +func FinalInstallScript(name, header string, nPairs int) string { + var b strings.Builder + w := func(line string) { b.WriteString(line); b.WriteByte('\n') } + nameLit := kids.MString(name) + + w(`S U="^",DUZ=1,DUZ(0)="@" S:'$D(DT) DT=$$DT^XLFDT`) + // Refuse a re-install up front — INST^XPDIL1 prompts "OK to continue with + // Load" when the name is already in #9.7, and there is no stdin over Exec. + w(`I $D(^XPD(9.7,"B",` + nameLit + `)) K ` + stageGbl + ` W "` + ResultMarker + `error=already-installed",! Q`) + // Count the staged nodes ($QUERY over the staging subtree) and refuse unless + // every pair arrived — guards against a silently-truncated chunk stage. + w(`N VC,VR S VC=0,VR=` + kids.MString(stageGbl)) + w(`F S VR=$Q(@VR) Q:(VR="")!(VR'[` + kids.MString("VPKGI") + `) S VC=VC+1`) + w(`W "` + ResultMarker + `staged=",VC,!`) + w(`I VC'=` + strconv.Itoa(nPairs) + ` K ` + stageGbl + ` W "` + ResultMarker + `error=stage-incomplete",! Q`) + w(`D HOME^%ZIS`) + w(`S XPDST=0,XPDIT=1,XPDST("H1")=` + kids.MString(header+" ;Created on ")) + w(`S XPDA=$$INST^XPDIL1(` + nameLit + `)`) + w(`S ^XTMP("XPDI",0)=$$FMADD^XLFDT(DT,7)_U_DT`) + w(`M ^XTMP("XPDI",XPDA)=` + stageGbl) // staged tree → live transport global + w(`D EN^XPDIJ`) + w(`K ` + stageGbl) + w(`W "` + ResultMarker + `status=",$P($G(^XPD(9.7,XPDA,0)),U,9),!`) + return b.String() +} + +// VerifyScript returns M source that reports whether name is installed: the +// #9.7 INSTALL presence + status (piece 9; 3 = "Install Completed") and, per +// routine, whether it is loaded ($T probe). Each fact is a ResultMarker line. +func VerifyScript(name string, routines []string) string { + var b strings.Builder + w := func(line string) { b.WriteString(line); b.WriteByte('\n') } + + w(`S U="^"`) + w(`S XPDA=$O(^XPD(9.7,"B",` + kids.MString(name) + `,0))`) + w(`W "` + ResultMarker + `installed=",$S(+XPDA:1,1:0),!`) + w(`W "` + ResultMarker + `status=",$P($G(^XPD(9.7,+XPDA,0)),U,9),!`) + for _, r := range routines { + w(`W "` + ResultMarker + `rtn:` + r + `=",$S($T(^` + r + `)]"":1,1:0),!`) + } + return b.String() +} + +// UninstallScript returns M source that reverses a routine-only install +// (T0a.4): delete each routine (^%ZOSF("DEL") removes the .m + .o) and the #9.7 +// INSTALL and #9.6 BUILD entries via FileMan DIK (which also clears their +// cross-references). KIDS ships no generic uninstall — back-out is the tool's +// job. The monotonic #9.x IEN counters are not rolled back (inherent to +// FileMan, not a leak). +func UninstallScript(name string, routines []string) string { + var b strings.Builder + w := func(line string) { b.WriteString(line); b.WriteByte('\n') } + + w(`S U="^",DUZ=1,DUZ(0)="@"`) + for _, r := range routines { + w(`S X=` + kids.MString(r) + ` X ^%ZOSF("DEL")`) + } + nameLit := kids.MString(name) + w(`S DA=$O(^XPD(9.7,"B",` + nameLit + `,0)),DIK="^XPD(9.7," I DA D ^DIK`) + w(`S DA=$O(^XPD(9.6,"B",` + nameLit + `,0)),DIK="^XPD(9.6," I DA D ^DIK`) + w(`W "` + ResultMarker + `uninstalled=1",!`) + return b.String() +} diff --git a/internal/installspec/script_test.go b/internal/installspec/script_test.go new file mode 100644 index 0000000..00161b7 --- /dev/null +++ b/internal/installspec/script_test.go @@ -0,0 +1,116 @@ +package installspec + +import ( + "strings" + "testing" + + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// zzskelPairs builds the same ZZSKEL transport-global pairs `v pkg build` +// produces, so the generated install script can be checked against the live- +// proven sequence (kids-installation-automation.md §7.1). +func zzskelPairs() []kids.Pair { + return kids.MakeBuildPairs(kids.BuildInput{ + InstallName: "ZZSKEL*1.0*1", + Namespace: "ZZSKEL", + Routines: []kids.RoutineSrc{{ + Name: "ZZSKEL", + Lines: []string{ + "ZZSKEL ;VCD/VSL - throwaway test package (M0a ZZSKEL) ;1.0", + " ;;1.0;ZZSKEL;;", + " quit", + "PING() ;", + ` quit "pong"`, + }, + }}, + }) +} + +// StageChunks splits the transport global into size-bounded routine bodies that +// populate the ^XTMP("VPKGI",…) staging global — never one routine big enough to +// exceed the driver's per-routine staging limit (which silently truncates large +// loads, T0b.2 discoveries P1). +func TestStageChunks_CoversAllPairsBounded(t *testing.T) { + pairs := zzskelPairs() + const maxBytes = 80 + chunks := StageChunks(pairs, maxBytes) + if len(chunks) < 2 { + t.Fatalf("want multiple chunks at maxBytes=%d, got %d", maxBytes, len(chunks)) + } + // The first chunk clears any stale staging global before populating it. + if !strings.HasPrefix(chunks[0], `K ^XTMP("VPKGI")`) { + t.Errorf("first chunk must clear the staging global, got:\n%s", chunks[0]) + } + all := strings.Join(chunks, "\n") + // Every pair is staged exactly once, into ^XTMP("VPKGI",…) (not the live + // ^XTMP("XPDI",XPDA,…) — that is filled by the finalize MERGE). + for _, p := range pairs { + ref := "S " + p.Subs.MRef(`^XTMP("VPKGI",`) + "=" + if n := strings.Count(all, ref); n != 1 { + t.Errorf("pair %q staged %d times, want 1", ref, n) + } + } + // Embedded quotes in routine source are doubled. + if !strings.Contains(all, `S ^XTMP("VPKGI","RTN","ZZSKEL",5,0)=" quit ""pong"""`) { + t.Errorf("RTN line 5 not M-escaped:\n%s", all) + } + // Each chunk stays bounded (a lone over-long SET may exceed maxBytes, but a + // chunk never accumulates well past it). + for i, c := range chunks { + if len(c) > maxBytes+256 { + t.Errorf("chunk %d = %d bytes, exceeds the bound", i, len(c)) + } + } +} + +// FinalInstallScript verifies the staged count, then installs in one process: +// INST → MERGE the staged tree into ^XTMP("XPDI",XPDA) → EN^XPDIJ. +func TestFinalInstallScript(t *testing.T) { + got := FinalInstallScript("ZZSKEL*1.0*1", "ZZSKEL via v pkg install", 7) + for _, want := range []string{ + `I $D(^XPD(9.7,"B","ZZSKEL*1.0*1"))`, // already-installed guard + `DUZ(0)="@"`, // full FM priv for EN^XPDIJ + ResultMarker + `staged=`, // staged-node count marker + `I VC'=7`, // truncation guard: count must match + ResultMarker + `error=stage-incomplete`, // …else refuse, do not install partial + `S XPDA=$$INST^XPDIL1("ZZSKEL*1.0*1")`, // real KIDS #9.7 entry + `M ^XTMP("XPDI",XPDA)=^XTMP("VPKGI")`, // staged tree → live transport global + `D EN^XPDIJ`, // synchronous install (same process) + `K ^XTMP("VPKGI")`, // clean the staging global + ResultMarker + `status=`, // #9.7 status marker + } { + if !strings.Contains(got, want) { + t.Errorf("FinalInstallScript missing %q\n---\n%s", want, got) + } + } +} + +func TestVerifyScript(t *testing.T) { + got := VerifyScript("ZZSKEL*1.0*1", []string{"ZZSKEL"}) + for _, want := range []string{ + `S XPDA=$O(^XPD(9.7,"B","ZZSKEL*1.0*1",0))`, + ResultMarker + `installed=`, + ResultMarker + `status=`, + `$T(^ZZSKEL)`, // routine-presence probe + ResultMarker + `rtn:ZZSKEL=`, // per-routine marker + } { + if !strings.Contains(got, want) { + t.Errorf("VerifyScript missing %q\n---\n%s", want, got) + } + } +} + +func TestUninstallScript(t *testing.T) { + got := UninstallScript("ZZSKEL*1.0*1", []string{"ZZSKEL"}) + for _, want := range []string{ + `S X="ZZSKEL" X ^%ZOSF("DEL")`, // routine delete + `S DA=$O(^XPD(9.7,"B","ZZSKEL*1.0*1",0)),DIK="^XPD(9.7," I DA D ^DIK`, // #9.7 + `S DA=$O(^XPD(9.6,"B","ZZSKEL*1.0*1",0)),DIK="^XPD(9.6," I DA D ^DIK`, // #9.6 + ResultMarker + `uninstalled=1`, + } { + if !strings.Contains(got, want) { + t.Errorf("UninstallScript missing %q\n---\n%s", want, got) + } + } +} diff --git a/internal/kids/assemble.go b/internal/kids/assemble.go index 5d42880..0cd573b 100644 --- a/internal/kids/assemble.go +++ b/internal/kids/assemble.go @@ -202,7 +202,7 @@ func rtnLineSubs(name string, line int64) Subs { func WriteKID(installNames []string, buildsPairs map[string][]Pair, outPath string) error { var lines []string lines = append(lines, - "KIDS Distribution saved by m-kids", + "KIDS Distribution saved by v-pkg", "m-kids reassembled output", "**KIDS**:"+strings.Join(installNames, "^")+"^", "", diff --git a/internal/kids/buildkids.go b/internal/kids/buildkids.go new file mode 100644 index 0000000..8b3587b --- /dev/null +++ b/internal/kids/buildkids.go @@ -0,0 +1,71 @@ +package kids + +import "strconv" + +// This file builds a KIDS transport global from a declarative spec (VSL T0a.2, +// coordination plan §7.2) — the inverse of decompose/assemble, which work from +// an existing .KID. It lives in package kids because constructing subscripts +// needs the unexported Sub constructors. + +// intSub builds an integer-kind subscript element. +func intSub(n int64) Sub { return Sub{kind: kindInt, intV: n} } + +// RoutineSrc is one routine's name and source lines. +type RoutineSrc struct { + Name string + Lines []string +} + +// BuildInput is the normalized input to MakeBuildPairs. Volatile fields are NOT +// carried (install date/user/real checksums) — the export is byte-identical for +// identical inputs, the deterministic-build invariant (§7.2 #2). +type BuildInput struct { + InstallName string // NAMESPACE*VERSION[*PATCH] + Namespace string // NAMESPACE + Routines []RoutineSrc // routine components, in build order + Platform string // VER node (Kernel^FileMan), default "8.0^22.2" +} + +// MakeBuildPairs constructs the ^XPD BUILD pairs for a routine-only KIDS package +// (BLD header + RTN sections + VER), with volatile fields NORMALIZED — the build +// date is 0 and routine checksums are stripped to 0 — so the same input yields a +// byte-identical export. This is the minimal correct shape for a routine package +// (the ZZSKEL throwaway); richer components (files/options/KRN/Required Builds) +// are added as the live install path (T0a.3+) validates them against real KIDS. +func MakeBuildPairs(in BuildInput) []Pair { + b := newBuild() + // BLD header: NAME^NAMESPACE^0^DATE — last field is the build date, normalized + // to 0 (with the type field also 0) so the artifact is diffable + reproducible. + b.Set(Subs{strSub("BLD"), intSub(1), intSub(0)}, in.InstallName+"^"+in.Namespace+"^0^0") + + b.Set(Subs{strSub("RTN")}, strconv.Itoa(len(in.Routines))) + for _, r := range in.Routines { + // 0^^^ — checksums stripped (0) for the + // normalized artifact; real checksums are computed at install time. + b.Set(Subs{strSub("RTN"), strSub(r.Name)}, "0^"+strconv.Itoa(len(r.Lines))+"^0^0") + for i, line := range r.Lines { + b.Set(Subs{strSub("RTN"), strSub(r.Name), intSub(int64(i + 1)), intSub(0)}, line) + } + } + + plat := in.Platform + if plat == "" { + plat = "8.0^22.2" + } + b.Set(Subs{strSub("VER")}, plat) + return b.Pairs() +} + +// RoutineNames returns the build's RTN component names in build order — the +// 2-subscript `"RTN",` header pairs (the per-routine line nodes have 4 +// subscripts, the RTN count node has 1). `v pkg verify`/`uninstall` use these to +// probe and delete each installed routine. +func (b *Build) RoutineNames() []string { + var names []string + for _, p := range b.Pairs() { + if len(p.Subs) == 2 && p.Subs[0].IsStr() && p.Subs[0].Str() == "RTN" && p.Subs[1].IsStr() { + names = append(names, p.Subs[1].Str()) + } + } + return names +} diff --git a/internal/kids/buildkids_test.go b/internal/kids/buildkids_test.go new file mode 100644 index 0000000..f842688 --- /dev/null +++ b/internal/kids/buildkids_test.go @@ -0,0 +1,74 @@ +package kids + +import ( + "strings" + "testing" +) + +func TestMakeBuildPairs_Deterministic_And_Shape(t *testing.T) { + in := BuildInput{ + InstallName: "ZZSKEL*1.0*1", + Namespace: "ZZSKEL", + Routines: []RoutineSrc{ + {Name: "ZZSKEL", Lines: []string{"ZZSKEL ; throwaway ;1.0", " quit"}}, + }, + } + a := MakeBuildPairs(in) + b := MakeBuildPairs(in) + + // Deterministic: identical inputs → identical pair sequence (invariant #2). + if len(a) != len(b) { + t.Fatalf("non-deterministic length: %d vs %d", len(a), len(b)) + } + for i := range a { + if formatSubscript(a[i].Subs) != formatSubscript(b[i].Subs) || a[i].Value != b[i].Value { + t.Fatalf("non-deterministic at %d: %q=%q vs %q=%q", i, + formatSubscript(a[i].Subs), a[i].Value, formatSubscript(b[i].Subs), b[i].Value) + } + } + + // Shape: a BLD header with normalized (zeroed) date/checksum, the RTN count, + // per-routine section with stripped checksums, the routine lines, and VER. + got := map[string]string{} + for _, p := range a { + got[formatSubscript(p.Subs)] = p.Value + } + if v := got[`"BLD",1,0)`]; v != "ZZSKEL*1.0*1^ZZSKEL^0^0" { + t.Errorf(`"BLD",1,0) = %q, want ZZSKEL*1.0*1^ZZSKEL^0^0 (date normalized)`, v) + } + if v := got[`"RTN")`]; v != "1" { + t.Errorf(`"RTN") = %q, want 1`, v) + } + if v := got[`"RTN","ZZSKEL")`]; v != "0^2^0^0" { + t.Errorf(`"RTN","ZZSKEL") = %q, want 0^2^0^0 (checksums stripped)`, v) + } + if v := got[`"RTN","ZZSKEL",1,0)`]; v != "ZZSKEL ; throwaway ;1.0" { + t.Errorf(`routine line 1 = %q`, v) + } + if v := got[`"VER")`]; !strings.Contains(v, "8.0") { + t.Errorf(`"VER") = %q, want a platform version`, v) + } +} + +// TestBuild_RoutineNames extracts the RTN component names (the 2-subscript +// `"RTN",` header pairs), in build order — what `v pkg verify`/`uninstall` +// need to probe/delete each routine. +func TestBuild_RoutineNames(t *testing.T) { + pairs := MakeBuildPairs(BuildInput{ + InstallName: "ZZSKEL*1.0*1", + Namespace: "ZZSKEL", + Routines: []RoutineSrc{ + {Name: "ZZSKEL", Lines: []string{"ZZSKEL ;x", " quit"}}, + {Name: "ZZSKEL1", Lines: []string{"ZZSKEL1 ;y", " quit"}}, + }, + }) + b := newBuild() + for _, p := range pairs { + b.Set(p.Subs, p.Value) + } + got := b.RoutineNames() + want := []string{"ZZSKEL", "ZZSKEL1"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("RoutineNames = %v, want %v", got, want) + } +} diff --git a/internal/kids/mref.go b/internal/kids/mref.go new file mode 100644 index 0000000..f61432d --- /dev/null +++ b/internal/kids/mref.go @@ -0,0 +1,25 @@ +package kids + +import "strings" + +// This file exposes the M-source rendering primitives the install-script +// generator (installspec) needs: a build's (subscript, value) pairs become +// `SET ^XTMP("XPDI",XPDA,)=` statements that populate the KIDS +// transport global directly (the non-interactive load path, T0a.3). They live in +// package kids because formatSubscript is unexported here. + +// MString renders v as an M string literal, doubling embedded quotes: +// `a"b` → `"a""b"`. It does NOT chunk for the 255-char M line limit — callers +// that may emit long routine lines must guard or split (see MaxMLine). +func MString(v string) string { + return `"` + strings.ReplaceAll(v, `"`, `""`) + `"` +} + +// MRef returns the M global reference for this subscript tail, opened by +// rootOpen. rootOpen must end at an open paren plus any fixed leading +// subscripts — e.g. `^XTMP("XPDI",XPDA,` — and MRef supplies the closing paren. +// So Subs{"RTN","ZZSKEL",5,0}.MRef(`^XTMP("XPDI",XPDA,`) yields +// `^XTMP("XPDI",XPDA,"RTN","ZZSKEL",5,0)`. +func (s Subs) MRef(rootOpen string) string { + return rootOpen + formatSubscript(s) +} diff --git a/main.go b/main.go index 711e169..4d869c8 100644 --- a/main.go +++ b/main.go @@ -1,49 +1,40 @@ -// Command m-kids is the VistA KIDS round-trip tool: it decomposes monolithic -// .KID distribution files into a per-component tree suitable for git, and -// reassembles that tree back to an installable .KID. It is a faithful Go port -// of py-kids-vc (the decompose/assemble/round-trip contract and KIDComponents/ -// layout are unchanged), built on the shared clikit conventions (--output -// text|json, schema, deterministic errors, the exit-code ladder). +// Command v-pkg is the VistA KIDS package tool — the standalone form of the +// `v pkg` domain. It decomposes monolithic .KID distribution files into a +// per-component tree suitable for git and reassembles that tree back to an +// installable .KID, on the shared clikit conventions (--output text|json, +// schema, deterministic errors, the exit-code ladder). The verb set lives in +// the importable pkgcli package so the `v` umbrella mounts the same commands as +// `v pkg ` (static-pinned composition). // // The round-trip guarantee is semantic equality after routine line-2 -// canonicalization — NOT byte-identity — exactly as py-kids-vc and XPDK2VC -// define it (volatile patch-list/date/build-number pieces are stripped). +// canonicalization — NOT byte-identity — exactly as py-kids-vc / XPDK2VC define +// it (volatile patch-list/date/build-number pieces are stripped). // // Try: // -// m-kids parse OR_3.0_484.KID -// m-kids decompose OR_3.0_484.KID ./patches/ -// m-kids assemble ./patches/ rebuilt.KID -// m-kids roundtrip OR_3.0_484.KID # exit 3 on drift -// m-kids canonicalize ./patches/ # LOSSY IEN substitution -// m-kids lint OR_3.0_484.KID # PIKS data-class gate (K2) -// m-kids schema | jq . +// v-pkg parse OR_3.0_484.KID +// v-pkg decompose OR_3.0_484.KID ./patches/ +// v-pkg assemble ./patches/ rebuilt.KID +// v-pkg roundtrip OR_3.0_484.KID # exit 3 on drift +// v-pkg canonicalize ./patches/ # LOSSY IEN substitution +// v-pkg lint OR_3.0_484.KID # PIKS data-class gate (K2) +// v-pkg schema | jq . package main import ( - "fmt" "os" - "path/filepath" - "sort" - "strings" "github.com/willabides/kongplete" - "github.com/vista-cloud-dev/m-kids/clikit" - "github.com/vista-cloud-dev/m-kids/internal/kids" + "github.com/vista-cloud-dev/v-pkg/clikit" + "github.com/vista-cloud-dev/v-pkg/pkgcli" ) -// CLI is the root command grammar — one typed struct Kong parses and `schema` -// reflects. +// CLI is the standalone v-pkg grammar: the pkgcli verbs at the top level, plus +// the shared clikit meta commands. type CLI struct { clikit.Globals - - Parse parseCmd `cmd:"" help:"Parse a .KID file and summarize its builds and sections."` - Decompose decomposeCmd `cmd:"" help:"Split a .KID into a per-component KIDComponents/ tree."` - Assemble assembleCmd `cmd:"" help:"Reassemble a component tree back into a .KID."` - Roundtrip roundtripCmd `cmd:"" help:"Verify decompose→assemble reproduces the build (exit 3 on drift)."` - Canonicalize canonicalizeCmd `cmd:"" help:"Substitute install-time IENs with \"IEN\" in a tree (LOSSY; review-only)."` - Lint lintCmd `cmd:"" help:"Run the PIKS data-class gate over a .KID (exit 3 on a blocked file)."` + pkgcli.Commands Schema clikit.SchemaCmd `cmd:"" help:"Emit the command/flag/enum tree as JSON (agent discovery)."` Version clikit.VersionCmd `cmd:"" help:"Show version and build info."` @@ -54,262 +45,8 @@ type CLI struct { func main() { cli := &CLI{} os.Exit(clikit.Run( - "m-kids", - "VistA KIDS round-trip — decompose / assemble / roundtrip / canonicalize / lint.", + "v-pkg", + "VistA KIDS package tool — decompose / assemble / roundtrip / canonicalize / lint.", cli, &cli.Globals, )) } - -// --- parse ------------------------------------------------------------------- - -type parseCmd struct { - KidFile string `arg:"" help:"Path to the .KID file."` -} - -type buildSummary struct { - Name string `json:"name"` - Subscripts int `json:"subscripts"` - Sections map[string]int `json:"sections"` -} - -type parseResult struct { - InstallNames []string `json:"installNames"` - Builds []buildSummary `json:"builds"` -} - -func (c *parseCmd) Run(cc *clikit.Context) error { - k, err := kids.ParseKID(c.KidFile) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") - } - res := parseResult{InstallNames: k.InstallNames} - for _, name := range k.InstallNames { - b := k.Builds[name] - bs := buildSummary{Name: name, Subscripts: b.Len(), Sections: map[string]int{}} - for _, p := range b.Pairs() { - bs.Sections[p.Subs.Section()]++ - } - res.Builds = append(res.Builds, bs) - } - return cc.Result(res, func() { - cc.Title("kids parse") - fmt.Fprintf(cc.Stdout, "install_names: %s\n", strings.Join(res.InstallNames, ", ")) - for _, bs := range res.Builds { - fmt.Fprintf(cc.Stdout, " %s %s\n", cc.Accent(bs.Name), cc.Faint(fmt.Sprintf("(%d subscripts)", bs.Subscripts))) - secs := make([]string, 0, len(bs.Sections)) - for s := range bs.Sections { - secs = append(secs, s) - } - sort.Strings(secs) - for _, s := range secs { - fmt.Fprintf(cc.Stdout, " %-8s %d\n", s, bs.Sections[s]) - } - } - }) -} - -// --- decompose --------------------------------------------------------------- - -type decomposeCmd struct { - KidFile string `arg:"" help:"Path to the .KID file."` - OutputDir string `arg:"" help:"Output directory for the component tree (replaced if it exists)."` -} - -type decomposeResult struct { - OutputDir string `json:"outputDir"` - Builds []string `json:"builds"` -} - -func (c *decomposeCmd) Run(cc *clikit.Context) error { - k, err := kids.ParseKID(c.KidFile) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") - } - if _, err := os.Stat(c.OutputDir); err == nil { - if err := os.RemoveAll(c.OutputDir); err != nil { - return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") - } - } - for _, name := range k.InstallNames { - dir := filepath.Join(c.OutputDir, kids.PatchDescriptorToDir(name), "KIDComponents") - if err := kids.DecomposeBuild(k.Builds[name], dir); err != nil { - return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") - } - } - res := decomposeResult{OutputDir: c.OutputDir, Builds: k.InstallNames} - return cc.Result(res, func() { - cc.Title("kids decompose") - fmt.Fprintf(cc.Stdout, "%s decomposed %d build(s) to %s\n", - cc.Success("ok"), len(res.Builds), cc.Accent(res.OutputDir)) - }) -} - -// --- assemble ---------------------------------------------------------------- - -type assembleCmd struct { - InputDir string `arg:"" help:"Component tree (a directory of /KIDComponents/)."` - OutputKid string `arg:"" help:"Output .KID path."` -} - -type assembleResult struct { - OutputKid string `json:"outputKid"` - InstallNames []string `json:"installNames"` -} - -func (c *assembleCmd) Run(cc *clikit.Context) error { - entries, err := os.ReadDir(c.InputDir) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") - } - var dirNames []string - for _, e := range entries { - if e.IsDir() { - dirNames = append(dirNames, e.Name()) - } - } - sort.Strings(dirNames) - - var installNames []string - buildsPairs := map[string][]kids.Pair{} - for _, dn := range dirNames { - comp := filepath.Join(c.InputDir, dn, "KIDComponents") - if _, err := os.Stat(comp); err != nil { - continue - } - installName := recoverInstallName(dn) - pairs, err := kids.AssembleBuild(comp, installName) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") - } - installNames = append(installNames, installName) - buildsPairs[installName] = pairs - } - if err := kids.WriteKID(installNames, buildsPairs, c.OutputKid); err != nil { - return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") - } - res := assembleResult{OutputKid: c.OutputKid, InstallNames: installNames} - return cc.Result(res, func() { - cc.Title("kids assemble") - fmt.Fprintf(cc.Stdout, "%s assembled %d build(s) → %s\n", - cc.Success("ok"), len(installNames), cc.Accent(res.OutputKid)) - }) -} - -// recoverInstallName reverses PatchDescriptorToDir: VMTEST_1.0_1 → VMTEST*1.0*1. -// Port of the directory-name parsing in py-kids-vc's _cmd_assemble. -func recoverInstallName(dirName string) string { - parts := strings.Split(dirName, "_") - if len(parts) >= 3 { - return parts[0] + "*" + strings.Join(parts[1:len(parts)-1], ".") + "*" + parts[len(parts)-1] - } - return dirName -} - -// --- roundtrip --------------------------------------------------------------- - -type roundtripCmd struct { - KidFile string `arg:"" help:"Path to the .KID file."` -} - -func (c *roundtripCmd) Run(cc *clikit.Context) error { - res, err := kids.Roundtrip(c.KidFile) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "ROUNDTRIP_ERROR", err.Error(), "") - } - if err := cc.Result(res, func() { - cc.Title("kids roundtrip") - if res.OK { - fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("roundtrip OK: %s", res.File))) - fmt.Fprintf(cc.Stdout, " builds: %d\n pairs: %d\n canonicalized equality verified\n", res.Builds, res.Pairs) - } else { - fmt.Fprintln(cc.Stdout, cc.Failure(fmt.Sprintf("roundtrip FAIL: %s", res.File))) - for _, d := range res.Diff { - fmt.Fprintf(cc.Stdout, " build %s: %d → %d pairs\n - %s\n + %s\n", - d.Build, d.PairsA, d.PairsB, d.FirstA, d.FirstB) - } - } - }); err != nil { - return err - } - if !res.OK { - return clikit.Fail(clikit.ExitCheck, "ROUNDTRIP_FAILED", - fmt.Sprintf("%s did not round-trip", res.File), "inspect the diff above") - } - return nil -} - -// --- canonicalize ------------------------------------------------------------ - -type canonicalizeCmd struct { - DecompDir string `arg:"" help:"A decomposed component tree to rewrite in place (LOSSY)."` -} - -func (c *canonicalizeCmd) Run(cc *clikit.Context) error { - stats, err := kids.CanonicalizeIENs(c.DecompDir) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "CANONICALIZE_FAILED", err.Error(), "") - } - return cc.Result(stats, func() { - cc.Title("kids canonicalize") - fmt.Fprintf(cc.Stdout, "%s %d IEN substitution(s)\n", cc.Success("ok"), stats.Total()) - fmt.Fprintf(cc.Stdout, " BLD: %d\n KRN: %d\n", stats.BLD, stats.KRN) - }) -} - -// --- lint (PIKS data-class gate) --------------------------------------------- - -type lintCmd struct { - KidFile string `arg:"" help:"Path to the .KID file."` - Piks string `name:"piks" help:"Path to an authoritative PIKS classification table (TSV: filenumberclass)."` - Strict bool `help:"Treat unclassified data files as gate failures (fail-closed)."` -} - -func (c *lintCmd) Run(cc *clikit.Context) error { - k, err := kids.ParseKID(c.KidFile) - if err != nil { - return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") - } - classifier := kids.NewPIKSClassifier() - if c.Piks != "" { - if err := classifier.LoadPIKS(c.Piks); err != nil { - return clikit.Fail(clikit.ExitUsage, "BAD_PIKS", err.Error(), "") - } - } - res := kids.LintDataClass(k, classifier, c.Strict) - - diags := make([]clikit.Diagnostic, 0, len(res.Findings)) - for _, f := range res.Findings { - diags = append(diags, clikit.Diagnostic{ - File: "file " + f.File, - Rule: "KIDS-DATA-CLASS", - Severity: f.Severity, - Message: fmt.Sprintf("%s [%s] %s", f.Section, f.Class, f.Message), - }) - } - summary := map[string]int{ - "dataFiles": res.DataFiles, "blocked": res.Blocked, "unclassified": res.Unclassified, - } - if err := cc.Diagnostics(summary, diags, func() { - cc.Title("kids lint — data-class gate") - for _, d := range diags { - fmt.Fprintf(cc.Stdout, "%s %s %s\n", cc.Severity(d.Severity), cc.Faint(d.File), d.Message) - } - if res.OK { - fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("data-class gate clean (%d data file(s))", res.DataFiles))) - } else { - fmt.Fprintln(cc.Stdout, cc.Failure(fmt.Sprintf("data-class gate FAILED — %d blocked file(s)", res.Blocked))) - } - }); err != nil { - return err - } - if !res.OK { - refused := res.Blocked - if c.Strict { - refused += res.Unclassified - } - return clikit.Fail(clikit.ExitCheck, "DATA_CLASS_GATE", - fmt.Sprintf("%d file(s) refused by the data-class gate", refused), - "Patient/Institution-class operational data must not be versioned") - } - return nil -} diff --git a/pkgcli/build.go b/pkgcli/build.go new file mode 100644 index 0000000..1ba6b72 --- /dev/null +++ b/pkgcli/build.go @@ -0,0 +1,82 @@ +package pkgcli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/vista-cloud-dev/v-pkg/clikit" + "github.com/vista-cloud-dev/v-pkg/internal/buildspec" + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// buildCmd is `v pkg build` (VSL T0a.2): assemble a KIDS transport global from a +// declarative build spec (kids/.build.json) + the routine source, producing +// a NORMALIZED, byte-identical export — the deterministic-build invariant +// (coordination plan §7.2 #2). Unlike `assemble` (which reassembles an existing +// decomposed .KID tree), `build` constructs the package from its git source of +// truth. +type buildCmd struct { + Spec string `arg:"" help:"Path to the kids/.build.json build spec."` + Src string `help:"Directory holding the routine source (.m)." default:"src" placeholder:"DIR"` + Out string `help:"Output .KID path (default: dist/kids/.kids)." placeholder:"PATH"` +} + +type buildResult struct { + InstallName string `json:"installName"` + Out string `json:"out"` + Routines int `json:"routines"` +} + +func (c *buildCmd) Run(cc *clikit.Context) error { + spec, err := buildspec.Load(c.Spec) + if err != nil { + return clikit.Fail(clikit.ExitUsage, "BAD_SPEC", err.Error(), "fix the build spec") + } + + rtns := make([]kids.RoutineSrc, 0, len(spec.Components.Routines)) + for _, name := range spec.Components.Routines { + p := filepath.Join(c.Src, name+".m") + data, rerr := os.ReadFile(p) + if rerr != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", rerr.Error(), + "stage the routine source under --src (e.g. "+filepath.Join(c.Src, name+".m")+")") + } + rtns = append(rtns, kids.RoutineSrc{Name: name, Lines: routineLines(data)}) + } + + pairs := kids.MakeBuildPairs(kids.BuildInput{ + InstallName: spec.InstallName(), + Namespace: spec.Package, + Routines: rtns, + }) + + out := c.Out + if out == "" { + out = filepath.Join("dist", "kids", spec.Package+".kids") + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") + } + if err := kids.WriteKID([]string{spec.InstallName()}, + map[string][]kids.Pair{spec.InstallName(): pairs}, out); err != nil { + return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") + } + + return cc.Result(buildResult{InstallName: spec.InstallName(), Out: out, Routines: len(rtns)}, func() { + cc.Title("pkg build") + fmt.Fprintf(cc.Stdout, "%s built %s (%d routine(s)) → %s\n", + cc.Success("ok"), cc.Accent(spec.InstallName()), len(rtns), cc.Accent(out)) + }) +} + +// routineLines splits routine source into lines, dropping a single trailing +// newline (so a normal text file does not yield a spurious empty final line). +func routineLines(data []byte) []string { + s := strings.TrimRight(string(data), "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} diff --git a/pkgcli/build_test.go b/pkgcli/build_test.go new file mode 100644 index 0000000..53882fe --- /dev/null +++ b/pkgcli/build_test.go @@ -0,0 +1,62 @@ +package pkgcli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/vista-cloud-dev/v-pkg/clikit" +) + +// runBuild builds the ZZSKEL test package to out, discarding the JSON envelope. +func runBuild(t *testing.T, out string) { + t.Helper() + cc := clikit.NewContext(&clikit.Globals{Output: "json"}, "build") + cc.Stdout = &bytes.Buffer{} + cmd := &buildCmd{ + Spec: filepath.Join("..", "testdata", "zzskel", "kids", "ZZSKEL.build.json"), + Src: filepath.Join("..", "testdata", "zzskel", "src"), + Out: out, + } + if err := cmd.Run(cc); err != nil { + t.Fatalf("v pkg build: %v", err) + } +} + +// TestBuild_ZZSKEL_Deterministic is the T0a.2 gate: `v pkg build` of the ZZSKEL +// package yields a byte-identical normalized export across runs (deterministic +// build, coordination plan §7.2 #2), and matches the committed golden. +func TestBuild_ZZSKEL_Deterministic(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.kids") + b := filepath.Join(dir, "b.kids") + runBuild(t, a) + runBuild(t, b) + + gotA, err := os.ReadFile(a) + if err != nil { + t.Fatal(err) + } + gotB, err := os.ReadFile(b) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotA, gotB) { + t.Fatal("v pkg build is not deterministic — two builds of the same spec differ") + } + + golden := filepath.Join("..", "testdata", "zzskel", "ZZSKEL.kids") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(golden, gotA, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (UPDATE_GOLDEN=1 to create): %v", err) + } + if !bytes.Equal(gotA, want) { + t.Errorf("ZZSKEL.kids drift — run UPDATE_GOLDEN=1\n--- got ---\n%s", gotA) + } +} diff --git a/pkgcli/commands.go b/pkgcli/commands.go new file mode 100644 index 0000000..1aceec5 --- /dev/null +++ b/pkgcli/commands.go @@ -0,0 +1,289 @@ +// Package pkgcli is the importable command surface of the v-pkg domain (the +// `v pkg` KIDS tool). It is exported so the `v` umbrella can mount it in-process +// as `v pkg ` (static-pinned composition, v-cli-platform §3) while the +// standalone `v-pkg` binary embeds the same Commands for top-level verbs. The +// offline verbs (decompose / assemble / roundtrip / canonicalize / parse / lint) +// are the byte-identical port of py-kids-vc / XPDK2VC; the live KIDS lifecycle +// (build / install / verify / uninstall / status) lands in M0a's later tasks. +package pkgcli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/vista-cloud-dev/v-pkg/clikit" + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// Commands is the v-pkg verb set. Embed it (anonymous) for top-level verbs in +// the standalone binary, or mount it as a named field (`Pkg Commands` with +// cmd:"" name:"pkg") under the `v` umbrella for `v pkg `. +type Commands struct { + Parse parseCmd `cmd:"" help:"Parse a .KID file and summarize its builds and sections."` + Decompose decomposeCmd `cmd:"" help:"Split a .KID into a per-component KIDComponents/ tree."` + Assemble assembleCmd `cmd:"" help:"Reassemble a component tree back into a .KID."` + Roundtrip roundtripCmd `cmd:"" help:"Verify decompose→assemble reproduces the build (exit 3 on drift)."` + Canonicalize canonicalizeCmd `cmd:"" help:"Substitute install-time IENs with \"IEN\" in a tree (LOSSY; review-only)."` + Lint lintCmd `cmd:"" help:"Run the PIKS data-class gate over a .KID (exit 3 on a blocked file)."` + Build buildCmd `cmd:"" help:"Build a KIDS transport global from a declarative build spec (deterministic, normalized export)."` + Install installCmd `cmd:"" help:"Install a built .KID on a live engine over the driver (non-interactive KIDS load+install)."` + Verify verifyCmd `cmd:"" help:"Verify a .KID's install on a live engine (#9.7 status + per-routine presence)."` + Uninstall uninstallCmd `cmd:"" help:"Uninstall a .KID from a live engine (routine-only back-out: routines + #9.7/#9.6)."` +} + +// --- parse ------------------------------------------------------------------- + +type parseCmd struct { + KidFile string `arg:"" help:"Path to the .KID file."` +} + +type buildSummary struct { + Name string `json:"name"` + Subscripts int `json:"subscripts"` + Sections map[string]int `json:"sections"` +} + +type parseResult struct { + InstallNames []string `json:"installNames"` + Builds []buildSummary `json:"builds"` +} + +func (c *parseCmd) Run(cc *clikit.Context) error { + k, err := kids.ParseKID(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + res := parseResult{InstallNames: k.InstallNames} + for _, name := range k.InstallNames { + b := k.Builds[name] + bs := buildSummary{Name: name, Subscripts: b.Len(), Sections: map[string]int{}} + for _, p := range b.Pairs() { + bs.Sections[p.Subs.Section()]++ + } + res.Builds = append(res.Builds, bs) + } + return cc.Result(res, func() { + cc.Title("pkg parse") + fmt.Fprintf(cc.Stdout, "install_names: %s\n", strings.Join(res.InstallNames, ", ")) + for _, bs := range res.Builds { + fmt.Fprintf(cc.Stdout, " %s %s\n", cc.Accent(bs.Name), cc.Faint(fmt.Sprintf("(%d subscripts)", bs.Subscripts))) + secs := make([]string, 0, len(bs.Sections)) + for s := range bs.Sections { + secs = append(secs, s) + } + sort.Strings(secs) + for _, s := range secs { + fmt.Fprintf(cc.Stdout, " %-8s %d\n", s, bs.Sections[s]) + } + } + }) +} + +// --- decompose --------------------------------------------------------------- + +type decomposeCmd struct { + KidFile string `arg:"" help:"Path to the .KID file."` + OutputDir string `arg:"" help:"Output directory for the component tree (replaced if it exists)."` +} + +type decomposeResult struct { + OutputDir string `json:"outputDir"` + Builds []string `json:"builds"` +} + +func (c *decomposeCmd) Run(cc *clikit.Context) error { + k, err := kids.ParseKID(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + if _, err := os.Stat(c.OutputDir); err == nil { + if err := os.RemoveAll(c.OutputDir); err != nil { + return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") + } + } + for _, name := range k.InstallNames { + dir := filepath.Join(c.OutputDir, kids.PatchDescriptorToDir(name), "KIDComponents") + if err := kids.DecomposeBuild(k.Builds[name], dir); err != nil { + return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") + } + } + res := decomposeResult{OutputDir: c.OutputDir, Builds: k.InstallNames} + return cc.Result(res, func() { + cc.Title("pkg decompose") + fmt.Fprintf(cc.Stdout, "%s decomposed %d build(s) to %s\n", + cc.Success("ok"), len(res.Builds), cc.Accent(res.OutputDir)) + }) +} + +// --- assemble ---------------------------------------------------------------- + +type assembleCmd struct { + InputDir string `arg:"" help:"Component tree (a directory of /KIDComponents/)."` + OutputKid string `arg:"" help:"Output .KID path."` +} + +type assembleResult struct { + OutputKid string `json:"outputKid"` + InstallNames []string `json:"installNames"` +} + +func (c *assembleCmd) Run(cc *clikit.Context) error { + entries, err := os.ReadDir(c.InputDir) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + var dirNames []string + for _, e := range entries { + if e.IsDir() { + dirNames = append(dirNames, e.Name()) + } + } + sort.Strings(dirNames) + + var installNames []string + buildsPairs := map[string][]kids.Pair{} + for _, dn := range dirNames { + comp := filepath.Join(c.InputDir, dn, "KIDComponents") + if _, err := os.Stat(comp); err != nil { + continue + } + installName := recoverInstallName(dn) + pairs, err := kids.AssembleBuild(comp, installName) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + installNames = append(installNames, installName) + buildsPairs[installName] = pairs + } + if err := kids.WriteKID(installNames, buildsPairs, c.OutputKid); err != nil { + return clikit.Fail(clikit.ExitRuntime, "WRITE_FAILED", err.Error(), "") + } + res := assembleResult{OutputKid: c.OutputKid, InstallNames: installNames} + return cc.Result(res, func() { + cc.Title("pkg assemble") + fmt.Fprintf(cc.Stdout, "%s assembled %d build(s) → %s\n", + cc.Success("ok"), len(installNames), cc.Accent(res.OutputKid)) + }) +} + +// recoverInstallName reverses PatchDescriptorToDir: VMTEST_1.0_1 → VMTEST*1.0*1. +// Port of the directory-name parsing in py-kids-vc's _cmd_assemble. +func recoverInstallName(dirName string) string { + parts := strings.Split(dirName, "_") + if len(parts) >= 3 { + return parts[0] + "*" + strings.Join(parts[1:len(parts)-1], ".") + "*" + parts[len(parts)-1] + } + return dirName +} + +// --- roundtrip --------------------------------------------------------------- + +type roundtripCmd struct { + KidFile string `arg:"" help:"Path to the .KID file."` +} + +func (c *roundtripCmd) Run(cc *clikit.Context) error { + res, err := kids.Roundtrip(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "ROUNDTRIP_ERROR", err.Error(), "") + } + if err := cc.Result(res, func() { + cc.Title("pkg roundtrip") + if res.OK { + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("roundtrip OK: %s", res.File))) + fmt.Fprintf(cc.Stdout, " builds: %d\n pairs: %d\n canonicalized equality verified\n", res.Builds, res.Pairs) + } else { + fmt.Fprintln(cc.Stdout, cc.Failure(fmt.Sprintf("roundtrip FAIL: %s", res.File))) + for _, d := range res.Diff { + fmt.Fprintf(cc.Stdout, " build %s: %d → %d pairs\n - %s\n + %s\n", + d.Build, d.PairsA, d.PairsB, d.FirstA, d.FirstB) + } + } + }); err != nil { + return err + } + if !res.OK { + return clikit.Fail(clikit.ExitCheck, "ROUNDTRIP_FAILED", + fmt.Sprintf("%s did not round-trip", res.File), "inspect the diff above") + } + return nil +} + +// --- canonicalize ------------------------------------------------------------ + +type canonicalizeCmd struct { + DecompDir string `arg:"" help:"A decomposed component tree to rewrite in place (LOSSY)."` +} + +func (c *canonicalizeCmd) Run(cc *clikit.Context) error { + stats, err := kids.CanonicalizeIENs(c.DecompDir) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "CANONICALIZE_FAILED", err.Error(), "") + } + return cc.Result(stats, func() { + cc.Title("pkg canonicalize") + fmt.Fprintf(cc.Stdout, "%s %d IEN substitution(s)\n", cc.Success("ok"), stats.Total()) + fmt.Fprintf(cc.Stdout, " BLD: %d\n KRN: %d\n", stats.BLD, stats.KRN) + }) +} + +// --- lint (PIKS data-class gate) --------------------------------------------- + +type lintCmd struct { + KidFile string `arg:"" help:"Path to the .KID file."` + Piks string `name:"piks" help:"Path to an authoritative PIKS classification table (TSV: filenumberclass)."` + Strict bool `help:"Treat unclassified data files as gate failures (fail-closed)."` +} + +func (c *lintCmd) Run(cc *clikit.Context) error { + k, err := kids.ParseKID(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + classifier := kids.NewPIKSClassifier() + if c.Piks != "" { + if err := classifier.LoadPIKS(c.Piks); err != nil { + return clikit.Fail(clikit.ExitUsage, "BAD_PIKS", err.Error(), "") + } + } + res := kids.LintDataClass(k, classifier, c.Strict) + + diags := make([]clikit.Diagnostic, 0, len(res.Findings)) + for _, f := range res.Findings { + diags = append(diags, clikit.Diagnostic{ + File: "file " + f.File, + Rule: "KIDS-DATA-CLASS", + Severity: f.Severity, + Message: fmt.Sprintf("%s [%s] %s", f.Section, f.Class, f.Message), + }) + } + summary := map[string]int{ + "dataFiles": res.DataFiles, "blocked": res.Blocked, "unclassified": res.Unclassified, + } + if err := cc.Diagnostics(summary, diags, func() { + cc.Title("pkg lint — data-class gate") + for _, d := range diags { + fmt.Fprintf(cc.Stdout, "%s %s %s\n", cc.Severity(d.Severity), cc.Faint(d.File), d.Message) + } + if res.OK { + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("data-class gate clean (%d data file(s))", res.DataFiles))) + } else { + fmt.Fprintln(cc.Stdout, cc.Failure(fmt.Sprintf("data-class gate FAILED — %d blocked file(s)", res.Blocked))) + } + }); err != nil { + return err + } + if !res.OK { + refused := res.Blocked + if c.Strict { + refused += res.Unclassified + } + return clikit.Fail(clikit.ExitCheck, "DATA_CLASS_GATE", + fmt.Sprintf("%d file(s) refused by the data-class gate", refused), + "Patient/Institution-class operational data must not be versioned") + } + return nil +} diff --git a/pkgcli/contract.go b/pkgcli/contract.go new file mode 100644 index 0000000..34cad37 --- /dev/null +++ b/pkgcli/contract.go @@ -0,0 +1,36 @@ +package pkgcli + +import ( + "github.com/alecthomas/kong" + + "github.com/vista-cloud-dev/v-pkg/clikit" + "github.com/vista-cloud-dev/v-pkg/vcontract" +) + +const ( + // Version is the declared SemVer of the v-pkg domain surface. It is a + // committed constant (distinct from the link-time build version reported by + // `version`) so the generated contract is drift-stable. + Version = "0.1.0" + + // ContractVersion bumps only on an incompatible command-surface change + // (v-cli-platform.md §4), independent of Version. + ContractVersion = "1.0" +) + +// Contract returns the v-pkg domain contract manifest, reflected from the actual +// pkgcli command tree. It is what the standalone `v-pkg` writes to +// dist/v-contract.json and what the `v` umbrella aggregates into its registry — +// one source, so the manifest can never drift from the real verbs. +func Contract() vcontract.Manifest { + var grammar struct { + clikit.Globals + Commands + } + k, err := kong.New(&grammar) + if err != nil { + // The grammar is static; a failure here is a programming error. + panic("pkgcli: build contract grammar: " + err.Error()) + } + return vcontract.Build("pkg", Version, ContractVersion, k) +} diff --git a/pkgcli/contract_test.go b/pkgcli/contract_test.go new file mode 100644 index 0000000..83c0330 --- /dev/null +++ b/pkgcli/contract_test.go @@ -0,0 +1,60 @@ +package pkgcli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestContract_Golden is the §4 drift gate: the committed dist/v-contract.json +// must match the contract reflected from the live command tree. Regenerate with +// `make contract` (UPDATE_GOLDEN=1). +func TestContract_Golden(t *testing.T) { + got, err := json.MarshalIndent(Contract(), "", " ") + if err != nil { + t.Fatalf("marshal contract: %v", err) + } + got = append(got, '\n') + + golden := filepath.Join("..", "dist", "v-contract.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatalf("mkdir dist: %v", err) + } + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden (run `make contract`): %v", err) + } + if string(got) != string(want) { + t.Errorf("dist/v-contract.json drift — run `make contract`\n--- got ---\n%s", got) + } +} + +// TestContract_Invariants checks contract-level facts independent of the golden. +func TestContract_Invariants(t *testing.T) { + m := Contract() + if m.Domain != "pkg" { + t.Errorf("domain = %q, want pkg", m.Domain) + } + if m.ContractVersion != ContractVersion { + t.Errorf("contractVersion = %q, want %q", m.ContractVersion, ContractVersion) + } + if len(m.Commands) == 0 { + t.Error("contract has no commands") + } + // The offline verbs must all be present. + want := map[string]bool{"parse": true, "decompose": true, "assemble": true, "roundtrip": true, "canonicalize": true, "lint": true} + for _, c := range m.Commands { + if len(c.Path) == 1 { + delete(want, c.Path[0]) + } + } + if len(want) != 0 { + t.Errorf("contract missing verbs: %v", want) + } +} diff --git a/pkgcli/lifecycle.go b/pkgcli/lifecycle.go new file mode 100644 index 0000000..45f1fc2 --- /dev/null +++ b/pkgcli/lifecycle.go @@ -0,0 +1,364 @@ +package pkgcli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/v-pkg/clikit" + "github.com/vista-cloud-dev/v-pkg/internal/installspec" + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// This file mounts the live KIDS lifecycle verbs — `v pkg install/verify/ +// uninstall` (VSL M0a, tasks T0a.3/T0a.4) — on top of the install-script +// generators in internal/installspec. The waterline split (org CLAUDE.md): the +// KIDS knowledge (the generated M, the #9.7/#9.6/^XTMP shapes) lives HERE, above +// the line; reaching a live engine is engine-neutral `m` work and is consumed +// through the shared m-driver-sdk reference Client (mdriver.Client) — v-pkg never +// hand-rolls transport or runs a driver binary directly. +// +// Each verb generates a one-shot M script, stages it as a scratch routine over +// the driver `exec load`, runs its EN entry over `exec run` (one process → one +// symbol table, so XPDA survives the SETs the install needs), and reads the +// machine-readable `<>key=value` markers back off the captured device +// output. Live-proven end-to-end on the YDB FOIA engine (ZZSKEL), see +// docs/kids-installation-automation.md §7.1. + +// Scratch routine names for the staged lifecycle scripts (ZV* keeps them in a +// local/throwaway namespace, clear of real VistA routines). They persist on the +// target after the run — harmless, and overwritten on the next invocation. +const ( + rtnInstall = "ZVPKGINS" + rtnVerify = "ZVPKGVFY" + rtnUninstall = "ZVPKGUNI" +) + +// engineConn selects which engine driver to drive and over which transport — the +// same neutral knobs as `m vista` (vista_cmd.go). The connection itself +// (container/base-url, credentials) is read by the driver from its M__* +// environment, so it never appears here. +type engineConn struct { + Engine string `help:"Engine to reach: ydb or iris." enum:"ydb,iris" required:""` + Transport string `help:"Driver transport: local | docker | remote." enum:"local,docker,remote" default:"remote"` +} + +// client resolves the m- driver binary (driver-contract §4) and returns +// the shared reference Client — the seam's single transport (waterline rule 3). +func (e engineConn) client() (*mdriver.Client, error) { + bin, err := mdriver.Locate(e.Engine, mdriver.DefaultLocateDeps()) + if err != nil { + return nil, err + } + return mdriver.NewClient(bin, e.Engine, e.Transport, nil, nil), nil +} + +func (e engineConn) noDriver(err error) *clikit.Error { + return clikit.Fail(clikit.ExitRefused, "NO_DRIVER", err.Error(), + "build the m-"+e.Engine+" driver (make build) or set M_"+strings.ToUpper(e.Engine)+"_BIN") +} + +// wrapRoutine turns a direct-mode M script body into a loadable routine: a +// column-1 header + an EN label, every body line indented one space, and a +// trailing quit. The generated scripts assume a single persistent symbol table, +// which running EN^ in one driver process provides. +func wrapRoutine(name, body string) string { + var b strings.Builder + b.WriteString(name + " ;v-pkg generated lifecycle routine — safe to delete\n") + b.WriteString("EN ;\n") + for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString(" Q\n") + return b.String() +} + +// parseMarkers extracts the `<>key=value` result lines from captured device +// output. A marker may appear mid-line (after KIDS' own device writes), so it is +// scanned anywhere in the stream and read to the next line break. +func parseMarkers(out string) map[string]string { + m := map[string]string{} + for _, seg := range strings.Split(out, installspec.ResultMarker)[1:] { + line := seg + if i := strings.IndexAny(seg, "\r\n"); i >= 0 { + line = seg[:i] + } + if k, v, ok := strings.Cut(line, "="); ok { + m[k] = v + } + } + return m +} + +// runMScript stages body as the scratch routine rtn over the driver, runs its EN +// entry, and returns the parsed markers (plus the raw device output for logging). +// A staging/compile fault or a run-time engine fault is surfaced as a Go error; +// the markers reflect whatever the script managed to write first. +func runMScript(ctx context.Context, cl *mdriver.Client, rtn, body string) (map[string]string, string, error) { + dir, err := os.MkdirTemp("", "vpkg-m-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, rtn+".m") + if err := os.WriteFile(path, []byte(wrapRoutine(rtn, body)), 0o600); err != nil { + return nil, "", err + } + lr, err := cl.Load(ctx, []string{path}) + if err != nil { + return nil, "", err + } + if lr.EngineError != nil { + return nil, "", fmt.Errorf("stage %s: %s %s", rtn, lr.EngineError.Mnemonic, lr.EngineError.Text) + } + // A driver that could not stage (e.g. no routine source directory configured) + // may report no fault yet load nothing; running EN^ would then fail with a + // confusing link error. Refuse up front so the cause is the staging, not the run. + if len(lr.Loaded) == 0 { + return nil, "", fmt.Errorf("stage %s: driver loaded no routine (check the engine's routine source path / connection)", rtn) + } + res, err := cl.ExecRun(ctx, "EN^"+rtn, nil) + if err != nil { + return nil, "", err + } + markers := parseMarkers(res.Stdout) + if res.EngineError != nil { + return markers, res.Stdout, fmt.Errorf("run EN^%s: %s %s", rtn, res.EngineError.Mnemonic, res.EngineError.Text) + } + return markers, res.Stdout, nil +} + +// loadBuild parses a .KID and returns the single build's install name and data. +func loadBuild(kidFile string) (string, *kids.Build, error) { + k, err := kids.ParseKID(kidFile) + if err != nil { + return "", nil, err + } + if len(k.InstallNames) != 1 { + return "", nil, fmt.Errorf("expected exactly one build in %s, found %d", kidFile, len(k.InstallNames)) + } + name := k.InstallNames[0] + return name, k.Builds[name], nil +} + +// --- install ---------------------------------------------------------------- + +type installResult struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Status int `json:"status"` // #9.7 STATUS piece 9 (3 = Install Completed) + Error string `json:"error,omitempty"` // e.g. "already-installed" +} + +// stageChunkBytes bounds each staging routine so the driver stages it reliably. +// A single routine large enough to carry a real package's transport global +// truncates silently when staged (T0b.2 discoveries P1); 40 KB is well under the +// observed limit while keeping the chunk count low. +const stageChunkBytes = 40000 + +// runInstall installs the build over the driver. The transport global is streamed +// into a staging global in size-bounded chunks (StageChunks), then a constant-size +// finalize routine verifies the staged count and runs INST → MERGE → EN^XPDIJ in +// one process. The outcome is read from the #9.7 status marker (or the +// already-installed guard). A staged-count mismatch is surfaced as an error. +func runInstall(ctx context.Context, cl *mdriver.Client, name, header string, pairs []kids.Pair) (installResult, error) { + chunks := installspec.StageChunks(pairs, stageChunkBytes) + for i, body := range chunks { + if _, _, err := runMScript(ctx, cl, rtnInstall, body); err != nil { + return installResult{Name: name}, fmt.Errorf("stage chunk %d/%d: %w", i+1, len(chunks), err) + } + } + markers, _, err := runMScript(ctx, cl, rtnInstall, installspec.FinalInstallScript(name, header, len(pairs))) + if err != nil { + return installResult{Name: name}, err + } + r := installResult{Name: name} + if e := markers["error"]; e != "" { + if e == "already-installed" { + r.Error = e + return r, nil + } + // e.g. stage-incomplete: a chunk was truncated — fail loudly, never + // install a partial package. + return installResult{Name: name}, fmt.Errorf("install refused: %s (staged %s of %d nodes)", + e, strings.TrimSpace(markers["staged"]), len(pairs)) + } + r.Status, _ = strconv.Atoi(strings.TrimSpace(markers["status"])) + r.Installed = r.Status == 3 + return r, nil +} + +type installCmd struct { + engineConn + KidFile string `arg:"" help:"Path to the built .KID transport file to install on the live engine."` +} + +func (c *installCmd) Run(cc *clikit.Context) error { + name, b, err := loadBuild(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + cl, err := c.client() + if err != nil { + return c.noDriver(err) + } + res, err := runInstall(context.Background(), cl, name, name+" via v pkg install", b.Pairs()) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "INSTALL_FAILED", err.Error(), "") + } + if err := cc.Result(res, func() { + cc.Title("pkg install — " + c.Engine) + switch { + case res.Error != "": + fmt.Fprintln(cc.Stdout, cc.Failure(res.Name+": "+res.Error)) + case res.Installed: + fmt.Fprintln(cc.Stdout, cc.Success(fmt.Sprintf("installed %s (#9.7 status %d)", res.Name, res.Status))) + default: + fmt.Fprintln(cc.Stdout, cc.Failure(fmt.Sprintf("%s did not complete (#9.7 status %d)", res.Name, res.Status))) + } + }); err != nil { + return err + } + if res.Error != "" { + return clikit.Fail(clikit.ExitRefused, "ALREADY_INSTALLED", res.Name+": "+res.Error, + "uninstall it first, or bump the patch") + } + if !res.Installed { + return clikit.Fail(clikit.ExitRuntime, "NOT_INSTALLED", + fmt.Sprintf("%s did not reach Install Completed (status %d)", res.Name, res.Status), "") + } + return nil +} + +// --- verify ----------------------------------------------------------------- + +type verifyResult struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Status int `json:"status"` + Routines map[string]bool `json:"routines"` +} + +// ok reports a fully verified install: #9.7 present + completed and every routine +// loaded. +func (r verifyResult) ok() bool { + if !r.Installed || r.Status != 3 { + return false + } + for _, present := range r.Routines { + if !present { + return false + } + } + return true +} + +func runVerify(ctx context.Context, cl *mdriver.Client, name string, routines []string) (verifyResult, error) { + markers, _, err := runMScript(ctx, cl, rtnVerify, installspec.VerifyScript(name, routines)) + if err != nil { + return verifyResult{Name: name}, err + } + r := verifyResult{Name: name, Routines: map[string]bool{}} + r.Installed = strings.TrimSpace(markers["installed"]) == "1" + r.Status, _ = strconv.Atoi(strings.TrimSpace(markers["status"])) + for _, rt := range routines { + r.Routines[rt] = strings.TrimSpace(markers["rtn:"+rt]) == "1" + } + return r, nil +} + +type verifyCmd struct { + engineConn + KidFile string `arg:"" help:"Path to the .KID whose install to verify (its name + routines)."` +} + +func (c *verifyCmd) Run(cc *clikit.Context) error { + name, b, err := loadBuild(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + cl, err := c.client() + if err != nil { + return c.noDriver(err) + } + res, err := runVerify(context.Background(), cl, name, b.RoutineNames()) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "VERIFY_FAILED", err.Error(), "") + } + if err := cc.Result(res, func() { + cc.Title("pkg verify — " + c.Engine) + cc.KV( + [2]string{"name", res.Name}, + [2]string{"installed", fmt.Sprint(res.Installed)}, + [2]string{"status", fmt.Sprint(res.Status)}, + ) + for rt, present := range res.Routines { + mark := cc.Success("ok") + if !present { + mark = cc.Failure("missing") + } + fmt.Fprintf(cc.Stdout, " %s %s\n", rt, mark) + } + }); err != nil { + return err + } + if !res.ok() { + return clikit.Fail(clikit.ExitCheck, "NOT_VERIFIED", + fmt.Sprintf("%s is not fully installed", res.Name), "install it with `v pkg install`") + } + return nil +} + +// --- uninstall -------------------------------------------------------------- + +type uninstallResult struct { + Name string `json:"name"` + Uninstalled bool `json:"uninstalled"` +} + +func runUninstall(ctx context.Context, cl *mdriver.Client, name string, routines []string) (uninstallResult, error) { + markers, _, err := runMScript(ctx, cl, rtnUninstall, installspec.UninstallScript(name, routines)) + if err != nil { + return uninstallResult{Name: name}, err + } + return uninstallResult{Name: name, Uninstalled: strings.TrimSpace(markers["uninstalled"]) == "1"}, nil +} + +type uninstallCmd struct { + engineConn + KidFile string `arg:"" help:"Path to the .KID whose install to reverse (routine-only back-out)."` +} + +func (c *uninstallCmd) Run(cc *clikit.Context) error { + name, b, err := loadBuild(c.KidFile) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "READ_FAILED", err.Error(), "") + } + cl, err := c.client() + if err != nil { + return c.noDriver(err) + } + res, err := runUninstall(context.Background(), cl, name, b.RoutineNames()) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "UNINSTALL_FAILED", err.Error(), "") + } + if err := cc.Result(res, func() { + cc.Title("pkg uninstall — " + c.Engine) + if res.Uninstalled { + fmt.Fprintln(cc.Stdout, cc.Success("uninstalled "+res.Name)) + } else { + fmt.Fprintln(cc.Stdout, cc.Failure("uninstall not confirmed for "+res.Name)) + } + }); err != nil { + return err + } + if !res.Uninstalled { + return clikit.Fail(clikit.ExitRuntime, "NOT_UNINSTALLED", + "uninstall of "+res.Name+" was not confirmed", "") + } + return nil +} diff --git a/pkgcli/lifecycle_test.go b/pkgcli/lifecycle_test.go new file mode 100644 index 0000000..c10f252 --- /dev/null +++ b/pkgcli/lifecycle_test.go @@ -0,0 +1,259 @@ +package pkgcli + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" + "github.com/vista-cloud-dev/v-pkg/internal/installspec" + "github.com/vista-cloud-dev/v-pkg/internal/kids" +) + +// --- fake driver ------------------------------------------------------------ + +// fakeDriver is a CmdRunner that answers the two verbs the lifecycle path uses +// (`exec load`, `exec run`) with canned clikit envelopes, recording the argv so +// tests can assert the staged path and the entryref. +type fakeDriver struct { + loadArgs, runArgs []string + runStdout string + loadEng, runEng *mdriver.EngineError + loadEmpty bool // driver stages nothing (no fault) — the silent no-op case + loads, runs int // call counts (the chunked install stages many times) +} + +func (f *fakeDriver) run(_ context.Context, _ string, args []string) (stdout, stderr []byte, exit int, err error) { + switch { + case len(args) >= 2 && args[0] == "exec" && args[1] == "load": + f.loadArgs, f.loads = args, f.loads+1 + loaded := []string{"EN"} + if f.loadEmpty { + loaded = nil + } + return envBytes("exec load", mdriver.LoadResult{Loaded: loaded}, f.loadEng), nil, 0, nil + case len(args) >= 2 && args[0] == "exec" && args[1] == "run": + f.runArgs, f.runs = args, f.runs+1 + return envBytes("exec run", mdriver.ExecResult{Stdout: f.runStdout}, f.runEng), nil, 0, nil + } + return envBytes("?", nil, nil), nil, 0, nil +} + +func envBytes(command string, data any, eng *mdriver.EngineError) []byte { + raw, _ := json.Marshal(data) + env := map[string]any{ + "schemaVersion": "1.0", "command": command, "ok": eng == nil, "exit": 0, + "data": json.RawMessage(raw), + } + if eng != nil { + env["engineError"] = eng + } + b, _ := json.Marshal(env) + return b +} + +func fakeClient(f *fakeDriver) *mdriver.Client { + return mdriver.NewClient("/bin/m-ydb", "ydb", "local", nil, f.run) +} + +func zzskelPairs() []kids.Pair { + return kids.MakeBuildPairs(kids.BuildInput{ + InstallName: "ZZSKEL*1.0*1", Namespace: "ZZSKEL", + Routines: []kids.RoutineSrc{{Name: "ZZSKEL", Lines: []string{"ZZSKEL ;x", " quit"}}}, + }) +} + +// --- pure helpers ----------------------------------------------------------- + +func TestWrapRoutine(t *testing.T) { + got := wrapRoutine("ZVPKGINS", "S X=1\nW \"hi\",!") + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + if lines[0] != "ZVPKGINS ;v-pkg generated lifecycle routine — safe to delete" { + t.Errorf("header line = %q", lines[0]) + } + if lines[1] != "EN ;" { + t.Errorf("label line = %q", lines[1]) + } + if lines[2] != " S X=1" || lines[3] != ` W "hi",!` { + t.Errorf("body not indented by one space: %q / %q", lines[2], lines[3]) + } + if lines[len(lines)-1] != " Q" { + t.Errorf("missing trailing quit: %q", lines[len(lines)-1]) + } +} + +func TestParseMarkers(t *testing.T) { + // Markers may appear mid-line (after KIDS device output) and over CRLF. + out := "Some KIDS chatter" + installspec.ResultMarker + "status=3\r\n" + + "junk\n" + installspec.ResultMarker + "installed=1\n" + + installspec.ResultMarker + "rtn:ZZSKEL=0\n" + m := parseMarkers(out) + if m["status"] != "3" || m["installed"] != "1" || m["rtn:ZZSKEL"] != "0" { + t.Errorf("parseMarkers = %v", m) + } +} + +func TestLoadBuild_ZZSKEL(t *testing.T) { + name, b, err := loadBuild(filepath.Join("..", "testdata", "zzskel", "ZZSKEL.kids")) + if err != nil { + t.Fatalf("loadBuild: %v", err) + } + if name != "ZZSKEL*1.0*1" { + t.Errorf("name = %q", name) + } + if got := b.RoutineNames(); len(got) != 1 || got[0] != "ZZSKEL" { + t.Errorf("routines = %v", got) + } +} + +// --- install ---------------------------------------------------------------- + +func TestRunInstall_Success(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "status=3\n"} + res, err := runInstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", "hdr", zzskelPairs()) + if err != nil { + t.Fatalf("runInstall: %v", err) + } + if !res.Installed || res.Status != 3 { + t.Errorf("res = %+v, want installed status 3", res) + } + // The script was staged as a .m and run via its EN entry. + if !anySuffix(f.loadArgs, rtnInstall+".m") { + t.Errorf("load argv missing …/%s.m: %v", rtnInstall, f.loadArgs) + } + if !contains(f.runArgs, "EN^"+rtnInstall) { + t.Errorf("run argv missing EN^%s: %v", rtnInstall, f.runArgs) + } +} + +// A package whose transport global exceeds one chunk must stage in several +// load+run cycles (none big enough to truncate), then finalize once. +func TestRunInstall_MultiChunkStages(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "status=3\n"} + lines := make([]string, 0, 4000) + lines = append(lines, "ZZBIG ;big") + for i := 0; i < 4000; i++ { + lines = append(lines, fmt.Sprintf(" S X=%d ; padding line %d to grow the transport global", i, i)) + } + pairs := kids.MakeBuildPairs(kids.BuildInput{ + InstallName: "ZZBIG*1.0*1", Namespace: "ZZBIG", + Routines: []kids.RoutineSrc{{Name: "ZZBIG", Lines: lines}}, + }) + chunks := installspec.StageChunks(pairs, stageChunkBytes) + if len(chunks) < 2 { + t.Fatalf("test needs a multi-chunk build, got %d chunks for %d pairs", len(chunks), len(pairs)) + } + res, err := runInstall(context.Background(), fakeClient(f), "ZZBIG*1.0*1", "hdr", pairs) + if err != nil { + t.Fatalf("runInstall: %v", err) + } + if !res.Installed || res.Status != 3 { + t.Errorf("res = %+v, want installed status 3", res) + } + // One load+run per chunk, plus the finalize routine. + if want := len(chunks) + 1; f.loads != want || f.runs != want { + t.Errorf("loads=%d runs=%d, want %d each (chunks+finalize)", f.loads, f.runs, want) + } +} + +func TestRunInstall_AlreadyInstalled(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "error=already-installed\n"} + res, err := runInstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", "hdr", zzskelPairs()) + if err != nil { + t.Fatalf("runInstall: %v", err) + } + if res.Installed || res.Error != "already-installed" { + t.Errorf("res = %+v, want refused already-installed", res) + } +} + +func TestRunInstall_EngineError(t *testing.T) { + f := &fakeDriver{runEng: &mdriver.EngineError{Mnemonic: "%GTM-E-UNDEF", Text: "XPDA"}} + _, err := runInstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", "hdr", zzskelPairs()) + if err == nil || !strings.Contains(err.Error(), "UNDEF") { + t.Errorf("want engine fault surfaced, got %v", err) + } +} + +func TestRunInstall_LoadError(t *testing.T) { + f := &fakeDriver{loadEng: &mdriver.EngineError{Mnemonic: "%GTM-E-ZLINKFILE", Text: "bad"}} + _, err := runInstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", "hdr", zzskelPairs()) + if err == nil || !strings.Contains(err.Error(), "stage") { + t.Errorf("want stage error, got %v", err) + } +} + +func TestRunInstall_SilentLoadNoOp(t *testing.T) { + // Driver reports no fault but stages nothing (e.g. no routine source dir) — + // must fail at staging, not proceed to a confusing run-time link error. + f := &fakeDriver{loadEmpty: true} + _, err := runInstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", "hdr", zzskelPairs()) + if err == nil || !strings.Contains(err.Error(), "loaded no routine") { + t.Errorf("want staging no-op surfaced, got %v", err) + } + if f.runArgs != nil { + t.Errorf("must not run EN after an empty load, ran: %v", f.runArgs) + } +} + +// --- verify ----------------------------------------------------------------- + +func TestRunVerify(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "installed=1\n" + + installspec.ResultMarker + "status=3\n" + + installspec.ResultMarker + "rtn:ZZSKEL=1\n"} + res, err := runVerify(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", []string{"ZZSKEL"}) + if err != nil { + t.Fatalf("runVerify: %v", err) + } + if !res.Installed || res.Status != 3 || !res.Routines["ZZSKEL"] { + t.Errorf("res = %+v", res) + } +} + +func TestRunVerify_NotInstalled(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "installed=0\n" + + installspec.ResultMarker + "status=\n" + + installspec.ResultMarker + "rtn:ZZSKEL=0\n"} + res, err := runVerify(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", []string{"ZZSKEL"}) + if err != nil { + t.Fatalf("runVerify: %v", err) + } + if res.Installed || res.Routines["ZZSKEL"] { + t.Errorf("res = %+v, want all-false", res) + } +} + +// --- uninstall -------------------------------------------------------------- + +func TestRunUninstall(t *testing.T) { + f := &fakeDriver{runStdout: installspec.ResultMarker + "uninstalled=1\n"} + res, err := runUninstall(context.Background(), fakeClient(f), "ZZSKEL*1.0*1", []string{"ZZSKEL"}) + if err != nil { + t.Fatalf("runUninstall: %v", err) + } + if !res.Uninstalled { + t.Errorf("res = %+v, want uninstalled", res) + } +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} + +func anySuffix(ss []string, suffix string) bool { + for _, s := range ss { + if strings.HasSuffix(s, suffix) { + return true + } + } + return false +} diff --git a/repo.meta.json b/repo.meta.json new file mode 100644 index 0000000..3a69d9a --- /dev/null +++ b/repo.meta.json @@ -0,0 +1,12 @@ +{ + "id": "tool:v-pkg", + "repo": "https://github.com/vista-cloud-dev/v-pkg", + "role": "VistA KIDS packaging — the `v pkg` domain (build/install/verify/uninstall over the m engine seam)", + "language": ["go"], + "layer": "v", + "license": "AGPL-3.0", + "exposes": { + "contract": "dist/v-contract.json" + }, + "verification_commands": ["go test ./...", "./dist/m arch check ."] +} diff --git a/testdata/zzskel/ZZSKEL.kids b/testdata/zzskel/ZZSKEL.kids new file mode 100644 index 0000000..ea1b3d7 --- /dev/null +++ b/testdata/zzskel/ZZSKEL.kids @@ -0,0 +1,26 @@ +KIDS Distribution saved by v-pkg +m-kids reassembled output +**KIDS**:ZZSKEL*1.0*1^ + +**INSTALL NAME** +ZZSKEL*1.0*1 +"BLD",1,0) +ZZSKEL*1.0*1^ZZSKEL^0^0 +"RTN") +1 +"RTN","ZZSKEL") +0^5^0^0 +"RTN","ZZSKEL",1,0) +ZZSKEL ;VCD/VSL - throwaway test package (M0a ZZSKEL) ;1.0 +"RTN","ZZSKEL",2,0) + ;;1.0;ZZSKEL;; +"RTN","ZZSKEL",3,0) + quit +"RTN","ZZSKEL",4,0) +PING() ; +"RTN","ZZSKEL",5,0) + quit "pong" +"VER") +8.0^22.2 +**END** +**END** diff --git a/testdata/zzskel/kids/ZZSKEL.build.json b/testdata/zzskel/kids/ZZSKEL.build.json new file mode 100644 index 0000000..6c2aacd --- /dev/null +++ b/testdata/zzskel/kids/ZZSKEL.build.json @@ -0,0 +1,8 @@ +{ + "package": "ZZSKEL", + "version": "1.0", + "patch": "1", + "components": { + "routines": ["ZZSKEL"] + } +} diff --git a/testdata/zzskel/src/ZZSKEL.m b/testdata/zzskel/src/ZZSKEL.m new file mode 100644 index 0000000..53b4c46 --- /dev/null +++ b/testdata/zzskel/src/ZZSKEL.m @@ -0,0 +1,5 @@ +ZZSKEL ;VCD/VSL - throwaway test package (M0a ZZSKEL) ;1.0 + ;;1.0;ZZSKEL;; + quit +PING() ; + quit "pong" diff --git a/vcontract/vcontract.go b/vcontract/vcontract.go new file mode 100644 index 0000000..ba60fdd --- /dev/null +++ b/vcontract/vcontract.go @@ -0,0 +1,56 @@ +// Package vcontract is the v CLI domain command-surface contract +// (v-cli-platform.md §4): the generated, drift-gated manifest a domain emits to +// dist/v-contract.json, which the `v` umbrella aggregates into its registry +// (§5). It is built by reflecting the domain's kong command tree (via +// clikit.BuildSchema), so the manifest can never drift from the real surface. +package vcontract + +import ( + "github.com/alecthomas/kong" + + "github.com/vista-cloud-dev/v-pkg/clikit" +) + +// Manifest is one domain's contract (§4): its name, the tool SemVer, a +// contractVersion that bumps only on an incompatible command-surface change +// (independent of SemVer), the exit-code ladder the surface uses, and the full +// reflected command tree. +type Manifest struct { + Domain string `json:"domain"` + Version string `json:"version"` + ContractVersion string `json:"contractVersion"` + Exits []ExitCode `json:"exits"` + Commands []clikit.SchemaCommand `json:"commands"` +} + +// ExitCode is one rung of the contract exit-code ladder. +type ExitCode struct { + Code int `json:"code"` + Meaning string `json:"meaning"` +} + +// Ladder is the clikit exit-code ladder a v domain may return (driver-contract +// §2 / clikit). Carried in the contract so consumers know the codes to branch on. +func Ladder() []ExitCode { + return []ExitCode{ + {clikit.ExitOK, "ok"}, + {clikit.ExitRuntime, "runtime error"}, + {clikit.ExitUsage, "usage error"}, + {clikit.ExitCheck, "check / gate failed"}, + {clikit.ExitRefused, "refused / substrate unavailable"}, + } +} + +// Build reflects a parsed kong model into a domain Manifest. domain is the +// plain-language domain noun (e.g. "pkg"); version is the tool SemVer; +// contractVersion bumps on an incompatible surface change. +func Build(domain, version, contractVersion string, k *kong.Kong) Manifest { + doc := clikit.BuildSchema(k, domain, version) + return Manifest{ + Domain: domain, + Version: version, + ContractVersion: contractVersion, + Exits: Ladder(), + Commands: doc.Commands, + } +}