diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 830864fd..5e482004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,8 @@ jobs: # # Add a second job ONLY for work the Nix sandbox cannot do: network access, # a real PTY/TTY, or artifact upload. "A different runner OS" is no longer - # one of them: the flake's aarch64-darwin target is for local development, - # while the Linux target is the hosted release and integration gate. The two - # extra jobs below still qualify; see the comment on each. + # one of them: the Linux target is the hosted release and integration gate. + # The two extra jobs below still qualify; see the comment on each. check: name: nix flake check runs-on: ubuntu-latest @@ -47,9 +46,7 @@ jobs: # suite (checks.weave), the treefmt formatting gate (checks.formatting), # the mkdocs --strict build (checks.docs), and the binary smoke test. # - # No `--all-systems`: this hosted job is the Linux gate. The Darwin target - # is intentionally evaluated on its native development platform rather - # than realised as a foreign derivation on this runner. + # No `--all-systems`: this hosted job is the sole declared target. - name: Check flake run: nix flake check --print-build-logs @@ -64,10 +61,7 @@ jobs: # processes — get genuine coverage on a runner where those facilities # exist. The sandbox has no controlling terminal, so there is nothing for # those tests to attach to. - # One runner, no matrix: aarch64-darwin (macos-14) used to carry this leg - # too, but its runs were unreliable enough under CI load to be worse than - # no coverage. x86_64-linux is what CI gates; aarch64-darwin remains the - # local development target (see flake.nix), just without a CI leg here. + # One runner, no matrix: x86_64-linux is the sole declared and gated target. name: integration tests (x86_64-linux) runs-on: ubuntu-latest timeout-minutes: 45 @@ -88,6 +82,7 @@ jobs: XDG_CONFIG_HOME: ${{ runner.temp }}/nshell-config XDG_STATE_HOME: ${{ runner.temp }}/nshell-state NSHELL_AI_COMMAND: /nonexistent + NSHELL_TEST_PTY: "1" run: nix develop -c sbcl --script run-tests.lisp coverage: @@ -143,8 +138,7 @@ jobs: # derivation that builds the image proves it links; only running it proves # `save-lisp-and-die`'s toplevel and saved runtime options survived. # - # One Linux runner, no matrix. The aarch64-darwin target is for local - # development; release artifacts are intentionally built on Linux. + # One Linux runner, no matrix. Release artifacts are built on Linux. name: build release binary runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e01dd333..696a6e9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,6 @@ concurrency: cancel-in-progress: false jobs: - # Gate the release once, before any binary is built. Splitting this out of - # the matrix means the org invariants are checked a single time rather than - # once per target, and a mismatch fails before spending two native builds on - # a release that must not be published. verify: name: Verify the tagged tree runs-on: ubuntu-latest @@ -57,10 +53,6 @@ jobs: fi echo "commit-sha=$commit_sha" >> "$GITHUB_OUTPUT" - # Enforces the org invariant "git tag == .asd :version" at the only point - # where it can still be corrected cheaply. Without this gate the two drift - # silently, which is how cl-boundary-kit reached :version 1.0.0 while its - # newest tag was still v0.6.0. - name: Verify tag matches .asd version env: PACKAGE: nshell @@ -84,19 +76,38 @@ jobs: cachix-cache: ${{ vars.CACHIX_CACHE || 'takeokunn-nshell' }} cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - # Never publish a release that does not pass its own test suite. - name: Check flake run: nix flake check --print-build-logs + integration: + name: Test the tagged tree with real PTYs + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + HOME: ${{ runner.temp }}/nshell-home + XDG_CACHE_HOME: ${{ runner.temp }}/nshell-cache + XDG_CONFIG_HOME: ${{ runner.temp }}/nshell-config + XDG_STATE_HOME: ${{ runner.temp }}/nshell-state + NSHELL_AI_COMMAND: /nonexistent + NSHELL_TEST_PTY: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.verify.outputs.commit-sha }} + persist-credentials: false + + - uses: ./.github/actions/nix-setup + with: + cachix-cache: ${{ vars.CACHIX_CACHE || 'takeokunn-nshell' }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Run full test suite + run: nix develop -c sbcl --script run-tests.lisp + release: - # One tarball, for the one platform `systems` declares. The matrix that used - # to sit here carried a macos-14 (aarch64-darwin) leg; the 2026-08-01 - # revision reduced `systems` to x86_64-linux alone, so `nix build` on macOS - # would now fail on a missing attribute. A one-element matrix is deleted - # rather than left in place — it signals an intent to add a platform, and - # there is none. See PACKAGE_STANDARD.md "CI の粒度". name: build and publish x86_64-linux - needs: verify + needs: [verify, integration] runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -120,6 +131,9 @@ jobs: - name: Build release bundle run: nix build .#releaseBundle --print-build-logs + - name: Verify release bundle + run: perl scripts/verify-release-bundle.pl result + - name: Package tarball + checksum run: | set -euo pipefail @@ -141,30 +155,7 @@ jobs: rm "dist/${name}.repro.tar.gz" ( cd dist && shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256" ) - # Creates the release as an empty draft, with the tarball and checksum - # attached. This workflow does not produce a release body at all. - # - # As of the 2026-08-01 revision the GitHub Release description is the only - # canonical changelog in this org; there is no CHANGELOG.md to read from. - # See RELEASE_STANDARD.md. After this job goes green the maintainer fills - # the body in and publishes: - # - # gh release edit "$RELEASE_REF" --notes-file --draft=false - # - # `draft: true` is load-bearing. Publishing straight away would make "the - # maintainer forgot the notes" a user-visible state. A draft appears - # neither under "Latest release" nor in the default output of - # `gh release list`, so an unfinished release never reaches downstream. - # - # `generate_release_notes` is deliberately left off. Generated notes are a - # list of commit titles and PR numbers, and RELEASE_STANDARD.md requires - # the notes to be selected by "does a user of this package have to change - # their own code" — a judgement no generator can make. - # - # Every `uses:` in this org references a 40-character SHA, never a mutable - # tag. Re-resolve before bumping, and do not copy a SHA you have not - # resolved yourself: - # gh api repos/softprops/action-gh-release/git/ref/tags/v2 --jq .object.sha + # Publish only after the maintainer supplies user-facing release notes. - name: Create draft release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: diff --git a/README.md b/README.md index d9866fa1..323cdd7d 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,37 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Documentation](https://img.shields.io/badge/docs-MkDocs%20Material-0a7a5a)](https://nerima-lisp.github.io/nshell/) -nshell is a modern, fish-inspired interactive shell written in Common Lisp for -SBCL. It puts the *interactive* experience first — real-time syntax -highlighting, history-aware autosuggestions, fish-style abbreviations, and a -context-aware completion engine driven by a logic knowledge base — on top of a -domain-driven core whose line editor is a pure reducer over an immutable input -state, and a reproducible Nix build that packages a dumped SBCL image with its -process-launch helper. - -> **Status: development preview (0.5.x).** The interactive editor and core -> pipeline execution are solid and heavily tested. The shell *language* is a -> growing subset of POSIX/fish semantics. nshell is usable as a daily -> interactive shell for common workflows; it is not a script-compatible -> `/bin/sh` replacement. - -Full documentation is published at . -The source for that site lives in [docs/src/](docs/src/). +nshell is a fish-inspired interactive shell written in Common Lisp for SBCL. +It provides syntax highlighting, history-aware autosuggestions, abbreviations, +and context-aware completion. + +> **Status: development preview (0.6.x).** CI tests the interactive editor and +> pipeline execution on `x86_64-linux`. The shell language implements a subset +> of POSIX/fish semantics; it is not a script-compatible `/bin/sh` replacement. + +Documentation source lives in [docs/src/](docs/src/). ## Quick Start +The release and Nix flake support `x86_64-linux` only. With +[Nix](https://nixos.org/download) and flakes enabled: + ```sh -nix run github:nerima-lisp/nshell/v0.5.0 +nix run github:nerima-lisp/nshell/v0.6.1 ``` Then type as you would in any shell. Commands and paths colorize live, and a -dimmed completion of the most recent matching history entry trails the cursor — +dimmed completion of the most recent matching history entry trails the cursor; press `→` or `Ctrl-F` to accept it: ``` -~/src/nshell main ❯ git com # "mit -m " suggested from history ~/src/nshell main ❯ string upper hello HELLO ``` +After running that command, type `string up` to see `per hello` suggested +from history. + Colors come from a theme; run `theme list` to see the built-in presets and `theme use dracula` (or any other name from that list) to switch, live, with no restart needed. @@ -50,26 +48,15 @@ the edited line to nshell. ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.5.0 +nix profile install github:nerima-lisp/nshell/v0.6.1 ``` -```nix -# flake.nix -inputs.nshell = { - url = "github:nerima-lisp/nshell/v0.5.0"; - inputs.nixpkgs.follows = "nixpkgs"; -}; -``` - -Pin a release tag rather than following the default branch. The v0.5.0 release -workflow publishes an `x86_64-linux` tarball only. `aarch64-darwin` remains a -local development target; other systems are outside the tested support -boundary. +Pin a release tag rather than following the default branch. The `x86_64-linux` release bundle removes Nix store references, carries its ELF runtime library closure, and is checked for required files and dependency metadata. CI also runs `--help`, `--version`, and an `echo` smoke test on the -bundle; use the pinned Nix commands above on other platforms. See [Getting +bundle. See [Getting started](https://nerima-lisp.github.io/nshell/getting-started/) for the bundle verification and installation procedure. @@ -83,7 +70,7 @@ verification and installation procedure. ## Development ```sh -nix develop # SBCL with CL_SOURCE_REGISTRY already set +nix develop # SBCL with CL_SOURCE_REGISTRY already set (x86_64-linux) perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 300; exec @ARGV' nix build .#checks.$(nix eval --raw --impure --expr 'builtins.currentSystem').default --no-link # run the test suite perl -e '$SIG{ALRM}=sub { exit 124 }; alarm 300; exec @ARGV' nix flake check # full hermetic gate on x86_64-linux CI nix fmt # format Nix sources (treefmt) @@ -92,6 +79,9 @@ nix build .#releaseBundle perl scripts/verify-release-bundle.pl result ``` +The Perl wrappers limit each local check to five minutes and return exit code +124 if that limit expires. + To measure executable-source coverage, keep the report outside the checkout and run the same hermetic test loader used by CI: @@ -101,10 +91,10 @@ NSHELL_COVERAGE_DIR="$(mktemp -d)" \ ``` The command writes `coverage-summary.json` and `coverage-files.json` to the -selected directory. Declarative data and package-definition forms are kept -out of the executable expression denominator; the report still lists every -source file so uncovered behavior is visible. The configured minimum is a -gate, while the target remains 100%. +selected directory. These reports cover executable source under `src/`, +excluding declarative data and package-definition files. The default minimum +is 85% (`NSHELL_COVERAGE_MIN`); the target is 100% +(`NSHELL_COVERAGE_TARGET`). Tests live in `t/` and run under [cl-weave](https://github.com/nerima-lisp/cl-weave), the org's test framework. diff --git a/docs/notes/nerima-lisp-package-audit.md b/docs/notes/nerima-lisp-package-audit.md index 31f7e2fa..9d5f0672 100644 --- a/docs/notes/nerima-lisp-package-audit.md +++ b/docs/notes/nerima-lisp-package-audit.md @@ -1,105 +1,75 @@ # nerima-lisp package integration audit -Which packages from the [nerima-lisp org](https://github.com/orgs/nerima-lisp/repositories) -nshell integrates, and — for every one it does not — a concrete reason. The goal -is "adopt every applicable nerima-lisp package at the direct dependency boundary, -without compatibility wrappers", so this table exists to prove the *un*-adopted -set is non-applicable rather than overlooked. +This audit covers [`nshell.asd`](../../nshell.asd), `src/`, the assistant feature +in `packages/`, and the dependency graph in [`flake.nix`](../../flake.nix) +for the v0.6.1 release line. -## Integrated and in active use +## Declared runtime dependencies with API use -Every runtime system in `nshell :depends-on` is genuinely exercised (qualified- -symbol counts are from `src/`; `cl-parser-kit` is `:import-from`, so its symbols -appear unqualified): - -| package | role in nshell | evidence | +| Package | Role | Source evidence | |---|---|---| -| `cl-prolog-kit` | completion knowledge base — facts/rules, `map-prolog-solutions` | `domain/completion/rule-data.lisp` (8 refs) | -| `cl-parser-kit` | `$((…))` arithmetic tokenizer + Pratt parser | `:import-from` in `package.lisp`; `domain/expansion/arithmetic.lisp` | -| `cl-dataflow-kit` | reactive dataflow wiring | 12 refs | -| `cl-host-kit` | host environment, pathname, and process boundaries | `presentation/repl-environment.lisp`, `infrastructure/terminal/ansi.lisp`, `application/builtin-runtime.lisp` | -| `cl-boundary-kit` | clock/sleeper boundaries (also under cl-process-kit) | 14 refs | -| `cl-cli` | argument-vector parsing for `main` | 13 refs | -| `cl-tty-kit` | terminal control / raw-mode / rendering | 17 refs | -| `cl-process-kit` | timeout-guarded external process launch (`run`) | `infrastructure/acl/syscall-process-execution.lisp` (6 refs) | -| `cl-history-kit` | command-history store, search, and recall navigation cursor | used directly (qualified `history-kit:...`) throughout `application/` and `presentation/`; nshell keeps only the tokenizer-coupled `!$`/Alt-. last-argument extraction in `domain/history/last-argument.lisp` | -| `cl-concurrent-kit` | structured task scopes and promises for concurrent syscall work | `infrastructure/acl/syscall.lisp` (`with-task-scope`, `spawn`, `await`) | - -No declared dependency is unused, so there is no dead dependency to drop. - -The flake inputs are pinned to explicit upstream release refs rather than -floating branches. The current pins include `cl-weave` `v1.3.0`, -`cl-prolog-kit` `v1.5.0`, `cl-parser-kit` `v1.1.1`, and `cl-dataflow-kit` -`v1.2.0`; the complete resolved set is recorded in `flake.lock`. A release -API's `latest` field is not treated as authoritative here because an upstream -tag may exist before its GitHub release metadata is published. A future upgrade -must update `flake.nix` and `flake.lock` together and rerun the complete check -matrix. - -The test systems are kept separate from the runtime dependency audit: - -| package | role in nshell's test systems | evidence | +| `cl-prolog-kit` | Completion facts and rules | [`src/domain/completion/rule-data.lisp`](../../src/domain/completion/rule-data.lisp): `make-rulebase`, `map-prolog-solutions` | +| `cl-parser-kit` | Arithmetic tokenizer and Pratt parser | [`src/package-domain.lisp`](../../src/package-domain.lisp) imports the API used by [`src/domain/expansion/arithmetic.lisp`](../../src/domain/expansion/arithmetic.lisp) | +| `cl-dataflow-kit` | DOT/Mermaid pipeline diagrams; graph validation before DOT rendering | [`src/application/pipeline-diagram.lisp`](../../src/application/pipeline-diagram.lisp): `make-graph`, `validate-graph`, `graph->dot`, `graph->mermaid` | +| `cl-host-kit` | Host environment, pathnames, and working directory | [`src/application/builtin-commands.lisp`](../../src/application/builtin-commands.lisp): `getcwd`, `chdir`; [`src/infrastructure/acl/syscall-environment.lisp`](../../src/infrastructure/acl/syscall-environment.lisp): `getenv` | +| `cl-boundary-kit` | Synchronization boundary | [`src/infrastructure/acl/syscall.lisp`](../../src/infrastructure/acl/syscall.lisp): `make-lock` | +| `cl-cli` | Executable argument parsing | [`src/main.lisp`](../../src/main.lisp): `make-option`, `option-value` | +| `cl-tty-kit` | ANSI rendering, terminal size, and grapheme widths | [`src/infrastructure/terminal/ansi.lisp`](../../src/infrastructure/terminal/ansi.lisp), [`src/infrastructure/acl/syscall-terminal.lisp`](../../src/infrastructure/acl/syscall-terminal.lisp), [`src/presentation/prompt-display.lisp`](../../src/presentation/prompt-display.lisp) | +| `cl-process-kit` | External-process lifecycle and bounded Git probes | [`src/infrastructure/acl/syscall-process-execution.lisp`](../../src/infrastructure/acl/syscall-process-execution.lisp): `spawn`, `communicate`; [`src/infrastructure/acl/git.lisp`](../../src/infrastructure/acl/git.lisp): `run` | +| `cl-history-kit` | History storage, search, and persistence | [`src/application/search-history.lisp`](../../src/application/search-history.lisp), [`src/infrastructure/persistence/file-history.lisp`](../../src/infrastructure/persistence/file-history.lisp) | +| `cl-concurrent-kit` | Task scopes and promises for syscall work | [`src/infrastructure/acl/syscall.lisp`](../../src/infrastructure/acl/syscall.lisp): `with-task-scope`, `spawn`, `await` | +| `cl-json-kit` | Assistant JSON/JSONL state, audit records, and MCP messages | [`packages/feature/assistant/src/infrastructure/audit-log.lisp`](../../packages/feature/assistant/src/infrastructure/audit-log.lisp), [`packages/feature/assistant/src/infrastructure/mcp-server.lisp`](../../packages/feature/assistant/src/infrastructure/mcp-server.lisp), [`src/infrastructure/assistant-sidecar-stream.lisp`](../../src/infrastructure/assistant-sidecar-stream.lisp) | + +## Explicit test/bootstrap inputs without direct API use + +`cl-regex-kit`, `cl-vcs-kit`, and `cl-tui-kit` are not direct API dependencies +of nshell. They are retained in the Nix source registry because +[`t/support/runtime.lisp`](../../t/support/runtime.lisp) loads their systems +when it bootstraps child processes, and because sibling package systems may +need those sources while the integration suite is running. Their presence in +that registry is not advertised as an nshell feature. + +They are no longer declared in `nshell.asd`'s direct `:depends-on` list. The +remaining explicit Nix inputs are intentional bootstrap/build inputs and are +covered by the tagged-tree integration gate. + +## Dependencies outside nshell's ASDF declaration + +The sibling derivations in `flake.nix` include these dependency edges. +`cl-date-kit` and `cl-codec-kit` are also explicit Nix `lispDependencies` of +nshell. Neither they nor `cl-log-kit` is a direct dependency in `nshell.asd`. + +| Package | Consumers in the build graph | nshell use | |---|---|---| -| `cl-weave` | the test framework for the weave suite | `nshell/weave` | -| `cl-prolog-kit/weave` | cl-prolog-kit-query coverage of the completion engine | `nshell/weave` | - -As of the current upstream tag listing, every pinned nerima-lisp input is -already at its newest published tag: `cl-nix-forge` `v0.5.0`, `paredit-cli` -`v1.6.2`, `cl-parser-kit` `v1.1.1`, `cl-dataflow-kit` `v1.2.0`, -`cl-boundary-kit` `v2.3.0`, `cl-cli` `v1.3.0`, `cl-tty-kit` `v1.6.1`, -`cl-log-kit` `v2.2.0`, `cl-process-kit` `v3.2.0`, `cl-history-kit` `v1.0.4`, -`cl-host-kit` `v0.3.1`, `cl-codec-kit` `v0.5.0`, `cl-concurrent-kit` `v0.6.1`, -and `cl-date-kit` `v1.0.0`. This is a tag comparison, not a claim that every -upstream branch is API-compatible; upgrade only when a newer release tag -appears and the full matrix accepts it. - -## Transitive, not adopted directly - -| package | why not direct | -|---|---| -| `cl-log-kit` | Pulled in transitively by `cl-process-kit` for *its* structured logging. nshell itself performs no logging (a shell's diagnostics go to the user's stderr, not a structured log sink), so it is deliberately absent from nshell's own `:depends-on`. Adopting it directly would mean inventing a logging concern the shell does not have. | -| `cl-date-kit` | Pulled in transitively by `cl-concurrent-kit`; nshell consumes concurrency primitives, not date formatting or parsing. | -| `cl-codec-kit` | Pulled in transitively by `cl-tty-kit` and `cl-process-kit`; nshell consumes terminal and process APIs, not the codec surface, so it remains an indirect build input. | - -## Non-applicable org repositories - -| package | what it is | why nshell cannot use it | -|---|---|---| -| `cl-json-kit` | dependency-free JSON reader/writer | nshell has no JSON I/O. The only `json` tokens in the tree are completion-catalog *values* (e.g. `kubectl --output json`), not parsing. | -| `cl-http-kit`, `cl-http-message-kit`, `cl-websocket-kit`, `cl-sse-kit` | HTTP, message, WebSocket, and SSE protocol libraries | nshell is a local process shell and exposes no network protocol endpoint. | -| `cl-postgresql-kit`, `cl-redis-kit` | database clients | No database connection or persistence boundary exists in the shell. | -| `cl-observability-kit`, `cl-log-kit`, `cl-resilience-kit`, `cl-event-sourcing-kit` | service observability, logging, resilience, and event-sourcing libraries | These solve service/runtime concerns absent from a local CLI; diagnostics remain user-facing stderr. | -| `cl-cffi-kit`, `cl-regex-kit`, `cl-hpack-codec-kit` | FFI, regular-expression, and compression helpers | No direct feature requires them; adding them would duplicate existing host/parser boundaries or add unused functionality. | -| `cl-tui-kit`, `cl-glfw3-kit`, `cl-vulkan-kit`, `cl-fx-quant-kit` | UI, graphics, and quantitative-computing libraries | nshell uses `cl-tty-kit` for terminal control and has no graphical or numerical UI. | -| `cl-asciiquarium`, `cl-chip8`, `cl-cmatrix`, `cl-cowsay`, `cl-nes`, `cl-nyancat`, `cl-sl`, `ncl`, `nerimux`, `loom`, `cachix` | standalone applications, tools, or infrastructure | They are independently runnable products rather than libraries used by a shell execution boundary. | -| `cl-tmux` | a full terminal multiplexer in CL | Orthogonal peer application. A multiplexer *hosts* shells; a shell does not embed one. Integration would be a dependency inversion. | -| `cl-cc`, `cl-cc-ast`, `cl-cc-binary`, `cl-cc-javascript`, `cl-cc-php`, `cl-cc-runtime`, `cl-cc-type` | a self-hosting CL compiler collection | Language-implementation infrastructure with no surface a shell consumes. | -| `cl-vcs-kit` | version-control toolkit | nshell only needs the prompt's small, timeout-bounded `git` probe; adopting a repository abstraction would add a broader policy surface without replacing the existing process boundary. | -| `paredit-cli` | the Rust S-expression refactoring CLI | A development *tool* used to perform these refactors, not a runtime dependency. | - -## Conclusion - -The applicable nerima-lisp surface is fully adopted: ten runtime systems plus -two test systems, all directly declared where used. The executable composition -root consumes `cl-cli` directly to parse `argv`; the command-line feature owns -the policy, contract, and help presentation, with no compatibility adapter or -duplicate parser. The un-adopted remainder is compiler infrastructure -(`cl-cc*`), a version-control helper whose surface is broader than nshell's -prompt probe (`cl-vcs-kit`), a peer application (`cl-tmux`), a format library -for a format nshell never handles (`cl-json-kit`), or a build-time tool -(`paredit-cli`). -Re-run the usage half of this audit with: - -```sh -rg -o '\\b(cl-prolog-kit|cl-dataflow-kit|cl-boundary-kit|cl-cli|cl-tty-kit|process-kit|history-kit|cl-concurrent-kit)::?[a-z]' src/ \\ - | perl -pe 's/:.*$//' | sort | uniq -c | sort -rn -``` - -To re-check the release refs and compare them with the lock file: +| `cl-log-kit` | `cl-process-kit`, `cl-vcs-kit` | No direct API reference identified. The assistant writes its own redacted JSONL audit records through `cl-json-kit`. | +| `cl-date-kit` | `cl-concurrent-kit`, `cl-log-kit` | No direct API reference identified. | +| `cl-codec-kit` | `cl-tty-kit`, `cl-process-kit` | No direct API reference identified. | + +## Test and build inputs + +`nshell/test` uses `cl-weave`. `nshell/weave` additionally depends on +`cl-prolog-kit/weave` for completion-query tests. Loading a library or its test +helpers is not the same as running that library's own suite. The sibling +derivations do not enable `doCheck`, and the CI jobs run nshell's suites, +not the dependencies' own suites. In particular, `checks.weave` runs +`nshell/weave`, not `cl-weave/test`. Dependency suite results remain unverified. + +`cl-nix-forge` supplies the Nix build helpers. `paredit-cli` is a development +tool, not an executable runtime dependency. Release refs are declared in +`flake.nix`; resolved revisions and hashes are recorded in +[`flake.lock`](../../flake.lock). This audit makes no claim that those refs are +the newest upstream tags. An upgrade must update the declaration and lock file, +review the local [`cl-process-kit` patch](../../nix/patches/cl-process-kit-no-duplicate-monotonic-seconds.patch), +and rerun the release gates. The dependency's own suite also needs a separate +run. + +## Reproduce the source checks + +Run these commands from the repository root. +Inspect imports and executable forms: a match in a comment or registry list is +not evidence of a runtime call, and textual absence is not a runtime test. ```sh -for repo in cl-weave cl-prolog-kit cl-parser-kit cl-dataflow-kit cl-boundary-kit cl-cli cl-tty-kit cl-process-kit cl-history-kit cl-host-kit cl-codec-kit cl-concurrent-kit cl-date-kit; do - printf '%s: ' "$repo" - gh api "repos/nerima-lisp/$repo/tags?per_page=1" --jq '.[0].name' -done +rg -n 'cl-(prolog|parser|dataflow|host|boundary|tty|concurrent|json)-kit|cl-cli|process-kit:|history-kit:|host-kit:|json-kit:' nshell.asd src packages +rg -n -i '\b(cl-(regex|vcs|tui|log|date|codec)-kit|regex-kit:|vcs-kit:|tui-kit:|log-kit:|date-kit:|codec-kit:)' nshell.asd src packages t/support/runtime.lisp ``` diff --git a/docs/notes/timeout-audit.md b/docs/notes/timeout-audit.md index 8076e04b..b1b3b8e5 100644 --- a/docs/notes/timeout-audit.md +++ b/docs/notes/timeout-audit.md @@ -8,7 +8,7 @@ after N seconds regardless of context." `run-external` (`infrastructure/acl/syscall-process.lisp`) — the path for every plain foreground external command typed at the interactive prompt — -applied `*external-command-timeout*` (30s default) unconditionally. That is +applied `*external-command-timeout*` unconditionally. That is the *opposite* failure mode from a missing timeout: typing `vim file.txt`, `ssh host`, or `top` at the prompt got the process SIGTERM'd, then SIGKILL'd, 30 seconds in, mid-edit, with no exemption for a foreground program a human @@ -44,10 +44,11 @@ under the `--non-interactive` batch test runner is never ## Confirmed sound, no change needed -- **`run-external-capture`** (command substitution, `$(...)`) — delegates to - `cl-process-kit`'s `run` with `:timeout *external-command-timeout* - :on-timeout :return`; single choke point, no bypass. Correctly unconditional - — command substitution has no terminal of its own to be interactive with. +- **`run-external-capture`** (command substitution, `$(...)`) — validates the + configured command-substitution timeout at the communication boundary and + delegates to `cl-process-kit`'s `run` with that finite value. Correctly + unconditional — command substitution has no terminal of its own to be + interactive with. - **`spawn-pipeline` / `%wait-pipeline-with-output`** — foreground OS-pipe pipelines bound by the same `*external-command-timeout*` via `%wait-pipeline-exit-with-timeout`. `spawn-pipeline-async` (background `cmd @@ -89,26 +90,17 @@ PTY-based e2e test harness (`t/e2e/test-smoke.lisp`'s present risk; would need a bound before being wired into a real interactive feature. -## Current conclusion (2026-08-26) +## Current behavior -The production-audit fixes removed the default timeout. The interactive gate -was found to be bypassed on the path real interactive commands take -(`%execute-external-pipeline-stage` and `run-external-capture` read the raw -special), so an interactive `sleep 30` was still killed at 30 seconds. The -resolution removes the default bound entirely rather than re-scoping it: +The shipped defaults are finite: external commands use 3600 seconds and +command substitution uses 300 seconds. An external command attached to the +interactive terminal is exempt from the timeout so editors, SSH sessions, and +other human-driven programs can run until they finish or the user interrupts +them. Redirected or otherwise non-interactive external commands remain +bounded, and command substitution is always bounded because it has no +terminal of its own. -- `*external-command-timeout*` and `*command-substitution-timeout*` now - default to `nil` (unbounded), matching POSIX shells, which impose no - execution ceiling in any mode. A long build, an editor, or an SSH session - is legitimate foreground work interactively *and* in scripts. -- Bounding is the caller's job: tests and the completion-help path bind the - specials to finite values, and external supervision (`timeout(1)`, CI job - limits) owns script-level runaway protection. A security review proposed - restoring a finite non-interactive fallback; that was declined as it would - re-break batch scripts running legitimate >30s commands — the original - production blocker. -- The table above therefore no longer describes default behavior: with the - shipped defaults, no foreground external command or command substitution - times out unless a caller binds the special. The gate function - `%foreground-external-command-timeout` remains, and still bounds - non-interactive runs whenever the special *is* bound. +Callers may bind either special variable for a narrower policy. Invalid values +(negative numbers, non-numbers, infinities, and NaN) are rejected at the +operational execution boundary; `nil` remains the explicit unbounded value for +callers that need it. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index eb8ffcc8..1b6a52f8 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -1,37 +1,58 @@ # Getting started +The release and Nix flake support `x86_64-linux` only. Other systems are +outside the tested support boundary. + ## Run without installing With [Nix](https://nixos.org/download) (flakes enabled): ```sh -nix run github:nerima-lisp/nshell/v0.5.0 +nix run github:nerima-lisp/nshell/v0.6.1 ``` ## Install ```sh -nix profile install github:nerima-lisp/nshell/v0.5.0 +nix profile install github:nerima-lisp/nshell/v0.6.1 nshell man nshell # the manual page is installed alongside the binary ``` -Consumers inside the nerima-lisp org pin a release tag rather than following -the default branch: +Pin a release tag rather than following the default branch. + +### Prebuilt Linux bundle + +Without Nix, download both assets from the +[v0.6.1 release](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.1), +verify the checksum, and extract the bundle. On `x86_64-linux`, with `curl`, +`sha256sum`, and `tar` available, run these commands in an empty directory: -```nix -# flake.nix -inputs.nshell = { - url = "github:nerima-lisp/nshell/v0.5.0"; - inputs.nixpkgs.follows = "nixpkgs"; -}; +```sh +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.1/nshell-v0.6.1-x86_64-linux.tar.gz +curl --fail --location --remote-name https://github.com/nerima-lisp/nshell/releases/download/v0.6.1/nshell-v0.6.1-x86_64-linux.tar.gz.sha256 +sha256sum --check nshell-v0.6.1-x86_64-linux.tar.gz.sha256 ``` +Proceed only if the checksum command succeeds: + +```sh +tar -xzf nshell-v0.6.1-x86_64-linux.tar.gz +./nshell-v0.6.1-x86_64-linux/bin/nshell --version +./nshell-v0.6.1-x86_64-linux/bin/nshell +man ./nshell-v0.6.1-x86_64-linux/share/man/man1/nshell.1 +``` + +Keep the extracted directory intact: the launcher resolves libraries and +helpers relative to itself. To invoke `nshell` by name, add its `bin` +directory to your existing shell's `PATH`. + The release workflow publishes an `x86_64-linux` tarball and SHA-256 checksum. The release bundle removes Nix store references, carries its ELF runtime library closure, and is checked for required files and dependency metadata. CI -also runs `--help`, `--version`, and an `echo` smoke test on the bundle. Use the -pinned Nix commands above on other platforms. +also runs `--help`, `--version`, and an `echo` smoke test on the bundle. The +checksum detects a mismatch with the published asset; it is not a provenance +attestation. ## First commands @@ -102,11 +123,11 @@ nix flake check # full hermetic gate on x86_64-linux CI nix develop # dev shell with SBCL + cl-weave ``` -`flake.nix` declares `x86_64-linux` and `aarch64-darwin`. The full hermetic -flake gate, the release-binary gate, and the non-sandboxed integration suite -all run in CI on `x86_64-linux` only; `aarch64-darwin` is a local development -target (`nix build`, `nix develop`), and some build checks can be unavailable -there when a pinned upstream package has no Darwin build. +`flake.nix` declares `x86_64-linux` only. The full hermetic flake gate, the +release-binary gate, and the non-sandboxed integration suite run in CI on that +target. A remote `x86_64-linux` builder can build the artifacts from another +host, but running the dev shell or the non-sandboxed PTY suite requires an +`x86_64-linux` execution environment. Inside `nix develop`, load the system into a REPL: diff --git a/docs/src/index.md b/docs/src/index.md index 4a982e60..50d19014 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -7,7 +7,7 @@ real-time syntax highlighting, history-aware autosuggestions, fish-style abbreviations, and a fast, context-aware completion engine — all built on a clean, test-driven Common Lisp core and a reproducible Nix build. -!!! note "Status: development preview (0.4.x)" +!!! note "Status: development preview (0.6.x)" The interactive editor and core pipeline execution are solid and heavily tested. The shell *language* is a growing subset of POSIX/fish semantics — diff --git a/docs/src/project/public-readiness.md b/docs/src/project/public-readiness.md index 05d07b31..6138d165 100644 --- a/docs/src/project/public-readiness.md +++ b/docs/src/project/public-readiness.md @@ -1,88 +1,96 @@ # Public Readiness -This document defines the release bar for presenting nshell as a serious -interactive shell. It is intentionally stricter than "the tests pass": each -area needs user-visible capability, repeatable evidence, and an explicit gap -status. +This document records the release gates, evidence, and known limits for +nshell's interactive features. ## Positioning -nshell targets daily interactive use first. The release claim is a modern -interactive shell with a tested editor, completion, history, process control, -and scripting subset. +nshell targets daily interactive use first. v0.6.1 is a development preview, +not a POSIX/fish-compatible script interpreter. ## Capability Matrix | Area | Release bar | Current evidence | Status | | --- | --- | --- | --- | -| Interactive editor | Live syntax feedback, Emacs bindings, optional vi mode, multiline editing, undo/yank, predictable rendering | README highlights, man page key bindings, input-state and rendering tests | Ready locally | -| History and suggestions | Persistent history, reverse search, prefix autosuggestions, safe handling of multiline entries | history, autosuggest, and E2E editing tests | Ready locally | -| Completion | Context-aware command/path/flag completion, candidate menu, deterministic cycling and cancellation | completion domain tests, REPL completion rendering tests, path-like argument completion, hierarchical command resolution, common external command metadata, help-text metadata loader with selective runtime enrichment, README/man page claims | Improved locally; broader command discovery/cache policy and subcommand coverage remain future work | -| Shell language | Practical interactive scripting: functions, control flow, command substitution, expansions, heredocs, here-strings, redirection, pipelines | parser, expansion, source, pipeline, process-substitution, descriptor-duplication, tab-stripping-heredoc, and smoke tests, structured unquoted list-variable expansion | Improved locally; broader expansion parity audit still required | -| Process control | Foreground/background jobs, `jobs`/`fg`/`bg`/`disown`, Ctrl-C foreground recovery, PTY smoke coverage, foreground external commands (editors, SSH) run to completion instead of being killed by a default timeout (removed entirely; see the addendum in `docs/notes/timeout-audit.md`) | job-control tests, PTY integration tests, and the non-sandboxed `nix develop -c sbcl --script run-tests.lisp` gate pass locally; `docs/notes/timeout-audit.md` contains the real-PTY regression test | Needs release evidence on `x86_64-linux` | -| Reliability | Hermetic build/test gate plus non-sandboxed OS-interactive gate for PTY, terminal, process, signal, and job-control behavior | `nix flake check --print-build-logs`; dev-shell E2E/integration command in README and CONTRIBUTING; the current coverage command and its report documented in `docs/notes/coverage-analysis.md` | Ready locally; CI evidence required across supported platforms | -| Distribution | Reproducible Nix build, installed man page, release binary smoke, checksummed artifacts | flake build, man page, CI/release workflows, release checklist | Needs nixpkgs/Homebrew/prebuilt binary publication | -| Security and operations | Private vulnerability reporting, explicit security scope, no accidental secret exposure through history/completion/diagnostics | the org security policy and [contribution guidelines](contributing.md) | Ready for 0.x scope | +| Interactive editor | Live syntax feedback, Emacs bindings, optional vi mode, multiline editing, undo/yank, predictable rendering | input-state, rendering, and PTY integration tests | CI-verified on `x86_64-linux` | +| History and suggestions | Persistent history, reverse search, prefix autosuggestions, handling of multiline entries | history, autosuggest, and E2E editing tests | CI-verified on `x86_64-linux` | +| Completion | Context-aware command/path/flag completion, candidate menu, deterministic cycling and cancellation | completion domain and REPL rendering tests, hierarchical command resolution, curated external command metadata and selective help-text enrichment | Broader command discovery/cache policy and subcommand coverage remain future work | +| Shell language | Functions, control flow, command substitution, expansions, heredocs, here-strings, redirection, pipelines | parser, expansion, source, pipeline, process-substitution, descriptor-duplication, tab-stripping-heredoc, and smoke tests | Tested subset; full POSIX/fish parity is not claimed | +| Process control | Foreground/background jobs, `jobs`/`fg`/`bg`/`disown`, Ctrl-C recovery, terminal restoration; external commands attached to the interactive terminal have no default timeout, while redirected execution and command substitution remain bounded | job-control and non-sandboxed PTY integration tests | CI-verified on `x86_64-linux` | +| Distribution | Reproducible Nix build, installed man page, release binary smoke, checksummed artifacts | flake build, man page, CI/release workflows, [v0.6.1 release assets](https://github.com/nerima-lisp/nshell/releases/tag/v0.6.1) | Prebuilt `x86_64-linux` binary and SHA-256 checksum published; nixpkgs/Homebrew remain future work | +| Security and operations | Private vulnerability reporting, explicit security scope, validation of secret handling in history/completion/diagnostics | [repository security policy](https://github.com/nerima-lisp/nshell/blob/v0.6.1/SECURITY.md) and [contribution guidelines](contributing.md) | Reporting policy exists; it does not establish a security audit of runtime behavior | + +The v0.6.1 CI and release run links are recorded here after the tag workflow +completes. The non-sandboxed integration job runs the PTY and external-process +cases skipped by the Nix sandbox. ## Release Gates -Before a public release can claim world-level interactive-shell quality: +These are the existing nshell gates, not a production-readiness verdict. +They do not close the dependency-suite and license-packaging gaps below. + +Before a public release can claim the capabilities listed here: 1. `nix flake check --print-build-logs` passes on `x86_64-linux` CI, the - hermetic check target. `aarch64-darwin` remains declared for development, - but is not a flake-check, integration, or release-binary CI target. + only declared hermetic check target. 2. The non-sandboxed integration suite passes for PTY, subprocess, terminal, signal, and job-control coverage on the sole CI matrix target, - `x86_64-linux`. -3. A release binary is built on `x86_64-linux`, starts successfully, and ships + `x86_64-linux`, without skipping those cases. +3. A release binary is built on `x86_64-linux`, starts, and ships with `README.md`, `LICENSE`, and the `nshell(1)` man page. 4. User-visible behavior changes are represented in README, man page, the GitHub Release notes, and completion metadata when applicable. 5. Open roadmap gaps remain explicit instead of being implied as complete. -## Local verification - -What is actually checkable on a plain `aarch64-darwin` development machine -(not CI), run directly rather than assumed: - -- `nix build --no-link .#checks.aarch64-darwin.docs` and - `nix build --no-link .#checks.aarch64-darwin.formatting` both exit 0 -- the - mkdocs `--strict` build and the treefmt gate pass on this platform. -- The Darwin `build`, `default`, and `smoke-test` check attributes can remain - unavailable when the pinned `cl-prolog-kit` package has no Darwin build. The - complete `nix flake check` and release-bundle gate are therefore verified on - `x86_64-linux` CI; run available Darwin checks directly rather than treating a - missing attribute as a feature failure. -- The equivalent SBCL-level verification -- what `checks.default` runs - under the hood -- is available through `nix run .#test`. The integrated - tree passes the complete `nshell/test` suite locally. -- The integrated suite also covers the user-visible behavior added across the - current work units: OSC 52 clipboard output, tab-stripping `<<-` heredocs, +The [release workflow](https://github.com/nerima-lisp/nshell/blob/v0.6.1/.github/workflows/release.yml) +runs hermetic checks and a non-sandboxed integration job against the same +resolved tagged commit, and only then builds a draft release. Publishing still +requires user-facing release notes and maintainer confirmation. + +## Verification outside CI + +The current flake declares only `x86_64-linux`. On another host, the Nix +checks require an `x86_64-linux` builder. Running the dev shell or PTY suite +also requires an `x86_64-linux` execution environment; a remote builder alone +does not supply that runtime coverage. + +- Run `nix flake check --print-build-logs` on an `x86_64-linux` builder. +- Run `NSHELL_TEST_PTY=1 nix develop -c sbcl --script run-tests.lisp` on the + same target for the non-sandboxed PTY and external-process suite. + Confirm nonempty test discovery and no skipped PTY or external-process cases + in the test report; an exit status of zero alone does not establish coverage. +- The integrated suite covers OSC 52 clipboard output, tab-stripping `<<-` heredocs, process substitution, ordered descriptor duplication, path-like argument completion, hierarchical command completion, and SGR mouse selection. -## Current Public Gaps +## Known Limits and Future Work - Broader help-text-driven command discovery remains future work, especially for subcommand coverage and non-curated external tools. -- Complete the remaining expansion-semantics audit beyond structured unquoted - list-variable and compound list expansion, plus other unimplemented edge - cases. -- Validate mouse selection and host clipboard behavior on every supported - terminal/platform; the editor now maps SGR coordinates through captured +- Full POSIX/fish expansion compatibility is outside the documented scripting + subset; scripts for those shells must not be assumed compatible. +- Mouse selection and clipboard behavior depend on the terminal. No exhaustive + terminal matrix is claimed; the editor maps SGR coordinates through captured prompt geometry and falls back to OSC 52 when host clipboard integration is unavailable. -- Collect release evidence on each CI matrix target, not only a local - development machine. -- Publish at least one low-friction installation path beyond `nix run`, such as - nixpkgs, Homebrew, or prebuilt release binaries. - The x86_64-linux release bundle is checked for Nix store references, its ELF - runtime closure, required files, dependency metadata, and `--help`, - `--version`, and `echo` smoke behavior in CI. Tarball extraction is not a - separate CI check. -- Verify the global all-target publication gate, checksums, and GitHub artifact - attestations in CI before describing release provenance as operational. + runtime closure, required files listed in + [`scripts/verify-release-bundle.pl`](https://github.com/nerima-lisp/nshell/blob/v0.6.1/scripts/verify-release-bundle.pl), + and `--help`, `--version`, and `echo` smoke behavior in CI via + `perl scripts/verify-release-bundle.pl result`. This checks the Nix build + output, not an extracted tarball. It does not run the full integration suite + against the bundled executable. +- The bundle verifier checks the Nix output directory, not a re-extracted + tarball. Tar extraction, checksum verification, and execution of the + extracted bundle remain release-consumer checks; the bundled executable is + smoke-tested in the Nix output during CI. +- CI runs nshell's test suites, not the dependency libraries' own suites. + Loading those libraries or their test helpers does not establish that their + suites pass. +- Release assets include a SHA-256 checksum. GitHub artifact attestations are + not configured; the checksum alone is not a provenance attestation. - Validate the process-isolated benchmark scenarios in CI and collect equivalent fixtures beyond the implemented minimal noninteractive literal-print case. - Collect privileged cold-cache, interactive, completion, and end-to-end - tail-latency evidence before making any broad "world-fastest" performance claim. + tail-latency evidence before making any broad performance claim. See + [Performance evidence](../guide/recipes.md#performance-evidence). diff --git a/docs/src/project/releasing.md b/docs/src/project/releasing.md index b4a62681..032cb0b1 100644 --- a/docs/src/project/releasing.md +++ b/docs/src/project/releasing.md @@ -24,9 +24,8 @@ Pushing a `v*.*.*` tag runs `release.yml`, which: building anything if it does not. 2. Runs `nix flake check --print-build-logs` against the tagged tree. 3. Builds the binary for the `x86_64-linux` release target, confirms it - starts, and packages a tarball plus a SHA-256 checksum. `aarch64-darwin` is - also declared by `flake.nix` for development and platform-specific local - checks, but is not a published binary target in this workflow. + starts, and packages a tarball plus a SHA-256 checksum. No other platform is + declared or published by the current flake. 4. Creates the GitHub Release as an empty **draft** with those files attached. It writes no release body: the GitHub Release description is the canonical history and there is no `CHANGELOG.md`. @@ -35,10 +34,8 @@ Pushing a `v*.*.*` tag runs `release.yml`, which: Verify the public artefacts from a clean checkout: -- `nix flake check --print-build-logs` passes on `x86_64-linux`. On macOS, use - the declared `aarch64-darwin` development environment and run the checks that - are available for the pinned dependency set; some build checks may be - unavailable when an upstream package has no Darwin build. +- `nix flake check --print-build-logs` passes on `x86_64-linux`. Other hosts + require an `x86_64-linux` builder for the release gate. - The non-sandboxed integration suite passes for PTY, subprocess, terminal, signal, and job-control coverage: diff --git a/docs/src/project/roadmap.md b/docs/src/project/roadmap.md index 212f35e0..5cd83baf 100644 --- a/docs/src/project/roadmap.md +++ b/docs/src/project/roadmap.md @@ -1,34 +1,35 @@ # Roadmap -Release readiness tracks the areas below. For the release bar each area is -measured against, see +For release gates and known limits, see [Release readiness](public-readiness.md). ## Near-term focus -**Shell language audit** — audit expansion semantics beyond the implemented -structured unquoted list-variable and compound list expansion. +**Shell language audit:** review quoting and list-variable expansion against +nshell's documented scripting subset, not full POSIX/fish compatibility. -Already done: quoting; parameter expansion with defaults, required checks, +Implemented features include quoting; parameter expansion with defaults, required checks, substring slicing, and patterns; arithmetic `$((...))` including `**`, bitwise, shift, and ternary operators; brace expansion; command substitution `$(...)`/`(...)`; fd redirections `2>`, `2>&1`, `&>`; here-docs `<<`; here-strings `<<<`; and function arguments via `$argv` / `$argv[N]`. +This list does not establish that every edge case has been audited. -**Job control verification** — local non-sandboxed integration now covers -foreground external commands and pipelines with `Ctrl-Z` suspension, `bg` -resumption, `fg` terminal handoff, and `Ctrl-C` interruption. Directly -launched terminal commands use a job-aware wait instead of the synchronous -capture wait that previously prevented suspension. Release evidence on -`x86_64-linux` remains outstanding. +**Command discovery:** extend help-text-driven discovery beyond the static +command catalog to additional commands and subcommands. See the +[completion model](../guide/concepts.md#completion-is-a-knowledge-base-not-a-table). -**Command discovery** — extend help-text-driven discovery to cover more -subcommands and non-curated external tools. +**Distribution:** evaluate nixpkgs and Homebrew packaging. Neither channel +is published; a packaging target has not been selected. A prebuilt +`x86_64-linux` bundle is available for v0.6.1; see the +[installation instructions](../getting-started.md#prebuilt-linux-bundle). -**Distribution** — publish at least one installation path beyond `nix run`: -nixpkgs, Homebrew, or prebuilt release binaries. +## Release evidence -## Released changes +The v0.6.1 Linux CI and release workflow links will be recorded after the tag +workflow completes. The non-sandboxed integration suite covers foreground +external commands and pipelines with `Ctrl-Z` suspension, `bg` resumption, `fg` +terminal handoff, and `Ctrl-C` interruption. This does not establish +compatibility with every terminal; see [Release readiness](public-readiness.md). -See the [GitHub Releases](https://github.com/nerima-lisp/nshell/releases). diff --git a/flake.lock b/flake.lock index 17bca671..dc0155f4 100644 --- a/flake.lock +++ b/flake.lock @@ -321,6 +321,23 @@ "type": "github" } }, + "cl-regex-kit": { + "flake": false, + "locked": { + "lastModified": 1790521261, + "narHash": "sha256-4BAunpX2NMUgt5usfJWp1dRqc0H01Is3MrGMGkquKio=", + "owner": "nerima-lisp", + "repo": "cl-regex-kit", + "rev": "da83905cad2bcb28518ebb28cac6628c654bb328", + "type": "github" + }, + "original": { + "owner": "nerima-lisp", + "ref": "v2.2.0", + "repo": "cl-regex-kit", + "type": "github" + } + }, "cl-tty-kit": { "flake": false, "locked": { @@ -338,6 +355,40 @@ "type": "github" } }, + "cl-tui-kit": { + "flake": false, + "locked": { + "lastModified": 1786772589, + "narHash": "sha256-eZGqIdJFhzETYiB/7Cw5/nrXQZ73dElVO1NqnDGwN7c=", + "owner": "nerima-lisp", + "repo": "cl-tui-kit", + "rev": "345ea0b3cb08bcdf850df096d0b34a22b8bcc077", + "type": "github" + }, + "original": { + "owner": "nerima-lisp", + "ref": "v1.0.0", + "repo": "cl-tui-kit", + "type": "github" + } + }, + "cl-vcs-kit": { + "flake": false, + "locked": { + "lastModified": 1786359167, + "narHash": "sha256-CXbyJBaB36mOSsAXh4AgVag47JaLz2WXCxQjjgfzXmY=", + "owner": "nerima-lisp", + "repo": "cl-vcs-kit", + "rev": "ceca9962b105c98c763e2f6fac178c4537bb2497", + "type": "github" + }, + "original": { + "owner": "nerima-lisp", + "ref": "v0.2.0", + "repo": "cl-vcs-kit", + "type": "github" + } + }, "cl-weave": { "inputs": { "cl-nix-forge": "cl-nix-forge_3", @@ -541,7 +592,10 @@ "cl-parser-kit": "cl-parser-kit", "cl-process-kit": "cl-process-kit", "cl-prolog-kit": "cl-prolog-kit", + "cl-regex-kit": "cl-regex-kit", "cl-tty-kit": "cl-tty-kit", + "cl-tui-kit": "cl-tui-kit", + "cl-vcs-kit": "cl-vcs-kit", "cl-weave": "cl-weave_2", "nixpkgs": "nixpkgs", "paredit-cli": "paredit-cli_3", diff --git a/flake.nix b/flake.nix index f1131929..abea1dc7 100644 --- a/flake.nix +++ b/flake.nix @@ -85,6 +85,18 @@ url = "github:nerima-lisp/cl-process-kit/v3.2.0"; flake = false; }; + cl-regex-kit = { + url = "github:nerima-lisp/cl-regex-kit/v2.2.0"; + flake = false; + }; + cl-vcs-kit = { + url = "github:nerima-lisp/cl-vcs-kit/v0.2.0"; + flake = false; + }; + cl-tui-kit = { + url = "github:nerima-lisp/cl-tui-kit/v1.0.0"; + flake = false; + }; # cl-history-kit backs the command-history store, search, and recall # navigation cursor; nshell itself keeps only the tokenizer-coupled # `!$`/Alt-. last-argument extraction on top of it. @@ -140,6 +152,9 @@ cl-tty-kit, cl-log-kit, cl-process-kit, + cl-regex-kit, + cl-vcs-kit, + cl-tui-kit, cl-history-kit, cl-codec-kit, cl-concurrent-kit, @@ -150,17 +165,9 @@ let lib = nixpkgs.lib; - # x86_64-linux is what CI gates; aarch64-darwin is the development - # machine. Every per-system output -- packages, checks, apps AND devShells - # -- comes from this one list, so leaving aarch64-darwin out takes `nix - # build` and `nix develop` off the development machine as well. That trade - # was made on 2026-08-01 and reverted on 2026-08-02; aarch64-darwin carries - # no CI gate, which PACKAGE_STANDARD.md's "systems" section accepts - # explicitly. aarch64-linux and x86_64-darwin are nobody's verification and - # are not declared. + # CI and release checks target Ubuntu x86_64 only. systems = [ "x86_64-linux" - "aarch64-darwin" ]; meta = { @@ -305,6 +312,27 @@ # patch when a release containing the upstream fix is available. patches = [ ./nix/patches/cl-process-kit-no-duplicate-monotonic-seconds.patch ]; }; + clRegexKit = sibling { + name = "cl-regex-kit"; + source = cl-regex-kit; + dependencies = [ + clConcurrentKit + clParserKit + ]; + }; + clVcsKit = sibling { + name = "cl-vcs-kit"; + source = cl-vcs-kit; + dependencies = [ + clProcessKit + clHostKit + clLogKit + ]; + }; + clTuiKit = sibling { + name = "cl-tui-kit"; + source = cl-tui-kit; + }; clHistoryKit = sibling { name = "cl-history-kit"; source = cl-history-kit; @@ -367,6 +395,9 @@ cp ${cl-date-kit}/LICENSE "$out/LICENSES/CL-DATE-KIT-LICENSE" cp ${cl-concurrent-kit}/LICENSE "$out/LICENSES/CL-CONCURRENT-KIT-LICENSE" cp ${cl-json-kit}/LICENSE "$out/LICENSES/CL-JSON-KIT-LICENSE" + cp ${cl-regex-kit}/LICENSE "$out/LICENSES/CL-REGEX-KIT-LICENSE" + cp ${cl-vcs-kit}/LICENSE "$out/LICENSES/CL-VCS-KIT-LICENSE" + cp ${cl-tui-kit}/LICENSE "$out/LICENSES/CL-TUI-KIT-LICENSE" cp ${./man/nshell.1} "$out/share/man/man1/nshell.1" ''; }); @@ -566,6 +597,9 @@ clCli clTtyKit clProcessKit + clRegexKit + clVcsKit + clTuiKit clHistoryKit clHostKit clCodecKit diff --git a/nshell.asd b/nshell.asd index c6523048..14b33670 100644 --- a/nshell.asd +++ b/nshell.asd @@ -19,7 +19,7 @@ :license "MIT" ;; Single source of truth for the version. flake.nix reads this form ;; line-by-line, and release.yml refuses a tag that disagrees with it. - :version "0.5.0" + :version "0.6.1" :homepage "https://github.com/nerima-lisp/nshell" :bug-tracker "https://github.com/nerima-lisp/nshell/issues" :source-control (:git "https://github.com/nerima-lisp/nshell.git") @@ -43,13 +43,26 @@ :serial t :components ((:file "package") (:file "domain/feature-registry"))) - (:file "package-domain-signals-input-abbreviation") - (:file "package-domain") - (:file "package-domain-completion") - (:file "package-domain-configuration-prompting") - (:file "package-application") - (:file "package-infrastructure") - (:file "package-presentation") + (:module "package-domain" + :pathname "." + :serial t + :components ((:file "package-domain-signals-input-abbreviation") + (:file "package-domain") + (:file "package-domain-completion") + (:file "package-domain-configuration-prompting"))) + (:module "package-application" + :pathname "." + :serial t + :components ((:file "package-application"))) + (:module "package-infrastructure" + :pathname "." + :serial t + :components ((:file "package-infrastructure"))) + (:module "package-presentation" + :pathname "." + :serial t + :components ((:file "package-presentation"))) + (:file "infrastructure/acl/timeout") (:module "feature-command-line" :pathname "../packages/feature/command-line/src" :serial t @@ -385,7 +398,7 @@ :in-order-to ((test-op (test-op "nshell/test")))) (asdf:defsystem "nshell/test" - :version "0.5.0" + :version "0.6.1" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -572,7 +585,7 @@ (error "cl-weave tests failed")))) (asdf:defsystem "nshell/weave" - :version "0.5.0" + :version "0.6.1" :author "takeokunn " :maintainer "takeokunn " :license "MIT" @@ -602,7 +615,7 @@ primary suite in nshell/test." :reporter :spec) (error "cl-weave suite failed")))) (asdf:defsystem "nshell/benchmark" - :version "0.5.0" + :version "0.6.1" :author "takeokunn " :maintainer "takeokunn " :license "MIT" diff --git a/scripts/verify-release-bundle.pl b/scripts/verify-release-bundle.pl index 6f6a02aa..8feec77f 100644 --- a/scripts/verify-release-bundle.pl +++ b/scripts/verify-release-bundle.pl @@ -29,10 +29,15 @@ LICENSES/CL-CODEC-KIT-LICENSE LICENSES/CL-DATE-KIT-LICENSE LICENSES/CL-CONCURRENT-KIT-LICENSE + LICENSES/CL-JSON-KIT-LICENSE + LICENSES/CL-REGEX-KIT-LICENSE + LICENSES/CL-VCS-KIT-LICENSE + LICENSES/CL-TUI-KIT-LICENSE share/man/man1/nshell.1 bin/nshell )) { die "missing release file: $relative\n" unless -f "$bundle/$relative"; + die "empty release file: $relative\n" unless -s "$bundle/$relative"; } die "bin/nshell is not executable\n" unless -x "$bundle/bin/nshell"; diff --git a/src/application/builtin-commands-history.lisp b/src/application/builtin-commands-history.lisp index cd6add27..d266beba 100644 --- a/src/application/builtin-commands-history.lisp +++ b/src/application/builtin-commands-history.lisp @@ -1,9 +1,9 @@ (in-package #:nshell.application) (declaim (ftype function - nshell.infrastructure.persistence::history-record-for-entry - nshell.infrastructure.persistence::history-record-matches-p - nshell.infrastructure.persistence::history-record-filter-token)) + nshell.infrastructure.persistence:history-record-for-entry + nshell.infrastructure.persistence:history-record-matches-p + nshell.infrastructure.persistence:history-record-filter-token)) (defparameter +history-filter-usage+ "history [--failed|--success|--exit CODE|--cwd PATH|--origin SOURCE]") @@ -52,7 +52,7 @@ (values filter (nreverse query) nil) (let* ((argument (first remaining)) (token-filter - (nshell.infrastructure.persistence::history-record-filter-token + (nshell.infrastructure.persistence:history-record-filter-token argument))) (cond ((string= argument "--failed") @@ -135,8 +135,8 @@ (parse args nil nil))) (defun %history-entry-matches-filter-p (history entry filter) - (apply #'nshell.infrastructure.persistence::history-record-matches-p - (nshell.infrastructure.persistence::history-record-for-entry + (apply #'nshell.infrastructure.persistence:history-record-matches-p + (nshell.infrastructure.persistence:history-record-for-entry history entry) filter)) diff --git a/src/application/command-substitution.lisp b/src/application/command-substitution.lisp index ab791817..c8b90d5f 100644 --- a/src/application/command-substitution.lisp +++ b/src/application/command-substitution.lisp @@ -24,7 +24,7 @@ (defun %command-sub-fields-at (context value open-paren &optional preserve-newlines-p) "Run the command substitution at OPEN-PAREN, if balanced and non-empty." - (let ((end (nshell.domain.parsing::%balanced-substitution-end value open-paren))) + (let ((end (nshell.domain.parsing:%balanced-substitution-end value open-paren))) (when (and end (> end (1+ open-paren))) (values (if preserve-newlines-p (%execute-command-substitution-output diff --git a/src/application/execute-command-substitution.lisp b/src/application/execute-command-substitution.lisp index 8639132c..37acab59 100644 --- a/src/application/execute-command-substitution.lisp +++ b/src/application/execute-command-substitution.lisp @@ -12,10 +12,14 @@ Returns its trailing-newline-trimmed output, or NIL on error or timeout." (execute-ast-in-context context ast) (declare (ignore exit-code)) (%trim-command-substitution-output output)))) - (if *command-substitution-timeout* - (sb-ext:with-timeout *command-substitution-timeout* - (execute-substitution)) - (execute-substitution))))) + (let ((timeout + (nshell.infrastructure.acl:ensure-timeout-seconds + *command-substitution-timeout* + "command substitution timeout"))) + (if timeout + (sb-ext:with-timeout timeout + (execute-substitution)) + (execute-substitution)))))) (:error nil) (:incomplete nil)) (sb-ext:timeout () diff --git a/src/application/execute-pipeline-expansion.lisp b/src/application/execute-pipeline-expansion.lisp index 91cfaf3e..f031d99b 100644 --- a/src/application/execute-pipeline-expansion.lisp +++ b/src/application/execute-pipeline-expansion.lisp @@ -5,10 +5,10 @@ ;;; %execute-command-substitution-fields is forward-referenced here; it lives in ;;; execute-pipeline-control.lisp (after execute-ast-in-context is defined). -(defvar *command-substitution-timeout* nil +(defvar *command-substitution-timeout* + nshell.infrastructure.acl:+default-command-substitution-timeout+ "Maximum seconds for a command substitution to complete before signalling an -error. NIL disables the timeout; bind a finite value where bounded execution is -wanted (tests, completion helpers).") +error.") (defparameter +here-doc-escaped-dollar+ (code-char #xe000)) (defparameter +here-doc-escaped-backtick+ (code-char #xe001)) @@ -78,7 +78,7 @@ wanted (tests, completion helpers).") (defun %expand-command-name-from-fragments (command-node environment &optional filesystem) - (nshell.domain.expansion::%single-command-name-or-error + (nshell.domain.expansion:%single-command-name-or-error (nshell.domain.parsing:command-node-command command-node) (%expand-command-name-fields-from-fragments command-node environment filesystem))) diff --git a/src/application/execute-pipeline-stage-external-wait.lisp b/src/application/execute-pipeline-stage-external-wait.lisp index 3e3a5797..77a7672a 100644 --- a/src/application/execute-pipeline-stage-external-wait.lisp +++ b/src/application/execute-pipeline-stage-external-wait.lisp @@ -6,7 +6,7 @@ (%start-external-process-copiers process stdout-buffer stderr-buffer)) (timeout - (nshell.infrastructure.acl::%foreground-external-command-timeout))) + (nshell.infrastructure.acl:%foreground-external-command-timeout))) (flet ((collect-output (status) (%finish-external-process-output stdout-buffer stderr-buffer redirect-plan status)) @@ -18,14 +18,14 @@ command timeout))) (unwind-protect (if (null timeout) - (nshell.infrastructure.acl::%wait-process-with-copiers-or-stop + (nshell.infrastructure.acl:%wait-process-with-copiers-or-stop process copiers (lambda () (collect-output (nshell.infrastructure.acl:process-exit-status-code process))) #'continue-stopped-process) - (nshell.infrastructure.acl::%wait-process-with-copiers + (nshell.infrastructure.acl:%wait-process-with-copiers process copiers timeout (lambda () (collect-output diff --git a/src/application/execute-pipeline-stage-external.lisp b/src/application/execute-pipeline-stage-external.lisp index 3affd73a..01067991 100644 --- a/src/application/execute-pipeline-stage-external.lisp +++ b/src/application/execute-pipeline-stage-external.lisp @@ -61,14 +61,14 @@ (defun %start-external-process-copiers (process stdout-buffer stderr-buffer) (let ((stdout-thread - (nshell.infrastructure.acl::%start-stream-copier + (nshell.infrastructure.acl:%start-stream-copier (sb-ext:process-output process) stdout-buffer "nshell process stdout copier")) (stderr-thread nil)) (when stderr-buffer (setf stderr-thread - (nshell.infrastructure.acl::%start-stream-copier + (nshell.infrastructure.acl:%start-stream-copier (sb-ext:process-error process) stderr-buffer "nshell process stderr copier"))) @@ -163,14 +163,14 @@ dropping Ctrl-Z for waits that cannot observe a stop." ;; there is no controlling terminal (batch mode, a ;; redirected pipeline stage, `-c`/script execution). (when pgid - (nshell.infrastructure.acl::%assign-process-group + (nshell.infrastructure.acl:%assign-process-group pid pgid)) (flet ((finish-process () (%finish-external-pipeline-process process stdout-buffer stderr-buffer redirect-plan command pgid))) (if pgid - (nshell.infrastructure.acl::%call-with-foreground-process-group + (nshell.infrastructure.acl:%call-with-foreground-process-group pgid #'finish-process) (finish-process))))) (when opened-input diff --git a/src/application/execute-pipeline.lisp b/src/application/execute-pipeline.lisp index 6e545bb9..37a1cdd9 100644 --- a/src/application/execute-pipeline.lisp +++ b/src/application/execute-pipeline.lisp @@ -276,7 +276,7 @@ spawns, so a stage's assignment reaches that stage's process alone.") (cons name (format nil "~{~a~^ ~}" fields)))) (defun %command-node-from-arg (arg rest-args span) - (let ((typed (and (nshell.domain.parsing::command-arg-p arg) arg))) + (let ((typed (and (nshell.domain.parsing:command-arg-p arg) arg))) (nshell.domain.parsing:make-command-node (nshell.domain.parsing:arg-value arg) rest-args @@ -300,7 +300,7 @@ NIL when the line was assignments only." (values (nreverse assignments) (and args (%command-node-from-arg (first args) (rest args) - (nshell.domain.parsing::ast-node-span command-node)))))))) + (nshell.domain.parsing:ast-node-span command-node)))))))) (defun %assign-shell-variables (context assignments) (dolist (assignment assignments (values nil 0)) diff --git a/src/application/manage-job.lisp b/src/application/manage-job.lisp index be2587c5..9af5fddb 100644 --- a/src/application/manage-job.lisp +++ b/src/application/manage-job.lisp @@ -31,11 +31,11 @@ (or ,previous (%shell-process-group-id)))))))) (defun %run-terminal-command (context command args) - (let ((process (nshell.infrastructure.acl::%spawn-terminal-command command args))) + (let ((process (nshell.infrastructure.acl:%spawn-terminal-command command args))) (unless process (return-from %run-terminal-command (values - (nshell.infrastructure.acl::%external-command-not-found-message command) + (nshell.infrastructure.acl:%external-command-not-found-message command) 127))) (let ((pgid (nshell.infrastructure.acl:process-pid process))) (unwind-protect @@ -104,7 +104,7 @@ (setf *foreground-job-pgid* nil) (%set-acl-foreground-pgid nil) (unless retained-p - (nshell.infrastructure.acl::%abort-pipeline (reverse processes) nil)))))) + (nshell.infrastructure.acl:%abort-pipeline (reverse processes) nil)))))) (defun fg (job-id &optional (job-monitor *job-monitor*) process-registry) "Move JOB-ID to the foreground, wait for it, then restore the shell PGID." diff --git a/src/application/search-history.lisp b/src/application/search-history.lisp index ea95cc6e..3e74b99e 100644 --- a/src/application/search-history.lisp +++ b/src/application/search-history.lisp @@ -1,9 +1,9 @@ (in-package #:nshell.application) (declaim (ftype function - nshell.infrastructure.persistence::history-record-filter-token - nshell.infrastructure.persistence::history-record-for-entry - nshell.infrastructure.persistence::history-record-matches-p)) + nshell.infrastructure.persistence:history-record-filter-token + nshell.infrastructure.persistence:history-record-for-entry + nshell.infrastructure.persistence:history-record-matches-p)) (defun %interactive-history-query-valid-p (query) (and query @@ -23,7 +23,7 @@ (dolist (part (uiop:split-string query :separator '(#\Space #\Tab #\Newline))) (let ((token-filter - (nshell.infrastructure.persistence::history-record-filter-token + (nshell.infrastructure.persistence:history-record-filter-token part))) (if token-filter (loop for (key value) on token-filter by #'cddr @@ -33,8 +33,8 @@ (defun %interactive-history-entry-filter-p (history entry filter) (or (null filter) - (apply #'nshell.infrastructure.persistence::history-record-matches-p - (nshell.infrastructure.persistence::history-record-for-entry + (apply #'nshell.infrastructure.persistence:history-record-matches-p + (nshell.infrastructure.persistence:history-record-for-entry history entry) filter))) @@ -97,8 +97,8 @@ (defun search-history-use-case (history query mode &key (exit-code :any) cwd origin) (remove-if-not (lambda (entry) - (nshell.infrastructure.persistence::history-record-matches-p - (nshell.infrastructure.persistence::history-record-for-entry history entry) + (nshell.infrastructure.persistence:history-record-matches-p + (nshell.infrastructure.persistence:history-record-for-entry history entry) :exit-code exit-code :cwd cwd :origin origin)) diff --git a/src/infrastructure/acl/syscall-process-execution.lisp b/src/infrastructure/acl/syscall-process-execution.lisp index 271eacc1..0185fb4e 100644 --- a/src/infrastructure/acl/syscall-process-execution.lisp +++ b/src/infrastructure/acl/syscall-process-execution.lisp @@ -10,7 +10,8 @@ redirected to a file/pipe or nshell itself is non-interactive. Per-command redirects already rebind *STANDARD-OUTPUT*, so this naturally covers `cmd > file` typed at an interactive prompt too." (and (not (interactive-stream-p *standard-output*)) - *external-command-timeout*)) + (ensure-timeout-seconds *external-command-timeout* + "external command timeout"))) (defmacro %with-foreground-process-group-if ((pgid) &body body) (let ((pgid-var (gensym "PGID-"))) @@ -112,17 +113,21 @@ deadlock the shell." (progn (when (and (integerp pid) (plusp pid)) (setf *foreground-pgid* pid)) - (let ((result (process-kit:communicate - process - :input input - :timeout *external-command-timeout* - :on-timeout :return))) + (let* ((timeout + (ensure-timeout-seconds + *external-command-timeout* + "external command timeout")) + (result (process-kit:communicate + process + :input input + :timeout timeout + :on-timeout :return))) (when separate-stderr-p (write-string (process-kit:process-result-stderr result) *error-output*)) (if (process-kit:process-result-timed-out-p result) (values (%external-command-timeout-message - cmd *external-command-timeout*) + cmd timeout) 124) (values (process-kit:process-result-stdout result) (%process-result-shell-exit result))))) diff --git a/src/infrastructure/acl/syscall-process-resolution.lisp b/src/infrastructure/acl/syscall-process-resolution.lisp index bf42ea05..0a109111 100644 --- a/src/infrastructure/acl/syscall-process-resolution.lisp +++ b/src/infrastructure/acl/syscall-process-resolution.lisp @@ -24,7 +24,7 @@ #o111))))) (defun %resolve-external-command (command &optional (environment (%get-environment))) - (nshell.domain.completion::%first-command-path-candidate + (nshell.domain.completion:%first-command-path-candidate command (or (%environment-value "PATH" environment) "/bin:/usr/bin") diff --git a/src/infrastructure/acl/syscall-process.lisp b/src/infrastructure/acl/syscall-process.lisp index 9095dd58..eddd3443 100644 --- a/src/infrastructure/acl/syscall-process.lisp +++ b/src/infrastructure/acl/syscall-process.lisp @@ -1,7 +1,7 @@ (in-package #:nshell.infrastructure.acl) -(defparameter *external-command-timeout* nil - "Maximum seconds for synchronous external commands. NIL disables the timeout.") +(defparameter *external-command-timeout* +default-external-command-timeout+ + "Maximum seconds for synchronous non-interactive external commands.") (defun %spawn-terminal-command (command args) (multiple-value-bind (resolved environment) (%prepare-external-command command) diff --git a/src/infrastructure/acl/syscall.lisp b/src/infrastructure/acl/syscall.lisp index a865012c..ef24946c 100644 --- a/src/infrastructure/acl/syscall.lisp +++ b/src/infrastructure/acl/syscall.lisp @@ -2,7 +2,7 @@ (eval-when (:compile-toplevel :load-toplevel :execute) (require :sb-posix) - (nshell.domain.completion::%configure-path-command-cache-locks + (nshell.domain.completion:%configure-path-command-cache-locks (lambda () (let ((mutex (sb-thread:make-mutex :name "PATH command directory cache"))) (cl-boundary-kit:make-lock diff --git a/src/infrastructure/acl/timeout.lisp b/src/infrastructure/acl/timeout.lisp new file mode 100644 index 00000000..4a10f0d0 --- /dev/null +++ b/src/infrastructure/acl/timeout.lisp @@ -0,0 +1,23 @@ +(in-package #:nshell.infrastructure.acl) + +(defconstant +default-external-command-timeout+ 3600 + "Maximum seconds for a non-interactive external command.") + +(defconstant +default-command-substitution-timeout+ 300 + "Maximum seconds for command substitution.") + +(defun timeout-seconds-p (value) + "Return true when VALUE is a finite, non-negative timeout in seconds." + (and (realp value) + (not (minusp value)) + (or (not (floatp value)) + (and (not (sb-ext:float-infinity-p value)) + (not (sb-ext:float-nan-p value)))))) + +(defun ensure-timeout-seconds (value &optional (context "timeout")) + "Validate VALUE at an operational timeout boundary and return it. +NIL means that the caller intentionally requested an unbounded wait." + (when (and value (not (timeout-seconds-p value))) + (error "~a must be a finite non-negative number, got ~s." + context value)) + value) diff --git a/src/infrastructure/assistant-sidecar-stream.lisp b/src/infrastructure/assistant-sidecar-stream.lisp index 8d71cf4e..d26b6954 100644 --- a/src/infrastructure/assistant-sidecar-stream.lisp +++ b/src/infrastructure/assistant-sidecar-stream.lisp @@ -334,7 +334,7 @@ (command (assistant-sidecar-state-command state))) (if cached-version (values cached-version :ok) - (nshell.infrastructure.acl::run-sidecar-version-cancellable + (nshell.infrastructure.acl:run-sidecar-version-cancellable command (lambda () (not (%assistant-sidecar-starting-p state)))))) (if (or (not (eq :ok version-status)) diff --git a/src/infrastructure/persistence/file-history.lisp b/src/infrastructure/persistence/file-history.lisp index 3fae4651..dbd9c665 100644 --- a/src/infrastructure/persistence/file-history.lisp +++ b/src/infrastructure/persistence/file-history.lisp @@ -131,10 +131,15 @@ (loop with entries = nil do (multiple-value-bind (entry status) (%read-history-record stream) (case status - (:eof (return (nreverse entries))) + (:eof (return (values (nreverse entries) nil))) (:entry (push entry entries)) - (:invalid (return (nreverse entries))) - (:truncated (return (nreverse entries))))))) + (:invalid (return (values (nreverse entries) :invalid))) + (:truncated (return (values (nreverse entries) :truncated))))))) + +(defun %report-history-error (operation path condition) + (format *error-output* + "nshell: history: unable to ~a ~a: ~a~%" + operation path condition)) (defun %append-history-record (stream record) (let ((payload (%history-record-payload record))) @@ -223,26 +228,37 @@ (defun load-history-file () "Return v3 records oldest first, promoting v2 records with NIL metadata." - (ignore-errors - (let ((path (history-file-path))) - (when (probe-file path) + (let ((path (history-file-path))) + (when (probe-file path) + (handler-case (with-open-file (f path :direction :input :if-does-not-exist nil) - (%read-history-records f)))))) + (multiple-value-bind (records status) (%read-history-records f) + (when status + (%report-history-error "read" path + (format nil "~a history record" status))) + records)) + (condition (condition) + (%report-history-error "read" path condition) + nil))))) (defun append-history-entry (text &key timestamp cwd exit-code duration-ms origin) - (ignore-errors - (progn - (ensure-directories-exist (history-file-path)) - (let ((record (or *history-record-to-append* - (%make-history-record-from-values - :text text - :timestamp timestamp - :cwd cwd - :exit-code exit-code - :duration-ms duration-ms - :origin origin)))) - (unless (string= text (history-record-text record)) - (error "History record text does not match the appended command.")) - (with-open-file (f (history-file-path) :direction :output - :if-exists :append :if-does-not-exist :create) - (%append-history-record f record)))))) + (let ((path (history-file-path))) + (handler-case + (progn + (ensure-directories-exist path) + (let ((record (or *history-record-to-append* + (%make-history-record-from-values + :text text + :timestamp timestamp + :cwd cwd + :exit-code exit-code + :duration-ms duration-ms + :origin origin)))) + (unless (string= text (history-record-text record)) + (error "History record text does not match the appended command.")) + (with-open-file (f path :direction :output + :if-exists :append :if-does-not-exist :create) + (%append-history-record f record)))) + (condition (condition) + (%report-history-error "append to" path condition) + nil)))) diff --git a/src/package-application.lisp b/src/package-application.lisp index ed8b6d08..e80741a3 100644 --- a/src/package-application.lisp +++ b/src/package-application.lisp @@ -12,6 +12,9 @@ permitted to know both the domain and infrastructure.") (:use #:cl) (:import-from #:nshell.util #:define-value-struct #:string-prefix-p) (:export #:*job-monitor* #:*shell-pgid* #:*foreground-job-pgid* + ;; Runtime seams consumed by presentation and assistant adapters. + #:*builtin-registry* #:*execution-confirmed-p* #:*execution-origin* + #:*foreground-terminal-runner* #:*agent-start-handler* #:*ai-reset-handler* #:*theme-apply-handler* diff --git a/src/package-domain-completion.lisp b/src/package-domain-completion.lisp index 033ff3b2..ad5473fd 100644 --- a/src/package-domain-completion.lisp +++ b/src/package-domain-completion.lisp @@ -43,4 +43,12 @@ exported predicates below are goals callers may query directly.") #:candidate-prefix-match-p #:completion-context-redirection-target-p #:filesystem-candidates-for-value-kind - #:command-path-candidates))) + #:command-path-candidates + ;; Cross-layer completion sources used by application/presentation. + #:%command-candidates-from-path + #:%first-command-path-candidate + #:%configure-path-command-cache-locks + #:%git-output-lines + #:%git-porcelain-status-paths + #:*git-branch-lister* + #:*git-modified-path-lister*))) diff --git a/src/package-domain.lisp b/src/package-domain.lisp index 78605355..c89959b4 100644 --- a/src/package-domain.lisp +++ b/src/package-domain.lisp @@ -87,6 +87,9 @@ continuation line. A pure string-to-AST function with no filesystem access.") #:command-list-redirect-split-result-clean-commands #:command-list-redirect-split-result-redirects #:ast-node->command-line + #:ast-node-span + #:%balanced-substitution-end + #:+control-flow-keywords+ #:command-arg #:command-arg-p #:make-command-arg #:command-arg-value #:command-arg-quote-style #:command-arg-fragments #:command-fragment #:command-fragment-p #:make-command-fragment @@ -166,6 +169,7 @@ filesystem capability, so expansion remains testable without a disk.") #:expand-command-name-by-quote-style #:expand-double-quoted #:expand-arithmetic #:evaluate-arithmetic #:expand-braces #:argv-reference-fields #:*positional-args* + #:%single-command-name-or-error #:parameter-expansion-error #:parameter-expansion-error-name #:parameter-expansion-error-message)) diff --git a/src/package-infrastructure.lisp b/src/package-infrastructure.lisp index 899d4ffd..bbc8805c 100644 --- a/src/package-infrastructure.lisp +++ b/src/package-infrastructure.lisp @@ -12,6 +12,22 @@ tests rebind these boundary functions when they need deterministic behavior.") (:use #:cl) (:import-from #:nshell.util #:define-value-struct) (:export #:*exported-environment* + #:+default-external-command-timeout+ + #:+default-command-substitution-timeout+ + #:timeout-seconds-p + #:ensure-timeout-seconds + ;; Cross-layer integration seams used by application and domain code. + #:%foreground-external-command-timeout + #:%spawn-terminal-command + #:%start-stream-copier + #:%wait-process-with-copiers + #:%wait-process-with-copiers-or-stop + #:%assign-process-group + #:%call-with-foreground-process-group + #:%abort-pipeline + #:%external-command-not-found-message + #:%run-git + #:run-sidecar-version-cancellable #:external-command-environment #:current-environment-entries #:current-environment-value #:current-working-directory @@ -103,13 +119,20 @@ line editor has a single place to import them from.") (defpackage #:nshell.infrastructure.persistence (:documentation "Infrastructure: state that outlives a session. Locates, reads, and appends -to the history file as plain text lines (the caller wraps them into -history-kit entries) and loads and saves the config file, converting +to the history file as length-framed records and loads and saves the config file, +converting between that on-disk format and nshell.domain.configuration's domain values.") (:use #:cl) (:export #:*history-file-path-override* #:load-history-file #:append-history-entry #:history-file-path + #:history-record #:history-record-p + #:history-record-text #:history-record-timestamp + #:history-record-cwd #:history-record-exit-code + #:history-record-duration-ms #:history-record-origin + #:history-record-add #:history-record-for-entry + #:history-record-matches-p #:history-record-filter-token + #:*history-record-to-append* #:load-config #:save-config)) ) diff --git a/src/presentation/repl-completion-seed.lisp b/src/presentation/repl-completion-seed.lisp index a77a3f5e..9739f6c5 100644 --- a/src/presentation/repl-completion-seed.lisp +++ b/src/presentation/repl-completion-seed.lisp @@ -15,21 +15,21 @@ (defun %seed-git-branch-lister (directory) (multiple-value-bind (output exit-code) - (nshell.infrastructure.acl::%run-git + (nshell.infrastructure.acl:%run-git directory '("for-each-ref" "--format=%(refname:short)" "refs/heads/")) (when (zerop exit-code) - (nshell.domain.completion::%git-output-lines output)))) + (nshell.domain.completion:%git-output-lines output)))) (defun %seed-git-modified-path-lister (directory) (multiple-value-bind (output exit-code) - (nshell.infrastructure.acl::%run-git + (nshell.infrastructure.acl:%run-git directory '("status" "--porcelain" "--untracked-files=no")) (when (zerop exit-code) - (nshell.domain.completion::%git-porcelain-status-paths output)))) + (nshell.domain.completion:%git-porcelain-status-paths output)))) (defun seed-repl-completion-knowledge-base (knowledge-base) - (setf nshell.domain.completion::*git-branch-lister* (function %seed-git-branch-lister) - nshell.domain.completion::*git-modified-path-lister* (function %seed-git-modified-path-lister)) + (setf nshell.domain.completion:*git-branch-lister* (function %seed-git-branch-lister) + nshell.domain.completion:*git-modified-path-lister* (function %seed-git-modified-path-lister)) (dolist (spec (append (nshell.domain.completion:builtin-completion-command-specs) (nshell.domain.completion:external-completion-command-specs) (nshell.domain.completion:external-subcommand-completion-command-specs)) diff --git a/src/presentation/repl-execution-context.lisp b/src/presentation/repl-execution-context.lisp index 58aaa8bd..ada8d6bb 100644 --- a/src/presentation/repl-execution-context.lisp +++ b/src/presentation/repl-execution-context.lisp @@ -50,7 +50,7 @@ (defun %execute-with-repl-shell-context (thunk) (let ((context (%make-repl-shell-context)) - (nshell.application::*foreground-terminal-runner* + (nshell.application:*foreground-terminal-runner* (and (not *capture-command-output-p*) *interactive-terminal-installed-p* (interactive-stream-p *standard-input*) diff --git a/src/presentation/repl-highlight.lisp b/src/presentation/repl-highlight.lisp index 635155a3..8c0034fc 100644 --- a/src/presentation/repl-highlight.lisp +++ b/src/presentation/repl-highlight.lisp @@ -2,7 +2,7 @@ (in-package #:nshell.presentation) (defparameter +repl-highlight-keywords+ - (append nshell.domain.parsing::+control-flow-keywords+ '("function")) + (append nshell.domain.parsing:+control-flow-keywords+ '("function")) "Words nshell's parser treats as control-flow syntax rather than commands.") (defvar *repl-highlight-path-cache* (make-hash-table :test #'equal) @@ -72,7 +72,7 @@ directory listing so a correction never walks PATH itself." (ignore-errors (mapcar (lambda (candidate) (nshell.domain.completion:candidate-text candidate)) - (nshell.domain.completion::%command-candidates-from-path + (nshell.domain.completion:%command-candidates-from-path path "" (nshell.infrastructure.acl:make-host-filesystem))))))) (defun %repl-table-names (table) @@ -81,7 +81,7 @@ directory listing so a correction never walks PATH itself." (defun %repl-known-command-names () (append +repl-highlight-keywords+ - (loop for name being the hash-keys of nshell.application::*builtin-registry* + (loop for name being the hash-keys of nshell.application:*builtin-registry* collect name) (%repl-table-names *functions*) (%repl-table-names *aliases*) diff --git a/src/presentation/repl-output-completion-help.lisp b/src/presentation/repl-output-completion-help.lisp index 1984cbd5..681427e0 100644 --- a/src/presentation/repl-output-completion-help.lisp +++ b/src/presentation/repl-output-completion-help.lisp @@ -36,7 +36,7 @@ (defun %fetch-completion-help (command) (let ((parts (%completion-help-command-parts command))) (when parts - (let ((nshell.infrastructure.acl::*external-command-timeout* + (let ((nshell.infrastructure.acl:*external-command-timeout* *completion-help-timeout*)) (nshell.infrastructure.acl:run-external-capture (first parts) diff --git a/src/presentation/repl-output-handlers.lisp b/src/presentation/repl-output-handlers.lisp index cd26b202..37d9313d 100644 --- a/src/presentation/repl-output-handlers.lisp +++ b/src/presentation/repl-output-handlers.lisp @@ -597,9 +597,9 @@ wrapped line's other rows on screen as stale duplicates." (let ((nshell.infrastructure.acl:*command-not-found-hook* (lambda (command) (setf *command-not-found-command* command))) - (nshell.application::*execution-origin* + (nshell.application:*execution-origin* *assistant-command-origin*) - (nshell.application::*execution-confirmed-p* + (nshell.application:*execution-confirmed-p* *assistant-command-confirmed-p*)) (setf *command-not-found-command* nil) (setf *last-command-output* nil) @@ -634,7 +634,7 @@ wrapped line's other rows on screen as stale duplicates." (write-string *last-command-output*)) (when *history-persistence-enabled-p* (multiple-value-bind (history record) - (nshell.infrastructure.persistence::history-record-add + (nshell.infrastructure.persistence:history-record-add *history* text :timestamp timestamp :cwd cwd @@ -643,7 +643,7 @@ wrapped line's other rows on screen as stale duplicates." :origin *assistant-command-origin*) (declare (ignore history)) (history-kit:history-reset-navigation *history*) - (let ((nshell.infrastructure.persistence::*history-record-to-append* + (let ((nshell.infrastructure.persistence:*history-record-to-append* record)) (nshell.infrastructure.persistence:append-history-entry text)))))) (setf *last-command-text* text diff --git a/src/presentation/repl-search-ui.lisp b/src/presentation/repl-search-ui.lisp index 0062cba4..9d922f75 100644 --- a/src/presentation/repl-search-ui.lisp +++ b/src/presentation/repl-search-ui.lisp @@ -147,8 +147,8 @@ below the prompt using the input state's already-clamped selection index." (list :text (history-kit:history-entry-text entry) :exit-code (history-kit:history-entry-exit-code entry) :origin - (nshell.infrastructure.persistence::history-record-origin - (nshell.infrastructure.persistence::history-record-for-entry + (nshell.infrastructure.persistence:history-record-origin + (nshell.infrastructure.persistence:history-record-for-entry *history* entry)))) entries))) (render-search-results-below-prompt diff --git a/src/presentation/repl-session-init.lisp b/src/presentation/repl-session-init.lisp index 92d182bf..f9b6c5a5 100644 --- a/src/presentation/repl-session-init.lisp +++ b/src/presentation/repl-session-init.lisp @@ -117,17 +117,17 @@ entry the newest entry in HISTORY too. Do not reverse the loaded list here -- that would hand HISTORY-ADD the newest entry first, burying it under every older entry added afterward and inverting recall order." (dolist (record (nshell.infrastructure.persistence:load-history-file)) - (nshell.infrastructure.persistence::history-record-add + (nshell.infrastructure.persistence:history-record-add history - (nshell.infrastructure.persistence::history-record-text record) + (nshell.infrastructure.persistence:history-record-text record) :timestamp - (nshell.infrastructure.persistence::history-record-timestamp record) - :cwd (nshell.infrastructure.persistence::history-record-cwd record) + (nshell.infrastructure.persistence:history-record-timestamp record) + :cwd (nshell.infrastructure.persistence:history-record-cwd record) :exit-code - (nshell.infrastructure.persistence::history-record-exit-code record) + (nshell.infrastructure.persistence:history-record-exit-code record) :duration-ms - (nshell.infrastructure.persistence::history-record-duration-ms record) - :origin (nshell.infrastructure.persistence::history-record-origin record)))) + (nshell.infrastructure.persistence:history-record-duration-ms record) + :origin (nshell.infrastructure.persistence:history-record-origin record)))) (defun %vi-mode-flag-enabled-p (flag) (and flag diff --git a/t/e2e/test-history.lisp b/t/e2e/test-history.lisp index 5d48b61a..652ecd73 100644 --- a/t/e2e/test-history.lisp +++ b/t/e2e/test-history.lisp @@ -53,7 +53,7 @@ history-path)) (expect (list older newer) :to-equal - (mapcar #'nshell.infrastructure.persistence::history-record-text + (mapcar #'nshell.infrastructure.persistence:history-record-text (nshell.infrastructure.persistence:load-history-file)))) (run-session (lambda (pty fd) diff --git a/t/helpers-runner.lisp b/t/helpers-runner.lisp index 39dc7bb7..c7d9e261 100644 --- a/t/helpers-runner.lisp +++ b/t/helpers-runner.lisp @@ -16,7 +16,8 @@ "True in hermetic Nix builds, not in impure nix develop shells. Real OS process and PTY integration tests are skipped only when the surrounding environment is expected to hide facilities such as /bin/sh, /bin/cat, or PTYs." - (and (host-kit:getenv "NIX_BUILD_TOP") + (and (not (string= (or (host-kit:getenv "NSHELL_TEST_PTY") "") "1")) + (host-kit:getenv "NIX_BUILD_TOP") (not (string= (or (host-kit:getenv "IN_NIX_SHELL") "") "impure")))) @@ -61,13 +62,17 @@ environment is expected to hide facilities such as /bin/sh, /bin/cat, or PTYs." "Run BODY only where raw PTY master/slave round-trip I/O is reliable. Reading bytes straight back through a PTY depends on the terminal line -discipline, which differs across platforms and is not honored by hosted CI -runners, so skip the hermetic sandbox, CI, and unavailable PTYs." - `(if (or (in-hermetic-sandbox-p) - (host-kit:getenv "CI") - (not (pty-available-p))) - (skip (format nil "~a (skipped in sandbox/CI/unavailable PTY)" ,reason)) - (progn ,@body))) +discipline. The hermetic Nix sandbox cannot provide this integration +facility, but the non-sandboxed CI job is required to exercise it." + `(cond + ((in-hermetic-sandbox-p) + (skip (format nil "~a (skipped in hermetic sandbox)" ,reason))) + ((not (pty-available-p)) + (if (host-kit:getenv "CI") + (error "~a (PTY unavailable in CI)" ,reason) + (skip (format nil "~a (unavailable PTY)" ,reason)))) + (t + (progn ,@body)))) (defun run-tests () "Run all nshell tests through cl-weave. diff --git a/t/integration/test-file-history.lisp b/t/integration/test-file-history.lisp index 9321845e..e0279203 100644 --- a/t/integration/test-file-history.lisp +++ b/t/integration/test-file-history.lisp @@ -15,7 +15,7 @@ (let ((loaded (nshell.infrastructure.persistence:load-history-file))) (expect (consp loaded) :to-be-truthy) (expect "test command" :to-equal - (nshell.infrastructure.persistence::history-record-text + (nshell.infrastructure.persistence:history-record-text (first loaded))))) ;; Cleanup (setf nshell.infrastructure.persistence:*history-file-path-override* nil) @@ -34,7 +34,7 @@ (nshell.infrastructure.persistence:append-history-entry command) (expect (list command) :to-equal - (mapcar #'nshell.infrastructure.persistence::history-record-text + (mapcar #'nshell.infrastructure.persistence:history-record-text (nshell.infrastructure.persistence:load-history-file)))) (setf nshell.infrastructure.persistence:*history-file-path-override* nil) (when (probe-file test-path) (delete-file test-path))))) @@ -59,16 +59,16 @@ (let ((record (first (nshell.infrastructure.persistence:load-history-file)))) (expect command :to-equal - (nshell.infrastructure.persistence::history-record-text record)) + (nshell.infrastructure.persistence:history-record-text record)) (dolist (value (list - (nshell.infrastructure.persistence::history-record-timestamp + (nshell.infrastructure.persistence:history-record-timestamp record) - (nshell.infrastructure.persistence::history-record-cwd record) - (nshell.infrastructure.persistence::history-record-exit-code + (nshell.infrastructure.persistence:history-record-cwd record) + (nshell.infrastructure.persistence:history-record-exit-code record) - (nshell.infrastructure.persistence::history-record-duration-ms + (nshell.infrastructure.persistence:history-record-duration-ms record) - (nshell.infrastructure.persistence::history-record-origin record))) + (nshell.infrastructure.persistence:history-record-origin record))) (expect value :to-be-null)))) (setf nshell.infrastructure.persistence:*history-file-path-override* nil) (when (probe-file test-path) (delete-file test-path))))) @@ -98,17 +98,17 @@ (let ((record (first (nshell.infrastructure.persistence:load-history-file)))) (expect command :to-equal - (nshell.infrastructure.persistence::history-record-text record)) + (nshell.infrastructure.persistence:history-record-text record)) (expect 123 :to-equal - (nshell.infrastructure.persistence::history-record-timestamp record)) + (nshell.infrastructure.persistence:history-record-timestamp record)) (expect "/tmp/nshell-history" :to-equal - (nshell.infrastructure.persistence::history-record-cwd record)) + (nshell.infrastructure.persistence:history-record-cwd record)) (expect 7 :to-equal - (nshell.infrastructure.persistence::history-record-exit-code record)) + (nshell.infrastructure.persistence:history-record-exit-code record)) (expect 42 :to-equal - (nshell.infrastructure.persistence::history-record-duration-ms record)) + (nshell.infrastructure.persistence:history-record-duration-ms record)) (expect :agent :to-equal - (nshell.infrastructure.persistence::history-record-origin record)))) + (nshell.infrastructure.persistence:history-record-origin record)))) (setf nshell.infrastructure.persistence:*history-file-path-override* nil) (when (probe-file test-path) (delete-file test-path))))) @@ -129,6 +129,45 @@ (setf nshell.infrastructure.persistence:*history-file-path-override* nil) (when (probe-file test-path) (delete-file test-path))))) + (it "file-history-malformed-record-does-not-abort-load" + "A malformed framed record is reported and does not escape the loader." + (let ((test-path (format nil "/tmp/nshell-test-history-malformed-~d.lisp" + (random 1000000)))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence:*history-file-path-override* + (pathname test-path)) + (when (probe-file test-path) (delete-file test-path)) + (with-open-file (stream test-path :direction :output + :if-exists :supersede + :if-does-not-exist :create) + (format stream "~a-not-a-length~%" + nshell.infrastructure.persistence::+history-record-v3-prefix+)) + (expect (nshell.infrastructure.persistence:load-history-file) + :to-be-null)) + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path))))) + + (it "file-history-truncated-record-does-not-abort-load" + "A truncated framed record is reported and does not escape the loader." + (let ((test-path (format nil "/tmp/nshell-test-history-truncated-~d.lisp" + (random 1000000)))) + (unwind-protect + (progn + (setf nshell.infrastructure.persistence:*history-file-path-override* + (pathname test-path)) + (when (probe-file test-path) (delete-file test-path)) + (with-open-file (stream test-path :direction :output + :if-exists :supersede + :if-does-not-exist :create) + (format stream "~a~d~%short~%" + nshell.infrastructure.persistence::+history-record-v3-prefix+ + 100)) + (expect (nshell.infrastructure.persistence:load-history-file) + :to-be-null)) + (setf nshell.infrastructure.persistence:*history-file-path-override* nil) + (when (probe-file test-path) (delete-file test-path))))) + (it "file-history-missing-file" "Loading a missing history file returns NIL." (let* ((test-path (format nil "/tmp/nshell-test-history-missing-~d.lisp" @@ -160,7 +199,7 @@ one seam over." (nshell.infrastructure.persistence:append-history-entry "newer") (expect (list "older" "newer") :to-equal - (mapcar #'nshell.infrastructure.persistence::history-record-text + (mapcar #'nshell.infrastructure.persistence:history-record-text (nshell.infrastructure.persistence:load-history-file)))) (setf nshell.infrastructure.persistence:*history-file-path-override* nil) (when (probe-file test-path) (delete-file test-path))))) diff --git a/t/integration/test-process.lisp b/t/integration/test-process.lisp index 8a528eae..73722ce7 100644 --- a/t/integration/test-process.lisp +++ b/t/integration/test-process.lisp @@ -291,15 +291,13 @@ (eq :signaled (sb-ext:process-status process))) processes))))))) - (it "foreground-external-command-timeout-is-nil-by-default-for-noninteractive-output" - "With *EXTERNAL-COMMAND-TIMEOUT* left at its production default (now NIL), -%FOREGROUND-EXTERNAL-COMMAND-TIMEOUT must return NIL even when -*STANDARD-OUTPUT* is not an interactive terminal -- pre-fix the default was -30, so this same non-interactive check would have returned 30 (a truthy, -enforced timeout) instead." + (it "foreground-external-command-timeout-uses-default-for-noninteractive-output" + "With *EXTERNAL-COMMAND-TIMEOUT* left at its production default, +%FOREGROUND-EXTERNAL-COMMAND-TIMEOUT applies the configured finite timeout +when *STANDARD-OUTPUT* is not an interactive terminal." (let ((*standard-output* (make-string-output-stream))) (expect (nshell.infrastructure.acl::%foreground-external-command-timeout) - :to-be nil))) + :to-be nshell.infrastructure.acl:*external-command-timeout*))) (it "foreground-process-group-macro-runs-body-without-a-pgid" "The foreground-group wrapper preserves execution when no group is available." diff --git a/t/integration/test-pty.lisp b/t/integration/test-pty.lisp index 0e916ac3..daad3fc2 100644 --- a/t/integration/test-pty.lisp +++ b/t/integration/test-pty.lisp @@ -384,26 +384,42 @@ (skip "PTY tests are only supported on Darwin and Linux")) (it "pty-open-write-read-close" - "PTY can be opened, used in both directions, and closed." + "PTY can carry input to a child and its output back to the master." #-(or darwin linux) (skip "PTY tests are only supported on Darwin and Linux") #+(or darwin linux) (skip-when-pty-round-trip-unreliable "PTY master/slave round-trip I/O is unreliable" - (with-open-pty (master slave slave-name) - (expect (integerp master) :to-be-truthy) - (expect (integerp slave) :to-be-truthy) - (expect (stringp slave-name) :to-be-truthy) - (let ((from-master (make-array 64 :element-type '(unsigned-byte 8)))) - (expect (length (line "master-to-slave")) :to-equal - (nshell.infrastructure.acl:pty-write master (line "master-to-slave"))) - (let ((count (nshell.infrastructure.acl:pty-read slave from-master 64))) - (expect (plusp count) :to-be-truthy) - (expect (search "master-to-slave" (octets->string from-master count)) :to-be-truthy))) - (let ((from-slave (make-array 64 :element-type '(unsigned-byte 8)))) - (nshell.infrastructure.acl:pty-write slave (string->octets (line "slave-to-master"))) - (let ((count (nshell.infrastructure.acl:pty-read master from-slave 64))) - (expect (plusp count) :to-be-truthy) - (expect (search "slave-to-master" (octets->string from-slave count)) :to-be-truthy)))))) + (let ((process + (nshell.infrastructure.acl:pty-spawn + "/bin/cat" + '()))) + (unwind-protect + (let ((master (nshell.infrastructure.acl:pty-process-master-fd process)) + (buffer (make-array 128 :element-type '(unsigned-byte 8))) + (received (make-array 0 :element-type 'character + :adjustable t :fill-pointer 0))) + (expect (integerp master) :to-be-truthy) + (expect (length (line "master-to-slave")) :to-equal + (nshell.infrastructure.acl:pty-write + master + (line "master-to-slave"))) + (loop repeat 8 + until (search "master-to-slave" received) + do (let ((count (nshell.infrastructure.acl:pty-read + master buffer (length buffer)))) + (loop for index below count + do (vector-push-extend + (code-char (aref buffer index)) + received))) + (expect (search "master-to-slave" received) :to-be-truthy)) + (unless (member (nshell.infrastructure.acl:pty-process-status process) + '(:exited :signaled)) + (ignore-errors + (nshell.infrastructure.acl:kill-process + (- (nshell.infrastructure.acl:pty-process-pgid process)) + :sigterm))) + (ignore-errors + (nshell.infrastructure.acl:pty-process-wait process))))))) (it "pty-close-is-idempotent-for-shared-descriptor" "PTY cleanup does not close a descriptor twice when both slots share it." diff --git a/t/scripts/test-release-bundle.pl b/t/scripts/test-release-bundle.pl index bf6663a6..b0a77b38 100644 --- a/t/scripts/test-release-bundle.pl +++ b/t/scripts/test-release-bundle.pl @@ -37,7 +37,7 @@ sub write_file { print " [Requesting program interpreter: $interpreter]\n"; } else { die "unexpected readelf flag $flag" } STUB -my @licenses = qw(SBCL-COPYING ZSTD-LICENSE CL-PROLOG-KIT-LICENSE CL-PARSER-KIT-LICENSE CL-DATAFLOW-KIT-LICENSE CL-HOST-KIT-LICENSE CL-BOUNDARY-KIT-LICENSE CL-CLI-LICENSE CL-TTY-KIT-LICENSE CL-LOG-KIT-LICENSE CL-PROCESS-KIT-LICENSE CL-HISTORY-KIT-LICENSE CL-CODEC-KIT-LICENSE CL-DATE-KIT-LICENSE CL-CONCURRENT-KIT-LICENSE GLIBC-COPYING.LIB); +my @licenses = qw(SBCL-COPYING ZSTD-LICENSE CL-PROLOG-KIT-LICENSE CL-PARSER-KIT-LICENSE CL-DATAFLOW-KIT-LICENSE CL-HOST-KIT-LICENSE CL-BOUNDARY-KIT-LICENSE CL-CLI-LICENSE CL-TTY-KIT-LICENSE CL-LOG-KIT-LICENSE CL-PROCESS-KIT-LICENSE CL-HISTORY-KIT-LICENSE CL-CODEC-KIT-LICENSE CL-DATE-KIT-LICENSE CL-CONCURRENT-KIT-LICENSE CL-JSON-KIT-LICENSE CL-REGEX-KIT-LICENSE CL-VCS-KIT-LICENSE CL-TUI-KIT-LICENSE GLIBC-COPYING.LIB); my @cases = ( ['control', undef, undef], ['missing-launcher', 'bin/cl-process-kit-spawn', qr/missing Linux release file: bin\/cl-process-kit-spawn/], @@ -48,12 +48,19 @@ sub write_file { ['interpreter', undef, qr/unexpected ELF interpreter/], ['readelf-failure', undef, qr/readelf -d failed/], ); +for my $package (qw(JSON REGEX VCS TUI)) { + my $relative = "LICENSES/CL-$package-KIT-LICENSE"; + push @cases, + ["missing-license-$package", $relative, qr/missing release file: \Q$relative\E/], + ["empty-license-$package", $relative, qr/empty release file: \Q$relative\E/]; +} for my $case (@cases) { my ($name, $target, $error) = @$case; my $bundle = "$root/$name"; make_path(map { "$bundle/$_" } qw(bin libexec lib LICENSES share/man/man1)); for my $relative ('README.md', 'LICENSE', 'share/man/man1/nshell.1', map { "LICENSES/$_" } @licenses) { - write_file("$bundle/$relative", "fixture\n", 0644); + next if $name =~ /^missing-/ && $relative eq $target; + write_file("$bundle/$relative", $name =~ /^empty-/ && $relative eq $target ? '' : "fixture\n", 0644); } for my $relative (qw(bin/nshell bin/cl-process-kit-spawn libexec/nshell libexec/cl-process-kit-spawn lib/ld-linux-x86-64.so.2)) { next if $name =~ /^missing-/ && $relative eq $target; @@ -84,4 +91,4 @@ sub write_file { like($calls, qr/-l .*libexec\/cl-process-kit-spawn/, 'helper interpreter inspected'); } } -done_testing(17); +done_testing(33); diff --git a/t/support/history.lisp b/t/support/history.lisp index 09abdc4e..93d1fb81 100644 --- a/t/support/history.lisp +++ b/t/support/history.lisp @@ -1,7 +1,7 @@ (in-package #:nshell/test) (defun add-history-record (history text &rest initargs) - (apply #'nshell.infrastructure.persistence::history-record-add + (apply #'nshell.infrastructure.persistence:history-record-add history text initargs) history) diff --git a/t/support/runtime.lisp b/t/support/runtime.lisp index 06f38a90..ad920c5e 100644 --- a/t/support/runtime.lisp +++ b/t/support/runtime.lisp @@ -2,11 +2,12 @@ (defparameter +nshell-runtime-dependencies+ '(:cl-prolog-kit :cl-parser-kit :cl-dataflow-kit :cl-boundary-kit :cl-cli - :cl-tty-kit :cl-process-kit :cl-history-kit :cl-host-kit :cl-log-kit + :cl-tty-kit :cl-process-kit + :cl-history-kit :cl-host-kit :cl-log-kit :cl-concurrent-kit :cl-codec-kit :cl-date-kit :cl-json-kit) "ASDF systems whose source directories a fresh nshell subprocess needs. -The list includes transitive dependencies because subprocess bootstrap uses an -explicit central registry rather than inheriting the parent's registry.") +The list is the transitive runtime closure of nshell, expressed explicitly +because subprocess bootstrap does not inherit the parent's registry.") (defun %asdf-output-translation-bootstrap-form () "Return a child form that shares the runner's compiled ASDF artifacts." diff --git a/t/unit/test-agent-mode.lisp b/t/unit/test-agent-mode.lisp index 4313db2e..707b9d83 100644 --- a/t/unit/test-agent-mode.lisp +++ b/t/unit/test-agent-mode.lisp @@ -232,10 +232,10 @@ (let* ((entry (first (history-kit:history-entries nshell.presentation::*history*))) (record - (nshell.infrastructure.persistence::history-record-for-entry + (nshell.infrastructure.persistence:history-record-for-entry nshell.presentation::*history* entry))) (expect :agent :to-equal - (nshell.infrastructure.persistence::history-record-origin + (nshell.infrastructure.persistence:history-record-origin record))))))))) (it "stops-after-the-configured-maximum-step-count" diff --git a/t/unit/test-cps.lisp b/t/unit/test-cps.lisp index 710e8163..e447f9aa 100644 --- a/t/unit/test-cps.lisp +++ b/t/unit/test-cps.lisp @@ -16,6 +16,14 @@ `(expect ,expected :to-equal (%trampoline-result-sequence ,values))) (describe "cps-tests" + (it "with-cps-trampoline-expands-to-trampoline" + (multiple-value-bind (expansion expanded-p) + (macroexpand-1 + '(nshell.presentation:with-cps-trampoline + (lambda () nil))) + (expect expanded-p :to-be-truthy) + (expect 'nshell.presentation:trampoline :to-be (first expansion)))) + (it "trampoline-sequential" (assert-trampoline-sequence '(3 2 1) '(1 2 3))) diff --git a/t/unit/test-execute-pipeline.lisp b/t/unit/test-execute-pipeline.lisp index c4c569b4..1e0946ac 100644 --- a/t/unit/test-execute-pipeline.lisp +++ b/t/unit/test-execute-pipeline.lisp @@ -440,16 +440,47 @@ nil))) (expect 127 :to-equal (nshell.application:execute-pipeline-use-case ast)))) - (it "external-command-timeout-defaults-to-nil" - "The production default disables the foreground command timeout; a -directly-launched external command is bounded only when a caller (e.g. a -non-interactive redirect, or a test) explicitly binds the special. Before -this change the default was 30, which killed any interactive foreground -command -- like `sleep 30` typed at the prompt -- after 30 seconds -regardless of interactivity; see %FOREGROUND-EXTERNAL-COMMAND-TIMEOUT in -src/infrastructure/acl/syscall-process.lisp for the interactivity gate that -now decides whether this default is even consulted." - (expect nil :to-equal nshell.infrastructure.acl:*external-command-timeout*)) + (it "external-command-timeout-defaults-to-one-hour" + (expect 3600 :to-equal nshell.infrastructure.acl:*external-command-timeout*) + (expect (nshell.infrastructure.acl:timeout-seconds-p + nshell.infrastructure.acl:*external-command-timeout*) + :to-be-truthy)) + + (it "timeout-seconds-p-rejects-invalid-boundaries" + "Timeout validation accepts finite non-negative values only." + (expect t :to-equal + (nshell.infrastructure.acl:timeout-seconds-p 0)) + (expect t :to-equal + (nshell.infrastructure.acl:timeout-seconds-p + most-positive-double-float)) + (expect t :to-equal + (nshell.infrastructure.acl:timeout-seconds-p + least-positive-double-float)) + (expect nil :to-equal + (nshell.infrastructure.acl:timeout-seconds-p -1)) + (expect nil :to-equal + (nshell.infrastructure.acl:timeout-seconds-p + sb-ext:double-float-negative-infinity)) + (expect nil :to-equal + (nshell.infrastructure.acl:timeout-seconds-p "3600")) + (expect nil :to-equal + (nshell.infrastructure.acl:timeout-seconds-p nil)) + (expect nil :to-equal + (nshell.infrastructure.acl:timeout-seconds-p + sb-ext:double-float-positive-infinity))) + + (it "ensure-timeout-seconds-validates-operational-boundaries" + "Operational timeout consumers reject invalid configured values." + (expect 0 :to-equal + (nshell.infrastructure.acl:ensure-timeout-seconds 0)) + (expect nil :to-equal + (nshell.infrastructure.acl:ensure-timeout-seconds nil)) + (expect (lambda () + (nshell.infrastructure.acl:ensure-timeout-seconds -1)) + :to-throw 'error) + (expect (lambda () + (nshell.infrastructure.acl:ensure-timeout-seconds "3600")) + :to-throw 'error)) (it "external-redirect-plan-preserves-routing-data" "External process routing is an immutable data boundary with no copier." @@ -608,15 +639,11 @@ now decides whether this default is even consulted." (expect "waited" :to-equal output) (expect 0 :to-equal code))))) - (it "command-substitution-timeout-defaults-to-nil" - "Command substitution is unbounded by default, like every other shell. -Before this change the default was 30, silently capping any `$(...)` at 30 -seconds in every mode; a caller that wants a bound binds the special (the -0.001-second binding test elsewhere in this file covers that branch). A -reverted default would still pass the e2e substitution smoke tests -- 30 is -a valid SB-EXT:WITH-TIMEOUT argument -- so only this direct assertion pins -it." - (expect nil :to-equal nshell.application::*command-substitution-timeout*)) + (it "command-substitution-timeout-defaults-to-five-minutes" + (expect 300 :to-equal nshell.application::*command-substitution-timeout*) + (expect (nshell.infrastructure.acl:timeout-seconds-p + nshell.application::*command-substitution-timeout*) + :to-be-truthy)) (it "execute-pipeline-node-in-context-times-out-external-stages-in-cps-mode" "The CPS execution path drains external output and times out long-running stages." diff --git a/t/unit/test-repl-execution.lisp b/t/unit/test-repl-execution.lisp index fb5e2f06..a6005f78 100644 --- a/t/unit/test-repl-execution.lisp +++ b/t/unit/test-repl-execution.lisp @@ -734,10 +734,10 @@ path must be converted from a pathname before being appended." (history-kit:history-entries nshell.presentation::*history*))) (record - (nshell.infrastructure.persistence::history-record-for-entry + (nshell.infrastructure.persistence:history-record-for-entry nshell.presentation::*history* entry))) (expect :proposal :to-be - (nshell.infrastructure.persistence::history-record-origin + (nshell.infrastructure.persistence:history-record-origin record)))))))))) (describe "command-resolution-fallback-tests"