From 0687a0bc70ed1ecbd634dd4163dc07e0d1eac2a3 Mon Sep 17 00:00:00 2001 From: Agent IX Date: Fri, 19 Jun 2026 11:51:20 -0700 Subject: [PATCH] feat(validate): default-root discovery + lazy-init via quoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped `quire validate` now also searches the default install root ~/.ix/filament/modules and IX_FILAMENT_MODULES_PATH (preferred over the legacy IX_SCHEMA_PATH alias) — pure filesystem discovery, no network. When that search finds zero modules it shells out once to `quoin plugin ensure-defaults` to bootstrap the default set, then reloads, so a fresh machine self-heals with no env var. Falls through to an actionable error when quoin is absent. Network is confined to the quoin child; quire links no network crate. NFR-004 is amended (CR note) to scope the no-network guarantee to quire's own process, with the lazy-init as the single documented exception (ADR-0001). FR-004 gains AC-13/AC-14; IT-081 (scoped discovery network-free) and IT-082 (quoin-absent actionable error) added and mapped in tests.md. Cargo.lock synced to the committed 0.7.3 version. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- ...001-validate-lazy-init-module-bootstrap.md | 91 ++++++++++++++ spec/assets/adr/index.md | 10 ++ spec/functional/FR-004-validate-subcommand.md | 29 ++++- spec/log.md | 1 + spec/non-functional/NFR-004-no-network.md | 19 ++- spec/tests.md | 10 +- src/commands/validate.rs | 114 ++++++++++++++++-- tests/audit_no_network.rs | 59 ++++++++- tests/cli_validate.rs | 32 +++++ 10 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 spec/assets/adr/0001-validate-lazy-init-module-bootstrap.md create mode 100644 spec/assets/adr/index.md diff --git a/Cargo.lock b/Cargo.lock index 0b180e4..8523134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,7 +954,7 @@ dependencies = [ [[package]] name = "quire-cli" -version = "0.7.2" +version = "0.7.3" dependencies = [ "anyhow", "assert_cmd", diff --git a/spec/assets/adr/0001-validate-lazy-init-module-bootstrap.md b/spec/assets/adr/0001-validate-lazy-init-module-bootstrap.md new file mode 100644 index 0000000..96b1897 --- /dev/null +++ b/spec/assets/adr/0001-validate-lazy-init-module-bootstrap.md @@ -0,0 +1,91 @@ +--- +id: ADR-0001 +title: "validate lazy-installs default modules via quoin (no-network exception)" +type: ADR +--- + +# ADR 0001: `validate` lazy-installs default modules via quoin + +**Status**: decided (v1) +**Date**: 2026-06-19 +**Decision authority**: quire-cli maintainer + +## Context + +`quire validate` in scoped mode (no `--module`) resolves its archetype +registry from a set of module search roots. Historically those roots were the +`--scope` directory, `--scope/.ix/modules`, and `IX_SCHEMA_PATH`. The canonical +location where `quoin` materializes the default module set — +`~/.ix/filament/modules` (also the default root `quire-rs` +`loader::paths::default_module_root()` reads) — was **not** searched. As a +result, scoped validation on an otherwise correctly-provisioned machine failed +with "no modules found" unless `IX_SCHEMA_PATH` was set by hand. + +Two fixes were considered to make scoped validation work out of the box: + +1. **Discovery fallback** — add the default install root (and the preferred + `IX_FILAMENT_MODULES_PATH` env var) to the scoped search set. Pure + filesystem; no behavioural risk. +2. **Lazy-init** — additionally, when discovery finds *zero* modules, bootstrap + the default set before failing, so a fresh machine self-heals. + +Lazy-init is the better UX, but it collides with +[NFR-004](../../non-functional/NFR-004-no-network.md): quire-cli is a static, +no-network CI gate, and `NFR-004` forbids opening any network socket "at any +point during any subcommand's execution," enforced by `IT-008` +(`strace -fe network`, which follows child processes) and `AUDIT-003` +(`cargo deny` bans HTTP-client crates). The default module set is installed by +**git-cloning public GitHub repos** — unavoidably network I/O. + +## Decision + +**Adopt both fixes, and bootstrap by delegating to `quoin` as a child process.** + +- The discovery roots gain the default install root and + `IX_FILAMENT_MODULES_PATH` (preferred) alongside the legacy `IX_SCHEMA_PATH`. + This is pure filesystem work and remains fully within `NFR-004`. +- When scoped discovery yields zero modules, `validate` shells out **once** to + `quoin plugin ensure-defaults`, then reloads the registry one time. `quoin` + owns the default-module manifest (`default-modules.yaml`), the pinned tags, + and the `ts-plugin-kit` reconcile/clone logic — quire neither links a network + crate nor implements any clone logic itself. +- The network access therefore happens **only** in the `quoin` child, only on + the empty-discovery path, and only when `quoin` is present on `PATH`. If + `quoin` is absent or fails, `validate` falls through to an actionable error. + +`NFR-004` is **amended** (CR note, 2026-06-19) to scope its guarantee precisely: +*quire-cli's own process* opens no network socket during any subcommand; the +scoped `validate` lazy-init MAY spawn `quoin`, whose network I/O for module +bootstrap is the single documented exception. `IT-008` keeps `strace -f` and +continues to assert zero sockets on the happy path (modules already present — +including the `--module` and populated-search-root cases); the empty-discovery +lazy-init path is explicitly out of its happy-path scope and verified by +demonstration plus the quoin-absent error test (`IT-082`). + +## Consequences + +- **Positive**: scoped validation self-heals on a fresh machine with no env + vars; in the normal `quoin`-first workflow (e.g. the `specify` skill runs + `quoin write` before `quire validate`) modules are already present and quire + never spawns anything. The static-binary / no-HTTP-crate posture + (`AUDIT-003`, `NFR-004-AC-1/AC-3`) is untouched — quire links no network + crate. +- **Negative**: `validate`'s worst-case behaviour now depends on an external + binary (`quoin`) and may, on the empty-discovery path only, cause network + I/O via that child. This is a deliberate, documented weakening of the + absolute "no socket ever" reading of `NFR-004`, accepted for the bootstrap + UX. Sandboxed/offline callers that must guarantee zero network can keep + modules pre-installed (the happy path never networks) — the lazy-init is a + fallback, not the primary path. + +## Alternatives rejected + +- **Discovery fallback only** (no lazy-init): preserves `NFR-004` verbatim but + a truly standalone `quire validate` on a bare machine still fails until the + user runs `quoin`. Rejected in favour of self-heal. +- **Opt-in flag** (`--install-missing`): keeps the default no-network, but adds + surface area and a footgun (silent no-install by default). Rejected as the + less ergonomic default. +- **Reimplement the clone in quire** (link a git/HTTP crate): directly violates + `AUDIT-003` / `NFR-004-AC-1` and duplicates `quoin`'s manifest + reconcile + logic. Rejected outright. diff --git a/spec/assets/adr/index.md b/spec/assets/adr/index.md new file mode 100644 index 0000000..2818d0d --- /dev/null +++ b/spec/assets/adr/index.md @@ -0,0 +1,10 @@ +--- +type: index +title: "Adr" +description: "Index of artifacts in this directory." +--- +# Adr + +## Contents + +* [0001 Validate Lazy-Init Module Bootstrap](./0001-validate-lazy-init-module-bootstrap.md) diff --git a/spec/functional/FR-004-validate-subcommand.md b/spec/functional/FR-004-validate-subcommand.md index f524f2c..296223f 100644 --- a/spec/functional/FR-004-validate-subcommand.md +++ b/spec/functional/FR-004-validate-subcommand.md @@ -60,6 +60,21 @@ relationships: > printed). The CLI remains a thin wrapper ([StR-004](../stakeholder/StR-004-thin-boundary-over-quire-rs.md)) — all validation/composition > logic lives in quire-rs. +> **CR note (scoped discovery roots + lazy-init, 2026-06-19):** Scoped +> validation now also searches the canonical install root +> `~/.ix/filament/modules` (the same default `quire-rs` +> `loader::paths::default_module_root()` reads, where `quoin` materializes the +> default module set) and the `IX_FILAMENT_MODULES_PATH` env var (preferred; +> `IX_SCHEMA_PATH` retained as the legacy alias). These additions are pure +> filesystem discovery — no network. Additionally, when scoped discovery finds +> **zero** modules, `validate` lazy-installs the default set by shelling out +> once to `quoin plugin ensure-defaults` and reloading the registry one time +> before failing; if `quoin` is absent or fails, it falls through to an +> actionable error. The lazy-install delegates all network I/O to the `quoin` +> child — quire links no network crate — and is the documented exception to +> [NFR-004](../non-functional/NFR-004-no-network.md). See +> [ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md). + ## Description The CLI SHALL expose a single-mode (markdown-only) `validate` subcommand that @@ -85,8 +100,16 @@ When the positional argument is a document path, glob, or `-`: Scoped validation resolves relative document globs under `--scope`. If `--scope` itself contains `manifest.yaml`, it is loaded as one exact module; otherwise -Quire loads module search roots from the scope, `--scope/.ix/modules`, and -`IX_SCHEMA_PATH`. `--module` remains the exact single-module compatibility path. +Quire loads module search roots from the scope, `--scope/.ix/modules`, the +`IX_FILAMENT_MODULES_PATH` / `IX_SCHEMA_PATH` env vars, and the default install +root `~/.ix/filament/modules`. If that search yields **zero** modules, `validate` +lazy-installs the default module set by shelling out once to +`quoin plugin ensure-defaults` and reloading the registry a single time; only +this child performs network I/O (the [NFR-004](../non-functional/NFR-004-no-network.md) +exception, [ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md)). +When the set is still empty (e.g. `quoin` not installed), it exits 1 with an +actionable diagnostic. `--module` remains the exact single-module compatibility +path and never triggers discovery or lazy-init. **Archetype-resolution failure paths** (all exit 1, structured diagnostic on stderr, no stdout): @@ -112,6 +135,8 @@ stderr, no stdout): | FR-004-AC-10 | A document that is otherwise conformant but declares a frontmatter `object:` the registry cannot resolve produces a quire-rs **warning**. Without `--strict`, `validate` exits **0** and prints the warning to stderr, clearly marked (`warning:` prefix in human format) and distinct from any error; stdout stays empty | Test | | FR-004-AC-11 | With `--strict`, the same unknown-`object:` warning becomes exit-failing: `validate` exits **1**, the warning still appears on stderr; stdout stays empty. A document with NO warnings and no errors still exits 0 under `--strict` | Test | | FR-004-AC-12 | Under `--diagnostics-format json`, a warning is emitted as a distinct JSON object carrying a `severity`/`kind` field marking it a warning (not an error), so machine consumers can tell warnings from errors. An error retains its error `kind` | Test | +| FR-004-AC-13 | Scoped validation discovers modules from the default install root `~/.ix/filament/modules` and from `IX_FILAMENT_MODULES_PATH` (in addition to `--scope`, `--scope/.ix/modules`, and the legacy `IX_SCHEMA_PATH`) with no env var required and no network: a document validates against a module provided only via the default-root/env discovery path, and the run opens no inet socket | Test | +| FR-004-AC-14 | When scoped discovery finds zero modules and `quoin` is not available on PATH, `validate` exits 1 with an actionable diagnostic naming `quoin plugin ensure-defaults` (and `IX_FILAMENT_MODULES_PATH`); empty stdout. When `quoin` IS available, the empty-discovery path shells out to `quoin plugin ensure-defaults` once and reloads before validating (the [NFR-004](../non-functional/NFR-004-no-network.md) network exception, [ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md)) | Test (quoin-absent path) + Demonstration (lazy install) | ## Dependencies diff --git a/spec/log.md b/spec/log.md index f4cccf2..ff833ed 100644 --- a/spec/log.md +++ b/spec/log.md @@ -10,3 +10,4 @@ description: "Chronological log of structural changes to this bundle." * **2026-06-15** — Adopted OKF-compatible bundle structure with directory indexes. * **2026-06-16** — Added [FR-014](./functional/FR-014-validate-okf-bundle.md) (`quire validate --okf` permissive OKF bundle posture: `type` required, unknown-type/broken-link/index-incompleteness warn). Added [FR-003-AC-5](./functional/FR-003-extract-subcommand.md) (extract emits shared `[frontmatter]` untyped-document diagnostic). Backsynced the `artifact_type` → `type` discriminator rename across [FR-003](./functional/FR-003-extract-subcommand.md)/004/007/013 and spec.md via CR notes. Mapped IT-069..072 (`tests/cli_okf.rs`) + IT-026 reuse in tests.md. * **2026-06-17** — Added [FR-015](./functional/FR-015-fix-subcommand.md) (`quire fix` subcommand, ADR 0007): surfaces quire-rs unlinked-reference suggestions (FR-039) and, with `--write`, applies the auto-fixable ones via byte-exact writeback. Dry-run lists `would-fix`/`warning` and exits 1 when auto-fixes remain (CI gate); `--write` is idempotent; warn-only (unresolved/ambiguous) tokens are never written. Mapped IT-076..080 + AUDIT-002 (thin boundary) in tests.md. +* **2026-06-19** — [FR-004](./functional/FR-004-validate-subcommand.md) scoped discovery now also searches the default install root `~/.ix/filament/modules` and `IX_FILAMENT_MODULES_PATH` (preferred over the legacy `IX_SCHEMA_PATH`); on zero discovered modules it lazy-installs the default set via `quoin plugin ensure-defaults` and reloads once (FR-004-AC-13/AC-14). Added [ADR-0001](./assets/adr/0001-validate-lazy-init-module-bootstrap.md) and amended [NFR-004](./non-functional/NFR-004-no-network.md) via CR note: the no-network guarantee is scoped to quire's own process; the lazy-init's `quoin` child is the sole documented network exception. Mapped IT-081 (scoped discovery network-free) + IT-082 (quoin-absent actionable error) in tests.md. diff --git a/spec/non-functional/NFR-004-no-network.md b/spec/non-functional/NFR-004-no-network.md index 92b4b00..075390f 100644 --- a/spec/non-functional/NFR-004-no-network.md +++ b/spec/non-functional/NFR-004-no-network.md @@ -12,10 +12,25 @@ relationships: cardinality: "1:1" --- +> **CR note (lazy-init delegation exception, 2026-06-19):** This NFR is scoped +> to *quire-cli's own process*. quire links no network client crate and opens no +> socket in its own process. The one documented exception is +> [FR-004](../functional/FR-004-validate-subcommand.md) scoped `validate`: when +> module discovery finds zero modules it MAY spawn `quoin plugin ensure-defaults` +> as a **child process**, whose network I/O bootstraps the default module set. +> The clone happens entirely in `quoin`, so the static-binary / no-HTTP-crate +> guarantee (AC-1, AC-3) is unchanged; only the absolute "no socket in any +> descendant" reading of AC-2 is relaxed for that one bootstrap path. See +> [ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md). + ## Statement The CLI SHALL NOT depend on any HTTP, gRPC, or other network client crate, and -SHALL NOT open any network socket at any point during any subcommand's execution. +SHALL NOT open any network socket **in its own process** at any point during any +subcommand's execution. The sole exception is the +[FR-004](../functional/FR-004-validate-subcommand.md) scoped-`validate` lazy-init, +which may spawn `quoin` as a child process to bootstrap modules (network confined +to that child; [ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md)). `quire-rs` already pins `jsonschema` with `default-features = false, features = ["resolve-file"]` to drop `reqwest`; this CLI SHALL maintain that constraint and add no network dependencies of its own. @@ -38,5 +53,5 @@ confirms none are present (cross-platform); on Linux, each subcommand is run und | ID | Criteria | Verification | |----|----------|--------------| | NFR-004-AC-1 | `cargo deny check bans` rejects `reqwest`, `hyper`, `tonic`, `surf`, `ureq` | Inspection | -| NFR-004-AC-2 | An IT runs each subcommand under `strace -e network` (or equivalent) and asserts zero `socket()` calls | Test | +| NFR-004-AC-2 | An IT runs each subcommand under `strace -fe network` (or equivalent) on its happy path (registry present — including scoped discovery that finds modules) and asserts zero `socket()` calls in quire's own process and descendants. The scoped-`validate` empty-discovery lazy-init (which spawns `quoin`) is the documented exception ([ADR-0001](../assets/adr/0001-validate-lazy-init-module-bootstrap.md)) and is out of this happy-path scope | Test | | NFR-004-AC-3 | `Cargo.lock` audit (CI gate) lists no HTTP client crate | Inspection | diff --git a/spec/tests.md b/spec/tests.md index 1a23255..0f3b153 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -28,7 +28,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep 3. **Exit-code rule** — every exit code in FR-007 has at least one IT producing it. 4. **Subcommand permutation rule** — for each subcommand (`parse`, `extract`, `lookup`, `edit`, `validate`, `schema`), the success path, the unknown-archetype path, and the validation-failure path each have a dedicated IT where applicable. (`render` removed — §2bis.) `validate` additionally has the `--okf` permissive bundle posture: its hard-error (untyped), warn (unknown-type / broken-link / index-incomplete), and scope-default paths each have a dedicated IT (IT-069..072). `fix` (ADR 0007 unlinked-reference autofix) has its dry-run (would-fix → exit 1), `--write` apply + idempotent re-run, warn-only, clean-bundle, and `--scope`/path-safety paths each covered (IT-076..080). 5. **Determinism rule** — primary JSON outputs (`parse`, `extract`, `lookup`, `schema`) have deterministic field order through Rust struct serialization. -6. **No-network rule** — IT-008 verifies zero `socket()` calls under strace across all subcommands. +6. **No-network rule** — IT-008 verifies zero `socket()` calls under strace across all subcommands on their happy path (registry present); IT-081 covers scoped discovery finding modules without network. The scoped-`validate` empty-discovery lazy-init spawns `quoin` to bootstrap modules — the documented NFR-004 exception (ADR-0001), out of these traces' scope. --- @@ -58,7 +58,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | FR-001 render subcommand | ⊘ RETIRED (§2bis) | IT-001, IT-009, IT-010, IT-017, IT-018 (all retired) | ⊘ | | FR-002 parse subcommand | AC-1..5 | IT-002, IT-011, IT-012, IT-013, IT-019 (byte-offset round-trip) | ✅ | | FR-003 extract subcommand | AC-1..5 | IT-004, IT-015, IT-016, IT-020 (determinism rerun), IT-069 (untyped doc → shared `[frontmatter]` diagnostic) | ✅ | -| FR-004 validate subcommand (markdown-only; `--json` removed; composed type+object + `--strict`) | AC-1..12 | IT-047 (md valid), IT-048 (md broken), IT-049 (--archetype), IT-014 (md sweep), IT-056 (no frontmatter), IT-057 (no string `type`), IT-050 (unknown archetype), IT-058 (path-safety arg label), IT-059 (stdin `-` exempt + validated), IT-021 (no stdout), IT-073 (unknown `object:` warns, exit 0), IT-074 (`--strict` escalates warning → exit 1), IT-075 (json warning distinct `kind`/severity), AUDIT-002 (thin boundary) | ✅ | +| FR-004 validate subcommand (markdown-only; `--json` removed; composed type+object + `--strict`; scoped discovery + lazy-init, ADR-0001) | AC-1..14 | IT-047 (md valid), IT-048 (md broken), IT-049 (--archetype), IT-014 (md sweep), IT-056 (no frontmatter), IT-057 (no string `type`), IT-050 (unknown archetype), IT-058 (path-safety arg label), IT-059 (stdin `-` exempt + validated), IT-021 (no stdout), IT-073 (unknown `object:` warns, exit 0), IT-074 (`--strict` escalates warning → exit 1), IT-075 (json warning distinct `kind`/severity), IT-081 (scoped env/default-root discovery validates, network-free → AC-13), IT-082 (empty discovery + no quoin → actionable error → AC-14), AUDIT-002 (thin boundary) | ✅ | | FR-010 required-section validation (recast onto FR-032) | AC-1..5 | IT-051 (placeholder), IT-052 (missing), IT-053 (assert), IT-047 (valid exit 0), IT-054 (empty stdout + diagnostics) | ✅ | | FR-005 path-safety | AC-1..5 | IT-005, IT-006, IT-007, IT-022 (--out reject), IT-023 (stdin bypasses) | ✅ | | FR-006 IO contract | AC-1..4 | IT-024 (no interleaving), IT-025 (--diagnostics-format=json), IT-011 (stdin) | ✅ | @@ -78,7 +78,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | NFR-001 render p95 ≤ 50 ms | ⊘ RETIRED (§2bis) | BENCH-001 (render bench removed) | ⊘ | | NFR-002 Static binary | static audit | AUDIT-001 (`ldd` IT verifies no project .so) | ✅ | | NFR-003 Zero unsafe | static audit | AUDIT-004 (`scripts/check_unsafe_comments.sh` CI gate) | ✅ | -| NFR-004 No network | static + runtime | AUDIT-003 (`cargo deny bans`), IT-008 (strace zero socket()) | ✅ | +| NFR-004 No network (own process; scoped lazy-init via quoin is the ADR-0001 exception) | static + runtime | AUDIT-003 (`cargo deny bans`), IT-008 (strace zero socket(), happy path), IT-081 (scoped discovery network-free) | ✅ | | NFR-005 Diagnostic format | unit + IT | IT-031 (each error class parses as Diagnostic JSON) | ✅ | | NFR-006 CLI stability | snapshot | IT-032 (`quire --help` snapshot pinned) | ✅ | @@ -95,7 +95,7 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | IT-005 | `--module ../escape` exits 1 with PathSafetyViolation | Integration | P0 | FR-005-AC-1, StR-003-AC-1 | | IT-006 | Symlink under module to /etc/passwd refused at load | Integration | P0 | FR-005-AC-4, StR-003-AC-4 | | IT-007 | ⊘ RETIRED (§2bis) — `--data ../../etc/passwd` exits 1 (replaced by IT-055 on the positional doc path) | Integration | P0 | FR-005-AC-3 (retired) | -| IT-008 | No network sockets opened (strace) | Integration | P0 | NFR-004-AC-2, StR-001-AC-4 | +| IT-008 | No network sockets opened (strace, happy path — registry present) | Integration | P0 | NFR-004-AC-2, StR-001-AC-4 | | IT-009 | ⊘ RETIRED (§2bis) — Render byte-parity vs minijinja-cli (FR archetype) | Integration | P0 | FR-001-AC-1 (retired), US-001-AC-2 (retired) | | IT-010 | ⊘ RETIRED (§2bis) — Schema violation exits 1 before stdout write (render) | Integration | P0 | FR-001-AC-4 (retired), US-001-AC-3 (retired) | | IT-011 | `parse -` reads stdin | Integration | P1 | FR-002-AC-2, US-002-AC-4 | @@ -163,6 +163,8 @@ The CLI is a thin process boundary over `quire-rs`; the upstream engine is indep | IT-073 | `quire validate --module $M` (no `--strict`) → exit 0, empty stdout; stderr carries a `warning:`-prefixed line naming the unknown object, distinct from any error | Integration | P0 | FR-004-AC-10 | | IT-074 | Same doc with `--strict` → exit 1; stderr still carries the `warning:` line; empty stdout. A clean doc (no warnings/errors) under `--strict` still exits 0 | Integration | P0 | FR-004-AC-11 | | IT-075 | Same doc with `--diagnostics-format json --strict` (or no `--strict`) → the warning is a distinct JSON object on stderr carrying a `severity`/`kind` field marking it a warning, separable from an error object | Integration | P1 | FR-004-AC-12 | +| IT-081 | Scoped `validate` whose module is reachable ONLY via `IX_FILAMENT_MODULES_PATH` (HOME repointed so the default root is empty) validates the doc (exit 0) and, under `strace -fe network`, opens no inet socket | Integration | P0 | FR-004-AC-13, NFR-004-AC-2 | +| IT-082 | Scoped `validate` with zero discoverable modules and no `quoin` on PATH (HOME repointed to an empty dir) exits 1 with a diagnostic naming `quoin plugin ensure-defaults`; empty stdout | Integration | P0 | FR-004-AC-14 | | IT-076 | `quire fix --module $M` (dry-run) over a bundle with a bare in-bundle reference → exit 1, stderr `would-fix: : -> []()`, no file modified | Integration | P0 | FR-015-AC-1 | | IT-077 | `quire fix --module $M --write` rewrites the reference to the suggested relative-path link; a second `--write` run changes nothing and exits 0 (idempotence) | Integration | P0 | FR-015-AC-2 | | IT-078 | A warn-only (unresolved/ambiguous) token is surfaced as `warning: … ()`, never written even under `--write`, and does not alone cause a nonzero exit | Integration | P0 | FR-015-AC-3 | diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 6510d2f..8da49c8 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -42,7 +42,10 @@ pub struct Args { pub module: Option, /// Directory that bounds relative document globs and repo-local module - /// discovery. Scoped mode also reads IX_SCHEMA_PATH for installed modules. + /// discovery. Scoped mode also searches the default install root + /// (~/.ix/filament/modules) and the IX_FILAMENT_MODULES_PATH / + /// IX_SCHEMA_PATH env vars; when nothing is found it lazy-installs the + /// default module set via `quoin plugin ensure-defaults`. #[arg(long, value_name = "DIR", default_value = ".")] pub scope: String, @@ -212,22 +215,80 @@ fn load_registry(ctx: &Ctx, args: &Args, scope: &Path) -> anyhow::Result = roots.iter().map(PathBuf::as_path).collect(); - let registry = Registry::load_from(&refs).context("loading scoped module registry")?; + // Scoped discovery. Load once; if nothing is found, lazy-install the + // default module set via quoin and reload exactly once before failing — + // so a fresh machine validates without any manual `quoin` step or env var. + let mut registry = load_scoped_registry(scope)?; + let mut installed = false; + if registry.module_names().count() == 0 && lazy_init_default_modules(ctx) { + installed = true; + registry = load_scoped_registry(scope)?; + } io::emit_quire_diagnostics(ctx.diagnostics, registry.diagnostics()); if registry.module_names().count() == 0 { if let Some(f) = registry.failures().first() { bail!("module load failed: {} ({})", f.reason, f.path.display()); } + if installed { + bail!( + "no modules found after installing the default set via quoin; \ + check `quoin plugin ensure-defaults`" + ); + } bail!( - "no modules found for scoped validation; add modules under --scope, \ - ~/.ix/schemas, or set IX_SCHEMA_PATH" + "no modules found for scoped validation, and automatic install via \ + quoin was unavailable; install quoin and run `quoin plugin \ + ensure-defaults` (modules install to ~/.ix/filament/modules), or set \ + IX_FILAMENT_MODULES_PATH" ); } Ok(registry) } +/// Build the scoped search roots and load a [`Registry`] from them. +fn load_scoped_registry(scope: &Path) -> anyhow::Result { + let roots = scoped_registry_roots(scope); + let refs: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect(); + Registry::load_from(&refs).context("loading scoped module registry") +} + +/// Best-effort lazy install of the default Filament module set by shelling out +/// to `quoin plugin ensure-defaults`. Returns `true` only when quoin ran and +/// exited successfully. A missing `quoin` (or any failure) returns `false`, so +/// the caller falls through to the standard "no modules" guidance. The child's +/// stdout is captured (never forwarded) to preserve the stdout-silent contract. +fn lazy_init_default_modules(ctx: &Ctx) -> bool { + io::emit_diagnostic( + ctx.diagnostics, + "Diagnostic", + "no spec modules found; installing the default set via `quoin plugin ensure-defaults`", + ); + let output = match std::process::Command::new("quoin") + .args(["plugin", "ensure-defaults"]) + .output() + { + Ok(output) => output, + Err(_) => { + io::emit_diagnostic( + ctx.diagnostics, + "Diagnostic", + "quoin not found on PATH; cannot auto-install default modules", + ); + return false; + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + io::emit_diagnostic( + ctx.diagnostics, + "Diagnostic", + &format!("quoin failed to install default modules: {}", stderr.trim()), + ); + return false; + } + true +} + fn scoped_registry_roots(scope: &Path) -> Vec { let mut roots = Vec::new(); let mut seen = HashSet::new(); @@ -238,14 +299,26 @@ fn scoped_registry_roots(scope: &Path) -> Vec { push_root(&mut roots, &mut seen, ix_modules); } - if let Some(paths) = std::env::var_os("IX_SCHEMA_PATH") { - for path in std::env::split_paths(&paths) { - if path.is_dir() { - push_root(&mut roots, &mut seen, path); + // Honour the engine's module-path env vars: IX_FILAMENT_MODULES_PATH is + // preferred, IX_SCHEMA_PATH is the legacy alias (mirrors quire-rs + // loader::paths::module_path_env). Both are unioned into the search set. + for var in ["IX_FILAMENT_MODULES_PATH", "IX_SCHEMA_PATH"] { + if let Some(paths) = std::env::var_os(var) { + for path in std::env::split_paths(&paths) { + if path.is_dir() { + push_root(&mut roots, &mut seen, path); + } } } } + // The canonical install root quoin materializes the default module set + // into, and the same directory quire-rs reads by default. Including it + // here lets scoped validation find installed defaults with no env var set. + if let Some(root) = quire_rs::loader::paths::default_module_root() { + push_root(&mut roots, &mut seen, root); + } + roots } @@ -401,3 +474,24 @@ fn line_prefix(line: Option) -> String { None => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scoped_roots_include_scope_and_default_install_root() { + let scope = Path::new("/tmp/quire-cli-scope-roots-test"); + let roots = scoped_registry_roots(scope); + // The --scope directory is always the first search root. + assert_eq!(roots.first(), Some(&scope.to_path_buf())); + // The canonical install root (~/.ix/filament/modules) is included so + // scoped validation finds quoin-installed defaults with no env var set. + if let Some(default_root) = quire_rs::loader::paths::default_module_root() { + assert!( + roots.contains(&default_root), + "default install root {default_root:?} missing from {roots:?}" + ); + } + } +} diff --git a/tests/audit_no_network.rs b/tests/audit_no_network.rs index 0615b72..75d100d 100644 --- a/tests/audit_no_network.rs +++ b/tests/audit_no_network.rs @@ -1,5 +1,11 @@ //! IT-008 / NFR-004-AC-2: under `strace -fe network`, none of the -//! subcommands opens an AF_INET / AF_INET6 socket on a happy-path run. +//! subcommands opens an AF_INET / AF_INET6 socket on a happy-path run +//! (registry present). IT-081 additionally proves the new scoped discovery +//! path (modules found via IX_FILAMENT_MODULES_PATH / the default root) is +//! network-free. The scoped-`validate` empty-discovery lazy-init — which +//! spawns `quoin` to bootstrap modules — is the documented NFR-004 exception +//! (ADR-0001) and is intentionally out of these happy-path traces; its +//! quoin-absent error path is covered by IT-082 in `cli_validate.rs`. //! //! `strace` is Linux-only. We additionally skip if strace isn't on PATH //! so the test stays no-op on minimal containers. @@ -137,3 +143,54 @@ fn lookup_does_not_open_inet_socket() { let trace = run_under_strace(&["lookup", doc.to_str().unwrap(), "--heading", "Purpose"]); assert_no_inet_socket(&trace, "lookup"); } + +// IT-081 (FR-004-AC-13, NFR-004-AC-2): scoped validation that discovers its +// module via the new `IX_FILAMENT_MODULES_PATH` root (modules present, so no +// `quoin` is spawned) validates the document AND opens no inet socket. HOME is +// pointed at an empty dir so the default install root resolves to a missing +// path, isolating the test from the host's real ~/.ix. +#[test] +fn scoped_env_discovery_validates_without_inet_socket() { + if !strace_available() { + eprintln!("skipping: strace not on PATH"); + return; + } + let base = std::env::temp_dir().join(format!("quire-cli-it081-{}", std::process::id())); + let modroot = base.join("modroot"); + let home = base.join("home"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&modroot).expect("mk modroot"); + std::fs::create_dir_all(&home).expect("mk home"); + // The iso module is reachable ONLY one level below the search root, via + // IX_FILAMENT_MODULES_PATH — exercising the discovery root added in FR-004. + std::os::unix::fs::symlink(iso_module(), modroot.join("iso")).expect("symlink iso module"); + + // Scope is a doc-only dir (no modules of its own). + let scope = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/iso-docs"); + let doc = iso_doc("FR-valid.md"); + let bin = quire_bin(); + + let mut cmd = Command::new("strace"); + cmd.arg("-f") + .arg("-e") + .arg("trace=network") + .arg("-o") + .arg("/dev/stderr") + .arg(&bin) + .arg("validate") + .arg(&doc) + .arg("--scope") + .arg(&scope) + .env("HOME", &home) + .env("IX_FILAMENT_MODULES_PATH", &modroot) + .env_remove("IX_SCHEMA_PATH"); + let out = cmd.output().expect("strace failed to launch"); + let trace = String::from_utf8_lossy(&out.stderr).into_owned(); + let _ = std::fs::remove_dir_all(&base); + + assert!( + out.status.success(), + "scoped validate via IX_FILAMENT_MODULES_PATH should exit 0; output:\n{trace}" + ); + assert_no_inet_socket(&trace, "validate (scoped env discovery)"); +} diff --git a/tests/cli_validate.rs b/tests/cli_validate.rs index dfc33df..c922227 100644 --- a/tests/cli_validate.rs +++ b/tests/cli_validate.rs @@ -332,6 +332,38 @@ fn it_061_scope_glob_surfaces_invalid_document() { ); } +// IT-082 (FR-004-AC-14, quoin-absent path): scoped validation with zero +// discoverable modules and no `quoin` on PATH exits 1 with an actionable +// diagnostic naming `quoin plugin ensure-defaults`; stdout stays empty. HOME +// is pointed at an empty dir so the default install root resolves to a missing +// path, isolating the test from the host's real ~/.ix. +#[test] +fn it_082_scoped_no_modules_without_quoin_reports_actionable_error() { + let base = std::env::temp_dir().join(format!("quire-cli-it082-{}", std::process::id())); + let empty_home = base.join("home"); + let empty_bin = base.join("bin"); + let scope = base.join("scope"); + for dir in [&empty_home, &empty_bin, &scope] { + std::fs::create_dir_all(dir).expect("mkdir test dir"); + } + + quire() + .env_clear() + .env("HOME", &empty_home) // default_module_root -> empty_home/.ix/... (missing) + .env("PATH", &empty_bin) // no `quoin` discoverable on PATH + .arg("validate") + .arg(iso_doc("FR-valid.md")) + .arg("--scope") + .arg(&scope) + .assert() + .failure() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("quoin plugin ensure-defaults")); + + let _ = std::fs::remove_dir_all(&base); +} + // ---------------------------------------------------------------------- // Composed type+object validation + --strict (FR-004-AC-10..12, // FR-032-AC-12 upstream). The doc is FR-conformant but declares an