diff --git a/context/architecture.md b/context/architecture.md index fd21b4c..7f06a3e 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -36,7 +36,7 @@ Current target renderer helper modules: - `config/pkl/renderers/metadata-coverage-check.pkl` - `config/pkl/generate.pkl` (single multi-file generation entrypoint) - `config/pkl/check-generated.sh` (dev-shell integration stale-output detection against committed generated files) -- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` runner, and JS validation inventory) +- `nix flake check` / `checks..{cli-tests,cli-clippy,cli-fmt,integrations-install-tests,integrations-install-clippy,integrations-install-fmt,integrations-cli-tests,integrations-cli-clippy,integrations-cli-fmt,npm-bun-tests,npm-biome-check,npm-biome-format,config-lib-bun-tests,config-lib-biome-check,config-lib-biome-format}` (root-flake check derivations for the current CLI, `integrations/install` + `integrations/cli` runners, and JS validation inventory) - `.github/workflows/pkl-generated-parity.yml` (CI wrapper that runs the parity check for pushes to `main` and pull requests targeting `main`) The scaffold provides stable canonical content-unit identifiers and reusable target-agnostic text primitives for all planned authored generated classes (agents, commands, skills, shared runtime assets, OpenCode plugin entrypoints, and generated OpenCode package manifests). @@ -78,7 +78,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - The current implemented first-wave install/distribution surface for the `sce` CLI is limited to repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the active implementation stage. - Nix-managed build/release entrypoints are the source of truth for first-wave build outputs and release automation. -- The root flake now also owns an opt-in install-channel integration-test app surface, `apps.install-channel-integration-tests`, which provides the execution contract for npm/Bun/Cargo install verification without enrolling that heavier coverage in default `checks.`. +- The root flake now also owns two opt-in integration app surfaces: `apps.install-channel-integration-tests` for npm/Bun/Cargo install verification and `apps.cli-integration-tests` for standalone `sce --help` plus `sce version` command-contract execution. Both remain outside default `checks.` coverage in the current task stack. - Repo-root `.version` is the canonical checked-in release version authority across GitHub Releases, Cargo publication, and npm publication. - GitHub Releases are the canonical publication surface for release archives, checksums, and release-manifest assets. - Cargo/crates.io and npm registry publication belong to separate downstream publish stages that consume already-versioned checked-in package metadata rather than inventing release versions during workflow execution. @@ -109,7 +109,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/sync.rs` runs the local adapter through a lazily initialized shared tokio current-thread runtime, applies bounded resilience policy to the local smoke operation, and composes a placeholder cloud-sync abstraction (`CloudSyncGateway`) so local Turso validation and deferred cloud planning remain separated. - `cli/src/services/` contains module boundaries for config, setup, doctor, hooks, sync, version, completion, and local DB adapters with explicit trait seams for future implementations. - `cli/README.md` is the crate-local onboarding and usage source of truth for placeholder behavior, safety limitations, and roadmap mapping back to service contracts. -- `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, and `integrations-install-fmt` derivations through Crane-backed paths so both Rust crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and `config/lib/bash-policy-plugin/`, and also exposes the non-default `apps.install-channel-integration-tests` flake app for install-channel integration coverage outside the default check set. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. +- `flake.nix` applies `rust-overlay` (`oxalica/rust-overlay`) to nixpkgs, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, reads the package/check version from repo-root `.version`, builds `packages.sce` through Crane (`buildDepsOnly` -> `buildPackage`) with a filtered repo-root source that preserves the Cargo tree plus `cli/assets/hooks`, then injects generated OpenCode/Claude config payloads and schema inputs into a temporary `cli/assets/generated/` mirror during derivation unpack so `cli/build.rs` can package the crate without requiring committed generated crate assets, builds `packages.cli-integration-tests` from `integrations/cli`, runs `cli-tests`, `cli-clippy`, and `cli-fmt` plus the dedicated `integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`, `integrations-cli-tests`, `integrations-cli-clippy`, and `integrations-cli-fmt` derivations through Crane-backed paths so both integration crates have first-class default-flake verification, exposes directory-scoped JS validation derivations for both `npm/` and `config/lib/bash-policy-plugin/`, and also exposes non-default integration apps (`apps.install-channel-integration-tests` and `apps.cli-integration-tests`) outside the default check set. `.github/workflows/publish-crates.yml` follows the same asset-preparation rule but runs Cargo packaging from a temporary clean repository copy so crates.io publish no longer needs `--allow-dirty`. - `flake.nix` exposes release install/run surfaces as `packages.sce` (`packages.default = packages.sce`) plus `apps.sce` and `apps.default`, all targeting `${packages.sce}/bin/sce`; this keeps repo-local and remote flake run/install flows (`nix run .`, `nix run github:crocoder-dev/shared-context-engineering`, `nix profile install github:crocoder-dev/shared-context-engineering`) aligned to the same packaged CLI output. - `biome.json` at the repository root is the canonical Biome configuration for the current JS tooling slice and deliberately scopes coverage to `npm/**` plus `config/lib/bash-policy-plugin/**` while excluding package-local `node_modules/**`; `flake.nix` exposes Biome through the default dev shell rather than through package-local installs. - `cli/Cargo.toml` now keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`), git (`cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`), and local checkout (`cargo install --path cli --locked`) guidance. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. diff --git a/context/glossary.md b/context/glossary.md index c137d55..2be1b0e 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -10,7 +10,7 @@ - `canonical OpenCode plugin registration source`: Shared Pkl-authored plugin-registration definition in `config/pkl/base/opencode.pkl`, re-exported from `config/pkl/renderers/common.pkl` as the canonical plugin list/path JSON consumed by OpenCode renderers before they emit generated `opencode.json` manifests. - `generated OpenCode plugin registration contract`: Current generated-config contract where `config/.opencode/opencode.json` and `config/automated/.opencode/opencode.json` serialize the OpenCode `plugin` field from canonical Pkl sources for SCE-managed plugins only; the current registered path is `./plugins/sce-bash-policy.ts`. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. - `root Biome contract`: Repository-root formatting/linting contract owned by `biome.json`, currently scoped only to `npm/**` and `config/lib/bash-policy-plugin/**` with package-local `node_modules/**` excluded; the canonical execution path is the root Nix dev shell (`nix develop -c biome ...`). -- `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split `config/lib/bash-policy-plugin/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. +- `cli flake checks`: Check derivations in root `flake.nix` (`checks..cli-tests`, `cli-clippy`, `cli-fmt`), dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), dedicated `integrations/cli` runner checks (`integrations-cli-tests`, `integrations-cli-clippy`, `integrations-cli-fmt`), plus `pkl-parity`, split `npm/` JS checks (`npm-bun-tests`, `npm-biome-check`, `npm-biome-format`), and split `config/lib/bash-policy-plugin/` JS checks (`config-lib-bun-tests`, `config-lib-biome-check`, `config-lib-biome-format`); invoked via `nix flake check` at repo root. - `npm JS flake checks`: The current `npm/` validation slice exposed by root `flake.nix`: `npm-bun-tests` runs only `bun test ./test/*.test.js`, `npm-biome-check` runs only Biome lint/check with formatter verification disabled, and `npm-biome-format` runs only Biome format verification with linter checks disabled. - `config-lib JS flake checks`: The current `config/lib/bash-policy-plugin/` validation slice exposed by root `flake.nix`: `config-lib-bun-tests` runs `bun test`, `config-lib-biome-check` runs Biome lint/check with formatter verification disabled, and `config-lib-biome-format` runs Biome format verification with linter checks disabled, all scoped to `config/lib/bash-policy-plugin/` only. - `cli rust overlay toolchain`: Toolchain contract in root `flake.nix` that applies `rust-overlay.overlays.default`, pins `rust-bin.stable.1.93.1.default` with `rustfmt` + `clippy`, uses that toolchain across both Crane package and check derivations, and keeps toolchain selection explicit rather than inheriting nixpkgs defaults. @@ -115,3 +115,4 @@ - `clean publish workspace`: Temporary `.git`-free copy of the checked-out repository used by the crates.io publish workflow so ephemeral generated crate assets can be prepared locally without forcing Cargo publish to run with `--allow-dirty`. - `cli generated asset mirror`: Ephemeral crate-local mirror under `cli/assets/generated/` created from canonical `config/` outputs just before Cargo packaging/builds so `cli/build.rs`, `cli/src/services/setup.rs`, and `cli/src/services/config.rs` can compile and package correctly without committing generated crate assets. - `install-channel integration-test entrypoint`: Optional root-flake app exposed as `nix run .#install-channel-integration-tests -- --channel ` that reserves a stable Nix-owned execution path for heavier install-and-run CLI channel coverage without adding that coverage to default `nix flake check`; current Rust-runner coverage includes real npm, Bun, and Cargo install-and-verify flows for all three first-wave channels. +- `cli command integration-test entrypoint`: Optional root-flake app exposed as `nix run .#cli-integration-tests`; the wrapper injects `SCE_CLI_INTEGRATION_SCE_BIN="${scePackage}/bin/sce"` and executes the standalone `integrations/cli` runner binary for `sce --help` plus `sce version` command-contract assertions without enrolling that coverage in default `nix flake check`. diff --git a/context/overview.md b/context/overview.md index b48c9c0..f773fac 100644 --- a/context/overview.md +++ b/context/overview.md @@ -27,14 +27,14 @@ The `sync` placeholder performs a local Turso smoke check through a lazily initi The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable toolchain pinned to `1.93.1` (with `rustfmt` and `clippy`), reads package/check version from the repo-root `.version` file, builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline with filtered package sources for the Cargo tree plus required embedded config/assets, and runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed check derivations (`cargoTest`, `cargoClippy`, `cargoFmt`) that reuse the same filtered source/toolchain setup. The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. The CLI Cargo package metadata now includes crates.io publication-ready fields with crate-local install guidance in `cli/README.md`; supported Cargo install paths are `cargo install shared-context-engineering --locked`, `cargo install --git https://github.com/crocoder-dev/shared-context-engineering shared-context-engineering --locked`, and local `cargo install --path cli --locked`. The published crate installs the `sce` binary. The crate also keeps `cargo clippy --manifest-path cli/Cargo.toml` warnings-denied through `cli/Cargo.toml` lint configuration, so an extra `-- -D warnings` flag is redundant. -The repository-root flake is now the single Nix entrypoint for both repo tooling and CLI packaging/checks, so root-level `nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), plus six split JavaScript check derivations: `npm-bun-tests`, `npm-biome-check`, `npm-biome-format`, `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format`, without nested-flake indirection. For Cargo packaging/builds, the crate now compiles against a temporary `cli/assets/generated/` mirror prepared from canonical `config/` outputs during Nix builds and crates.io publish runs rather than from a committed crate-local snapshot. +The repository-root flake is now the single Nix entrypoint for both repo tooling and CLI packaging/checks, so root-level `nix flake check` evaluates the Crane-backed CLI checks (`cli-tests`, `cli-clippy`, `cli-fmt`), the dedicated `integrations/install` runner checks (`integrations-install-tests`, `integrations-install-clippy`, `integrations-install-fmt`), the dedicated `integrations/cli` runner checks (`integrations-cli-tests`, `integrations-cli-clippy`, `integrations-cli-fmt`), plus six split JavaScript check derivations: `npm-bun-tests`, `npm-biome-check`, `npm-biome-format`, `config-lib-bun-tests`, `config-lib-biome-check`, and `config-lib-biome-format`, without nested-flake indirection. For Cargo packaging/builds, the crate now compiles against a temporary `cli/assets/generated/` mirror prepared from canonical `config/` outputs during Nix builds and crates.io publish runs rather than from a committed crate-local snapshot. Local developer Nix tuning guidance now lives in `AGENTS.md`, including optional user-level `~/.config/nix/nix.conf` recommendations for `max-jobs` and `cores` plus an explicit system-level-only note for `auto-optimise-store`. The Pkl authoring layer owns generated OpenCode plugin registration for SCE-managed plugins: `config/pkl/base/opencode.pkl` defines the canonical plugin entries, `config/pkl/renderers/common.pkl` re-exports the shared plugin list for renderer use, and generated `config/.opencode/opencode.json` plus `config/automated/.opencode/opencode.json` register `./plugins/sce-bash-policy.ts` through OpenCode's `plugin` field. Claude does not use an OpenCode-style plugin manifest; bash-policy enforcement for Claude has been removed from generated outputs. The current first-wave CLI install/distribution contract is now defined for `sce`: the active implemented channel set is repo-flake Nix, Cargo, and npm; `Homebrew` is deferred from the current implementation stage. Nix-managed build/release entrypoints are the source of truth for this rollout, npm consumes Nix-produced release artifacts, and repo-root `.version` is the canonical checked-in release version source that release packaging and downstream Cargo/npm publication must match. The shared release artifact foundation is now implemented through root-flake apps `release-artifacts` and `release-manifest`, which emit canonical `sce-v-.tar.gz` archives, SHA-256 checksum files, merged manifest outputs, and a detached `sce-v-release-manifest.json.sig` produced from a non-repo private signing key; the npm distribution surface is now implemented as a checked-in `npm/` launcher package plus root-flake `release-npm-package`, which packs `sce-v-npm.tgz`, refuses mismatched checked-in package metadata, and installs the native CLI by downloading the release manifest plus detached signature, verifying the manifest with the bundled npm public key, and only then checksum-verifying the matching GitHub release archive at npm `postinstall` time. GitHub Releases are the canonical publication surface for those release artifacts, while crates.io and npm registry publication are separate non-bumping publish stages under the approved release topology. GitHub CLI release automation now lives in dedicated `release-sce*.yml` workflows split by Linux, Linux ARM, and macOS ARM, and `.github/workflows/release-sce.yml` now orchestrates those three reusable platform lanes before assembling the signed release manifest, npm tarball, and GitHub release payload. The orchestrator now tags/releases the checked-in `.version` directly and rejects version mismatches instead of generating a new semver during workflow execution, `.github/workflows/publish-crates.yml` is the dedicated crates.io publish stage triggered from a published GitHub release or manual dispatch with the same `.version`/tag/Cargo parity checks and a clean temporary repo copy for Cargo packaging, and `release-agents.yml` remains Tessl-only. The current supported automated release target matrix is `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, and `aarch64-apple-darwin`; npm launcher platform support remains a separate current-state surface documented in the npm distribution contract and launcher code. The downstream publish-stage implementation is now complete for both registries: `.github/workflows/publish-crates.yml` publishes the checked-in crate version after `.version`/tag/Cargo parity checks, and `.github/workflows/publish-npm.yml` publishes the checked-in npm package after `.version`/tag/npm parity checks plus verification of the canonical `sce-v-npm.tgz` GitHub release asset. The repository root now also owns the canonical Biome contract for the current JavaScript tooling slice: `biome.json` scopes formatting/linting to `npm/` and `config/lib/bash-policy-plugin/` only, and the root Nix dev shell provides the `biome` binary so contributors do not need a host-installed formatter/linter for those areas. -The root flake now also exposes an explicit opt-in install-channel integration-test app, `nix run .#install-channel-integration-tests -- --channel `, which remains outside the default `nix flake check` path while the Rust runner now executes real npm, Bun, and Cargo install-and-verify flows for all three first-wave channels. +The root flake now also exposes explicit opt-in integration apps: `nix run .#install-channel-integration-tests -- --channel ` for install-channel coverage and `nix run .#cli-integration-tests` for standalone `sce --help` plus `sce version` command-contract coverage. Both remain outside the default `nix flake check` path in the current task stack. Shared Context Plan and Shared Context Code remain separate agent roles by design; planning (`/change-to-plan`) and implementation (`/next-task`) stay split while shared baseline guidance is deduplicated via canonical skill-owned contracts. Their shared baseline doctrine (core principles, `context/` authority, and quality posture) is defined once as canonical snippets in `config/pkl/base/shared-content-common.pkl` and composed into both agent bodies during generation; the aggregation surfaces `config/pkl/base/shared-content.pkl` (manual) and `config/pkl/base/shared-content-automated.pkl` (automated) import from grouped `plan`, `code`, and `commit` modules for downstream renderers. The `/next-task` command body is intentionally thin orchestration: readiness gating + phase sequencing are command-owned, while detailed implementation/context-sync contracts are skill-owned (`sce-plan-review`, `sce-task-execution`, `sce-context-sync`). The generated OpenCode command doc now also emits machine-readable frontmatter for this chain via `entry-skill: sce-plan-review` and an ordered `skills` list. diff --git a/context/patterns.md b/context/patterns.md index 835642f..5301ceb 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -135,7 +135,7 @@ - For hosted rewrite mapping seams, resolve candidates deterministically in strict precedence order (patch-id exact, then range-diff score, then fuzzy score), classify top-score ties as `ambiguous`, enforce low-confidence unresolved behavior below `0.60`, and preserve stable outcome ordering via canonical candidate SHA sorting. - For hosted reconciliation observability, publish run-level mapped/unmapped counts, confidence histogram buckets, runtime timing, and normalized error-class labels so retry/quality drift can be monitored without requiring a full dashboard surface. - Keep crate-local onboarding docs in `cli/README.md` and sanity-check command examples against actual `sce` output whenever command messaging changes. -- Keep Rust verification in flake checks under stable named derivations re-exported by the root flake: `checks..cli-tests`, `checks..cli-clippy`, `checks..cli-fmt`, `checks..integrations-install-tests`, `checks..integrations-install-clippy`, and `checks..integrations-install-fmt`. +- Keep Rust verification in flake checks under stable named derivations re-exported by the root flake: `checks..cli-tests`, `checks..cli-clippy`, `checks..cli-fmt`, `checks..integrations-install-tests`, `checks..integrations-install-clippy`, `checks..integrations-install-fmt`, `checks..integrations-cli-tests`, `checks..integrations-cli-clippy`, and `checks..integrations-cli-fmt`. - In `flake.nix`, select the Rust toolchain via an explicit Rust overlay (`rust-overlay`) and thread that toolchain through Crane package/check derivations so CLI builds and checks do not rely on implicit nixpkgs Rust defaults. - For installable CLI release surfaces in the root flake, expose an explicit named package plus default alias (`packages.sce` and `packages.default = packages.sce`) and pair it with a runnable app output (`apps.sce`) that points to the packaged binary path. - For root-flake CLI release metadata, source the package/check version from repo-root `.version` and trim it at eval time so packaged outputs stay aligned without hardcoded semver strings in `flake.nix`. diff --git a/flake.nix b/flake.nix index a0bb5c4..c4e3972 100644 --- a/flake.nix +++ b/flake.nix @@ -84,6 +84,15 @@ ]; }; + integrationsCliSrc = pkgs.lib.fileset.toSource { + root = workspaceRoot; + fileset = pkgs.lib.fileset.unions [ + ./integrations/cli/Cargo.toml + ./integrations/cli/Cargo.lock + ./integrations/cli/src + ]; + }; + # Fixed-output derivation to fetch Bun dependencies # The output hash must be updated when package.json or bun.lock changes configLibBashPolicyDeps = pkgs.stdenv.mkDerivation { @@ -190,6 +199,43 @@ } ); + integrationsCliCargoArgs = { + pname = "sce-cli-integration-tests"; + version = "0.2.0-pre-alpha-v2"; + src = integrationsCliSrc; + cargoToml = ./integrations/cli/Cargo.toml; + cargoLock = ./integrations/cli/Cargo.lock; + strictDeps = true; + doCheck = false; + + nativeBuildInputs = [ + rustToolchain + ]; + + postUnpack = '' + cd "$sourceRoot/integrations/cli" + sourceRoot="." + ''; + }; + + integrationsCliCargoArtifacts = craneLib.buildDepsOnly ( + integrationsCliCargoArgs + // { + pname = "sce-cli-integration-tests-deps"; + } + ); + + integrationsCliPackage = craneLib.buildPackage ( + integrationsCliCargoArgs + // { + cargoArtifacts = integrationsCliCargoArtifacts; + meta = { + mainProgram = "cli-integration-tests"; + description = "Opt-in CLI help integration runner for sce"; + }; + } + ); + scePackage = craneLib.buildPackage ( commonCargoArgs // { @@ -649,6 +695,16 @@ ''; }; + cliIntegrationTestsApp = pkgs.writeShellApplication { + name = "cli-integration-tests"; + text = '' + set -euo pipefail + + export SCE_CLI_INTEGRATION_SCE_BIN="${scePackage}/bin/sce" + exec "${integrationsCliPackage}/bin/cli-integration-tests" "$@" + ''; + }; + pklParityCheck = pkgs.runCommand "pkl-parity-check" { @@ -834,6 +890,7 @@ { packages = { sce = scePackage; + cli-integration-tests = integrationsCliPackage; default = scePackage; }; @@ -887,6 +944,30 @@ } ); + integrations-cli-tests = craneLib.cargoTest ( + integrationsCliCargoArgs + // { + pname = "sce-integrations-cli-tests"; + cargoArtifacts = integrationsCliCargoArtifacts; + } + ); + + integrations-cli-clippy = craneLib.cargoClippy ( + integrationsCliCargoArgs + // { + pname = "sce-integrations-cli-clippy"; + cargoArtifacts = integrationsCliCargoArtifacts; + cargoClippyExtraArgs = "--all-targets --all-features"; + } + ); + + integrations-cli-fmt = craneLib.cargoFmt ( + integrationsCliCargoArgs + // { + pname = "sce-integrations-cli-fmt"; + } + ); + pkl-parity = pklParityCheck; npm-bun-tests = npmTests; @@ -941,6 +1022,14 @@ description = "Run opt-in install-channel integration entrypoint"; }; }; + + cli-integration-tests = { + type = "app"; + program = "${cliIntegrationTestsApp}/bin/cli-integration-tests"; + meta = { + description = "Run opt-in CLI help integration entrypoint"; + }; + }; }; devShells.default = pkgs.mkShell { diff --git a/integrations/cli/Cargo.lock b/integrations/cli/Cargo.lock new file mode 100644 index 0000000..8aebd6f --- /dev/null +++ b/integrations/cli/Cargo.lock @@ -0,0 +1,207 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sce-cli-integration-tests" +version = "0.2.0-pre-alpha-v2" +dependencies = [ + "clap", + "thiserror", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/integrations/cli/Cargo.toml b/integrations/cli/Cargo.toml new file mode 100644 index 0000000..8e95ba5 --- /dev/null +++ b/integrations/cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sce-cli-integration-tests" +version = "0.2.0-pre-alpha-v2" +edition = "2021" +publish = false + +[[bin]] +name = "cli-integration-tests" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +thiserror = "2" diff --git a/integrations/cli/src/cli.rs b/integrations/cli/src/cli.rs new file mode 100644 index 0000000..b7cb794 --- /dev/null +++ b/integrations/cli/src/cli.rs @@ -0,0 +1,10 @@ +use clap::Parser; + +/// Standalone command-contract integration runner for `sce` CLI surfaces. +#[derive(Debug, Parser)] +#[command(name = "cli-integration-tests")] +pub(crate) struct Args { + /// Run a single command suite (for example: nix run .#cli-integration-tests -- --command version). + #[arg(long = "command", value_name = "name")] + pub(crate) command: Option, +} diff --git a/integrations/cli/src/error.rs b/integrations/cli/src/error.rs new file mode 100644 index 0000000..089923c --- /dev/null +++ b/integrations/cli/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum HarnessError { + #[error("missing required environment variable: {env}")] + MissingEnv { env: &'static str }, + + #[error("failed to run command '{program}': {error}")] + CommandRunFailed { program: String, error: String }, + + #[error( + "unknown command suite '{selected}'. Choose one of: {available}. Run '--help' for usage." + )] + UnknownCommandSelector { selected: String, available: String }, + + #[error("[FAIL] case '{case}' failed: {reason}\ncommand: {command}\nstatus: {status}\nstdout: {stdout}\nstderr: {stderr}")] + AssertionFailed { + case: &'static str, + reason: String, + command: String, + status: i32, + stdout: String, + stderr: String, + }, +} diff --git a/integrations/cli/src/main.rs b/integrations/cli/src/main.rs new file mode 100644 index 0000000..0a5a958 --- /dev/null +++ b/integrations/cli/src/main.rs @@ -0,0 +1,23 @@ +mod cli; +mod error; +mod runner; + +use std::process::ExitCode; + +use clap::Parser; +use cli::Args; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), error::HarnessError> { + let args = Args::parse(); + runner::Runner::new().run(args) +} diff --git a/integrations/cli/src/runner.rs b/integrations/cli/src/runner.rs new file mode 100644 index 0000000..c145813 --- /dev/null +++ b/integrations/cli/src/runner.rs @@ -0,0 +1,459 @@ +use std::path::PathBuf; +use std::process::{Command, Output}; + +use crate::cli::Args; +use crate::error::HarnessError; + +const SCE_BINARY_ENV: &str = "SCE_CLI_INTEGRATION_SCE_BIN"; + +pub(crate) struct Runner; + +#[derive(Clone, Copy)] +struct CommandSuite { + name: &'static str, + cases: &'static [CommandCase], +} + +#[derive(Clone, Copy)] +enum ExpectedStatus { + Success, +} + +#[derive(Clone, Copy)] +struct OutputExpectation { + must_be_empty: bool, + must_be_non_empty: bool, + required_substrings: &'static [&'static str], + validator: Option, +} + +type OutputValidator = fn(&str) -> Result<(), String>; + +impl OutputExpectation { + const fn non_empty() -> Self { + Self { + must_be_empty: false, + must_be_non_empty: true, + required_substrings: &[], + validator: None, + } + } + + const fn with_required_substrings( + mut self, + required_substrings: &'static [&'static str], + ) -> Self { + self.required_substrings = required_substrings; + self + } + + const fn with_validator(mut self, validator: OutputValidator) -> Self { + self.validator = Some(validator); + self + } +} + +#[derive(Clone, Copy)] +struct CaseExpectation { + status: ExpectedStatus, + stdout: OutputExpectation, +} + +#[derive(Clone, Copy)] +struct CommandCase { + name: &'static str, + argv: &'static [&'static str], + expectation: CaseExpectation, +} + +const HELP_CASES: &[CommandCase] = &[CommandCase { + name: "top-level-help", + argv: &["--help"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_required_substrings(&["Usage:"]), + }, +}]; + +const VERSION_CASES: &[CommandCase] = &[ + CommandCase { + name: "version-default-text", + argv: &["version"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_validator(validate_version_text_output), + }, + }, + CommandCase { + name: "version-explicit-text-format", + argv: &["version", "--format", "text"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_validator(validate_version_text_output), + }, + }, + CommandCase { + name: "version-json-format", + argv: &["version", "--format", "json"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_validator(validate_version_json_output), + }, + }, + CommandCase { + name: "top-level-version-long-flag", + argv: &["--version"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_validator(validate_version_text_output), + }, + }, + CommandCase { + name: "top-level-version-short-flag", + argv: &["-V"], + expectation: CaseExpectation { + status: ExpectedStatus::Success, + stdout: OutputExpectation::non_empty().with_validator(validate_version_text_output), + }, + }, +]; + +const COMMAND_SUITES: &[CommandSuite] = &[ + CommandSuite { + name: "help", + cases: HELP_CASES, + }, + CommandSuite { + name: "version", + cases: VERSION_CASES, + }, +]; + +impl Runner { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) fn run(self, args: Args) -> Result<(), HarnessError> { + let sce_binary = resolve_sce_binary()?; + + for suite in select_suites(args.command.as_deref())? { + for case in suite.cases { + run_case(&sce_binary, *case)?; + } + } + + Ok(()) + } +} + +fn select_suites(command: Option<&str>) -> Result, HarnessError> { + match command { + Some(name) => { + let suite = COMMAND_SUITES + .iter() + .find(|suite| suite.name == name) + .ok_or_else(|| HarnessError::UnknownCommandSelector { + selected: name.to_string(), + available: render_available_command_suites(), + })?; + Ok(vec![suite]) + } + None => Ok(COMMAND_SUITES.iter().collect()), + } +} + +fn render_available_command_suites() -> String { + let mut rendered = String::new(); + for (index, suite) in COMMAND_SUITES.iter().enumerate() { + if index > 0 { + rendered.push_str(", "); + } + rendered.push_str(suite.name); + } + rendered +} + +fn run_case(sce_binary: &PathBuf, case: CommandCase) -> Result<(), HarnessError> { + let output = run_command(sce_binary, case.argv)?; + let status = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let command = render_command(sce_binary, case.argv); + + assert_status(case, status, &stdout, &stderr, &command)?; + assert_stdout_output( + case, + &stdout, + &stderr, + status, + &command, + case.expectation.stdout, + )?; + + Ok(()) +} + +fn run_command(sce_binary: &PathBuf, args: &[&str]) -> Result { + let mut command = Command::new(sce_binary); + command.args(args); + command + .output() + .map_err(|error| HarnessError::CommandRunFailed { + program: render_command(sce_binary, args), + error: error.to_string(), + }) +} + +fn render_command(sce_binary: &PathBuf, args: &[&str]) -> String { + let mut command = sce_binary.display().to_string(); + for argument in args { + command.push(' '); + command.push_str(argument); + } + command +} + +fn assert_status( + case: CommandCase, + status: i32, + stdout: &str, + stderr: &str, + command: &str, +) -> Result<(), HarnessError> { + let status_matches = match case.expectation.status { + ExpectedStatus::Success => status == 0, + }; + + if status_matches { + return Ok(()); + } + + let reason = match case.expectation.status { + ExpectedStatus::Success => format!("expected success status 0, got {status}"), + }; + + Err(HarnessError::AssertionFailed { + case: case.name, + reason, + command: command.to_string(), + status, + stdout: stdout.trim().to_string(), + stderr: stderr.trim().to_string(), + }) +} + +fn assert_stdout_output( + case: CommandCase, + stdout: &str, + stderr: &str, + status: i32, + command: &str, + expectation: OutputExpectation, +) -> Result<(), HarnessError> { + let trimmed_stdout = stdout.trim(); + let trimmed_stderr = stderr.trim(); + + if expectation.must_be_empty && !trimmed_stdout.is_empty() { + return Err(HarnessError::AssertionFailed { + case: case.name, + reason: "expected stdout to be empty".to_string(), + command: command.to_string(), + status, + stdout: trimmed_stdout.to_string(), + stderr: trimmed_stderr.to_string(), + }); + } + + if expectation.must_be_non_empty && trimmed_stdout.is_empty() { + return Err(HarnessError::AssertionFailed { + case: case.name, + reason: "expected stdout to be non-empty".to_string(), + command: command.to_string(), + status, + stdout: trimmed_stdout.to_string(), + stderr: trimmed_stderr.to_string(), + }); + } + + for required in expectation.required_substrings { + if !stdout.contains(required) { + return Err(HarnessError::AssertionFailed { + case: case.name, + reason: format!("expected stdout to contain '{required}'"), + command: command.to_string(), + status, + stdout: trimmed_stdout.to_string(), + stderr: trimmed_stderr.to_string(), + }); + } + } + + if let Some(validator) = expectation.validator { + if let Err(reason) = validator(stdout) { + return Err(HarnessError::AssertionFailed { + case: case.name, + reason: format!("invalid stdout contract: {reason}"), + command: command.to_string(), + status, + stdout: trimmed_stdout.to_string(), + stderr: trimmed_stderr.to_string(), + }); + } + } + + Ok(()) +} + +fn validate_version_text_output(stream: &str) -> Result<(), String> { + let payload = stream.trim(); + if payload.is_empty() { + return Err("expected non-empty text payload".to_string()); + } + + let mut parts = payload.splitn(3, ' '); + let binary = parts.next().unwrap_or_default(); + let version = parts.next().unwrap_or_default(); + let profile = parts.next().unwrap_or_default(); + + if binary.is_empty() { + return Err("expected non-empty binary segment".to_string()); + } + if binary.chars().any(char::is_whitespace) { + return Err("expected binary segment without whitespace".to_string()); + } + + if version.is_empty() { + return Err("expected non-empty version segment".to_string()); + } + if version.chars().any(char::is_whitespace) { + return Err("expected version segment without whitespace".to_string()); + } + + if !profile.starts_with('(') || !profile.ends_with(')') || profile.len() <= 2 { + return Err("expected profile segment formatted as '(...)'".to_string()); + } + + Ok(()) +} + +fn validate_version_json_output(stream: &str) -> Result<(), String> { + const MAX_DYNAMIC_FIELD_LENGTH: usize = 64; + + let payload = stream.trim(); + if payload.is_empty() { + return Err("expected non-empty JSON payload".to_string()); + } + + assert_json_field_equals(payload, "status", "ok")?; + assert_json_field_equals(payload, "command", "version")?; + assert_json_field_equals(payload, "binary", "shared-context-engineering")?; + + let version = extract_json_string_field(payload, "version")?; + assert_non_empty_bounded_field("version", &version, MAX_DYNAMIC_FIELD_LENGTH)?; + if !version + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-+".contains(character)) + { + return Err( + "expected 'version' to contain only ASCII alphanumeric characters or one of: '.', '-', '+'" + .to_string(), + ); + } + if !version.chars().any(|character| character.is_ascii_digit()) { + return Err("expected 'version' to contain at least one digit".to_string()); + } + + let git_commit = extract_json_string_field(payload, "git_commit")?; + assert_non_empty_bounded_field("git_commit", &git_commit, MAX_DYNAMIC_FIELD_LENGTH)?; + if !git_commit + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + { + return Err( + "expected 'git_commit' to contain only ASCII alphanumeric characters or one of: '.', '_', '-'" + .to_string(), + ); + } + + Ok(()) +} + +fn assert_json_field_equals(payload: &str, field: &str, expected: &str) -> Result<(), String> { + let actual = extract_json_string_field(payload, field)?; + if actual == expected { + return Ok(()); + } + + Err(format!( + "expected '{field}' to equal '{expected}', got '{actual}'" + )) +} + +fn assert_non_empty_bounded_field( + field: &str, + value: &str, + max_length: usize, +) -> Result<(), String> { + if value.is_empty() { + return Err(format!("expected '{field}' to be non-empty")); + } + + if value.len() > max_length { + return Err(format!( + "expected '{field}' length <= {max_length}, got {}", + value.len() + )); + } + + Ok(()) +} + +fn extract_json_string_field(payload: &str, field: &str) -> Result { + let field_token = format!("\"{field}\""); + let field_start = payload + .find(&field_token) + .ok_or_else(|| format!("missing JSON string field '{field}'"))?; + let after_field = &payload[field_start + field_token.len()..]; + let colon_offset = after_field + .find(':') + .ok_or_else(|| format!("missing ':' after JSON field '{field}'"))?; + let after_colon = after_field[colon_offset + 1..].trim_start(); + + if !after_colon.starts_with('"') { + return Err(format!("expected JSON string value for field '{field}'")); + } + + let mut value = String::new(); + let mut escaped = false; + + for character in after_colon[1..].chars() { + if escaped { + value.push(character); + escaped = false; + continue; + } + + if character == '\\' { + escaped = true; + continue; + } + + if character == '"' { + return Ok(value); + } + + value.push(character); + } + + Err(format!( + "unterminated JSON string value for field '{field}'" + )) +} + +fn resolve_sce_binary() -> Result { + let binary = std::env::var_os(SCE_BINARY_ENV).ok_or(HarnessError::MissingEnv { + env: SCE_BINARY_ENV, + })?; + Ok(PathBuf::from(binary)) +} diff --git a/integrations/install/src/harness.rs b/integrations/install/src/harness.rs index a158c23..a2020a1 100644 --- a/integrations/install/src/harness.rs +++ b/integrations/install/src/harness.rs @@ -125,13 +125,6 @@ impl ChannelHarness { }); } - if !is_valid_version_output(&output.stdout) { - return Err(HarnessError::SceVersionUnexpected { - channel: self.channel.as_str().to_string(), - output: output.stdout, - }); - } - if !output.stderr.is_empty() { return Err(HarnessError::SceVersionStderr { channel: self.channel.as_str().to_string(), @@ -394,21 +387,6 @@ fn normalize_output(bytes: &[u8]) -> String { .to_string() } -fn is_valid_version_output(output: &str) -> bool { - let mut parts = output.splitn(3, ' '); - let binary = parts.next().unwrap_or_default(); - let version = parts.next().unwrap_or_default(); - let profile = parts.next().unwrap_or_default(); - - !binary.is_empty() - && !binary.contains(char::is_whitespace) - && !version.is_empty() - && !version.contains(char::is_whitespace) - && profile.starts_with('(') - && profile.ends_with(')') - && profile.len() > 2 -} - pub(crate) fn copy_directory_recursive( source: &Path, destination: &Path,