Merge branch 'main' into farm/a4aed75b/npm-plugin-load-deps
This commit is contained in:
@@ -12,9 +12,17 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Release runs publish a `v*` tag pushed atomically with main HEAD; sharing
|
||||
# the cheap branch-wide `CI-refs/heads/main` group meant a later main push
|
||||
# silently cancelled the in-flight release and left the tag without a GitHub
|
||||
# Release or npm publish (#2564). Detect release runs at workflow-scheduling
|
||||
# time via the release-script commit subject (`chore: bump version to vX.Y.Z`,
|
||||
# see scripts/release.ts) and via `v*` tag-ref dispatches, then scope them to
|
||||
# a per-sha group with no cancellation. Every other event keeps the
|
||||
# branch-wide cancel-in-progress for PR/main churn.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: "${{ github.workflow }}-${{ (startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v')) && format('release-{0}', github.sha) || github.ref }}"
|
||||
cancel-in-progress: "${{ !(startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v')) }}"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -172,9 +180,9 @@ jobs:
|
||||
echo "cross-platform-run-id=$cross_platform_run_id"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Fast lint + type check (no Rust, no native build needed)
|
||||
# Fast lint, type check, and browser bundle build (no Rust, no native build needed)
|
||||
check:
|
||||
name: Lint & type check
|
||||
name: Lint, type check & web build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -189,6 +197,8 @@ jobs:
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Type check workspace
|
||||
run: bun run ci:check:full
|
||||
- name: Build collab web
|
||||
run: bun run collab:web:build
|
||||
|
||||
# Linux x64 baseline + modern: required by `test`, so it runs on every PR
|
||||
# unless native_artifact_lookup found a cached run. Release runs always
|
||||
|
||||
@@ -56,6 +56,7 @@ pi-*.html
|
||||
|
||||
# Generated files
|
||||
packages/coding-agent/src/internal-urls/docs-index.generated.ts
|
||||
packages/coding-agent/src/export/html/tool-views.generated.js
|
||||
packages/natives/npm/
|
||||
/runs/
|
||||
python/omp-rpc/src/omp_rpc.egg-info/
|
||||
@@ -70,3 +71,6 @@ python/robomp/.cache/
|
||||
python/robomp/src/robomp/static/
|
||||
python/robomp/web/dist/
|
||||
python/robomp/.env
|
||||
|
||||
# Local, machine-specific boot perf baseline (see packages/coding-agent/scripts/bench-guard.ts)
|
||||
packages/coding-agent/bench/boot-baseline.json
|
||||
|
||||
@@ -237,6 +237,7 @@ Location: `packages/*/CHANGELOG.md` (per package).
|
||||
**Rules:**
|
||||
- New entries always go under `## [Unreleased]`.
|
||||
- Never modify already-released sections (e.g., `## [0.12.2]`) — they are immutable.
|
||||
- Don't flag changelog section order or formatting in reviews or PRs — `bun run release` runs `fix-changelogs` which normalizes everything automatically.
|
||||
|
||||
**Attribution:**
|
||||
- Internal (from issues): `Fixed foo bar ([#123](https://github.com/can1357/oh-my-pi/issues/123))`.
|
||||
|
||||
Generated
+24
-24
@@ -501,9 +501,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -1672,9 +1672,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.100"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1773,9 +1773,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
@@ -2330,7 +2330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "15.11.2"
|
||||
version = "15.12.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2398,7 +2398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "15.11.2"
|
||||
version = "15.12.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2410,7 +2410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "15.11.2"
|
||||
version = "15.12.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2458,7 +2458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "15.11.2"
|
||||
version = "15.12.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
@@ -3025,9 +3025,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -4182,9 +4182,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
@@ -4209,9 +4209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.123"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4222,9 +4222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.123"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -4232,9 +4232,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.123"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -4245,9 +4245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.123"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -4358,9 +4358,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.100"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "15.11.2"
|
||||
version = "15.12.6"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
The most capable agent surface that ships. Continuously tuned by real-world use — complete out of the box, open all the way down.
|
||||
|
||||
**40+** providers · **32** built-in tools · **13** lsp ops · **27** dap ops · **~27k** lines of Rust core.
|
||||
**40+** providers · **32** built-in tools · **14** lsp ops · **28** dap ops · **~55k** lines of Rust core.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -171,11 +171,11 @@ Every other agent ships an importer and expects you to convert. omp reads the ei
|
||||
|
||||
### 14 · omp commit: atomic splits, validated messages
|
||||
|
||||
omp reads the working tree through git-overview, git-file-diff, and git-hunk, then splits unrelated changes into atomic commits ordered by their dependencies. Cycles are rejected before anything is written. Source files score above tests, docs, and configs, so the headline commit is the one that matters. Lock files are excluded from analysis entirely.
|
||||
omp reads the working tree through git_overview, git_file_diff, and git_hunk, then splits unrelated changes into atomic commits ordered by their dependencies. Cycles are rejected before anything is written. Source files score above tests, docs, and configs, so the headline commit is the one that matters. Lock files are excluded from analysis entirely.
|
||||
|
||||
### 15 · Read PRs. _Walk skills._ Pull JSON out of subagents.
|
||||
|
||||
Ten internal schemes — `pr://`, `issue://`, `agent://`, `skill://`, `rule://`, and the rest — resolve transparently inside every FS-shaped tool the agent already calls. `read pr://1428` returns the same shape as `read src/foo.ts`. `search` walks a diff like a directory. `agent://<id>/findings.0.path` pulls a field out of a subagent's output by path.
|
||||
Twelve internal schemes — `pr://`, `issue://`, `agent://`, `skill://`, `rule://`, and the rest — resolve transparently inside every FS-shaped tool the agent already calls. `read pr://1428` returns the same shape as `read src/foo.ts`. `search` walks a diff like a directory. `agent://<id>/findings.0.path` pulls a field out of a subagent's output by path.
|
||||
|
||||
![omp TUI reading pr://can1357/oh-my-pi/1063 and then /diff/1, showing hunk headers, added lines, and a [MODIFIED] (+12 -0) summary.](https://omp.sh/captures/pr.webp)
|
||||
|
||||
@@ -239,9 +239,10 @@ Stealth's on by default, so pages see a normal user instead of a headless bot. T
|
||||
- `browser` — Puppeteer tabs over headless Chromium or CDP-attached apps.
|
||||
- `web_search` — one query across configured providers, returning answer plus citations.
|
||||
- `github` — GitHub CLI ops — repo, PR, issues, code search, Actions run-watch.
|
||||
- `generate_image` — generate or edit raster images via Gemini image models.
|
||||
- `generate_image` — generate or edit raster images via Gemini, GPT, or xAI Grok image models.
|
||||
- `inspect_image` — vision-model analysis of a local image file.
|
||||
- `render_mermaid` — Mermaid source to terminal-friendly ASCII or PNG.
|
||||
- `tts` — text-to-speech via xAI Grok Voice — five built-in voices, WAV or MP3.
|
||||
|
||||
**Memory & state**
|
||||
|
||||
@@ -256,7 +257,7 @@ Stealth's on by default, so pages see a normal user instead of a headless bot. T
|
||||
- `resolve` — apply or discard a queued preview action.
|
||||
- `search_tool_bm25` — BM25 over the hidden tool index; activates top matches mid-session.
|
||||
|
||||
Setting-gated, off by default: `github`, `inspect_image`, `render_mermaid`, `checkpoint`, `rewind`, `search_tool_bm25`, `retain`, `recall`, `reflect`. Flip them on once, scoped per project.
|
||||
Setting-gated, off by default: `github`, `inspect_image`, `render_mermaid`, `tts`, `checkpoint`, `rewind`, `search_tool_bm25`, `retain`, `recall`, `reflect`. Flip them on once, scoped per project.
|
||||
|
||||
[Full reference →](https://omp.sh/docs/tools)
|
||||
|
||||
@@ -288,7 +289,7 @@ Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM
|
||||
|
||||
- **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`.
|
||||
- **Fallback chains** — Per-role chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown.
|
||||
- **Path-scoped roles** — Nest `paths:` under `modelRoles` to pin a heavier `default` on one repo without touching the global config. Closest path wins.
|
||||
- **Path-scoped models** — Scope `enabledModels` and `disabledProviders` entries to a `path:` prefix to pin a different model set on one repo without touching the global config. Scoped entries cover the path and everything under it.
|
||||
- **Round-robin credentials** — Stack API keys per provider and the runtime rotates with session affinity and per-credential backoff. Useful when one key would burn its quota by lunch.
|
||||
|
||||
Full provider & routing reference at [omp.sh/docs/providers](https://omp.sh/docs/providers).
|
||||
@@ -341,11 +342,11 @@ Vuln lookups answer with vendor data, not blog summaries.
|
||||
|
||||
[`web_search` reference ↗](https://omp.sh/docs/tools#web_search)
|
||||
|
||||
## Roughly **~27,000** lines of Rust, doing the work other harnesses shell out for.
|
||||
## Roughly **~55,000** lines of Rust, doing the work other harnesses shell out for.
|
||||
|
||||
Three crates, one platform-tagged N-API addon. Search, shell, AST, highlight, PTY, image decode, BPE counting — all in-process on the libuv pool. No fork/exec on the hot path.
|
||||
Four crates, one platform-tagged N-API addon. Search, shell, AST, highlight, PTY, image decode, BPE counting — all in-process on the libuv pool. No fork/exec on the hot path.
|
||||
|
||||
- Crates: `pi-natives`, `pi-shell`, `pi-ast`
|
||||
- Crates: `pi-natives`, `pi-shell`, `pi-ast`, `pi-iso`
|
||||
- Platforms: `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`
|
||||
|
||||
The table below is a per-module breakdown that intentionally omits glue and tests.
|
||||
@@ -356,13 +357,13 @@ The table below is a per-module breakdown that intentionally omits glue and test
|
||||
| grep | Regex search · parallel/sequential · glob & type filters · fuzzy find | grep-regex · grep-searcher | 1,900 |
|
||||
| keys | Kitty keyboard protocol with xterm fallback · PHF perfect-hash lookup | phf | 1,490 |
|
||||
| text | ANSI-aware width · truncation · column slicing · SGR-preserving wrap | unicode-width · segmentation | 1,450 |
|
||||
| summarize | Tree-sitter structural source summaries with elision controls | tree-sitter · ast-grep-core | 1,040 |
|
||||
| summary | Tree-sitter structural source summaries with elision controls | tree-sitter · ast-grep-core | 1,040 |
|
||||
| ast | ast-grep pattern matching and structural rewrites | ast-grep-core | 1,000 |
|
||||
| fs_cache | Mtime-keyed file cache shared by read · grep · lsp | in-tree | 840 |
|
||||
| highlight | Syntax highlighting · 11 semantic categories · 30+ aliases | syntect | 470 |
|
||||
| pty | Native PTY allocation for sudo · ssh interactive prompts | portable-pty | 455 |
|
||||
| glob | Discovery with glob · type filters · mtime sort · gitignore respect | ignore · globset | 410 |
|
||||
| workspace | Workspace walker with gitignore + AGENTS.md discovery in one pass | ignore · git2 | 385 |
|
||||
| workspace | Workspace walker with gitignore + AGENTS.md discovery in one pass | ignore | 385 |
|
||||
| appearance | Mode 2031 + native macOS dark/light via CoreFoundation FFI | core-foundation | 270 |
|
||||
| power | macOS power-assertion API for idle/system/display-sleep prevention | IOKit FFI | 270 |
|
||||
| task | Blocking work on libuv thread pool · cancellation · timeout · profiling | tokio · napi | 260 |
|
||||
@@ -370,9 +371,9 @@ The table below is a per-module breakdown that intentionally omits glue and test
|
||||
| iso | Workspace isolation shim · apfs · btrfs · zfs · reflink · overlayfs · projfs · rcopy | pi-iso (PAL) | 245 |
|
||||
| prof | Circular buffer profiler with folded-stack and SVG flamegraph output | inferno | 240 |
|
||||
| ps | Cross-platform process-tree kill and descendant listing | libc · libproc · CreateToolhelp32Snapshot | 195 |
|
||||
| image | Decode/encode PNG · JPEG · WebP · GIF · resize with 5 filters | image | 190 |
|
||||
| clipboard | Text copy and image read from system clipboard · no xclip/pbcopy | arboard | 80 |
|
||||
| tokens | O200k / Cl100k BPE token counting · both tables embedded | tiktoken-rs | 65 |
|
||||
| sixel | Terminal image rendering · decode PNG · JPEG · WebP · GIF · resize · SIXEL encode | icy_sixel · image | 55 |
|
||||
| html | HTML to Markdown with optional content cleaning | html-to-markdown-rs | 50 |
|
||||
|
||||
## Four entry points: _interactive_, _one-shot_, RPC, and ACP.
|
||||
@@ -394,16 +395,21 @@ The same prompt cards surface over ACP, so editors get the picker without writin
|
||||
Node and TypeScript hosts pull the engine in directly. The package exposes `ModelRegistry`, `SessionManager`, `createAgentSession`, and `discoverAuthStorage`; the session emits typed events you subscribe to.
|
||||
|
||||
```ts
|
||||
import { ModelRegistry, SessionManager, createAgentSession, discoverAuthStorage } from "@oh-my-pi/pi-coding-agent";
|
||||
import {
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
createAgentSession,
|
||||
discoverAuthStorage,
|
||||
} from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
const auth = await discoverAuthStorage();
|
||||
const models = new ModelRegistry(auth);
|
||||
await models.refresh();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
authStorage: auth,
|
||||
modelRegistry: models,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
authStorage: auth,
|
||||
modelRegistry: models,
|
||||
});
|
||||
await session.prompt("list .ts files");
|
||||
```
|
||||
@@ -433,7 +439,7 @@ The [Agent Client Protocol](https://github.com/zed-industries/agent-client-proto
|
||||
| `bash` | `terminal/create + terminal/output` |
|
||||
| `read` | `fs/read_text_file` |
|
||||
| `write` | `fs/write_text_file` |
|
||||
| `edit, ast_edit, write, bash` | `session/request_permission` |
|
||||
| `edit, bash` | `session/request_permission` |
|
||||
|
||||
Full reference: [omp.sh/docs/sdk](https://omp.sh/docs/sdk).
|
||||
|
||||
@@ -481,6 +487,7 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
|
||||
|
||||
| Package | Description |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **[@oh-my-pi/collab-web](packages/collab-web)** | Browser guest client, mock host, and local relay for collab live sessions |
|
||||
| **[@oh-my-pi/pi-ai](packages/ai)** | Multi-provider LLM client with streaming and model/provider integration |
|
||||
| **[@oh-my-pi/pi-catalog](packages/catalog)** | Model catalog: bundled model database, provider descriptors, and identity |
|
||||
| **[@oh-my-pi/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
@@ -489,6 +496,7 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
|
||||
| **[@oh-my-pi/pi-natives](packages/natives)** | N-API bindings for grep, shell, image, text, syntax highlighting, and more |
|
||||
| **[@oh-my-pi/omp-stats](packages/stats)** | Local observability dashboard for AI usage statistics |
|
||||
| **[@oh-my-pi/pi-utils](packages/utils)** | Shared utilities (logging, streams, dirs/env/process helpers) |
|
||||
| **[@oh-my-pi/pi-wire](packages/wire)** | Shared collab live-session protocol types and relay constants |
|
||||
| **[@oh-my-pi/hashline](packages/hashline)** | Line-anchored patch language and applier behind the `edit` tool |
|
||||
| **[@oh-my-pi/pi-mnemopi](packages/mnemopi)** | Local SQLite memory engine for Oh My Pi agents |
|
||||
| **[@oh-my-pi/snapcompact](packages/snapcompact)** | Bitmap-frame context compression package and SQuAD eval suite |
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"!**/vendor/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!**/test-sessions.ts",
|
||||
"!**/template.generated.ts",
|
||||
"!**/docs-index.generated.ts",
|
||||
"!**/agent_pb.ts",
|
||||
"!.worktrees/**/*",
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "omp-monorepo",
|
||||
"dependencies": {
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"sherpa-onnx-darwin-arm64": "1.12.37",
|
||||
"sherpa-onnx-node": "1.12.37",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -15,7 +20,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -32,12 +37,11 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"openai": "catalog:",
|
||||
"partial-json": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -47,7 +51,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -60,7 +64,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -77,6 +81,7 @@
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-tui": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"@oh-my-pi/snapcompact": "catalog:",
|
||||
"@opentelemetry/api": "catalog:",
|
||||
"@opentelemetry/context-async-hooks": "catalog:",
|
||||
@@ -104,11 +109,28 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@huggingface/transformers": "catalog:",
|
||||
"sherpa-onnx-node": "1.13.2",
|
||||
},
|
||||
},
|
||||
"packages/collab-web": {
|
||||
"name": "@oh-my-pi/collab-web",
|
||||
"version": "15.11.7",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -119,7 +141,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -127,17 +149,25 @@
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
"onnxruntime-node": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"onnxruntime-node": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastembed": "2.1.0",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"fastembed",
|
||||
"onnxruntime-node",
|
||||
],
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -145,7 +175,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -157,7 +187,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -183,7 +213,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -199,7 +229,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -240,7 +270,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.11.2",
|
||||
"version": "15.12.6",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"beautiful-mermaid": "catalog:",
|
||||
@@ -252,6 +282,13 @@
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "15.12.6",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
},
|
||||
"python/robomp/web": {
|
||||
"name": "robomp-web",
|
||||
"version": "0.1.0",
|
||||
@@ -268,8 +305,11 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"beautiful-mermaid@1.1.3": "patches/beautiful-mermaid@1.1.3.patch",
|
||||
},
|
||||
"catalog": {
|
||||
"@agentclientprotocol/sdk": "0.22.1",
|
||||
"@agentclientprotocol/sdk": "0.25.0",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
@@ -280,17 +320,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.11.2",
|
||||
"@oh-my-pi/omp-stats": "15.11.2",
|
||||
"@oh-my-pi/pi-agent-core": "15.11.2",
|
||||
"@oh-my-pi/pi-ai": "15.11.2",
|
||||
"@oh-my-pi/pi-catalog": "15.11.2",
|
||||
"@oh-my-pi/pi-coding-agent": "15.11.2",
|
||||
"@oh-my-pi/pi-mnemopi": "15.11.2",
|
||||
"@oh-my-pi/pi-natives": "15.11.2",
|
||||
"@oh-my-pi/pi-tui": "15.11.2",
|
||||
"@oh-my-pi/pi-utils": "15.11.2",
|
||||
"@oh-my-pi/snapcompact": "15.11.2",
|
||||
"@oh-my-pi/hashline": "15.12.6",
|
||||
"@oh-my-pi/omp-stats": "15.12.6",
|
||||
"@oh-my-pi/pi-agent-core": "15.12.6",
|
||||
"@oh-my-pi/pi-ai": "15.12.6",
|
||||
"@oh-my-pi/pi-catalog": "15.12.6",
|
||||
"@oh-my-pi/pi-coding-agent": "15.12.6",
|
||||
"@oh-my-pi/pi-mnemopi": "15.12.6",
|
||||
"@oh-my-pi/pi-natives": "15.12.6",
|
||||
"@oh-my-pi/pi-tui": "15.12.6",
|
||||
"@oh-my-pi/pi-utils": "15.12.6",
|
||||
"@oh-my-pi/pi-wire": "15.12.6",
|
||||
"@oh-my-pi/snapcompact": "15.12.6",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -303,49 +344,48 @@
|
||||
"@types/babel__generator": "^7.27.0",
|
||||
"@types/babel__traverse": "^7.28.0",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "5.0.6",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260609.1",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"chalk": "^5.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"diff": "^9.0.0",
|
||||
"fastembed": "2.1.0",
|
||||
"fflate": "0.8.3",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"linkedom": "^0.18.12",
|
||||
"lint-staged": "^17.0.5",
|
||||
"lint-staged": "^17.0.7",
|
||||
"lru-cache": "11.5.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"marked": "^18.0.5",
|
||||
"markit-ai": "0.5.3",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"openai": "^6.39.0",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.8.4",
|
||||
"puppeteer-core": "^25.1.0",
|
||||
"react": "19.2.6",
|
||||
"react": "19.2.7",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.6",
|
||||
"react-dom": "19.2.7",
|
||||
"regexp-tree": "^0.1.27",
|
||||
"solid-js": "^1.9.13",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"turndown": "7.2.4",
|
||||
"turndown-plugin-gfm": "1.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-solid": "^2.11.12",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"zod": "4.4.3",
|
||||
"zod": "^4",
|
||||
},
|
||||
"packages": {
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.22.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.25.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ=="],
|
||||
|
||||
"@anush008/tokenizers": ["@anush008/tokenizers@0.0.0", "", { "optionalDependencies": { "@anush008/tokenizers-darwin-universal": "0.0.0", "@anush008/tokenizers-linux-x64-gnu": "0.0.0", "@anush008/tokenizers-win32-x64-msvc": "0.0.0" } }, "sha512-IQD9wkVReKAhsEAbDjh/0KrBGTEXelqZLpOBRDaIRvlzZ9sjmUP+gKbpvzyJnei2JHQiE8JAgj7YcNloINbGBw=="],
|
||||
|
||||
@@ -429,7 +469,7 @@
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
"@huggingface/tasks": ["@huggingface/tasks@0.21.8", "", {}, "sha512-+XNkgBks3dPVzSrnngHwsfB0BBjRZKmvmO6f45UDrvjygmZuzPTQN3+vbaMbxsAW2CHfEF6YQT3dAUvVNUGgug=="],
|
||||
"@huggingface/tasks": ["@huggingface/tasks@0.21.11", "", {}, "sha512-Nmgbrjil1sjLYRXTcClB6x2zy5HwwdVGjBHyYFyPZOT5beLxM/5m8Th/7tlh0IOsgkCALJETY6/G9fAslcS0Ug=="],
|
||||
|
||||
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
|
||||
|
||||
@@ -611,7 +651,7 @@
|
||||
|
||||
"@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
|
||||
"@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="],
|
||||
|
||||
@@ -667,6 +707,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@oh-my-pi/collab-web": ["@oh-my-pi/collab-web@workspace:packages/collab-web"],
|
||||
|
||||
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@workspace:packages/hashline"],
|
||||
|
||||
"@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"],
|
||||
@@ -687,6 +729,8 @@
|
||||
|
||||
"@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@workspace:packages/utils"],
|
||||
|
||||
"@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@workspace:packages/wire"],
|
||||
|
||||
"@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@workspace:packages/snapcompact"],
|
||||
|
||||
"@oh-my-pi/swarm-extension": ["@oh-my-pi/swarm-extension@workspace:packages/swarm-extension"],
|
||||
@@ -823,7 +867,7 @@
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
|
||||
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
@@ -833,21 +877,21 @@
|
||||
|
||||
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260527.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-j81qKiwCPgMEjtk8uDLP+TDW60l6mugoJ7SNzfHWv1PJ6bUjIAHuag4P1jSLm1IpKuMuB3TTi4f61n7TJi8Jog=="],
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260609.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260609.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260609.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260609.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260609.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260609.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260609.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260609.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-1HOuH/u/451O3hx4Z9fesNqarpeit6UfkgwK96sCVWi5p69F0N3v+6bI969lLIjF7K9dbYQNiWUaZ6Wik87iKg=="],
|
||||
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260527.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bDi6FJ644n3uKdp/ZI7j50ChVyGOsrJrkwihQb6x3yByFQkTINLu3e6ZkY+HveQ2Zw2vy9SGN8E7b3A5iSOO0A=="],
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260609.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yf/zHEadP/yUiWUdM/mZVfEVFJuGMf6nhRSFif0vp+FwtfGU4jmlpNF7BTJJdOHrrcWkwEJKzAoMCtEtyxhuyQ=="],
|
||||
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260527.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-r6GXrTdalXZu1/b5goMpAe+efZvOfwdE45gl8Tti3fckP9icK3xdiN+VnNi0RL2/c2L86RyN8nGxihaCHGCKbw=="],
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260609.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-z4dYWI57CPHs0wV/FWFth8fWmqYH7iOm7THOfZ5Fv0jo/SWK6kE1kEUIqIAExqo7ueRNqSrCw0I8U1J4TJszAw=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260527.1", "", { "os": "linux", "cpu": "arm" }, "sha512-BlfQBatMkZHi3o+atxoUW0czGJNjo9cpO1BoQeB3gxZ7D/cDZHYHmKFSSRx8UxMktwP5k5lPxi0wgA3Ic2mQyQ=="],
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260609.1", "", { "os": "linux", "cpu": "arm" }, "sha512-mEtN8BbAgVtBu/5MVomYquXNvgok2C0KG6V0D4SV1jfBJNtlcqbp0WuIqT0bnM9DA4TgzcHvnFMpwGSK/dqI5A=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260527.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QJAFyPJgJqJVLbVPHl5xL7FCn3HNPLdpEm8l7KBgiYpltLhU1p/LJ3iN0XpFRAhq9ojWbZebo8t/h8MX35QjTQ=="],
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260609.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OxNVWH9IhrMAzNlDyDit1dPO64GFIDPOUKoruIkJ9A1ZEONfIHXG5f+V3si9jtuNmuomiz9FjpbzOqLsgaxt+w=="],
|
||||
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260527.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UFB7ZdK2/vIIi62nfn3JhyGV7qR/qXjKPQaPVXwzCvaPieTZcsNsALjKU0W5WHThyi+5p3U7O3dGE7n6P4q4Yw=="],
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260609.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KO8WO1gBIC09T3255RlTY42TGu8en5mEkLPQu2wkMn+dX2T8KYL64zXrCeLeUWa0NvmVdJUeyWu3pFOn3zKemw=="],
|
||||
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260527.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-rp/q9+9H77JQvepC/UpDP8CdeTGSGyhp9BVbmFwqUV2NhMHPldfys3ihY7OQdoVBgWIKQyxEHB+FTr8Z7kre1Q=="],
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260609.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+8q19LWjnMKK6SF3PLeMEalbfWDYWHs0AU8kSFCBCke/RLoDG4FjQzVtLgUo+KWhsmZMosiEyqEnZmSlED2tIQ=="],
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260527.1", "", { "os": "win32", "cpu": "x64" }, "sha512-864Pq4qoDcacUJhs2/kQplyfwNO0APUmx1k8qUaJt2P9ZGF0Pu++afJi7OagImHMiEQcmigjmZPuOodOk5YmqQ=="],
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260609.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qNPcss+6yRoNFfFIKQbPwJWYxDfOZwyL8JBJh4J+yMLOad/+/AOjsO4EtZsIpv5PMCjpnD75coBoDkw+5NkItw=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
@@ -861,6 +905,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
@@ -1037,7 +1083,7 @@
|
||||
|
||||
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
"global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
@@ -1143,7 +1189,7 @@
|
||||
|
||||
"markit-ai": ["markit-ai@0.5.3", "", { "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "exifr": "^7.1.3", "fast-xml-parser": "^5.5.9", "jszip": "^3.10.1", "mammoth": "^1.9.0", "mupdf": "^1.27.0", "music-metadata": "^11.12.3", "rss-parser": "^3.13.0", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2" }, "bin": { "markit": "dist/main.js" } }, "sha512-h4nhn6a/SNXEdc3kLVtL37TspxjUNCNL0OM7LRWxd389ZByI/B7bjNNgxFdVAT0O+H7ZekSwLdVe/lws1l2AZQ=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
"matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="],
|
||||
|
||||
"media-typer": ["media-typer@2.0.0", "", {}, "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q=="],
|
||||
|
||||
@@ -1191,14 +1237,12 @@
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||
"onnxruntime-common": ["onnxruntime-common@1.26.0", "", {}, "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw=="],
|
||||
|
||||
"onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="],
|
||||
"onnxruntime-node": ["onnxruntime-node@1.26.0", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^4.1.3", "onnxruntime-common": "1.26.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ=="],
|
||||
|
||||
"onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="],
|
||||
|
||||
"openai": ["openai@6.42.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg=="],
|
||||
|
||||
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
@@ -1219,21 +1263,21 @@
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
"prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
|
||||
"protobufjs": ["protobufjs@7.6.3", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@25.1.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.4", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1624250", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
@@ -1263,11 +1307,11 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
|
||||
"semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
"serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="],
|
||||
|
||||
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
|
||||
|
||||
@@ -1277,6 +1321,22 @@
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"sherpa-onnx": ["sherpa-onnx@1.12.37", "", {}, "sha512-3luwSdHwR8BtJiiFwqHfb15FE2FX0KsN4aOBbfq9Ma23r3w9C3bprFc/WBusXk56nUbzcEN5YczN7t9w1JwdtQ=="],
|
||||
|
||||
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.12.37", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zpqbH+2TI6dvg7mxGm30Mnv17aJL3ZfRGshMiBK85dHBfhzqMbKUHNXCzsHhiKBQTzti6JqG1YoRbYrJwvdUjA=="],
|
||||
|
||||
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VyiTiaU/QmBKh5ymEVefc86kMONyJxHaIKpAq0Cf60v/PGEfyfiFaWGGEJyysf8R8PXECB2YLv2wtKAomfcfcw=="],
|
||||
|
||||
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-IJNyd6ORcMpy1oR2ZXGNidRiPuIh5KWoOYSIOhW9UQ/BUIY43P6fq8Y3XcFj1xNjBtwke7keMW9DA7ZPYxlYoQ=="],
|
||||
|
||||
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CI2pTKgbOTOpAbm6cSwsFzJZ9qD+xcpEKPfmMCMI7KjaEePnTfky+FYxVBvllbYNk9DQznuTT0Ob9XsBhwtE/Q=="],
|
||||
|
||||
"sherpa-onnx-node": ["sherpa-onnx-node@1.12.37", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.37", "sherpa-onnx-darwin-x64": "^1.12.37", "sherpa-onnx-linux-arm64": "^1.12.37", "sherpa-onnx-linux-x64": "^1.12.37", "sherpa-onnx-win-ia32": "^1.12.37", "sherpa-onnx-win-x64": "^1.12.37" } }, "sha512-SpblPUl/ODliBk4WzKLRa0VPyc30I3HU9U/qRUmhya7eTNLkJE8sQOmj2kvRL8k2cWrHES2pyvRwwq1jT/MPpw=="],
|
||||
|
||||
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-PJxFuZB6VcwxscP9whLdxBMhHWZ88Ax35LeKpAZWXIvFjIviX1yPJB1+ndhXvF9Nh1L8gPgO9MUBfC1BMKqzvw=="],
|
||||
|
||||
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.2", "", { "os": "win32", "cpu": "x64" }, "sha512-D11eEIW4LZLK6Q+yPGmpJ+45gV8EqiwcPNZiE/RoveBsasZCBtl3YXXqvH2APyHafhDk+L8pD/dDR7HdH3GZJw=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
@@ -1301,7 +1361,7 @@
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
"strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
@@ -1329,7 +1389,7 @@
|
||||
|
||||
"typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="],
|
||||
|
||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
@@ -1397,15 +1457,19 @@
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="],
|
||||
|
||||
"@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"@oh-my-pi/pi-coding-agent/sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
@@ -1455,10 +1519,18 @@
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||
|
||||
"@oh-my-pi/pi-coding-agent/sherpa-onnx-node/sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sakY1+WH/Va/vhzwlhIKXaMr0ioKsJ55w785QH+VDFyUqq1+2InbmB3BgX5gyYKeuW40R0U0IW5c7wnMArhpdw=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"fastembed/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"fastembed/onnxruntime-node/onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||
|
||||
"fastembed/onnxruntime-node/tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
|
||||
@@ -1469,8 +1541,16 @@
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/global-agent/serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"fastembed/onnxruntime-node/global-agent/matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"fastembed/onnxruntime-node/global-agent/serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"fastembed/onnxruntime-node/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"fastembed/onnxruntime-node/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
@@ -1481,6 +1561,10 @@
|
||||
|
||||
"log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"fastembed/onnxruntime-node/global-agent/serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ saveTextLockfile = true
|
||||
# scratch dirs so a root-level `bun test` doesn't walk into them.
|
||||
pathIgnorePatterns = [
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/target/**",
|
||||
"**/dist/**",
|
||||
"python/**",
|
||||
"docs/**",
|
||||
"runs/**",
|
||||
"python/robomp/data/**",
|
||||
".wt/**",
|
||||
".worktrees/**",
|
||||
|
||||
@@ -164,6 +164,10 @@ pub struct Registration<SE: extensions::ShellExtensions> {
|
||||
|
||||
/// Is this builtin one that takes specially handled declarations?
|
||||
pub declaration_builtin: bool,
|
||||
|
||||
/// Can async background job spawning skip this builtin and launch its first
|
||||
/// word argument directly?
|
||||
pub transparent_background_wrapper: bool,
|
||||
}
|
||||
|
||||
impl<SE: extensions::ShellExtensions> Registration<SE> {
|
||||
@@ -172,6 +176,14 @@ impl<SE: extensions::ShellExtensions> Registration<SE> {
|
||||
pub const fn special(self) -> Self {
|
||||
Self { special_builtin: true, ..self }
|
||||
}
|
||||
|
||||
/// Marks this builtin as a transparent wrapper when used as an asynchronous
|
||||
/// background command. The first word argument becomes the launched command,
|
||||
/// and the wrapper itself is skipped so `$!` can refer to the real child PID.
|
||||
#[must_use]
|
||||
pub const fn transparent_background_wrapper(self) -> Self {
|
||||
Self { transparent_background_wrapper: true, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
fn get_builtin_man_page(name: &str, command: &clap::Command) -> Result<String, error::Error> {
|
||||
@@ -510,6 +522,7 @@ pub fn simple_builtin<B: SimpleCommand + Send + Sync, SE: extensions::ShellExten
|
||||
disabled: false,
|
||||
special_builtin: false,
|
||||
declaration_builtin: false,
|
||||
transparent_background_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,6 +535,7 @@ pub fn builtin<B: Command + Send + Sync, SE: extensions::ShellExtensions>() -> R
|
||||
disabled: false,
|
||||
special_builtin: false,
|
||||
declaration_builtin: false,
|
||||
transparent_background_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +550,7 @@ pub fn decl_builtin<B: DeclarationCommand + Send + Sync, SE: extensions::ShellEx
|
||||
disabled: false,
|
||||
special_builtin: false,
|
||||
declaration_builtin: true,
|
||||
transparent_background_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +571,7 @@ pub fn raw_arg_builtin<
|
||||
disabled: false,
|
||||
special_builtin: false,
|
||||
declaration_builtin: true,
|
||||
transparent_background_wrapper: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -380,10 +380,10 @@ async fn spawn_async_ao_list_as_job<'a, SE: extensions::ShellExtensions>(
|
||||
async_params.set_fd(openfiles::OpenFiles::STDIN_FD, null);
|
||||
}
|
||||
|
||||
let job = if should_try_spawn_pipeline_as_job(ao_list, shell, &async_params).await? {
|
||||
match try_spawn_pipeline_as_job(&ao_list.first, ao_list.to_string(), shell, &async_params)
|
||||
.await?
|
||||
{
|
||||
let direct_pipeline =
|
||||
background_process_pipeline_for_async_job(ao_list, shell, &async_params).await?;
|
||||
let job = if let Some(pipeline) = direct_pipeline {
|
||||
match try_spawn_pipeline_as_job(&pipeline, ao_list.to_string(), shell, &async_params).await? {
|
||||
Some(job) => job,
|
||||
None => spawn_async_ao_list_in_task(ao_list, shell, &async_params),
|
||||
}
|
||||
@@ -394,47 +394,98 @@ async fn spawn_async_ao_list_as_job<'a, SE: extensions::ShellExtensions>(
|
||||
Ok(shell.jobs_mut().add_as_current(job))
|
||||
}
|
||||
|
||||
async fn should_try_spawn_pipeline_as_job<SE: extensions::ShellExtensions>(
|
||||
enum BackgroundProcessPipeline {
|
||||
Direct,
|
||||
Wrapper(ast::Pipeline),
|
||||
Internal,
|
||||
}
|
||||
|
||||
async fn background_process_pipeline_for_async_job<SE: extensions::ShellExtensions>(
|
||||
ao_list: &ast::AndOrList,
|
||||
shell: &mut Shell<SE>,
|
||||
params: &ExecutionParameters,
|
||||
) -> Result<bool, error::Error> {
|
||||
) -> Result<Option<ast::Pipeline>, error::Error> {
|
||||
if !ao_list.additional.is_empty() {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pipeline = &ao_list.first;
|
||||
let mut pipeline = ao_list.first.clone();
|
||||
for _ in 0..8 {
|
||||
match classify_background_process_pipeline(&pipeline, shell, params).await? {
|
||||
BackgroundProcessPipeline::Direct => return Ok(Some(pipeline)),
|
||||
BackgroundProcessPipeline::Wrapper(unwrapped) => pipeline = unwrapped,
|
||||
BackgroundProcessPipeline::Internal => return Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn classify_background_process_pipeline<SE: extensions::ShellExtensions>(
|
||||
pipeline: &ast::Pipeline,
|
||||
shell: &mut Shell<SE>,
|
||||
params: &ExecutionParameters,
|
||||
) -> Result<BackgroundProcessPipeline, error::Error> {
|
||||
if pipeline.bang {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
}
|
||||
|
||||
let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
};
|
||||
let Some(command_word) = simple_cmd.word_or_name.as_ref() else {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
};
|
||||
|
||||
let expanded = expansion::full_expand_and_split_word(shell, params, command_word).await?;
|
||||
let [command_name] = expanded.as_slice() else {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
};
|
||||
|
||||
if shell.aliases().contains_key(command_name) {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
}
|
||||
if shell
|
||||
.builtins()
|
||||
.get(command_name.as_str())
|
||||
.is_some_and(|registration| !registration.disabled)
|
||||
if let Some(registration) = shell.builtins().get(command_name.as_str())
|
||||
&& !registration.disabled
|
||||
{
|
||||
return Ok(false);
|
||||
return if registration.transparent_background_wrapper {
|
||||
Ok(unwrap_transparent_background_wrapper(pipeline)
|
||||
.map_or(BackgroundProcessPipeline::Internal, BackgroundProcessPipeline::Wrapper))
|
||||
} else {
|
||||
Ok(BackgroundProcessPipeline::Internal)
|
||||
};
|
||||
}
|
||||
if shell.funcs().get(command_name.as_str()).is_some() {
|
||||
return Ok(false);
|
||||
return Ok(BackgroundProcessPipeline::Internal);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(BackgroundProcessPipeline::Direct)
|
||||
}
|
||||
|
||||
fn unwrap_transparent_background_wrapper(pipeline: &ast::Pipeline) -> Option<ast::Pipeline> {
|
||||
let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let mut unwrapped = simple_cmd.clone();
|
||||
let suffix = unwrapped.suffix.as_mut()?;
|
||||
let operand_index = suffix
|
||||
.0
|
||||
.iter()
|
||||
.position(|item| matches!(item, CommandPrefixOrSuffixItem::Word(_)))?;
|
||||
let CommandPrefixOrSuffixItem::Word(operand_word) = suffix.0.remove(operand_index) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
unwrapped.word_or_name = Some(operand_word);
|
||||
if suffix.0.is_empty() {
|
||||
unwrapped.suffix = None;
|
||||
}
|
||||
|
||||
Some(ast::Pipeline {
|
||||
timed: pipeline.timed.clone(),
|
||||
bang: pipeline.bang,
|
||||
seq: vec![ast::Command::Simple(unwrapped)],
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_spawn_pipeline_as_job<SE: extensions::ShellExtensions>(
|
||||
|
||||
@@ -77,8 +77,20 @@ pub fn block_range_at(options: BlockRangeOptions) -> Result<Option<BlockRange>>
|
||||
};
|
||||
let root = tree.root_node();
|
||||
|
||||
// Query a one-column-wide range over the first content character rather
|
||||
// than a zero-width point. Some grammars (e.g. tree-sitter-swift) insert a
|
||||
// zero-width separator node at the start of a statement that follows a
|
||||
// blank line. An empty point range at that node's start gets absorbed into
|
||||
// the invisible node, which has no children and is not "relevant", so
|
||||
// `named_descendant_for_point_range` bubbles back up to the last visible
|
||||
// ancestor (the enclosing body, or the file root). That made `replace
|
||||
// block` on a line like `var body: some View {` preceded by a blank line
|
||||
// resolve to the whole enclosing type body and then fail. Spanning the
|
||||
// first character skips the zero-width node (its end is < the range end)
|
||||
// and forces the descent into the node that begins on `row`.
|
||||
let point = Point::new(row, col);
|
||||
let Some(leaf) = root.named_descendant_for_point_range(point, point) else {
|
||||
let point_end = Point::new(row, col + 1);
|
||||
let Some(leaf) = root.named_descendant_for_point_range(point, point_end) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A leaf whose own start row is earlier than `row` means `point` landed on
|
||||
@@ -298,6 +310,20 @@ mod tests {
|
||||
assert_eq!(resolve(TS_EXAMPLE, "x.unknownext", 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_zsh_if_block_in_extensionless_rc_file() {
|
||||
// Regression: an extensionless shell rc file (`zshrc`/`.zshrc`) must
|
||||
// infer the bash grammar so `replace block` / `insert after block`
|
||||
// works. Previously `Path::extension` returned `None`, leaving block
|
||||
// ops permanently unresolvable on these files.
|
||||
let code = "ZSH_COMPDUMP=x\nif [[ -f \"$ZSH_COMPDUMP\" ]]; then\n compinit -C\nelse\n \
|
||||
compinit\nfi\n";
|
||||
let span = Some(BlockRange { start_line: 2, end_line: 6 });
|
||||
assert_eq!(resolve(code, "modules/zsh/zshrc", 2), span);
|
||||
assert_eq!(resolve(code, ".zshrc", 2), span);
|
||||
assert_eq!(resolve(code, "/home/u/.bashrc", 2), span);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_top_level_python_def() {
|
||||
let code = "x = 1\ndef greet():\n return 1\n";
|
||||
@@ -358,6 +384,34 @@ mod tests {
|
||||
assert_eq!(resolve(code, "r.rs", 2), Some(BlockRange { start_line: 2, end_line: 4 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_swift_computed_property_after_blank_line() {
|
||||
// Regression: a block whose opening line is preceded by a blank line
|
||||
// (here the SwiftUI `var body: some View {` computed property) used to
|
||||
// resolve to nothing. tree-sitter-swift inserts a zero-width separator
|
||||
// node at the start of a statement that follows a blank line; a
|
||||
// zero-width point query at the first content column gets absorbed into
|
||||
// that invisible node and bubbles back up to the enclosing type body. A
|
||||
// one-column-wide query skips the zero-width node and descends into the
|
||||
// property that actually begins on the line.
|
||||
let code = "struct MenuBarUsage: View {\n let metric: AccountMetric\n\n var body: \
|
||||
some View {\n VStack {\n Text(\"Usage\")\n }\n \
|
||||
}\n}\n";
|
||||
assert_eq!(
|
||||
resolve(code, "MenuBarUsage.swift", 4),
|
||||
Some(BlockRange { start_line: 4, end_line: 8 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_swift_top_level_decl_after_blank_line() {
|
||||
// Same zero-width-separator regression one level up: a top-level
|
||||
// declaration following a blank line. Without the fix the query
|
||||
// resolved to the whole `source_file` root and was rejected.
|
||||
let code = "import Foundation\n\nfunc greet() {\n print(\"hi\")\n}\n";
|
||||
assert_eq!(resolve(code, "g.swift", 3), Some(BlockRange { start_line: 3, end_line: 5 }));
|
||||
}
|
||||
|
||||
fn boundaries(code: &str, path: &str, ranges: &[(u32, u32)]) -> Option<Vec<u32>> {
|
||||
enclosing_block_boundaries(EnclosingBoundaryOptions {
|
||||
code: code.to_string(),
|
||||
|
||||
@@ -627,6 +627,31 @@ fn from_extension(path: &Path) -> Option<SupportLang> {
|
||||
return Some(SupportLang::Dockerfile);
|
||||
}
|
||||
|
||||
// Extensionless shell rc/profile files. `Path::extension` returns `None`
|
||||
// for both bare (`zshrc`) and dotfile (`.zshrc`) forms, so they would
|
||||
// otherwise resolve to no language and disable block-aware ops on them.
|
||||
let stem = name.strip_prefix('.').unwrap_or(name);
|
||||
if matches!(
|
||||
stem,
|
||||
"zshrc"
|
||||
| "zshenv"
|
||||
| "zprofile"
|
||||
| "zlogin"
|
||||
| "zlogout"
|
||||
| "zsh_aliases"
|
||||
| "bashrc"
|
||||
| "bash_profile"
|
||||
| "bash_login"
|
||||
| "bash_logout"
|
||||
| "bash_aliases"
|
||||
| "profile"
|
||||
| "kshrc"
|
||||
| "mkshrc"
|
||||
| "shrc"
|
||||
) {
|
||||
return Some(SupportLang::Bash);
|
||||
}
|
||||
|
||||
let ext = path.extension()?.to_str()?;
|
||||
SupportLang::all_langs()
|
||||
.iter()
|
||||
|
||||
+17
-12
@@ -553,7 +553,16 @@ mod imp {
|
||||
};
|
||||
|
||||
if matched {
|
||||
let extended_info = symlink_extended_info(entry.symlink_target.as_deref());
|
||||
let extended_info = entry
|
||||
.symlink_target
|
||||
.as_deref()
|
||||
.map(|target| PRJ_EXTENDED_INFO {
|
||||
InfoType: PRJ_EXT_INFO_TYPE_SYMLINK,
|
||||
NextInfoOffset: 0,
|
||||
Anonymous: PRJ_EXTENDED_INFO_0 {
|
||||
Symlink: PRJ_EXTENDED_INFO_0_0 { TargetName: target.as_ptr() },
|
||||
},
|
||||
});
|
||||
let extended_info_ptr = extended_info
|
||||
.as_ref()
|
||||
.map_or(std::ptr::null(), |info| info as *const _);
|
||||
@@ -599,7 +608,13 @@ mod imp {
|
||||
Ok(target) => target,
|
||||
Err(err) => return io_error_to_hresult(&err),
|
||||
};
|
||||
let extended_info = symlink_extended_info(symlink_target.as_deref());
|
||||
let extended_info = symlink_target.as_deref().map(|target| PRJ_EXTENDED_INFO {
|
||||
InfoType: PRJ_EXT_INFO_TYPE_SYMLINK,
|
||||
NextInfoOffset: 0,
|
||||
Anonymous: PRJ_EXTENDED_INFO_0 {
|
||||
Symlink: PRJ_EXTENDED_INFO_0_0 { TargetName: target.as_ptr() },
|
||||
},
|
||||
});
|
||||
let extended_info_ptr = extended_info
|
||||
.as_ref()
|
||||
.map_or(std::ptr::null(), |info| info as *const _);
|
||||
@@ -782,16 +797,6 @@ mod imp {
|
||||
Ok(Some(target))
|
||||
}
|
||||
|
||||
fn symlink_extended_info(target: Option<&[u16]>) -> Option<PRJ_EXTENDED_INFO> {
|
||||
target.map(|target| PRJ_EXTENDED_INFO {
|
||||
InfoType: PRJ_EXT_INFO_TYPE_SYMLINK,
|
||||
NextInfoOffset: 0,
|
||||
Anonymous: PRJ_EXTENDED_INFO_0 {
|
||||
Symlink: PRJ_EXTENDED_INFO_0_0 { TargetName: target.as_ptr() },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn callback_relative_path(callback_data: &PRJ_CALLBACK_DATA) -> PathBuf {
|
||||
if callback_data.FilePathName.is_null() {
|
||||
return PathBuf::new();
|
||||
|
||||
@@ -111,9 +111,7 @@ mod imp {
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
|
||||
};
|
||||
let meta = fs::metadata(&resolved).map_err(|err| {
|
||||
IsoError::other(format!("invalid block-clone source {}: {err}", resolved.display()))
|
||||
@@ -164,6 +162,13 @@ mod imp {
|
||||
}
|
||||
let mut permissions = meta.permissions();
|
||||
if permissions.readonly() {
|
||||
// This backend only removes a temporary Windows block-clone tree; clearing
|
||||
// the readonly file attribute is required so removal can proceed.
|
||||
#[allow(
|
||||
clippy::permissions_set_readonly_false,
|
||||
reason = "Windows block-clone cleanup must clear the readonly file attribute before \
|
||||
deletion"
|
||||
)]
|
||||
permissions.set_readonly(false);
|
||||
let _ = fs::set_permissions(path, permissions);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -53,8 +53,109 @@ pub mod tokens;
|
||||
pub(crate) mod utils;
|
||||
pub mod workspace;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use napi::bindgen_prelude::create_custom_tokio_runtime;
|
||||
use napi_derive::{module_init, napi};
|
||||
|
||||
/// Upper bound on Windows Tokio *scheduler* workers. These only drive async I/O
|
||||
/// futures (shell/process/PTY/ISO) and light glue tasks; all CPU-heavy and
|
||||
/// blocking native work runs elsewhere — libuv tasks (`task::blocking`), Rayon,
|
||||
/// or Tokio's separate blocking pool via `spawn_blocking` — so a handful of
|
||||
/// async workers is plenty regardless of core count.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NAPI_TOKIO_MAX_WORKER_THREADS: usize = 4;
|
||||
/// Cap on Tokio's lazily-grown blocking pool (used by `spawn_blocking` offloads
|
||||
/// such as `iso_start`/`iso_stop`/`pty.start`/`walk_diff`). Threads here are
|
||||
/// created on demand, not at load, so this only bounds peak fan-out.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NAPI_TOKIO_MAX_BLOCKING_THREADS: usize = 8;
|
||||
|
||||
/// Windows worker count we'd *like*, before checking what the OS will actually
|
||||
/// grant: the Tokio default (one per core) clamped to
|
||||
/// [`NAPI_TOKIO_MAX_WORKER_THREADS`].
|
||||
#[cfg(target_os = "windows")]
|
||||
fn desired_worker_threads() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map_or(1, |threads| threads.get())
|
||||
.clamp(1, NAPI_TOKIO_MAX_WORKER_THREADS)
|
||||
}
|
||||
|
||||
/// Probe how many worker threads Windows will let us hold alive
|
||||
/// *simultaneously*, up to `target`. Returns the count actually spawned (0 when
|
||||
/// not even one extra thread is possible).
|
||||
///
|
||||
/// `Builder::build()` for a multi-thread runtime spawns every worker eagerly
|
||||
/// and **panics** (not `Err`) when Windows refuses one — on a
|
||||
/// memory-constrained host (tiny pagefile / commit limit, `os error 1455`) that
|
||||
/// aborts the whole process at addon load before any JS error can surface. The
|
||||
/// release profile is `panic = "abort"`, so the panic can't even be caught. We
|
||||
/// instead pre-flight with `std::thread::Builder::spawn`, which returns an
|
||||
/// `io::Result`, holding each probe thread alive (so their stacks are committed
|
||||
/// concurrently, matching how real workers coexist) until we know the safe
|
||||
/// count. Probe threads use the std default stack, exactly like Tokio's workers
|
||||
/// (it leaves `thread_stack_size` unset), so the probe is representative.
|
||||
///
|
||||
/// Keep this Windows-only. On Linux, spawning probe threads from `module_init`
|
||||
/// can deadlock while Bun is loading the `.node`; napi-rs's default runtime
|
||||
/// loads cleanly there and avoids any custom loader-time thread probe.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn probe_spawnable_workers(target: usize) -> usize {
|
||||
let keep_running = Arc::new(AtomicBool::new(true));
|
||||
let mut handles = Vec::with_capacity(target);
|
||||
for _ in 0..target {
|
||||
let keep = Arc::clone(&keep_running);
|
||||
match std::thread::Builder::new().spawn(move || {
|
||||
while keep.load(Ordering::Relaxed) {
|
||||
std::thread::park_timeout(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}) {
|
||||
Ok(handle) => handles.push(handle),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let spawned = handles.len();
|
||||
keep_running.store(false, Ordering::Relaxed);
|
||||
for handle in handles {
|
||||
handle.thread().unpark();
|
||||
let _ = handle.join();
|
||||
}
|
||||
spawned
|
||||
}
|
||||
|
||||
/// Build the custom Tokio runtime napi-rs uses on Windows, sized to what the
|
||||
/// host can actually spawn. Never panics: backs off from
|
||||
/// [`desired_worker_threads`] to whatever the probe allows, and falls back to a
|
||||
/// current-thread runtime (which spawns no workers at build time, so it can't
|
||||
/// abort under commit-limit pressure) when not even one worker is available.
|
||||
/// Returns `None` only if even that fails, in which case we leave napi-rs to
|
||||
/// construct its own default.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
let workers = probe_spawnable_workers(desired_worker_threads());
|
||||
let multi_thread = (workers > 0)
|
||||
.then(|| {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(workers)
|
||||
.max_blocking_threads(NAPI_TOKIO_MAX_BLOCKING_THREADS)
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
multi_thread.or_else(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Version sentinel — exists solely so the JS loader can prove at load time
|
||||
/// that the `.node` file on disk is from the same package release as the
|
||||
/// `index.js` ESM wrapper invoking it.
|
||||
@@ -71,12 +172,54 @@ use napi_derive::{module_init, napi};
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV15_11_2")]
|
||||
#[napi(js_name = "__piNativesV15_12_6")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
/// invoke a panicking or allocating native call. Runs once at `.node` load.
|
||||
/// invoke a panicking or allocating native call. This runs during `.node`
|
||||
/// load, while the dynamic-loader lock is held, so it MUST NOT spawn threads —
|
||||
/// the Tokio runtime is installed afterwards on Windows by
|
||||
/// [`omp_install_tokio_runtime`], which the JS loader calls once `dlopen` has
|
||||
/// returned.
|
||||
///
|
||||
/// On Windows, the custom Tokio runtime is host-sized to prevent aborts under
|
||||
/// memory limits (see [`create_windows_napi_tokio_runtime`]). Non-Windows
|
||||
/// builds intentionally use napi-rs's default path. Linux source builds can
|
||||
/// deadlock if this module initializer or post-load setup performs its own
|
||||
/// thread probe, so we keep the probe and custom runtime Windows-only.
|
||||
#[module_init]
|
||||
fn install_native_crash_handler() {
|
||||
crash_handler::install();
|
||||
}
|
||||
|
||||
/// Guards [`omp_install_tokio_runtime`] so the runtime is built at most once
|
||||
/// per process even if the loader invokes it more than once.
|
||||
#[cfg(target_os = "windows")]
|
||||
static TOKIO_RUNTIME_INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Install the bounded Tokio runtime napi-rs adopts for async exports.
|
||||
///
|
||||
/// The JS loader calls this exactly once, synchronously, right *after* `dlopen`
|
||||
/// returns and *before* any async native runs — never from `#[module_init]`.
|
||||
/// Building a multi-thread runtime eagerly spawns worker threads, and doing
|
||||
/// that during module init (while the dynamic-loader lock is held) deadlocks on
|
||||
/// some hosts: a fresh worker blocks acquiring the loader lock that the init
|
||||
/// thread still owns. napi-rs only materializes its runtime on the first async
|
||||
/// call (`RT` is a `LazyLock`) and `create_custom_tokio_runtime` merely records
|
||||
/// the runtime in a `OnceLock`, so installing it post-load is still honored.
|
||||
/// Without it napi builds its own default (one worker per CPU, spawned eagerly)
|
||||
/// which aborts the process (`os error 1455`) on a memory-constrained Windows
|
||||
/// host before any JS error can surface; [`create_windows_napi_tokio_runtime`]
|
||||
/// pre-flights the spawn instead. If no runtime can be built we leave napi-rs
|
||||
/// to its default. Idempotent.
|
||||
#[napi(js_name = "__ompInstallTokioRuntime")]
|
||||
pub const fn omp_install_tokio_runtime() {
|
||||
#[cfg(target_os = "windows")]
|
||||
if TOKIO_RUNTIME_INSTALLED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(runtime) = create_windows_napi_tokio_runtime() {
|
||||
create_custom_tokio_runtime(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Snapcompact frame rendering.
|
||||
//!
|
||||
//! Rasterizes pre-normalized conversation text onto a square bitmap using one
|
||||
//! of the bundled public-domain pixel fonts, then encodes it as PNG:
|
||||
//! Rasterizes pre-normalized conversation text onto a `size`-wide bitmap
|
||||
//! (height hugs the rows the text actually needs) using one of the bundled
|
||||
//! public-domain pixel fonts, then encodes it as PNG:
|
||||
//!
|
||||
//! - `5x8` — X.org BDF font (legacy shape).
|
||||
//! - `8x8` — unscii-8 hex font (Latin-1 subset), the square cell that won the
|
||||
//! snapcompact `SQuAD` evals.
|
||||
//! - `6x12` / `8x13` — X.org misc BDF fonts (higher-density eval winners).
|
||||
//!
|
||||
//! Shape controls, all eval-validated in `packages/snapcompact`:
|
||||
//!
|
||||
@@ -18,9 +20,19 @@
|
||||
//! font's natural cell, glyphs are rasterized at native size and the canvas
|
||||
//! is Lanczos3-resampled to the target (anisotropic stretch, e.g. the
|
||||
//! OpenAI-optimal "6x6u" shape), producing an anti-aliased RGB frame.
|
||||
//! - **stretch** — `false` disables resampling: glyphs print at natural size on
|
||||
//! the requested cell box while staying indexed (e.g. 8x13 glyphs on an 8x16
|
||||
//! pitch, the "8on16" shapes). `true`/unset keeps the auto rule above.
|
||||
//! - **columns** — `2` flows pre-wrapped `\n`-separated lines down two
|
||||
//! newspaper columns (the "doc" shapes); word wrap and pagination happen in
|
||||
//! the TypeScript caller.
|
||||
//! - **dim spans** — `U+000E`/`U+000F` in the text toggle dim gray ink on/off
|
||||
//! without occupying a cell; the TypeScript serializer wraps tool output in
|
||||
//! them so archived conversation reads louder than archived tool noise.
|
||||
//! - **line breaks** — `U+2588` (FULL BLOCK) fills its entire cell with pitch
|
||||
//! black ink regardless of variant or dim state; the TypeScript normalizer
|
||||
//! folds newline runs to it so line structure survives whitespace collapse at
|
||||
//! a one-cell cost.
|
||||
//!
|
||||
//! Text normalization, frame chunking, provider shape selection, and archive
|
||||
//! management live in `packages/snapcompact/src/snapcompact.ts`; this module
|
||||
@@ -59,9 +71,17 @@ const INK_DIM: u8 = 9;
|
||||
/// Zero-width ink toggles embedded in the text stream (shift-out/shift-in).
|
||||
const DIM_ON: u32 = 0x0e;
|
||||
const DIM_OFF: u32 = 0x0f;
|
||||
/// FULL BLOCK: fills its entire cell box with pitch-black ink (`INK_BLACK`,
|
||||
/// ignoring sentence hue and dim state). The TypeScript normalizer folds
|
||||
/// newline runs to it.
|
||||
const FULL_BLOCK: u32 = 0x2588;
|
||||
|
||||
static FONT_5X8: LazyLock<Font> = LazyLock::new(|| parse_bdf(include_str!("fonts/5x8.bdf"), 5, 8));
|
||||
static FONT_8X8: LazyLock<Font> = LazyLock::new(|| parse_hex(include_str!("fonts/unscii-8.hex")));
|
||||
static FONT_6X12: LazyLock<Font> =
|
||||
LazyLock::new(|| parse_bdf(include_str!("fonts/6x12.bdf"), 6, 12));
|
||||
static FONT_8X13: LazyLock<Font> =
|
||||
LazyLock::new(|| parse_bdf(include_str!("fonts/8x13.bdf"), 8, 13));
|
||||
|
||||
struct Glyph {
|
||||
/// Glyph width in pixels (≤ 8 for the bundled fonts).
|
||||
@@ -150,25 +170,124 @@ fn resolve_font(name: &str) -> Option<&'static Font> {
|
||||
match name {
|
||||
"5x8" => Some(&FONT_5X8),
|
||||
"8x8" => Some(&FONT_8X8),
|
||||
"6x12" => Some(&FONT_6X12),
|
||||
"8x13" => Some(&FONT_8X13),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame grid geometry shared with the TypeScript caller.
|
||||
/// Frame grid geometry shared with the TypeScript caller. The cell box
|
||||
/// (`cell_w` x `cell_h`) is the advance/pitch glyphs are laid out on; it may
|
||||
/// differ from the font's natural cell (e.g. 8x13 glyphs on an 8x16 pitch).
|
||||
struct Grid {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
repeat: usize,
|
||||
/// Cell advance (x) in pixels.
|
||||
cell_w: usize,
|
||||
/// Cell pitch (y) in pixels.
|
||||
cell_h: usize,
|
||||
}
|
||||
|
||||
/// Rasterize `text` onto a `width` x `height` palette-indexed bitmap at the
|
||||
/// font's natural cell size, row-major with no word wrap. Each text line is
|
||||
/// printed `grid.repeat` times; copies after the first sit on the highlight
|
||||
/// band. Ink cycles through six hues at sentence boundaries (terminator in
|
||||
/// `.!?` followed by a space) unless `black_ink` pins it to black; `U+000E`/
|
||||
/// `U+000F` toggle dim gray ink without occupying a cell, and dim wins over
|
||||
/// both variants. Characters beyond `cols * rows` are ignored; code points
|
||||
/// missing from the font leave their cell blank.
|
||||
/// Grid rows the text actually occupies, so the canvas height hugs the
|
||||
/// content instead of padding the frame to a full square. Mirrors the
|
||||
/// renderers' cell accounting: dim toggles are zero-width, every other code
|
||||
/// point (including `U+2588` and glyphs missing from the font) consumes one
|
||||
/// cell; doc layout fills one row per `\n`-separated line down the first
|
||||
/// column before spilling into the second.
|
||||
fn used_rows(text: &str, grid: &Grid, doc: bool) -> usize {
|
||||
let rows = if doc {
|
||||
text.split('\n').count()
|
||||
} else {
|
||||
let cells = text
|
||||
.chars()
|
||||
.filter(|&ch| !matches!(ch as u32, DIM_ON | DIM_OFF))
|
||||
.count();
|
||||
cells.div_ceil(grid.cols)
|
||||
};
|
||||
rows.clamp(1, grid.rows)
|
||||
}
|
||||
|
||||
/// Paint the pale highlight bands behind line copies after the first.
|
||||
fn fill_repeat_bands(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) {
|
||||
if grid.repeat <= 1 {
|
||||
return;
|
||||
}
|
||||
for row in 0..grid.rows {
|
||||
for copy in 1..grid.repeat {
|
||||
let band_top = (row * grid.repeat + copy) * grid.cell_h;
|
||||
for y in band_top..(band_top + grid.cell_h).min(height) {
|
||||
pixels[y * width..y * width + width].fill(BG_REPEAT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blit one glyph's bitmask rows at (`left`, `top`), clipped to the canvas.
|
||||
fn blit_glyph(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
glyph: &Glyph,
|
||||
left: i32,
|
||||
top: i32,
|
||||
ink: u8,
|
||||
) {
|
||||
for (r, &bits) in glyph.rows.iter().enumerate() {
|
||||
if bits == 0 {
|
||||
continue;
|
||||
}
|
||||
let y = top + r as i32;
|
||||
if y < 0 || y >= height as i32 {
|
||||
continue;
|
||||
}
|
||||
let row_base = y as usize * width;
|
||||
for b in 0..glyph.w {
|
||||
if bits & (0x80u8 >> b) != 0 {
|
||||
let x = left + i32::from(b);
|
||||
if x >= 0 && (x as usize) < width {
|
||||
pixels[row_base + x as usize] = ink;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill one cell box (every repeat copy) with solid ink, clipped to canvas.
|
||||
fn fill_cell(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
grid: &Grid,
|
||||
x_origin: usize,
|
||||
row: usize,
|
||||
ink: u8,
|
||||
) {
|
||||
let x0 = x_origin.min(width);
|
||||
let x1 = (x_origin + grid.cell_w).min(width);
|
||||
if x0 >= x1 {
|
||||
return;
|
||||
}
|
||||
for copy in 0..grid.repeat {
|
||||
let top = (row * grid.repeat + copy) * grid.cell_h;
|
||||
for y in top..(top + grid.cell_h).min(height) {
|
||||
pixels[y * width + x0..y * width + x1].fill(ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterize `text` onto a `width` x `height` palette-indexed bitmap on the
|
||||
/// grid's cell box, row-major with no word wrap. Glyphs keep their natural
|
||||
/// size with the baseline at the font's ascent from the cell top, so a cell
|
||||
/// taller than the font pads below the baseline (the "8on16" shapes). Each
|
||||
/// text line is printed `grid.repeat` times; copies after the first sit on
|
||||
/// the highlight band. Ink cycles through six hues at sentence boundaries
|
||||
/// (terminator in `.!?` followed by a space or full block) unless `black_ink`
|
||||
/// pins it to black; `U+000E`/`U+000F` toggle dim gray ink without occupying a
|
||||
/// cell, and dim wins over both variants. `U+2588` fills its whole cell with
|
||||
/// pitch-black ink, ignoring hue and dim state. Characters beyond
|
||||
/// `cols * rows` are ignored; code points missing from the font leave their
|
||||
/// cell blank.
|
||||
fn render_bitmap(
|
||||
text: &str,
|
||||
width: usize,
|
||||
@@ -182,16 +301,7 @@ fn render_bitmap(
|
||||
if capacity == 0 {
|
||||
return pixels;
|
||||
}
|
||||
if grid.repeat > 1 {
|
||||
for row in 0..grid.rows {
|
||||
for copy in 1..grid.repeat {
|
||||
let band_top = (row * grid.repeat + copy) * font.cell_h;
|
||||
for y in band_top..(band_top + font.cell_h).min(height) {
|
||||
pixels[y * width..y * width + width].fill(BG_REPEAT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fill_repeat_bands(&mut pixels, width, height, grid);
|
||||
let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
|
||||
let mut sentence = 0usize;
|
||||
let mut dim = false;
|
||||
@@ -219,40 +329,125 @@ fn render_bitmap(
|
||||
} else {
|
||||
(1 + sentence % INK_COLORS) as u8
|
||||
};
|
||||
if matches!(code, 0x2e | 0x21 | 0x3f) && codes.get(i + 1) == Some(&0x20) {
|
||||
if matches!(code, 0x2e | 0x21 | 0x3f)
|
||||
&& matches!(codes.get(i + 1), Some(&(0x20 | FULL_BLOCK)))
|
||||
{
|
||||
sentence += 1;
|
||||
}
|
||||
let row = cell / grid.cols;
|
||||
let col = cell - row * grid.cols;
|
||||
cell += 1;
|
||||
if code == FULL_BLOCK {
|
||||
fill_cell(&mut pixels, width, height, grid, col * grid.cell_w, row, INK_BLACK);
|
||||
continue;
|
||||
}
|
||||
let Some(glyph) = font.glyphs.get(&code) else {
|
||||
continue;
|
||||
};
|
||||
if glyph.rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let left = (col * font.cell_w) as i32 + glyph.xoff;
|
||||
let left = (col * grid.cell_w) as i32 + glyph.xoff;
|
||||
for copy in 0..grid.repeat {
|
||||
let cell_top = ((row * grid.repeat + copy) * font.cell_h) as i32;
|
||||
let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
|
||||
let top = cell_top + font.ascent - glyph.h - glyph.yoff;
|
||||
for (r, &bits) in glyph.rows.iter().enumerate() {
|
||||
if bits == 0 {
|
||||
continue;
|
||||
blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Character cells between the two doc columns (eval `exp14` layout).
|
||||
const GUTTER: usize = 3;
|
||||
|
||||
/// Rasterize pre-wrapped text as a two-column "doc" page onto a `width` x
|
||||
/// `height` palette-indexed bitmap. Input splits on `'\n'` (zero-width): line
|
||||
/// `li` lands at column `li / rows`, row `li % rows`; each column is
|
||||
/// `(cols - GUTTER) / 2` cells wide, the second starts `col_w + GUTTER` cells
|
||||
/// in, and no rule is drawn between them. Lines longer than the column width
|
||||
/// are clipped (the TypeScript caller pre-wraps; clipping is the overflow
|
||||
/// guard) and lines past the second column are ignored. Sentence hues advance
|
||||
/// on a terminator in `.!?` followed by a space, newline, *or* full block;
|
||||
/// dim toggles, repeat bands, and the `U+2588` black cell fill behave exactly
|
||||
/// as in the grid renderer.
|
||||
fn render_doc_bitmap(
|
||||
text: &str,
|
||||
width: usize,
|
||||
height: usize,
|
||||
font: &Font,
|
||||
grid: &Grid,
|
||||
black_ink: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut pixels = vec![0u8; width * height]; // 0 = white background
|
||||
let col_w = grid.cols.saturating_sub(GUTTER) / 2;
|
||||
if col_w == 0 || grid.rows == 0 {
|
||||
return pixels;
|
||||
}
|
||||
fill_repeat_bands(&mut pixels, width, height, grid);
|
||||
let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
|
||||
let mut sentence = 0usize;
|
||||
let mut dim = false;
|
||||
let mut line = 0usize;
|
||||
let mut col = 0usize;
|
||||
for i in 0..codes.len() {
|
||||
let code = codes[i];
|
||||
match code {
|
||||
DIM_ON => {
|
||||
dim = true;
|
||||
continue;
|
||||
},
|
||||
DIM_OFF => {
|
||||
dim = false;
|
||||
continue;
|
||||
},
|
||||
0x0a => {
|
||||
line += 1;
|
||||
col = 0;
|
||||
if line >= grid.rows * 2 {
|
||||
break; // past the second column; the caller paginates
|
||||
}
|
||||
let y = top + r as i32;
|
||||
if y < 0 || y >= height as i32 {
|
||||
continue;
|
||||
}
|
||||
let row_base = y as usize * width;
|
||||
for b in 0..glyph.w {
|
||||
if bits & (0x80u8 >> b) != 0 {
|
||||
let x = left + i32::from(b);
|
||||
if x >= 0 && (x as usize) < width {
|
||||
pixels[row_base + x as usize] = ink;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
let ink = if dim {
|
||||
INK_DIM
|
||||
} else if black_ink {
|
||||
INK_BLACK
|
||||
} else {
|
||||
(1 + sentence % INK_COLORS) as u8
|
||||
};
|
||||
if matches!(code, 0x2e | 0x21 | 0x3f)
|
||||
&& matches!(codes.get(i + 1), Some(&(0x20 | 0x0a | FULL_BLOCK)))
|
||||
{
|
||||
sentence += 1;
|
||||
}
|
||||
let cell = col;
|
||||
col += 1;
|
||||
if cell >= col_w {
|
||||
continue; // clip past the column width
|
||||
}
|
||||
if code == FULL_BLOCK {
|
||||
let column = line / grid.rows;
|
||||
let row = line - column * grid.rows;
|
||||
let x_origin = (column * (col_w + GUTTER) + cell) * grid.cell_w;
|
||||
fill_cell(&mut pixels, width, height, grid, x_origin, row, INK_BLACK);
|
||||
continue;
|
||||
}
|
||||
let Some(glyph) = font.glyphs.get(&code) else {
|
||||
continue;
|
||||
};
|
||||
if glyph.rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let column = line / grid.rows;
|
||||
let row = line - column * grid.rows;
|
||||
let x_origin = column * (col_w + GUTTER) * grid.cell_w;
|
||||
let left = (x_origin + cell * grid.cell_w) as i32 + glyph.xoff;
|
||||
for copy in 0..grid.repeat {
|
||||
let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
|
||||
let top = cell_top + font.ascent - glyph.h - glyph.yoff;
|
||||
blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
|
||||
}
|
||||
}
|
||||
pixels
|
||||
@@ -339,48 +534,76 @@ fn resize_rgb(src: &[f32], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f3
|
||||
// PNG encoding
|
||||
// ============================================================================
|
||||
|
||||
/// Pack one-byte-per-pixel palette indices into 4-bit PNG scanline data
|
||||
/// (two pixels per byte, high nibble first). With only 9 palette entries,
|
||||
/// 4-bit depth halves the pre-deflate stream vs 8-bit.
|
||||
fn pack_nibbles(pixels: &[u8], size: usize) -> Vec<u8> {
|
||||
let row_bytes = size.div_ceil(2);
|
||||
let mut packed = vec![0u8; row_bytes * size];
|
||||
for y in 0..size {
|
||||
let src = &pixels[y * size..(y + 1) * size];
|
||||
/// Pack one-byte-per-pixel palette indices into `bits`-per-pixel PNG
|
||||
/// scanline data (big-endian within each byte), remapping each global
|
||||
/// palette index through `remap` to its per-frame slot on the way.
|
||||
fn pack_bits(
|
||||
pixels: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
bits: usize,
|
||||
remap: &[u8; PALETTE.len()],
|
||||
) -> Vec<u8> {
|
||||
let per = 8 / bits;
|
||||
let row_bytes = width.div_ceil(per);
|
||||
let mut packed = vec![0u8; row_bytes * height];
|
||||
for y in 0..height {
|
||||
let src = &pixels[y * width..(y + 1) * width];
|
||||
let dst = &mut packed[y * row_bytes..(y + 1) * row_bytes];
|
||||
for (x, &px) in src.iter().enumerate() {
|
||||
dst[x / 2] |= px << (4 * (1 - x % 2));
|
||||
dst[x / per] |= remap[px as usize] << (bits * (per - 1 - x % per));
|
||||
}
|
||||
}
|
||||
packed
|
||||
}
|
||||
|
||||
/// Encode a palette-indexed bitmap as a 4-bit indexed PNG with `None` row
|
||||
/// Encode a palette-indexed bitmap as an indexed PNG with `None` row
|
||||
/// filtering (the glyph bitmap is already minimal-entropy; filtering costs
|
||||
/// encode time without helping deflate).
|
||||
///
|
||||
/// The palette is narrowed to the colors the frame actually uses and the bit
|
||||
/// depth follows: a plain `bw` frame (background + ink) packs 1-bit rows, a
|
||||
/// dim/banded frame 2-bit, sentence-hue frames 4-bit — bw frames shed
|
||||
/// another ~half of the pre-deflate stream vs the fixed 4-bit layout.
|
||||
fn encode_indexed_png(
|
||||
pixels: &[u8],
|
||||
size: usize,
|
||||
width: usize,
|
||||
height: usize,
|
||||
compression: png::Compression,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut palette = Vec::with_capacity(PALETTE.len() * 3);
|
||||
for rgb in PALETTE {
|
||||
palette.extend_from_slice(&rgb);
|
||||
let mut used = [false; PALETTE.len()];
|
||||
for &px in pixels {
|
||||
used[px as usize] = true;
|
||||
}
|
||||
let mut remap = [0u8; PALETTE.len()];
|
||||
let mut palette = Vec::with_capacity(PALETTE.len() * 3);
|
||||
let mut count = 0u8;
|
||||
for (global, &is_used) in used.iter().enumerate() {
|
||||
if is_used {
|
||||
remap[global] = count;
|
||||
count += 1;
|
||||
palette.extend_from_slice(&PALETTE[global]);
|
||||
}
|
||||
}
|
||||
let (depth, bits) = match count {
|
||||
0..=2 => (png::BitDepth::One, 1),
|
||||
3..=4 => (png::BitDepth::Two, 2),
|
||||
_ => (png::BitDepth::Four, 4),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, size as u32, size as u32);
|
||||
let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
|
||||
encoder.set_color(png::ColorType::Indexed);
|
||||
encoder.set_depth(png::BitDepth::Four);
|
||||
encoder.set_depth(depth);
|
||||
encoder.set_palette(Cow::Owned(palette));
|
||||
encoder.set_compression(compression);
|
||||
// MUST come after `set_compression`, which resets the filter to the
|
||||
// compression level's default (`Adaptive` for `Balanced`).
|
||||
// compression level's default (`Adaptive` for `Balanced`/`High`).
|
||||
encoder.set_filter(png::Filter::NoFilter);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|err| Error::from_reason(format!("Failed to write PNG header: {err}")))?;
|
||||
writer
|
||||
.write_image_data(&pack_nibbles(pixels, size))
|
||||
.write_image_data(&pack_bits(pixels, width, height, bits, &remap))
|
||||
.map_err(|err| Error::from_reason(format!("Failed to write PNG data: {err}")))?;
|
||||
writer
|
||||
.finish()
|
||||
@@ -390,9 +613,14 @@ fn encode_indexed_png(
|
||||
|
||||
/// Encode an interleaved RGB8 buffer as PNG. Stretched frames are
|
||||
/// continuous-tone, so adaptive filtering (the `Balanced` default) helps.
|
||||
fn encode_rgb_png(pixels: &[u8], size: usize, compression: png::Compression) -> Result<Vec<u8>> {
|
||||
fn encode_rgb_png(
|
||||
pixels: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
compression: png::Compression,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, size as u32, size as u32);
|
||||
let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.set_compression(compression);
|
||||
@@ -416,9 +644,12 @@ fn encode_rgb_png(pixels: &[u8], size: usize, compression: png::Compression) ->
|
||||
#[napi(object)]
|
||||
#[derive(Default)]
|
||||
pub struct SnapcompactRenderOptions {
|
||||
/// Frame edge in pixels.
|
||||
/// Frame width in pixels; also bounds the grid rows
|
||||
/// (`floor(size/cellHeight/lineRepeat)`). Output height hugs the rows the
|
||||
/// text actually uses instead of padding to a square.
|
||||
pub size: u32,
|
||||
/// Bundled font: `"5x8"` (X.org BDF) or `"8x8"` (unscii-8). Default `"5x8"`.
|
||||
/// Bundled font: `"5x8"`, `"6x12"`, `"8x13"` (X.org BDF) or `"8x8"`
|
||||
/// (unscii-8). Default `"5x8"`.
|
||||
pub font: Option<String>,
|
||||
/// Target cell advance in pixels. Differing from the font's natural cell
|
||||
/// triggers the Lanczos stretch path. Default: font natural width.
|
||||
@@ -431,17 +662,30 @@ pub struct SnapcompactRenderOptions {
|
||||
/// Print each text line this many times; copies after the first sit on a
|
||||
/// pale highlight band. Default 1.
|
||||
pub line_repeat: Option<u32>,
|
||||
/// Stretch behavior. Unset: auto — Lanczos-stretch whenever the target
|
||||
/// cell differs from the font's natural cell. `false`: never stretch —
|
||||
/// render indexed with glyphs at natural size on the requested cell box
|
||||
/// (e.g. 8x13 glyphs on an 8x16 pitch, the "8on16" shapes). `true`: force
|
||||
/// the stretch path (identical to auto; natural cells render indexed).
|
||||
pub stretch: Option<bool>,
|
||||
/// Layout columns: `1` (default) row-major grid; `2` two newspaper "doc"
|
||||
/// columns of pre-wrapped newline-separated lines.
|
||||
pub columns: Option<u32>,
|
||||
}
|
||||
|
||||
/// Render one snapcompact frame: print pre-normalized text onto a square
|
||||
/// bitmap and encode it as PNG.
|
||||
/// Render one snapcompact frame: print pre-normalized text onto a
|
||||
/// `size`-wide bitmap and encode it as PNG.
|
||||
///
|
||||
/// The glyph grid holds `floor(size/cellWidth) *
|
||||
/// The bitmap height hugs the rows the text actually occupies
|
||||
/// (`usedRows * lineRepeat * cellHeight`), so a partially filled frame never
|
||||
/// pays for blank padding rows. The glyph grid holds `floor(size/cellWidth) *
|
||||
/// floor(size/cellHeight/lineRepeat)` characters; input beyond that is ignored
|
||||
/// (the caller chunks text to capacity). Native-cell shapes encode as 4-bit
|
||||
/// indexed PNG; stretched shapes (target cell != font cell) encode as RGB.
|
||||
/// `U+000E`/`U+000F` in `text` toggle dim-gray ink spans without occupying a
|
||||
/// cell.
|
||||
/// `stretch: false` pins the indexed path, printing natural-size glyphs on the
|
||||
/// requested cell box; `columns: 2` flows pre-wrapped newline-separated lines
|
||||
/// down two newspaper columns. `U+000E`/`U+000F` in `text` toggle dim-gray ink
|
||||
/// spans without occupying a cell.
|
||||
/// Returns the PNG encoded as base64, created as a one-byte (Latin-1) JS
|
||||
/// string straight from native code — no `Uint8Array` hop or JS-side
|
||||
/// re-encode.
|
||||
@@ -459,7 +703,7 @@ pub fn render_snapcompact_png(
|
||||
let font_name = options.font.as_deref().unwrap_or("5x8");
|
||||
let font = resolve_font(font_name).ok_or_else(|| {
|
||||
Error::from_reason(format!(
|
||||
"Unknown snapcompact font {font_name:?}: expected \"5x8\" or \"8x8\""
|
||||
"Unknown snapcompact font {font_name:?}: expected \"5x8\", \"8x8\", \"6x12\", or \"8x13\""
|
||||
))
|
||||
})?;
|
||||
let black_ink = match options.variant.as_deref().unwrap_or("sent") {
|
||||
@@ -474,29 +718,60 @@ pub fn render_snapcompact_png(
|
||||
let target_w = options.cell_width.unwrap_or(font.cell_w as u32).max(1) as usize;
|
||||
let target_h = options.cell_height.unwrap_or(font.cell_h as u32).max(1) as usize;
|
||||
let repeat = options.line_repeat.unwrap_or(1).max(1) as usize;
|
||||
let columns = options.columns.unwrap_or(1);
|
||||
if !matches!(columns, 1 | 2) {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Invalid snapcompact columns {columns}: expected 1 or 2"
|
||||
)));
|
||||
}
|
||||
let doc = columns == 2;
|
||||
let size = size as usize;
|
||||
let grid = Grid { cols: size / target_w, rows: size / target_h / repeat, repeat };
|
||||
let grid = Grid {
|
||||
cols: size / target_w,
|
||||
rows: size / target_h / repeat,
|
||||
repeat,
|
||||
cell_w: target_w,
|
||||
cell_h: target_h,
|
||||
};
|
||||
if grid.cols == 0 || grid.rows == 0 {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Frame size {size} cannot fit a {target_w}x{target_h} cell grid (repeat {repeat})"
|
||||
)));
|
||||
}
|
||||
// Tight canvas: width stays the frame edge (the reading geometry the
|
||||
// caller derives cols from), height hugs the rows the text needs.
|
||||
let used = used_rows(&text, &grid, doc);
|
||||
let height = used * grid.repeat * grid.cell_h;
|
||||
|
||||
if (target_w, target_h) == (font.cell_w, font.cell_h) {
|
||||
// Native cell: rasterize straight onto the frame, indexed.
|
||||
let pixels = render_bitmap(&text, size, size, font, &grid, black_ink);
|
||||
let stretch =
|
||||
options.stretch != Some(false) && (target_w, target_h) != (font.cell_w, font.cell_h);
|
||||
if !stretch {
|
||||
// Indexed path: rasterize straight onto the frame at the requested
|
||||
// cell box (the natural cell, or natural glyphs on a padded pitch
|
||||
// when `stretch: false`).
|
||||
let pixels = if doc {
|
||||
render_doc_bitmap(&text, size, height, font, &grid, black_ink)
|
||||
} else {
|
||||
render_bitmap(&text, size, height, font, &grid, black_ink)
|
||||
};
|
||||
return Ok(STANDARD
|
||||
.encode(encode_indexed_png(&pixels, size, png::Compression::Balanced)?)
|
||||
.encode(encode_indexed_png(&pixels, size, height, png::Compression::High)?)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Stretch shape: rasterize at the font's natural cell on a tight canvas,
|
||||
// Lanczos3-resample to the target cell, paste onto the white frame.
|
||||
// Stretch shape: rasterize at the font's natural cell on a tight canvas
|
||||
// (layout stays in character cells from the target grid), Lanczos3-
|
||||
// resample to the target cell, paste onto the white frame.
|
||||
let native = Grid { cell_w: font.cell_w, cell_h: font.cell_h, ..grid };
|
||||
let src_w = grid.cols * font.cell_w;
|
||||
let src_h = grid.rows * grid.repeat * font.cell_h;
|
||||
let src_h = used * grid.repeat * font.cell_h;
|
||||
let dst_w = grid.cols * target_w;
|
||||
let dst_h = grid.rows * grid.repeat * target_h;
|
||||
let indexed = render_bitmap(&text, src_w, src_h, font, &grid, black_ink);
|
||||
let dst_h = used * grid.repeat * target_h;
|
||||
let indexed = if doc {
|
||||
render_doc_bitmap(&text, src_w, src_h, font, &native, black_ink)
|
||||
} else {
|
||||
render_bitmap(&text, src_w, src_h, font, &native, black_ink)
|
||||
};
|
||||
let mut rgb = vec![0f32; src_w * src_h * 3];
|
||||
for (dst, &idx) in rgb.chunks_exact_mut(3).zip(&indexed) {
|
||||
let [r, g, b] = PALETTE[idx as usize];
|
||||
@@ -505,8 +780,8 @@ pub fn render_snapcompact_png(
|
||||
dst[2] = f32::from(b);
|
||||
}
|
||||
let resized = resize_rgb(&rgb, src_w, src_h, dst_w, dst_h);
|
||||
let mut frame = vec![255u8; size * size * 3];
|
||||
for y in 0..dst_h.min(size) {
|
||||
let mut frame = vec![255u8; size * dst_h * 3];
|
||||
for y in 0..dst_h {
|
||||
let src_row = &resized[y * dst_w * 3..(y + 1) * dst_w * 3];
|
||||
let dst_row = &mut frame[y * size * 3..];
|
||||
for (d, &s) in dst_row[..dst_w.min(size) * 3].iter_mut().zip(src_row) {
|
||||
@@ -514,7 +789,7 @@ pub fn render_snapcompact_png(
|
||||
}
|
||||
}
|
||||
Ok(STANDARD
|
||||
.encode(encode_rgb_png(&frame, size, png::Compression::Balanced)?)
|
||||
.encode(encode_rgb_png(&frame, size, dst_h, png::Compression::High)?)
|
||||
.into())
|
||||
}
|
||||
|
||||
@@ -540,7 +815,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bitmap_inks_sentences_and_caps_capacity() {
|
||||
// 40px -> 8 cols x 5 rows = 40 cells (5x8 font).
|
||||
let grid = Grid { cols: 8, rows: 5, repeat: 1 };
|
||||
let grid = Grid { cols: 8, rows: 5, repeat: 1, cell_w: 5, cell_h: 8 };
|
||||
let pixels = render_bitmap("Hi. Ok.", 40, 40, &FONT_5X8, &grid, false);
|
||||
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
|
||||
assert!(inks.contains(&1), "first sentence should use ink 1");
|
||||
@@ -554,7 +829,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bw_variant_prints_black_only() {
|
||||
let grid = Grid { cols: 8, rows: 8, repeat: 1 };
|
||||
let grid = Grid { cols: 8, rows: 8, repeat: 1, cell_w: 8, cell_h: 8 };
|
||||
let pixels = render_bitmap("Hi. Ok.", 64, 64, &FONT_8X8, &grid, true);
|
||||
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
|
||||
assert!(!inks.is_empty());
|
||||
@@ -563,7 +838,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dim_markers_toggle_gray_without_consuming_cells() {
|
||||
let grid = Grid { cols: 8, rows: 8, repeat: 1 };
|
||||
let grid = Grid { cols: 8, rows: 8, repeat: 1, cell_w: 8, cell_h: 8 };
|
||||
let pixels = render_bitmap("\u{e}AB\u{f}CD", 64, 64, &FONT_8X8, &grid, true);
|
||||
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
|
||||
assert!(inks.contains(&INK_DIM), "dim span must ink gray");
|
||||
@@ -578,7 +853,7 @@ mod tests {
|
||||
#[test]
|
||||
fn line_repeat_duplicates_rows_on_highlight_bands() {
|
||||
// 64px, 8x8 font, repeat 2 -> 8 cols x 4 unique rows.
|
||||
let grid = Grid { cols: 8, rows: 4, repeat: 2 };
|
||||
let grid = Grid { cols: 8, rows: 4, repeat: 2, cell_w: 8, cell_h: 8 };
|
||||
let pixels = render_bitmap("ABCDEFGH", 64, 64, &FONT_8X8, &grid, true);
|
||||
// Copy band (rows 8..16) carries the highlight background.
|
||||
assert!(pixels[9 * 64..10 * 64].contains(&BG_REPEAT), "duplicate band must be highlighted");
|
||||
@@ -593,6 +868,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_block_fills_cell_pitch_black() {
|
||||
let grid = Grid { cols: 8, rows: 4, repeat: 2, cell_w: 8, cell_h: 8 };
|
||||
// The block's black fill beats both the dim span and the sent hues.
|
||||
let pixels = render_bitmap("\u{e}a\u{2588}b\u{f}", 64, 64, &FONT_8X8, &grid, false);
|
||||
for copy in 0..2 {
|
||||
for y in copy * 8..(copy + 1) * 8 {
|
||||
for x in 8..16 {
|
||||
assert_eq!(pixels[y * 64 + x], INK_BLACK, "block pixel ({x},{y}) must be black");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(pixels.contains(&INK_DIM), "neighbours keep their dim ink");
|
||||
let hued = render_bitmap("Hi.\u{2588}Ok.", 64, 64, &FONT_8X8, &grid, false);
|
||||
assert!(hued.contains(&2), "block must advance the sentence hue like a space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_full_block_fills_cell() {
|
||||
// col_w = (13 - GUTTER) / 2 = 5; block at line 0, cell 1 -> x 8..16.
|
||||
let grid = Grid { cols: 13, rows: 2, repeat: 1, cell_w: 8, cell_h: 8 };
|
||||
let pixels = render_doc_bitmap("a\u{2588}b\nc", 104, 16, &FONT_8X8, &grid, true);
|
||||
for y in 0..8 {
|
||||
for x in 8..16 {
|
||||
assert_eq!(pixels[y * 104 + x], INK_BLACK, "block pixel ({x},{y}) must be black");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the base64 JS-string payload back to PNG bytes for inspection.
|
||||
fn png_bytes(encoded: Latin1String) -> Vec<u8> {
|
||||
STANDARD
|
||||
@@ -631,6 +935,63 @@ mod tests {
|
||||
assert_eq!(legacy[25], 3, "default shape stays the legacy 5x8 indexed path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_png_narrows_palette_and_bit_depth() {
|
||||
// IHDR bit depth lives at byte 24; PLTE length is the chunk length
|
||||
// word 8 bytes after the "PLTE" tag position.
|
||||
fn depth_and_palette(png: &[u8]) -> (u8, usize) {
|
||||
let tag = png
|
||||
.windows(4)
|
||||
.position(|w| w == b"PLTE")
|
||||
.expect("PLTE chunk");
|
||||
let len = u32::from_be_bytes(png[tag - 4..tag].try_into().unwrap()) as usize;
|
||||
(png[24], len / 3)
|
||||
}
|
||||
|
||||
// Plain bw, no dim/band/repeat: background + black ink = 1-bit.
|
||||
let bw = png_bytes(
|
||||
render_snapcompact_png("Hello world. Again.".into(), SnapcompactRenderOptions {
|
||||
size: 128,
|
||||
font: Some("8x8".into()),
|
||||
variant: Some("bw".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(depth_and_palette(&bw), (1, 2));
|
||||
|
||||
// bw with a dim span and repeat bands: 4 colors = 2-bit.
|
||||
let dim = png_bytes(
|
||||
render_snapcompact_png(
|
||||
"Read \u{e}the dim part\u{f} now.".into(),
|
||||
SnapcompactRenderOptions {
|
||||
size: 128,
|
||||
font: Some("8x8".into()),
|
||||
variant: Some("bw".into()),
|
||||
line_repeat: Some(2),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(depth_and_palette(&dim), (2, 4));
|
||||
|
||||
// Sentence hues exceed 4 colors: stays 4-bit, palette still narrowed
|
||||
// to the inks actually printed (bg + 2 hues here).
|
||||
let sent = png_bytes(
|
||||
render_snapcompact_png("Hi. Ok.".into(), SnapcompactRenderOptions {
|
||||
size: 128,
|
||||
font: Some("8x8".into()),
|
||||
variant: Some("sent".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
let (sent_depth, sent_colors) = depth_and_palette(&sent);
|
||||
assert_eq!(sent_depth, 2, "two hues + bg fit 2-bit");
|
||||
assert_eq!(sent_colors, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_shapes() {
|
||||
assert!(render_snapcompact_png("x".into(), opts(0)).is_err());
|
||||
@@ -651,4 +1012,183 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xorg_fonts_parse_and_render() {
|
||||
for (font, ascent, name) in [(&*FONT_6X12, 10, "6x12"), (&*FONT_8X13, 11, "8x13")] {
|
||||
assert_eq!(font.ascent, ascent, "{name} ascent");
|
||||
for cp in 0x20u32..0x7f {
|
||||
assert!(font.glyphs.contains_key(&cp), "{name} missing glyph U+{cp:04X}");
|
||||
}
|
||||
}
|
||||
for (name, size) in [("6x12", 60u32), ("8x13", 104u32)] {
|
||||
let png = png_bytes(
|
||||
render_snapcompact_png("Hello world. Again!".into(), SnapcompactRenderOptions {
|
||||
size,
|
||||
font: Some(name.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(png[25], 3, "{name} natural cell must encode indexed");
|
||||
}
|
||||
// Non-blank: the raster must carry glyph ink.
|
||||
let grid = Grid { cols: 10, rows: 5, repeat: 1, cell_w: 6, cell_h: 12 };
|
||||
let pixels = render_bitmap("Hello", 60, 60, &FONT_6X12, &grid, true);
|
||||
assert!(pixels.contains(&INK_BLACK), "6x12 must ink pixels");
|
||||
let grid = Grid { cols: 8, rows: 8, repeat: 1, cell_w: 8, cell_h: 13 };
|
||||
let pixels = render_bitmap("Hello", 64, 104, &FONT_8X13, &grid, true);
|
||||
assert!(pixels.contains(&INK_BLACK), "8x13 must ink pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretch_false_renders_natural_glyphs_on_padded_pitch() {
|
||||
let png = png_bytes(
|
||||
render_snapcompact_png("Hello there. General Kenobi!".into(), SnapcompactRenderOptions {
|
||||
size: 128,
|
||||
font: Some("8x13".into()),
|
||||
cell_width: Some(8),
|
||||
cell_height: Some(16),
|
||||
stretch: Some(false),
|
||||
variant: Some("bw".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(png[25], 3, "8on16 must stay indexed");
|
||||
// IHDR width/height live at bytes 16..24, big-endian.
|
||||
let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
|
||||
// "Hello there. General Kenobi!" is 28 chars on a 16-col grid: 2 rows
|
||||
// of the 16px pitch — the height hugs them instead of padding to 128.
|
||||
assert_eq!((dim(16), dim(20)), (128, 32), "declared geometry must match");
|
||||
|
||||
// Glyph ink must sit in the top 13px of every 16px pitch row.
|
||||
let grid = Grid { cols: 16, rows: 8, repeat: 1, cell_w: 8, cell_h: 16 };
|
||||
let pixels = render_bitmap("Hgjpqy. Mixed descenders!", 128, 128, &FONT_8X13, &grid, true);
|
||||
assert!(pixels.contains(&INK_BLACK));
|
||||
for (i, &p) in pixels.iter().enumerate() {
|
||||
if p == INK_BLACK {
|
||||
assert!((i / 128) % 16 < 13, "ink leaked into pitch padding at y={}", i / 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_layout_flows_lines_into_second_column() {
|
||||
// 64px, 8x16 cells -> cols 8, rows 4, col_w = (8 - 3) / 2 = 2.
|
||||
let grid = Grid { cols: 8, rows: 4, repeat: 1, cell_w: 8, cell_h: 16 };
|
||||
let pixels = render_doc_bitmap("A\nB\nC\nD\nE", 64, 64, &FONT_8X13, &grid, true);
|
||||
// Line 4 (the rows+1-th) lands at the second column's x origin:
|
||||
// 1 * (col_w + GUTTER) * cell_w = 40, row band 0.
|
||||
let col2 = (0..13).any(|y| (40..48).any(|x| pixels[y * 64 + x] == INK_BLACK));
|
||||
assert!(col2, "fifth line must start at the second column's x origin");
|
||||
// '\n' consumes no cell: line 1 starts back at x 0 in row band 1.
|
||||
let row1 = (16..29).any(|y| (0..8).any(|x| pixels[y * 64 + x] == INK_BLACK));
|
||||
assert!(row1, "second line must start at column 0 of the next row band");
|
||||
// One-char lines leave the rest of column 0 and the gutter blank.
|
||||
for y in 0..64 {
|
||||
for x in 8..40 {
|
||||
assert_eq!(pixels[y * 64 + x], 0, "gutter must stay blank at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_sentence_hue_advances_across_newline_boundary() {
|
||||
// 152px wide: cols 19, col_w = (19 - 3) / 2 = 8.
|
||||
let grid = Grid { cols: 19, rows: 4, repeat: 1, cell_w: 8, cell_h: 16 };
|
||||
let pixels = render_doc_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
|
||||
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
|
||||
assert!(inks.contains(&1), "first sentence must use ink 1");
|
||||
assert!(inks.contains(&2), "hue must advance across the newline boundary");
|
||||
assert!(!inks.contains(&3), "no third sentence ink expected");
|
||||
|
||||
// Grid mode keeps the space-only rule: no advance across '\n'.
|
||||
let gridmode = render_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
|
||||
let inks: Vec<u8> = gridmode.iter().copied().filter(|&p| p != 0).collect();
|
||||
assert!(inks.contains(&1));
|
||||
assert!(!inks.contains(&2), "grid mode must not advance hue across newline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_height_hugs_used_rows() {
|
||||
let dims = |png: &[u8]| {
|
||||
let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
|
||||
(dim(16), dim(20))
|
||||
};
|
||||
let render = |text: &str, opts: SnapcompactRenderOptions| {
|
||||
png_bytes(render_snapcompact_png(text.into(), opts).unwrap())
|
||||
};
|
||||
let opts_8x8 =
|
||||
|| SnapcompactRenderOptions { size: 64, font: Some("8x8".into()), ..Default::default() };
|
||||
// 8 cols of 8x8 cells: 10 chars span 2 rows -> 16px tall.
|
||||
assert_eq!(dims(&render("0123456789", opts_8x8())), (64, 16));
|
||||
// Dim toggles are zero-width and must not add a row.
|
||||
assert_eq!(dims(&render("\u{e}01234567\u{f}", opts_8x8())), (64, 8));
|
||||
// Capacity-filling text keeps the full grid height.
|
||||
assert_eq!(dims(&render(&"x".repeat(64), opts_8x8())), (64, 64));
|
||||
// Repeat shapes hug `usedRows * repeat` copy bands.
|
||||
let repeated =
|
||||
render("0123456789", SnapcompactRenderOptions { line_repeat: Some(2), ..opts_8x8() });
|
||||
assert_eq!(dims(&repeated), (64, 32));
|
||||
// Doc layout counts `\n` lines down the first column.
|
||||
let doc = render("Hello there.\nSecond line", SnapcompactRenderOptions {
|
||||
size: 256,
|
||||
font: Some("8x13".into()),
|
||||
cell_width: Some(8),
|
||||
cell_height: Some(16),
|
||||
stretch: Some(false),
|
||||
columns: Some(2),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(dims(&doc), (256, 32));
|
||||
// The stretch path hugs too (RGB output, 6x6 target cells).
|
||||
let stretched = render("0123456789ab", SnapcompactRenderOptions {
|
||||
size: 60,
|
||||
font: Some("8x8".into()),
|
||||
cell_width: Some(6),
|
||||
cell_height: Some(6),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(dims(&stretched), (60, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_validates_and_renders_doc_frames() {
|
||||
assert!(
|
||||
render_snapcompact_png("x".into(), SnapcompactRenderOptions {
|
||||
size: 64,
|
||||
columns: Some(3),
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
// Indexed doc frame (stretch: false on a padded pitch).
|
||||
let doc = png_bytes(
|
||||
render_snapcompact_png("Hello there.\nSecond line".into(), SnapcompactRenderOptions {
|
||||
size: 256,
|
||||
font: Some("8x13".into()),
|
||||
cell_width: Some(8),
|
||||
cell_height: Some(16),
|
||||
stretch: Some(false),
|
||||
columns: Some(2),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(doc[25], 3, "8on16 doc frame must encode indexed");
|
||||
// Doc layout also applies on the stretch path (RGB output).
|
||||
let stretched = png_bytes(
|
||||
render_snapcompact_png("Hello there.\nSecond line".into(), SnapcompactRenderOptions {
|
||||
size: 256,
|
||||
font: Some("8x13".into()),
|
||||
cell_width: Some(6),
|
||||
cell_height: Some(12),
|
||||
columns: Some(2),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(stretched[25], 2, "stretched doc frame must encode RGB");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +76,12 @@ impl From<()> for CancelToken {
|
||||
}
|
||||
|
||||
impl CancelToken {
|
||||
#[must_use]
|
||||
pub fn new(timeout_ms: Option<u32>) -> Self {
|
||||
Self::with_timeout(timeout_ms.map(|ms| Duration::from_millis(u64::from(ms))))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timeout(timeout: Option<Duration>) -> Self {
|
||||
Self { deadline: timeout.map(|duration| Instant::now() + duration), flag: None }
|
||||
}
|
||||
@@ -132,6 +134,7 @@ impl CancelToken {
|
||||
AbortToken(Some(Arc::downgrade(self.flag.get_or_insert_default())))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn aborted(&self) -> bool {
|
||||
if let Some(flag) = &self.flag
|
||||
&& flag.cause().is_some()
|
||||
|
||||
@@ -25,7 +25,13 @@
|
||||
|
||||
use std::{io::BufReader, sync::LazyLock};
|
||||
|
||||
use brush_parser::{Parser, ParserOptions, SourceInfo, ast::*};
|
||||
use brush_parser::{
|
||||
Parser, ParserOptions, SourceInfo,
|
||||
ast::{
|
||||
AndOr, AndOrList, Command, CommandPrefixOrSuffixItem, CompoundListItem, IoFileRedirectKind,
|
||||
IoFileRedirectTarget, IoRedirect, Pipeline, SourceLocation,
|
||||
},
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
/// Result of [`apply_bash_fixups`].
|
||||
@@ -38,6 +44,7 @@ pub struct BashFixupResult {
|
||||
}
|
||||
|
||||
/// Apply the bash fixups to `cmd`. See module docs for full rules.
|
||||
#[must_use]
|
||||
pub fn apply_bash_fixups(cmd: &str) -> BashFixupResult {
|
||||
// Multi-line input is out of scope: heredoc/loop bodies can't be safely
|
||||
// rewritten and the agent rarely passes them as bash tool input. Bailing
|
||||
|
||||
@@ -76,6 +76,7 @@ impl MinimizerOutput {
|
||||
|
||||
/// Transformed output. Caller-supplied `input_bytes` lets the savings
|
||||
/// metric compare pre- and post-filter sizes.
|
||||
#[must_use]
|
||||
pub const fn transformed(text: String, input_bytes: usize) -> Self {
|
||||
let output_bytes = text.len();
|
||||
Self { text, changed: true, input_bytes, output_bytes, filter: "", original_text: None }
|
||||
@@ -111,6 +112,7 @@ impl MinimizerOutput {
|
||||
|
||||
/// Byte count saved by this filter (0 for passthrough).
|
||||
#[allow(dead_code, reason = "test-only API surface")]
|
||||
#[must_use]
|
||||
pub const fn bytes_saved(&self) -> usize {
|
||||
self.input_bytes.saturating_sub(self.output_bytes)
|
||||
}
|
||||
@@ -141,6 +143,7 @@ pub(crate) fn chain_output(
|
||||
/// Apply the configured filter pipeline to a captured buffer.
|
||||
/// Returns the original text unchanged when minimization is disabled, no
|
||||
/// filter matches, or a filter panics.
|
||||
#[must_use]
|
||||
pub fn apply(
|
||||
command: &str,
|
||||
captured: &str,
|
||||
|
||||
@@ -110,6 +110,7 @@ impl Default for MinimizerConfig {
|
||||
impl MinimizerConfig {
|
||||
/// Build a resolved configuration from `MinimizerOptions`, optionally
|
||||
/// merging in a TOML settings file.
|
||||
#[must_use]
|
||||
pub fn from_options(opts: &MinimizerOptions) -> Self {
|
||||
let mut cfg = Self::default();
|
||||
if let Some(enabled) = opts.enabled {
|
||||
@@ -185,6 +186,7 @@ impl MinimizerConfig {
|
||||
}
|
||||
|
||||
/// Whether the engine should attempt to minimize output for `program`.
|
||||
#[must_use]
|
||||
pub fn is_program_enabled(&self, program: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
@@ -200,11 +202,13 @@ impl MinimizerConfig {
|
||||
}
|
||||
|
||||
/// Fetch a per-command TOML table, if any.
|
||||
#[must_use]
|
||||
pub fn per_command(&self, program: &str) -> Option<&toml::Value> {
|
||||
self.per_command.get(&program.to_lowercase())
|
||||
}
|
||||
|
||||
/// Whether opted-in filters should fall back to pre-PR legacy behavior.
|
||||
#[must_use]
|
||||
pub const fn legacy_filters_active(&self) -> bool {
|
||||
self.legacy_filters_active
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/basedpyright.toml
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
|
||||
[filters.basedpyright]
|
||||
description = "Compact basedpyright type checker output — strip blank lines, keep errors"
|
||||
match_command = "^basedpyright$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^Searching for source files",
|
||||
"^Found \\d+ source file",
|
||||
"^Pyright \\d+\\.\\d+",
|
||||
"^basedpyright \\d+\\.\\d+",
|
||||
]
|
||||
max_lines = 50
|
||||
on_empty = "basedpyright: ok"
|
||||
|
||||
[[tests.basedpyright]]
|
||||
name = "strips noise, keeps errors and summary"
|
||||
input = """
|
||||
basedpyright 1.22.0
|
||||
Searching for source files
|
||||
Found 42 source files
|
||||
|
||||
/home/user/app/main.py
|
||||
/home/user/app/main.py:10:5 - error: "foo" is not defined (reportUndefinedVariable)
|
||||
/home/user/app/main.py:25:1 - error: Type "str" is not assignable to type "int" (reportAssignmentType)
|
||||
|
||||
/home/user/app/utils.py
|
||||
/home/user/app/utils.py:8:9 - warning: Variable "x" is not accessed (reportUnusedVariable)
|
||||
|
||||
3 errors, 1 warning, 0 informations
|
||||
"""
|
||||
expected = "/home/user/app/main.py\n /home/user/app/main.py:10:5 - error: \"foo\" is not defined (reportUndefinedVariable)\n /home/user/app/main.py:25:1 - error: Type \"str\" is not assignable to type \"int\" (reportAssignmentType)\n/home/user/app/utils.py\n /home/user/app/utils.py:8:9 - warning: Variable \"x\" is not accessed (reportUnusedVariable)\n3 errors, 1 warning, 0 informations\n"
|
||||
|
||||
[[tests.basedpyright]]
|
||||
name = "clean output"
|
||||
input = """
|
||||
basedpyright 1.22.0
|
||||
Searching for source files
|
||||
Found 10 source files
|
||||
|
||||
0 errors, 0 warnings, 0 informations
|
||||
"""
|
||||
expected = "0 errors, 0 warnings, 0 informations\n"
|
||||
|
||||
[[tests.basedpyright]]
|
||||
name = "empty input returns on_empty message"
|
||||
input = ""
|
||||
expected = "basedpyright: ok"
|
||||
@@ -1,65 +0,0 @@
|
||||
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/bundle-install.toml
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
|
||||
[filters.bundle-install]
|
||||
description = "Compact bundle install/update — strip 'Using' lines, keep installs and errors"
|
||||
match_command = "^bundle$"
|
||||
match_subcommand = "^(install|update)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^Using ",
|
||||
"^\\s*$",
|
||||
"^Fetching gem metadata",
|
||||
"^Resolving dependencies",
|
||||
]
|
||||
match_output = [
|
||||
{ pattern = "Bundle complete!", message = "ok bundle: complete" },
|
||||
{ pattern = "Bundle updated!", message = "ok bundle: updated" },
|
||||
]
|
||||
max_lines = 30
|
||||
|
||||
[[tests.bundle-install]]
|
||||
name = "all cached short-circuits"
|
||||
input = """
|
||||
Using bundler 2.5.6
|
||||
Using rake 13.1.0
|
||||
Using ast 2.4.2
|
||||
Using base64 0.2.0
|
||||
Using minitest 5.22.2
|
||||
Bundle complete! 85 Gemfile dependencies, 200 gems now installed.
|
||||
Use `bundle info [gemname]` to see where a bundled gem is installed.
|
||||
"""
|
||||
expected = "ok bundle: complete"
|
||||
|
||||
[[tests.bundle-install]]
|
||||
name = "mixed install keeps Fetching and Installing lines"
|
||||
input = """
|
||||
Fetching gem metadata from https://rubygems.org/.........
|
||||
Resolving dependencies...
|
||||
Using rake 13.1.0
|
||||
Using ast 2.4.2
|
||||
Fetching rspec 3.13.0
|
||||
Installing rspec 3.13.0
|
||||
Using rubocop 1.62.0
|
||||
Fetching simplecov 0.22.0
|
||||
Installing simplecov 0.22.0
|
||||
Bundle complete! 85 Gemfile dependencies, 202 gems now installed.
|
||||
"""
|
||||
expected = "ok bundle: complete"
|
||||
|
||||
[[tests.bundle-install]]
|
||||
name = "update output"
|
||||
input = """
|
||||
Fetching gem metadata from https://rubygems.org/.........
|
||||
Resolving dependencies...
|
||||
Using rake 13.1.0
|
||||
Fetching rspec 3.14.0 (was 3.13.0)
|
||||
Installing rspec 3.14.0 (was 3.13.0)
|
||||
Bundle updated!
|
||||
"""
|
||||
expected = "ok bundle: updated"
|
||||
|
||||
[[tests.bundle-install]]
|
||||
name = "empty output"
|
||||
input = ""
|
||||
expected = ""
|
||||
@@ -1,68 +0,0 @@
|
||||
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/dotnet-build.toml
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
|
||||
[filters.dotnet-build]
|
||||
description = "Compact dotnet build output — short-circuit on success, strip banners"
|
||||
match_command = "^dotnet$"
|
||||
match_subcommand = "^build$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^Microsoft \\(R\\)",
|
||||
"^Copyright \\(C\\)",
|
||||
"^ Determining projects",
|
||||
]
|
||||
match_output = [
|
||||
{ pattern = "0 Warning\\(s\\)\\n\\s+0 Error\\(s\\)", message = "ok (build succeeded)" },
|
||||
]
|
||||
max_lines = 40
|
||||
|
||||
[[tests.dotnet-build]]
|
||||
name = "successful build short-circuits to ok"
|
||||
input = """
|
||||
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
|
||||
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Determining projects to restore...
|
||||
All projects are up-to-date for restore.
|
||||
MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll
|
||||
|
||||
Build succeeded.
|
||||
0 Warning(s)
|
||||
0 Error(s)
|
||||
|
||||
Time Elapsed 00:00:02.34
|
||||
"""
|
||||
expected = "ok (build succeeded)"
|
||||
|
||||
[[tests.dotnet-build]]
|
||||
name = "build with warnings not short-circuited"
|
||||
input = """
|
||||
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
|
||||
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Determining projects to restore...
|
||||
MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll
|
||||
|
||||
Build succeeded.
|
||||
3 Warning(s)
|
||||
0 Error(s)
|
||||
|
||||
Time Elapsed 00:00:01.87
|
||||
"""
|
||||
expected = " MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll\nBuild succeeded.\n 3 Warning(s)\n 0 Error(s)\nTime Elapsed 00:00:01.87\n"
|
||||
|
||||
[[tests.dotnet-build]]
|
||||
name = "build errors pass through"
|
||||
input = """
|
||||
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
|
||||
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Determining projects to restore...
|
||||
src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj]
|
||||
|
||||
Build FAILED.
|
||||
0 Warning(s)
|
||||
1 Error(s)
|
||||
"""
|
||||
expected = "src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj]\nBuild FAILED.\n 0 Warning(s)\n 1 Error(s)\n"
|
||||
@@ -12,7 +12,7 @@ strip_lines_matching = [
|
||||
"^\\s*\\$ gcloud components update",
|
||||
"^Your current Cloud SDK version is:",
|
||||
"^The latest available version is:",
|
||||
"^WARNING: You do not appear to have access to project",
|
||||
"^\\s*$",
|
||||
]
|
||||
truncate_lines_at = 300
|
||||
max_lines = 120
|
||||
@@ -27,3 +27,8 @@ expected = "NAME: my-project\nPROJECT_ID: my-project-123\n"
|
||||
name = "empty after stripping update notice"
|
||||
input = "Updates are available for some Google Cloud CLI components.\n"
|
||||
expected = "gcloud: ok"
|
||||
|
||||
[[tests.gcloud]]
|
||||
name = "blank separator line removed between content"
|
||||
input = "NAME: my-project\n\nPROJECT_ID: my-project-123\n"
|
||||
expected = "NAME: my-project\nPROJECT_ID: my-project-123\n"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Gradle + ./gradlew build output.
|
||||
# Strip daemon chatter and UP-TO-DATE/NO-SOURCE/FROM-CACHE tasks, keep real
|
||||
# tasks and errors.
|
||||
|
||||
[filters.gradle]
|
||||
description = "Compact gradle / gradlew build output"
|
||||
match_command = "^(gradle|gradlew)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^> Configuring project",
|
||||
"^> Resolving dependencies",
|
||||
"^> Transform ",
|
||||
"^Download(ing)?\\s+http",
|
||||
"^\\s*<-+>\\s*$",
|
||||
"^> Task :.*UP-TO-DATE$",
|
||||
"^> Task :.*NO-SOURCE$",
|
||||
"^> Task :.*FROM-CACHE$",
|
||||
"^Starting a Gradle Daemon",
|
||||
"^Daemon will be stopped",
|
||||
]
|
||||
truncate_lines_at = 200
|
||||
max_lines = 80
|
||||
on_empty = "gradle: ok"
|
||||
|
||||
[[tests.gradle]]
|
||||
name = "strips UP-TO-DATE and keeps result"
|
||||
input = "> Configuring project :app\n> Task :app:compileJava UP-TO-DATE\n> Task :app:compileKotlin UP-TO-DATE\n> Task :app:test\n3 tests completed, 1 failed\nBUILD FAILED in 12s"
|
||||
expected = "> Task :app:test\n3 tests completed, 1 failed\nBUILD FAILED in 12s\n"
|
||||
|
||||
[[tests.gradle]]
|
||||
name = "clean build untouched"
|
||||
input = "BUILD SUCCESSFUL in 8s\n7 actionable tasks: 7 executed"
|
||||
expected = "BUILD SUCCESSFUL in 8s\n7 actionable tasks: 7 executed\n"
|
||||
|
||||
[[tests.gradle]]
|
||||
name = "empty after stripping"
|
||||
input = "> Configuring project :app\n"
|
||||
expected = "gradle: ok"
|
||||
@@ -8,6 +8,8 @@ match_command = "^helm$"
|
||||
match_subcommand = "^(repo|search|list|history)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^W\\d{4}",
|
||||
"^Hang tight while we grab the latest",
|
||||
"^\\.\\.\\.Successfully got an update from",
|
||||
"^\\.\\.\\.Unable to get an update from",
|
||||
@@ -28,3 +30,8 @@ expected = "NAME\tURL\nbitnami\thttps://charts.bitnami.com/bitnami\n"
|
||||
name = "empty after stripping"
|
||||
input = "Hang tight while we grab the latest from your chart repositories...\n"
|
||||
expected = "helm: ok"
|
||||
|
||||
[[tests.helm]]
|
||||
name = "strips glog W-prefix warning and blank line keeps table rows"
|
||||
input = "W0419 10:30:00.123456 1234 warnings.go:70] cannot validate\nNAME\tNAMESPACE\tREVISION\nmy-release\tdefault\t3\n\nother-release\tdefault\t1\n"
|
||||
expected = "NAME\tNAMESPACE\tREVISION\nmy-release\tdefault\t3\nother-release\tdefault\t1\n"
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
description = "Compact iptables rule listings"
|
||||
match_command = "^(ip6?tables|iptables-save|ip6tables-save)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^Chain DOCKER",
|
||||
"^Chain BR-",
|
||||
"^\\s*$",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
head_lines = 40
|
||||
tail_lines = 20
|
||||
@@ -14,3 +19,8 @@ on_empty = "iptables: empty"
|
||||
name = "small listing passes through"
|
||||
input = "Chain INPUT (policy ACCEPT 0 packets, 0 bytes)\n pkts bytes target prot opt in out source destination\n"
|
||||
expected = "Chain INPUT (policy ACCEPT 0 packets, 0 bytes)\n pkts bytes target prot opt in out source destination\n"
|
||||
|
||||
[[tests.iptables]]
|
||||
name = "strips docker/bridge chain headers and blanks, keeps policy and rules"
|
||||
input = "Chain INPUT (policy ACCEPT)\n1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0\n\nChain DOCKER (1 references)\n DOCKER all -- 0.0.0.0/0 0.0.0.0/0\nChain BR-abcdef (0 references)\n"
|
||||
expected = "Chain INPUT (policy ACCEPT)\n1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0\n DOCKER all -- 0.0.0.0/0 0.0.0.0/0\n"
|
||||
|
||||
@@ -6,6 +6,7 @@ match_command = "^jj$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^Hint:",
|
||||
]
|
||||
truncate_lines_at = 200
|
||||
max_lines = 80
|
||||
@@ -20,3 +21,8 @@ expected = "Working copy (@) : abc def\nParent commit : xyz 123 main | msg
|
||||
name = "empty becomes sentinel"
|
||||
input = "\n\n"
|
||||
expected = "jj: ok"
|
||||
|
||||
[[tests.jj]]
|
||||
name = "strips hint keeps working copy update"
|
||||
input = "Working copy now at: qpvuntsm abc123 feat: add new feature\nHint: use `jj log` to see the full history\n"
|
||||
expected = "Working copy now at: qpvuntsm abc123 feat: add new feature\n"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Liquibase update / rollback output.
|
||||
# Liquibase update / rollback / status / --version output. Strip the ASCII-art
|
||||
# banner (hash border + body rows), the INFO/FINE log chatter, and the startup
|
||||
# environment dump (Java Home / Libraries / jar list) from --version. The
|
||||
# Liquibase Version line is the one meaningful line of --version output, so it
|
||||
# is deliberately kept.
|
||||
|
||||
[filters.liquibase]
|
||||
description = "Compact liquibase output"
|
||||
@@ -13,21 +17,34 @@ strip_lines_matching = [
|
||||
"^\\[\\S+\\] INFO ",
|
||||
"^\\[\\S+\\] FINE ",
|
||||
"^Starting Liquibase",
|
||||
"^Liquibase Version:",
|
||||
"^Liquibase Community",
|
||||
"^Liquibase (Community|Open Source)",
|
||||
"^Liquibase Home:",
|
||||
"^Running Changeset:",
|
||||
"^####+",
|
||||
"^\\s*##",
|
||||
"^\\[INFO\\]\\s*#+$",
|
||||
"^Java Home",
|
||||
"^Libraries:",
|
||||
"^\\s*-\\s+\\S+\\.jar",
|
||||
]
|
||||
truncate_lines_at = 300
|
||||
max_lines = 80
|
||||
on_empty = "liquibase: ok"
|
||||
|
||||
[[tests.liquibase]]
|
||||
name = "keeps severe"
|
||||
input = "INFO Parsing changelog\nRunning Changeset: db/changelog.xml::1::author\nSEVERE ChangeSet failed: ORA-00942"
|
||||
name = "strips banner and info keeps severe"
|
||||
input = "####################################################\n## _ _ _ _ ##\n####################################################\nINFO Parsing changelog\nRunning Changeset: db/changelog.xml::1::author\nSEVERE ChangeSet failed: ORA-00942\n"
|
||||
expected = "SEVERE ChangeSet failed: ORA-00942\n"
|
||||
|
||||
[[tests.liquibase]]
|
||||
name = "version output reduces to the version line"
|
||||
input = "####################################################\n## _ _ _ _ ##\n####################################################\nStarting Liquibase at 13:45:24 (version 4.30.0)\nLiquibase Version: 4.30.0\nLiquibase Open Source 4.30.0 by Liquibase\nJava Home C:\\Program Files\\Java\\jdk-17.0.15\nLibraries:\n - internal\\lib\\commons-io.jar: Apache Commons IO\n"
|
||||
expected = "Liquibase Version: 4.30.0\n"
|
||||
|
||||
[[tests.liquibase]]
|
||||
name = "status passthrough without changeset line"
|
||||
input = "Starting Liquibase at 10:00:00 (version 4.30.0)\nLiquibase Version: 4.30.0\nLiquibase Open Source 4.30.0 by Liquibase\nRunning Changeset: filepath::id::author\nHR@jdbc:oracle:thin:@localhost:1523:XE is up to date\nLiquibase command 'status' was executed successfully.\n"
|
||||
expected = "Liquibase Version: 4.30.0\nHR@jdbc:oracle:thin:@localhost:1523:XE is up to date\nLiquibase command 'status' was executed successfully.\n"
|
||||
|
||||
[[tests.liquibase]]
|
||||
name = "info noise collapses"
|
||||
input = "INFO Parsing changelog\nINFO Successful update"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
[filters.maven]
|
||||
description = "Compact Maven build output"
|
||||
match_command = "^(mvn|mvnw)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\[INFO\\] Downloading ",
|
||||
"^\\[INFO\\] Downloaded ",
|
||||
"^\\[INFO\\] -+",
|
||||
"^\\[INFO\\]\\s*$",
|
||||
"^\\[INFO\\] Building ",
|
||||
"^\\[INFO\\] Scanning for projects",
|
||||
"^\\[INFO\\] --- ",
|
||||
"^Progress ",
|
||||
]
|
||||
truncate_lines_at = 200
|
||||
max_lines = 80
|
||||
on_empty = "maven: ok"
|
||||
|
||||
[[tests.maven]]
|
||||
name = "keeps build failure"
|
||||
input = "[INFO] Scanning for projects...\n[INFO] Downloading from central: https://repo.maven.apache.org/foo.jar\n[INFO] -----\n[ERROR] Failed to execute goal\n[INFO] BUILD FAILURE"
|
||||
expected = "[ERROR] Failed to execute goal\n[INFO] BUILD FAILURE\n"
|
||||
|
||||
[[tests.maven]]
|
||||
name = "clean success"
|
||||
input = "[INFO] BUILD SUCCESS"
|
||||
expected = "[INFO] BUILD SUCCESS\n"
|
||||
@@ -9,6 +9,9 @@ strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^mise\\s+hint\\s",
|
||||
"^mise\\s+.*already installed",
|
||||
"^mise\\s+Downloading\\s",
|
||||
"^mise\\s+Extracting\\s",
|
||||
"^mise\\s+(trust|install|upgrade).*✓",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
max_lines = 80
|
||||
@@ -23,3 +26,13 @@ expected = "mise node@22.0.0 install\nmise node@22.0.0 installed\n"
|
||||
name = "all already installed"
|
||||
input = "mise node@20.0.0 already installed\n"
|
||||
expected = "mise: ok"
|
||||
|
||||
[[tests.mise]]
|
||||
name = "preserves error output"
|
||||
input = "mise run lint\nError: biome check failed\nsrc/index.ts:5 — unused variable\n"
|
||||
expected = "mise run lint\nError: biome check failed\nsrc/index.ts:5 — unused variable\n"
|
||||
|
||||
[[tests.mise]]
|
||||
name = "strips trust/download/extract noise keeps installed result"
|
||||
input = "mise trust ~/dev/.mise.toml ✓\nmise Downloading node@22.0.0\nmise Extracting node@22.0.0\nmise node@22.0.0 installed\n"
|
||||
expected = "mise node@22.0.0 installed\n"
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mvn-build.toml
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
|
||||
[filters.mvn-build]
|
||||
description = "Compact Maven build output"
|
||||
match_command = "^(mvn|mvnw)$"
|
||||
match_subcommand = "^(compile|package|clean|install)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\[INFO\\] ---",
|
||||
"^\\[INFO\\] Building\\s",
|
||||
"^\\[INFO\\] Downloading\\s",
|
||||
"^\\[INFO\\] Downloaded\\s",
|
||||
"^\\[INFO\\]\\s*$",
|
||||
"^\\s*$",
|
||||
"^Downloading:",
|
||||
"^Downloaded:",
|
||||
"^Progress",
|
||||
]
|
||||
max_lines = 50
|
||||
on_empty = "mvn: ok"
|
||||
|
||||
[[tests.mvn-build]]
|
||||
name = "strips INFO noise, preserves errors and summary"
|
||||
input = """
|
||||
[INFO] ---
|
||||
[INFO] Building myapp 1.0-SNAPSHOT
|
||||
[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0
|
||||
[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0
|
||||
[INFO]
|
||||
[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol
|
||||
symbol: method foo()
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] Total time: 2.543 s
|
||||
"""
|
||||
expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s\n"
|
||||
|
||||
[[tests.mvn-build]]
|
||||
name = "successful build keeps BUILD SUCCESS line"
|
||||
input = """
|
||||
[INFO] ---
|
||||
[INFO] Building myapp 1.0-SNAPSHOT
|
||||
[INFO]
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] Total time: 4.123 s
|
||||
[INFO] Finished at: 2024-01-15T10:30:00Z
|
||||
"""
|
||||
expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z\n"
|
||||
@@ -0,0 +1,87 @@
|
||||
# Ported from snip/filters/npx.yaml (declarative DSL → local TOML schema).
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
#
|
||||
# This def is the catch-all for UNKNOWN `npx <tool>` invocations only. It is NOT
|
||||
# safe by construction on `match_command` alone: engine.rs reaches a pipeline
|
||||
# along TWO paths, and a bare `^npx$` with no subcommand gate leaks on both.
|
||||
# 1. Standalone (apply_identity -> resolve_pipeline): fires when
|
||||
# filters::supports("npx", sub) is FALSE. `npx nx` lands here, and a bare
|
||||
# npx def sorts before `nx-wrapped` (npx.toml < nx.toml in build.rs file
|
||||
# order), so find() would return THIS def first and shadow nx-wrapped's
|
||||
# NX-banner strip.
|
||||
# 2. Overlay (apply_pipeline_overlay): fires AFTER the Rust filter when
|
||||
# filters::supports("npx", sub) is TRUE. `npx eslint`/`npx jest`/... run
|
||||
# their Rust lint/test filter, then resolve_pipeline re-selects this def
|
||||
# and re-applies max_lines/strip on top, truncating diagnostics the Rust
|
||||
# filter intentionally kept.
|
||||
# match_subcommand below excludes every subcommand owned elsewhere so neither
|
||||
# path leaks. The excluded set is the SOURCE-OF-TRUTH union of:
|
||||
# - filters::supports("npx", _) == true (mod.rs npx arm: tsc, eslint, biome,
|
||||
# jest, vitest, playwright; js_tools NPX_ROUTABLE_TOOLS: tsc, eslint, prisma,
|
||||
# prettier, next), and
|
||||
# - nx (claimed by nx-wrapped in nx.toml).
|
||||
# The pattern is the lookahead-free anchored COMPLEMENT of that word set: the
|
||||
# `regex` crate is RE2-style with no look-around, so a `(?!...)` negative cannot
|
||||
# be used and a positive allowlist would match the wrong (excluded) tokens. It
|
||||
# was machine-generated as the trie-complement and verified against the full
|
||||
# known set plus prefix/suffix edge cases (e.g. `eslint-config`, `nextfoo`,
|
||||
# `playwrigh` must still be claimed; `eslint`, `next`, `playwright` must not).
|
||||
# If a tool is ever added to the npx routing in filters::supports, add it here
|
||||
# too — the engine regression tests in engine.rs (npx_def_does_not_shadow_*/
|
||||
# npx_def_does_not_overlay_*/npx_def_fires_for_unknown_tool) fail loudly on
|
||||
# desync.
|
||||
#
|
||||
# snip's `^Need to install` / `^Ok to proceed` patterns are widened here to the
|
||||
# full literal npm install-prompt lines so the strip is unambiguous against
|
||||
# default human output (the JSON-reporter tiers never apply to npx preambles).
|
||||
|
||||
[filters.npx]
|
||||
description = "Condensed npx output for unknown tools — strip install prompts, npm warn/notice noise, and blank lines"
|
||||
match_command = "^npx$"
|
||||
# Anchored complement of {nx, tsc, eslint, biome, jest, vitest, playwright,
|
||||
# prisma, prettier, next}: matches any npx subcommand EXCEPT those routed/owned
|
||||
# elsewhere (see header). Lookahead-free for the RE2-style `regex` crate.
|
||||
match_subcommand = "^(?:|[^bejnptv].*|b(?:|[^i].*|i(?:|[^o].*|o(?:|[^m].*|m(?:|[^e].*|e(?:.+)))))|e(?:|[^s].*|s(?:|[^l].*|l(?:|[^i].*|i(?:|[^n].*|n(?:|[^t].*|t(?:.+))))))|j(?:|[^e].*|e(?:|[^s].*|s(?:|[^t].*|t(?:.+))))|n(?:|[^ex].*|e(?:|[^x].*|x(?:|[^t].*|t(?:.+)))|x(?:.+))|p(?:|[^lr].*|l(?:|[^a].*|a(?:|[^y].*|y(?:|[^w].*|w(?:|[^r].*|r(?:|[^i].*|i(?:|[^g].*|g(?:|[^h].*|h(?:|[^t].*|t(?:.+)))))))))|r(?:|[^ei].*|e(?:|[^t].*|t(?:|[^t].*|t(?:|[^i].*|i(?:|[^e].*|e(?:|[^r].*|r(?:.+))))))|i(?:|[^s].*|s(?:|[^m].*|m(?:|[^a].*|a(?:.+))))))|t(?:|[^s].*|s(?:|[^c].*|c(?:.+)))|v(?:|[^i].*|i(?:|[^t].*|t(?:|[^e].*|e(?:|[^s].*|s(?:|[^t].*|t(?:.+)))))))$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^Need to install the following packages",
|
||||
"^Ok to proceed\\? \\(y\\)",
|
||||
"^npm warn",
|
||||
"^npm notice",
|
||||
"^\\s*$",
|
||||
]
|
||||
truncate_lines_at = 120
|
||||
max_lines = 50
|
||||
|
||||
[[tests.npx]]
|
||||
name = "first-run strips install preamble + npm warn/notice, keeps tool output"
|
||||
# Exercises every content-strip pattern: the install-prompt header, the
|
||||
# `Ok to proceed?` line, npm warn/notice noise, and blank lines. snip's port
|
||||
# does not strip the bare `name@version` package line, so it survives — that
|
||||
# residue is faithful to the donor and documented here.
|
||||
input = """
|
||||
Need to install the following packages:
|
||||
cowsay@1.6.0
|
||||
Ok to proceed? (y)
|
||||
|
||||
npm warn deprecated foo@1.0.0: use bar instead
|
||||
npm notice New major version of npm available!
|
||||
_______
|
||||
< Hello >
|
||||
-------
|
||||
\\ ^__^
|
||||
\\ (oo)\\_______
|
||||
(__)\\ )\\/\\
|
||||
||----w |
|
||||
|| ||
|
||||
"""
|
||||
expected = """cowsay@1.6.0
|
||||
_______
|
||||
< Hello >
|
||||
-------
|
||||
\\ ^__^
|
||||
\\ (oo)\\_______
|
||||
(__)\\ )\\/\\
|
||||
||----w |
|
||||
|| ||
|
||||
"""
|
||||
@@ -12,6 +12,9 @@ strip_lines_matching = [
|
||||
"^\\s*>\\s+NX\\s+Cloud",
|
||||
"^\\s*>\\s+NX\\s+Distributed",
|
||||
"^\\s*>\\s+NX\\s+View logs",
|
||||
"^———————",
|
||||
"^\\s*>\\s*NX\\s+Nx read the output",
|
||||
"^\\s+Nx \\(powered by",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
max_lines = 80
|
||||
@@ -26,3 +29,42 @@ expected = "> nx run app:build\nError: build failed\n > NX Ran target build f
|
||||
name = "all noise becomes ok"
|
||||
input = "\n > NX Successfully ran target build for 3 projects\n\n"
|
||||
expected = "nx: ok"
|
||||
|
||||
[[tests.nx]]
|
||||
name = "strips separator cache-replay and footer ad"
|
||||
input = "————————————————————————————————————————\n > NX Nx read the output from the cache instead of running the command\nCompiled successfully.\n Nx (powered by Nx Cloud)\n"
|
||||
expected = "Compiled successfully.\n"
|
||||
|
||||
# Wrapper invocations: `pnpm nx build`, `npx nx ...`, `yarn nx ...` detect as
|
||||
# (program=pnpm|npx|yarn, subcommand=nx) and fall through pkg::supports to the
|
||||
# builtin defs. The bare `nx` entry only matches the program token, so this
|
||||
# companion claims the wrapped form with the same strip/cap/on_empty rules.
|
||||
[filters.nx-wrapped]
|
||||
description = "Compact Nx task runner output (pnpm/npx/yarn wrapper invocations)"
|
||||
match_command = "^(pnpm|npx|yarn)$"
|
||||
match_subcommand = "^nx$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^\\s*>\\s+NX\\s+Running target",
|
||||
"^\\s*>\\s+NX\\s+Successfully ran target",
|
||||
"^\\s*>\\s+NX\\s+Cloud",
|
||||
"^\\s*>\\s+NX\\s+Distributed",
|
||||
"^\\s*>\\s+NX\\s+View logs",
|
||||
"^———————",
|
||||
"^\\s*>\\s*NX\\s+Nx read the output",
|
||||
"^\\s+Nx \\(powered by",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
max_lines = 80
|
||||
on_empty = "nx: ok"
|
||||
|
||||
[[tests.nx-wrapped]]
|
||||
name = "wrapped pnpm nx strips banner keeps task output"
|
||||
input = "\n > NX Running target build for 3 projects\n\n> nx run app:build\nError: build failed\n\n > NX Ran target build for 3 projects (2s)\n"
|
||||
expected = "> nx run app:build\nError: build failed\n > NX Ran target build for 3 projects (2s)\n"
|
||||
|
||||
[[tests.nx-wrapped]]
|
||||
name = "wrapped all noise becomes ok"
|
||||
input = "\n > NX Successfully ran target build for 3 projects\n\n"
|
||||
expected = "nx: ok"
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# ollama output. Drop pull/download progress spinners and verifying/writing
|
||||
# chatter, keep the final lines.
|
||||
# ollama output. For management subcommands (pull/push/list/...), drop
|
||||
# pull/download progress spinners and verifying/writing chatter, keep the
|
||||
# final lines, and cap. For `ollama run`, the model response IS the payload,
|
||||
# so it must pass uncapped — only spinner residue is stripped.
|
||||
#
|
||||
# BTreeMap/first-match note: `[filters.ollama]` sorts before
|
||||
# `[filters.ollama-run]`, but its match_subcommand gate excludes `run`, so the
|
||||
# run entry is the one that claims `ollama run`.
|
||||
|
||||
[filters.ollama]
|
||||
description = "Compact ollama output"
|
||||
description = "Compact ollama management output"
|
||||
match_command = "^ollama$"
|
||||
match_subcommand = "^(pull|push|list|ls|rm|cp|show|create|serve|stop|ps)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
@@ -17,6 +24,15 @@ truncate_lines_at = 200
|
||||
max_lines = 60
|
||||
on_empty = "ollama: ok"
|
||||
|
||||
[filters.ollama-run]
|
||||
description = "Strip spinner residue from ollama run, keep the full response"
|
||||
match_command = "^ollama$"
|
||||
match_subcommand = "^run$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\\s]*$",
|
||||
]
|
||||
|
||||
[[tests.ollama]]
|
||||
name = "strips pull progress keeps success"
|
||||
input = "pulling manifest\npulling abc123 100%\nverifying sha256 digest\nwriting manifest\nsuccess\n"
|
||||
@@ -26,3 +42,8 @@ expected = "success\n"
|
||||
name = "empty becomes sentinel"
|
||||
input = "pulling manifest\n"
|
||||
expected = "ollama: ok"
|
||||
|
||||
[[tests.ollama-run]]
|
||||
name = "long run response passes uncapped with spinner residue stripped"
|
||||
input = "⠋ \n⠙ \nHere is a long answer.\nLine two of the response.\nLine three of the response.\nLine four of the response.\n"
|
||||
expected = "Here is a long answer.\nLine two of the response.\nLine three of the response.\nLine four of the response.\n"
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
# ping: the per-packet lines are rarely useful once a run is over. Keep the
|
||||
# header, the "--- ... ping statistics ---" separator, the loss line, and the
|
||||
# rtt summary. Drop everything else.
|
||||
# ping: runs as an OVERLAY on system.rs compact_ping_output (which already
|
||||
# strips PING/Pinging headers and per-packet reply lines). The per-packet
|
||||
# lines are rarely useful once a run is over. Keep the stats separators, the
|
||||
# loss/rtt summary, and — critically — any error or timeout lines.
|
||||
#
|
||||
# Failures must never be masked: gate on exit 0 so nonzero-exit runs (e.g.
|
||||
# "ping: unknown host") pass the native output through untouched instead of
|
||||
# collapsing to the on_empty sentinel.
|
||||
|
||||
[filters.ping]
|
||||
description = "Keep only ping summary + loss line"
|
||||
description = "Keep only ping summary + loss/error lines (exit 0 only)"
|
||||
match_command = "^ping6?$"
|
||||
strip_ansi = true
|
||||
only_on_exit = [0]
|
||||
keep_lines_matching = [
|
||||
"^PING ",
|
||||
"^--- .* ping statistics ---$",
|
||||
"packet loss",
|
||||
"^rtt min",
|
||||
"^round-trip min",
|
||||
"timeout",
|
||||
"[Uu]nreachable",
|
||||
"^ping:",
|
||||
"^From ",
|
||||
"^Ping statistics for",
|
||||
"Packets: Sent",
|
||||
"^\\s*Minimum =",
|
||||
"^Approximate round trip",
|
||||
]
|
||||
tail_lines = 6
|
||||
on_empty = "ping: ok"
|
||||
|
||||
[[tests.ping]]
|
||||
name = "keeps summary tail drops per-packet lines"
|
||||
input = "PING google.com (1.2.3.4) 56(84) bytes of data.\n64 bytes from a (1.2.3.4): icmp_seq=1 ttl=58 time=12.3 ms\n64 bytes from a (1.2.3.4): icmp_seq=2 ttl=58 time=12.5 ms\n\n--- google.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1001ms\nrtt min/avg/max/mdev = 12.3/12.4/12.5/0.1 ms\n"
|
||||
expected = "PING google.com (1.2.3.4) 56(84) bytes of data.\n--- google.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1001ms\nrtt min/avg/max/mdev = 12.3/12.4/12.5/0.1 ms\n"
|
||||
|
||||
[[tests.ping]]
|
||||
name = "unknown host on nonzero exit passes through unchanged"
|
||||
exit = 2
|
||||
input = "ping: unknown host\nName or service not known\n"
|
||||
expected = "ping: unknown host\nName or service not known\n"
|
||||
|
||||
[[tests.ping]]
|
||||
name = "windows stats block survives"
|
||||
input = "Pinging 192.0.2.1 with 32 bytes of data:\n\nPing statistics for 192.0.2.1:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),\nApproximate round trip times in milli-seconds:\n Minimum = 13ms, Maximum = 14ms, Average = 13ms\n"
|
||||
expected = "Ping statistics for 192.0.2.1:\n Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),\nApproximate round trip times in milli-seconds:\n Minimum = 13ms, Maximum = 14ms, Average = 13ms\n"
|
||||
|
||||
[[tests.ping]]
|
||||
name = "timeout lines survive on exit 0"
|
||||
input = "Request timeout for icmp_seq 0\nRequest timeout for icmp_seq 1\n\n--- host ping statistics ---\n2 packets transmitted, 0 received, 100% packet loss, time 1001ms\n"
|
||||
expected = "Request timeout for icmp_seq 0\nRequest timeout for icmp_seq 1\n--- host ping statistics ---\n2 packets transmitted, 0 received, 100% packet loss, time 1001ms\n"
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/poetry-install.toml
|
||||
# match_command adapted for the local (program, subcommand) dispatch model.
|
||||
|
||||
[filters.poetry-install]
|
||||
description = "Compact poetry install/lock/update output — strip downloads, short-circuit when up-to-date"
|
||||
match_command = "^poetry$"
|
||||
match_subcommand = "^(install|lock|update)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^ [-•] Downloading ",
|
||||
"^ [-•] Installing .* \\(",
|
||||
"^Creating virtualenv",
|
||||
"^Using virtualenv",
|
||||
]
|
||||
match_output = [
|
||||
{ pattern = "No dependencies to install or update|No changes\\.", message = "ok (up to date)" },
|
||||
]
|
||||
max_lines = 30
|
||||
|
||||
[[tests.poetry-install]]
|
||||
name = "up to date short-circuits"
|
||||
input = """
|
||||
Installing dependencies from lock file
|
||||
|
||||
No dependencies to install or update
|
||||
"""
|
||||
expected = "ok (up to date)"
|
||||
|
||||
[[tests.poetry-install]]
|
||||
name = "poetry 2.x bullet syntax short-circuits to ok"
|
||||
input = """
|
||||
• Installing requests (2.31.0)
|
||||
• Installing certifi (2023.11.17)
|
||||
|
||||
No changes.
|
||||
"""
|
||||
expected = "ok (up to date)"
|
||||
|
||||
[[tests.poetry-install]]
|
||||
name = "install strips download lines"
|
||||
input = """
|
||||
Installing dependencies from lock file
|
||||
|
||||
- Downloading requests-2.31.0-py3-none-any.whl (62.6 kB)
|
||||
- Installing certifi (2023.11.17)
|
||||
- Installing charset-normalizer (3.3.2)
|
||||
- Installing idna (3.6)
|
||||
- Installing urllib3 (2.1.0)
|
||||
- Installing requests (2.31.0)
|
||||
|
||||
Writing lock file
|
||||
"""
|
||||
expected = "Installing dependencies from lock file\nWriting lock file\n"
|
||||
@@ -0,0 +1,90 @@
|
||||
# Compact rails db:migrate / db:rollback output.
|
||||
|
||||
[filters.rails-migrate]
|
||||
description = "Compact rails db:migrate / db:rollback output"
|
||||
match_command = "^(rails|rake)$"
|
||||
match_subcommand = "^db:(migrate|rollback)$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^\\s*(--|->)",
|
||||
]
|
||||
keep_lines_matching = ["(migrating|migrated|aborted|error|Error|reverting|reverted)"]
|
||||
replace_after = [
|
||||
{ pattern = "^== \\d+ ([\\w:]+): (?:migrated|reverted).*$", replacement = "✓ $1" },
|
||||
]
|
||||
max_lines = 40
|
||||
on_empty = "ok (no pending migrations)"
|
||||
|
||||
[[tests.rails-migrate]]
|
||||
name = "strips step lines and compacts migrations"
|
||||
input = """\
|
||||
== 20240115123456 CreateUsers: migrating ======================================
|
||||
-- create_table(:users)
|
||||
-> 0.0234s
|
||||
== 20240115123456 CreateUsers: migrated (0.0235s) =============================
|
||||
|
||||
== 20240116789012 AddIndexToOrders: migrating =================================
|
||||
-- add_index(:orders, :user_id)
|
||||
-> 0.0123s
|
||||
== 20240116789012 AddIndexToOrders: migrated (0.0124s) ========================
|
||||
|
||||
== 20240117111111 CreateProducts: migrating ===================================
|
||||
-- create_table(:products)
|
||||
-> 0.0456s
|
||||
-- add_index(:products, :category_id)
|
||||
-> 0.0078s
|
||||
== 20240117111111 CreateProducts: migrated (0.0534s) ==========================
|
||||
"""
|
||||
expected = """\
|
||||
== 20240115123456 CreateUsers: migrating ======================================
|
||||
✓ CreateUsers
|
||||
== 20240116789012 AddIndexToOrders: migrating =================================
|
||||
✓ AddIndexToOrders
|
||||
== 20240117111111 CreateProducts: migrating ===================================
|
||||
✓ CreateProducts
|
||||
"""
|
||||
|
||||
[[tests.rails-migrate]]
|
||||
name = "rollback keeps revert lines and compacts"
|
||||
input = """\
|
||||
== 20240115123456 CreateUsers: reverting ======================================
|
||||
-- drop_table(:users)
|
||||
-> 0.0234s
|
||||
== 20240115123456 CreateUsers: reverted (0.0235s) =============================
|
||||
|
||||
== 20240116789012 AddIndexToOrders: reverting =================================
|
||||
-- remove_index(:orders, :user_id)
|
||||
-> 0.0123s
|
||||
== 20240116789012 AddIndexToOrders: reverted (0.0124s) ========================
|
||||
"""
|
||||
expected = """\
|
||||
== 20240115123456 CreateUsers: reverting ======================================
|
||||
✓ CreateUsers
|
||||
== 20240116789012 AddIndexToOrders: reverting =================================
|
||||
✓ AddIndexToOrders
|
||||
"""
|
||||
|
||||
[[tests.rails-migrate]]
|
||||
name = "no pending migrations collapses to sentinel"
|
||||
input = """\
|
||||
-- create_table(:users)
|
||||
-> 0.0234s
|
||||
|
||||
-- add_index(:orders, :user_id)
|
||||
-> 0.0123s
|
||||
"""
|
||||
expected = "ok (no pending migrations)"
|
||||
|
||||
[[tests.rails-migrate]]
|
||||
name = "namespaced migration name is preserved"
|
||||
input = """\
|
||||
== 20240115123456 Admin::CreateUsers: migrating =================================
|
||||
-- create_table(:users)
|
||||
-> 0.0234s
|
||||
== 20240115123456 Admin::CreateUsers: migrated (0.0235s) ========================
|
||||
"""
|
||||
expected = """\
|
||||
== 20240115123456 Admin::CreateUsers: migrating =================================
|
||||
✓ Admin::CreateUsers
|
||||
"""
|
||||
@@ -0,0 +1,75 @@
|
||||
# Compact rails routes output.
|
||||
|
||||
[filters.rails-routes]
|
||||
description = "Compact rails routes output"
|
||||
match_command = "^(rails|rake)$"
|
||||
match_subcommand = "^routes$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^\\s*Prefix\\s+Verb",
|
||||
]
|
||||
truncate_lines_at = 160
|
||||
max_lines = 40
|
||||
|
||||
[[tests.rails-routes]]
|
||||
name = "strips header and truncates long lines"
|
||||
input = """\
|
||||
Prefix Verb URI Pattern Controller#Action
|
||||
root GET / home#index
|
||||
users GET /users(.:format) users#index
|
||||
POST /users(.:format) users#create
|
||||
new_user GET /users/new(.:format) users#new
|
||||
edit_user GET /users/:id/edit(.:format) users#edit
|
||||
user GET /users/:id(.:format) users#show
|
||||
PATCH /users/:id(.:format) users#update
|
||||
PUT /users/:id(.:format) users#update
|
||||
DELETE /users/:id(.:format) users#destroy
|
||||
orders GET /orders(.:format) orders#index
|
||||
POST /orders(.:format) orders#create
|
||||
new_order GET /orders/new(.:format) orders#new
|
||||
edit_order GET /orders/:id/edit(.:format) orders#edit
|
||||
order GET /orders/:id(.:format) orders#show
|
||||
PATCH /orders/:id(.:format) orders#update
|
||||
PUT /orders/:id(.:format) orders#update
|
||||
DELETE /orders/:id(.:format) orders#destroy
|
||||
products GET /products(.:format) products#index
|
||||
POST /products(.:format) products#create
|
||||
new_product GET /products/new(.:format) products#new
|
||||
edit_product GET /products/:id/edit(.:format) products#edit
|
||||
product GET /products/:id(.:format) products#show
|
||||
PATCH /products/:id(.:format) products#update
|
||||
PUT /products/:id(.:format) products#update
|
||||
DELETE /products/:id(.:format) products#destroy
|
||||
categories GET /categories(.:format) categories#index
|
||||
POST /categories(.:format) categories#create
|
||||
"""
|
||||
expected = """
|
||||
root GET / home#index
|
||||
users GET /users(.:format) users#index
|
||||
POST /users(.:format) users#create
|
||||
new_user GET /users/new(.:format) users#new
|
||||
edit_user GET /users/:id/edit(.:format) users#edit
|
||||
user GET /users/:id(.:format) users#show
|
||||
PATCH /users/:id(.:format) users#update
|
||||
PUT /users/:id(.:format) users#update
|
||||
DELETE /users/:id(.:format) users#destroy
|
||||
orders GET /orders(.:format) orders#index
|
||||
POST /orders(.:format) orders#create
|
||||
new_order GET /orders/new(.:format) orders#new
|
||||
edit_order GET /orders/:id/edit(.:format) orders#edit
|
||||
order GET /orders/:id(.:format) orders#show
|
||||
PATCH /orders/:id(.:format) orders#update
|
||||
PUT /orders/:id(.:format) orders#update
|
||||
DELETE /orders/:id(.:format) orders#destroy
|
||||
products GET /products(.:format) products#index
|
||||
POST /products(.:format) products#creat…[+1]
|
||||
new_product GET /products/new(.:format) products#new
|
||||
edit_product GET /products/:id/edit(.:format) products#edit
|
||||
product GET /products/:id(.:format) products#show
|
||||
PATCH /products/:id(.:format) products#updat…[+1]
|
||||
PUT /products/:id(.:format) products#updat…[+1]
|
||||
DELETE /products/:id(.:format) products#destr…[+2]
|
||||
categories GET /categories(.:format) categories#ind…[+2]
|
||||
POST /categories(.:format) categories#cre…[+3]
|
||||
"""
|
||||
@@ -6,9 +6,14 @@ description = "Compact rsync transfer logs"
|
||||
match_command = "^rsync$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^sending incremental file list$",
|
||||
"^receiving incremental file list$",
|
||||
]
|
||||
only_on_exit = [0]
|
||||
match_output = [
|
||||
{ pattern = "total size is", message = "ok (synced)", unless = "(?i)(error|failed|no such file)" },
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
head_lines = 5
|
||||
tail_lines = 15
|
||||
@@ -18,4 +23,19 @@ on_empty = "rsync: ok"
|
||||
[[tests.rsync]]
|
||||
name = "strips incremental banner"
|
||||
input = "sending incremental file list\nfile1\nfile2\nsent 200 bytes received 20 bytes\ntotal size is 1024 speedup is 4.65\n"
|
||||
expected = "file1\nfile2\nsent 200 bytes received 20 bytes\ntotal size is 1024 speedup is 4.65\n"
|
||||
expected = "ok (synced)"
|
||||
|
||||
[[tests.rsync]]
|
||||
name = "successful sync short-circuits to ok (synced)"
|
||||
input = "sending incremental file list\n./\nfile1.txt\nfile2.txt\n\nsent 1,234 bytes received 42 bytes 2,552.00 bytes/sec\ntotal size is 98,765 speedup is 77.31\n"
|
||||
expected = "ok (synced)"
|
||||
|
||||
[[tests.rsync]]
|
||||
name = "errors not swallowed when total size present"
|
||||
input = "rsync: [sender] error\nerror in rsync protocol data stream (code 12)\nsent 100 bytes received 200 bytes 60.00 bytes/sec\ntotal size is 1000 speedup is 3.33\n"
|
||||
expected = "rsync: [sender] error\nerror in rsync protocol data stream (code 12)\nsent 100 bytes received 200 bytes 60.00 bytes/sec\ntotal size is 1000 speedup is 3.33\n"
|
||||
|
||||
[[tests.rsync]]
|
||||
name = "error case-insensitive not swallowed"
|
||||
input = "sending incremental file list\nERROR: some error\ntotal size is 1024 speedup is 4.65\n"
|
||||
expected = "ERROR: some error\ntotal size is 1024 speedup is 4.65\n"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# rustc: stateless per-line keep for direct `rustc` invocations (NOT the
|
||||
# cargo-claimed path — cargo/clippy run through filters/cargo.rs). rustc is not
|
||||
# Rust-claimed in mod.rs, so a TOML def is the correct home.
|
||||
#
|
||||
# Keep diagnostics that carry failure signal: error[E…]/error: headers, warning
|
||||
# headers, the `-->` source-pointer lines that name file:line:col, the trailing
|
||||
# "aborting due to" / "For more information" summary, "could not compile"
|
||||
# trailers, and — critically — ICE/panic lines ("thread '…' panicked",
|
||||
# "internal compiler error", "panicked at", "rustc <version> running on …").
|
||||
# The intervening snippet/caret/label lines (` |`, ` 3 | …`, ` = note:`) are
|
||||
# dropped: they are recoverable context, not signal, and the kept `-->` pointer
|
||||
# already localizes the diagnostic.
|
||||
#
|
||||
# DELIBERATELY no on_empty: the on_empty stage is NOT exit-gated
|
||||
# (pipeline.rs:330-335), so a failed compile whose error text happened to miss
|
||||
# the keep-set would otherwise collapse to a fake "rustc: ok". The engine's
|
||||
# ensure_success_visible prints "OK" only on exit 0, which is the correct,
|
||||
# exit-gated success sentinel.
|
||||
|
||||
[filters.rustc]
|
||||
description = "Compact rustc output — keep diagnostics, ICE/panic, and failure trailers"
|
||||
match_command = "^rustc$"
|
||||
strip_ansi = true
|
||||
keep_lines_matching = [
|
||||
"^error",
|
||||
"^warning",
|
||||
"^\\s*-->",
|
||||
"aborting due to",
|
||||
"For more information",
|
||||
"could not compile",
|
||||
"^rustc \\d",
|
||||
"^thread '",
|
||||
"internal compiler error",
|
||||
"panicked at",
|
||||
]
|
||||
truncate_lines_at = 300
|
||||
max_lines = 80
|
||||
|
||||
[[tests.rustc]]
|
||||
name = "borrow error keeps header and source pointer"
|
||||
input = """
|
||||
error[E0382]: borrow of moved value: `s`
|
||||
--> src/main.rs:4:20
|
||||
|
|
||||
3 | let s = String::from("hi");
|
||||
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
|
||||
4 | let _t = s;
|
||||
| - value moved here
|
||||
5 | println!("{s}");
|
||||
| ^^^ value borrowed here after move
|
||||
|
||||
error: aborting due to 1 previous error
|
||||
|
||||
For more information about this error, try `rustc --explain E0382`.
|
||||
"""
|
||||
expected = "error[E0382]: borrow of moved value: `s`\n --> src/main.rs:4:20\nerror: aborting due to 1 previous error\nFor more information about this error, try `rustc --explain E0382`.\n"
|
||||
|
||||
[[tests.rustc]]
|
||||
name = "internal compiler error / panic is never collapsed to ok"
|
||||
input = """
|
||||
warning: this is unrelated
|
||||
|
||||
thread 'rustc' panicked at compiler/rustc_middle/src/ty/mod.rs:1234:13:
|
||||
internal compiler error: unexpected type variable
|
||||
stack backtrace:
|
||||
0: std::panicking::begin_panic
|
||||
1: rustc_middle::something
|
||||
rustc 1.79.0 (129f3b996 2024-06-10) running on x86_64-unknown-linux-gnu
|
||||
note: compiler flags: --crate-type bin
|
||||
error: the compiler unexpectedly panicked. this is a bug.
|
||||
"""
|
||||
expected = "warning: this is unrelated\nthread 'rustc' panicked at compiler/rustc_middle/src/ty/mod.rs:1234:13:\ninternal compiler error: unexpected type variable\nrustc 1.79.0 (129f3b996 2024-06-10) running on x86_64-unknown-linux-gnu\nerror: the compiler unexpectedly panicked. this is a bug.\n"
|
||||
|
||||
[[tests.rustc]]
|
||||
name = "warning-only run keeps the warning and pointer"
|
||||
input = """
|
||||
warning: unused variable: `x`
|
||||
--> src/lib.rs:2:9
|
||||
|
|
||||
2 | let x = 1;
|
||||
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
||||
|
|
||||
= note: `#[warn(unused_variables)]` on by default
|
||||
|
||||
warning: 1 warning emitted
|
||||
"""
|
||||
expected = "warning: unused variable: `x`\n --> src/lib.rs:2:9\nwarning: 1 warning emitted\n"
|
||||
@@ -11,8 +11,9 @@ strip_lines_matching = [
|
||||
"^Synced ",
|
||||
"^Uploaded ",
|
||||
"^Downloaded ",
|
||||
"^\\s*Downloading",
|
||||
"^Fetching ",
|
||||
"^Uploading ",
|
||||
"^\\s*Uploading",
|
||||
"^Hashing ",
|
||||
"^Processing ",
|
||||
"^Preview your theme: ",
|
||||
@@ -30,3 +31,8 @@ expected = "Error: Liquid syntax error in templates/product.liquid line 5\n"
|
||||
name = "push success kept"
|
||||
input = "Fetching theme\nUploading assets/foo.js\nTheme pushed successfully"
|
||||
expected = "Theme pushed successfully\n"
|
||||
|
||||
[[tests.shopify-theme]]
|
||||
name = "strips indented downloading/uploading progress"
|
||||
input = " Downloading templates/index.liquid\n Uploading assets/app.css\nTheme pulled successfully"
|
||||
expected = "Theme pulled successfully\n"
|
||||
|
||||
@@ -27,3 +27,13 @@ expected = "skopeo: ok"
|
||||
name = "keep fatal errors"
|
||||
input = "Copying blob abc123 done\nFATA[0000] Error parsing image name\n"
|
||||
expected = "FATA[0000] Error parsing image name\n"
|
||||
|
||||
[[tests.skopeo]]
|
||||
name = "inspect keeps output"
|
||||
input = "{\n \"Name\": \"docker.io/library/nginx\",\n \"Tag\": \"latest\",\n \"Digest\": \"sha256:abc123\",\n \"RepoTags\": [\"latest\", \"1.25\"],\n \"Created\": \"2026-01-01T00:00:00Z\"\n}\n"
|
||||
expected = "{\n \"Name\": \"docker.io/library/nginx\",\n \"Tag\": \"latest\",\n \"Digest\": \"sha256:abc123\",\n \"RepoTags\": [\"latest\", \"1.25\"],\n \"Created\": \"2026-01-01T00:00:00Z\"\n}\n"
|
||||
|
||||
[[tests.skopeo]]
|
||||
name = "empty input returns on_empty message"
|
||||
input = ""
|
||||
expected = "skopeo: ok"
|
||||
|
||||
@@ -6,7 +6,7 @@ description = "Strip ssh login banner and motd"
|
||||
match_command = "^ssh$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^Welcome to ",
|
||||
"^Welcome to .*(LTS|GNU/Linux|UNIX|Fedora|Debian|CentOS|AlmaLinux|Rocky Linux)",
|
||||
"^Last login:",
|
||||
"^Last failed login:",
|
||||
"^ \\* Documentation:",
|
||||
@@ -15,6 +15,12 @@ strip_lines_matching = [
|
||||
"^The programs included with ",
|
||||
"^Ubuntu comes with ABSOLUTELY NO WARRANTY",
|
||||
"^This system has been minimized",
|
||||
"^Warning: Permanently added",
|
||||
"^Connection to .+ closed",
|
||||
"^Authenticated to",
|
||||
"^debug1:",
|
||||
"^OpenSSH_",
|
||||
"^Pseudo-terminal",
|
||||
]
|
||||
max_lines = 200
|
||||
|
||||
@@ -27,3 +33,13 @@ expected = "hello from remote\n"
|
||||
name = "pure command output passthrough"
|
||||
input = "hello from remote\n"
|
||||
expected = "hello from remote\n"
|
||||
|
||||
[[tests.ssh]]
|
||||
name = "strips connection banners retains blank lines"
|
||||
input = "Warning: Permanently added '192.168.1.10' (ED25519) to the list of known hosts.\n\ntotal 32\ndrwxr-xr-x 4 user user 4096 Mar 10 12:00 app\n-rw-r--r-- 1 user user 1234 Mar 10 11:00 config.yaml\n\nConnection to 192.168.1.10 closed.\n"
|
||||
expected = "\ntotal 32\ndrwxr-xr-x 4 user user 4096 Mar 10 12:00 app\n-rw-r--r-- 1 user user 1234 Mar 10 11:00 config.yaml\n\n"
|
||||
|
||||
[[tests.ssh]]
|
||||
name = "verbose debug lines stripped retains blanks"
|
||||
input = "debug1: Connecting to host.example.com port 22.\ndebug1: Connection established.\nAuthenticated to host.example.com ([1.2.3.4]:22).\nuptime: 12:00:00 up 42 days, load average: 0.10, 0.15, 0.12\nConnection to host.example.com closed.\n"
|
||||
expected = "uptime: 12:00:00 up 42 days, load average: 0.10, 0.15, 0.12\n"
|
||||
|
||||
@@ -14,9 +14,14 @@ strip_lines_matching = [
|
||||
"^Resolved source packages",
|
||||
"^Compiling \\S",
|
||||
"^Building for ",
|
||||
"^Linking ",
|
||||
"^\\[\\d+/\\d+\\] Compiling ",
|
||||
"^\\[\\d+/\\d+\\] Emitting ",
|
||||
"^\\[\\d+/\\d+\\] Write ",
|
||||
"^\\[\\d+/\\d+\\] Linking ",
|
||||
]
|
||||
match_output = [
|
||||
{ pattern = "Build complete!", message = "ok (build complete)", unless = "warning:|error:|Test Suite|failed" },
|
||||
]
|
||||
truncate_lines_at = 300
|
||||
max_lines = 80
|
||||
@@ -31,3 +36,13 @@ expected = "Foo.swift:3:5: error: cannot find 'baz' in scope\nBuild failed\n"
|
||||
name = "noise collapses to sentinel"
|
||||
input = "Fetching https://github.com/foo/bar\nResolving foo\nCompiling Foo Bar.swift"
|
||||
expected = "swift: ok"
|
||||
|
||||
[[tests.swift-build]]
|
||||
name = "warnings not swallowed when Build complete present"
|
||||
input = "CompileSwift normal x86_64 MyFile.swift\n/path/to/MyFile.swift:42:10: warning: unused variable 'x'\nBuild complete! (with warnings)\n"
|
||||
expected = "CompileSwift normal x86_64 MyFile.swift\n/path/to/MyFile.swift:42:10: warning: unused variable 'x'\nBuild complete! (with warnings)\n"
|
||||
|
||||
[[tests.swift-build]]
|
||||
name = "passing swift test not short-circuited"
|
||||
input = "Building for debugging...\n[1/1] Linking MyAppPackageTests\nBuild complete! (1.23s)\nTest Suite 'All tests' started at 2026-06-10 12:00:00\nTest Suite 'All tests' passed at 2026-06-10 12:00:01\n"
|
||||
expected = "Build complete! (1.23s)\nTest Suite 'All tests' started at 2026-06-10 12:00:00\nTest Suite 'All tests' passed at 2026-06-10 12:00:01\n"
|
||||
|
||||
@@ -11,6 +11,8 @@ strip_lines_matching = [
|
||||
"^•\\s+Running ",
|
||||
"^•\\s+Remote caching",
|
||||
"cache hit, replaying logs",
|
||||
"cache (miss, executing|bypass, force executing)",
|
||||
"^\\s*\\d+ packages in scope",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
max_lines = 80
|
||||
@@ -25,3 +27,8 @@ expected = "app:build: > next build\n Tasks: 1 successful, 1 total\n"
|
||||
name = "preamble only collapses"
|
||||
input = "• Running build in 1 packages\n"
|
||||
expected = "turbo: ok"
|
||||
|
||||
[[tests.turbo]]
|
||||
name = "strips cache noise keeps task output and summary"
|
||||
input = "app:build: cache hit, replaying logs abc123\ncache miss, executing abc456\n\n3 packages in scope\n\n> myapp:build\n\nCompiled successfully.\n\nTasks: 2 successful, 2 total (1 cached)\nDuration: 3.2s\n"
|
||||
expected = "> myapp:build\nCompiled successfully.\nTasks: 2 successful, 2 total (1 cached)\nDuration: 3.2s\n"
|
||||
|
||||
@@ -7,6 +7,8 @@ match_command = "^ty$"
|
||||
strip_ansi = true
|
||||
strip_lines_matching = [
|
||||
"^\\s*$",
|
||||
"^Checking \\d+ file",
|
||||
"^ty \\d+\\.\\d+",
|
||||
]
|
||||
truncate_lines_at = 250
|
||||
max_lines = 80
|
||||
@@ -26,3 +28,8 @@ expected = "ty: ok"
|
||||
name = "keeps diagnostics"
|
||||
input = "\nerror[invalid-type]: bad type\n --> src/foo.py:10:5\n"
|
||||
expected = "error[invalid-type]: bad type\n --> src/foo.py:10:5\n"
|
||||
|
||||
[[tests.ty]]
|
||||
name = "strips noise, keeps diagnostics"
|
||||
input = "ty 0.1.0\nChecking 15 files\n\nerror[unresolved-reference]: Name `foo` used when not defined\n --> app/main.py:10:5\n\nFound 1 error, 0 warnings\n"
|
||||
expected = "error[unresolved-reference]: Name `foo` used when not defined\n --> app/main.py:10:5\nFound 1 error, 0 warnings\n"
|
||||
|
||||
@@ -28,6 +28,22 @@ strip_lines_matching = [
|
||||
"^\\s+cd ",
|
||||
"^\\s+export ",
|
||||
"^\\s+builtin-",
|
||||
"^CodeSign\\s",
|
||||
"^Signing Identity:",
|
||||
"^RegisterWithLaunchServices",
|
||||
"^Validate\\s",
|
||||
"^ProcessProductPackaging",
|
||||
"^GenerateDSYMFile",
|
||||
"^LinkStoryboards",
|
||||
"^PBXCp\\s",
|
||||
"^SetMode\\s",
|
||||
"^SetOwnerAndGroup\\s",
|
||||
"^Ditto\\s",
|
||||
"^CpResource\\s",
|
||||
"^CpHeader\\s",
|
||||
"^note: Using new build system",
|
||||
"^\\s+/Applications/Xcode",
|
||||
"^\\s+/usr/bin/",
|
||||
]
|
||||
truncate_lines_at = 300
|
||||
max_lines = 80
|
||||
@@ -42,3 +58,13 @@ expected = "/src/Foo.swift:1:1: error: cannot find type 'Bar'\n** BUILD FAILED *
|
||||
name = "clean success"
|
||||
input = "CompileC /tmp/x.o main.m\n** BUILD SUCCEEDED **"
|
||||
expected = "** BUILD SUCCEEDED **\n"
|
||||
|
||||
[[tests.xcodebuild]]
|
||||
name = "test output keeps test results"
|
||||
input = "note: Using new build system\nCompileSwift normal arm64 /Users/dev/AppTests/Tests.swift\n cd /Users/dev/App\nTest Suite 'All tests' started at 2026-03-10 12:00:00\nTest Suite 'AppTests' started at 2026-03-10 12:00:00\nTest Case '-[AppTests testExample]' passed (0.001 seconds).\nTest Case '-[AppTests testFailing]' failed (0.002 seconds).\nTest Suite 'AppTests' passed at 2026-03-10 12:00:01\nExecuted 2 tests, with 1 failure in 0.003 seconds"
|
||||
expected = "Test Suite 'All tests' started at 2026-03-10 12:00:00\nTest Suite 'AppTests' started at 2026-03-10 12:00:00\nTest Case '-[AppTests testExample]' passed (0.001 seconds).\nTest Case '-[AppTests testFailing]' failed (0.002 seconds).\nTest Suite 'AppTests' passed at 2026-03-10 12:00:01\nExecuted 2 tests, with 1 failure in 0.003 seconds\n"
|
||||
|
||||
[[tests.xcodebuild]]
|
||||
name = "empty input returns on_empty message"
|
||||
input = ""
|
||||
expected = "xcodebuild: ok"
|
||||
|
||||
@@ -12,17 +12,54 @@ pub struct CommandIdentity {
|
||||
/// The detector intentionally handles the common interactive subset instead
|
||||
/// of emulating a full shell parser. Ambiguous commands return `None` and are
|
||||
/// left streaming unchanged.
|
||||
#[must_use]
|
||||
pub fn detect(command: &str) -> Option<CommandIdentity> {
|
||||
let tokens = tokenize(command);
|
||||
detect_tokens(&tokens)
|
||||
}
|
||||
|
||||
/// Extract command identity from an already-expanded argv vector.
|
||||
#[must_use]
|
||||
pub fn detect_tokens(tokens: &[String]) -> Option<CommandIdentity> {
|
||||
let tokens = strip_launch_prefix(tokens)?;
|
||||
let (program, rest) = tokens.split_first()?;
|
||||
let normalized = normalize_program(program)?;
|
||||
let subcommand = detect_subcommand(&normalized, rest);
|
||||
let is_docker_compose = program
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case("docker-compose"));
|
||||
let subcommand = if is_docker_compose {
|
||||
// docker-compose v1 flags that consume a value; skip them so the
|
||||
// real action (up/down/ps/logs/etc.) is found, matching docker compose
|
||||
// routing in docker.rs.
|
||||
first_non_global_arg(
|
||||
rest,
|
||||
&[
|
||||
"-f",
|
||||
"--file",
|
||||
"--profile",
|
||||
"-p",
|
||||
"--project-name",
|
||||
"--env-file",
|
||||
"--parallel",
|
||||
"--progress",
|
||||
"--project-directory",
|
||||
"--workdir",
|
||||
"-w",
|
||||
"--ansi",
|
||||
"--log-level",
|
||||
"-H",
|
||||
"--host",
|
||||
"--tlscacert",
|
||||
"--tlscert",
|
||||
"--tlskey",
|
||||
],
|
||||
&["--compatibility", "--dry-run", "--verbose", "-v", "--no-ansi"],
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
detect_subcommand(&normalized, rest)
|
||||
};
|
||||
Some(CommandIdentity { program: normalized, subcommand })
|
||||
}
|
||||
|
||||
@@ -87,7 +124,17 @@ fn normalize_program(program: &str) -> Option<String> {
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(name.to_lowercase())
|
||||
let lowered = name.to_lowercase();
|
||||
// Exact-match allowlist for Windows launcher scripts whose `.bat`/`.cmd`
|
||||
// twin should dispatch identically to the bare wrapper. Kept explicit on
|
||||
// purpose: a generic `.bat`/`.cmd` strip would over-match unrelated scripts
|
||||
// (e.g. `foo.bat`, `deploy.cmd`).
|
||||
Some(match lowered.as_str() {
|
||||
"gradlew.bat" => "gradlew".to_string(),
|
||||
"mvnw.cmd" => "mvnw".to_string(),
|
||||
"docker-compose" => "docker".to_string(),
|
||||
_ => lowered,
|
||||
})
|
||||
}
|
||||
|
||||
fn skip_env_options(tokens: &[String], mut index: usize) -> Option<usize> {
|
||||
@@ -300,6 +347,10 @@ fn detect_subcommand(program: &str, args: &[String]) -> Option<String> {
|
||||
&["--paginate", "--slurp", "--verbose"],
|
||||
&[],
|
||||
),
|
||||
// glab's clap-level globals are `-R`/`--repo` and `-g`/`--group`
|
||||
// (donor rtk/src/cmds/git/glab_cmd.rs + README); both take a value, so
|
||||
// skip them and their argument to reach the real subcommand.
|
||||
"glab" => first_non_global_arg(args, &["-R", "--repo", "-g", "--group"], &[], &[]),
|
||||
"gt" => first_non_global_arg(
|
||||
args,
|
||||
&["--repo", "--cwd", "--config", "--debug-context"],
|
||||
@@ -336,7 +387,15 @@ fn detect_subcommand(program: &str, args: &[String]) -> Option<String> {
|
||||
],
|
||||
&["--yes", "--no", "--no-install", "--quiet", "--silent", "--verbose"],
|
||||
&[],
|
||||
),
|
||||
)
|
||||
// Strip npm version qualifier from package specs (jest@latest → jest,
|
||||
// @scope/pkg@1.0 → @scope/pkg). The idx > 0 guard preserves the leading
|
||||
// `@` on scoped packages. This keeps version-pinned invocations routed to
|
||||
// the same filter as their unpinned equivalents.
|
||||
.map(|s| match s.rfind('@') {
|
||||
Some(idx) if idx > 0 => s[..idx].to_string(),
|
||||
_ => s,
|
||||
}),
|
||||
"pnpm" => first_non_global_arg(
|
||||
args,
|
||||
&["--dir", "-C", "--filter", "-F", "--workspace", "--config", "--store-dir"],
|
||||
@@ -598,6 +657,50 @@ mod tests {
|
||||
assert_eq!(command.subcommand.as_deref(), Some("tsconfig.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_gradle_and_maven_wrapper_scripts() {
|
||||
let command = detect("./gradlew build").expect("gradlew command is detected");
|
||||
assert_eq!(command.program, "gradlew");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("build"));
|
||||
|
||||
let command = detect("gradlew.bat assembleDebug").expect("gradlew.bat normalizes to gradlew");
|
||||
assert_eq!(command.program, "gradlew");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("assembledebug"));
|
||||
|
||||
let command = detect("mvnw.cmd package").expect("mvnw.cmd normalizes to mvnw");
|
||||
assert_eq!(command.program, "mvnw");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("package"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_allowlist_does_not_strip_unrelated_bat_or_cmd() {
|
||||
// The allowlist is exact-match only; arbitrary `.bat`/`.cmd` scripts keep
|
||||
// their full basename so unrelated tools are not silently re-dispatched.
|
||||
let command = detect("foo.bat run").expect("foo.bat command is detected");
|
||||
assert_eq!(command.program, "foo.bat");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("run"));
|
||||
|
||||
let command = detect("deploy.cmd go").expect("deploy.cmd command is detected");
|
||||
assert_eq!(command.program, "deploy.cmd");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_glab_subcommand_past_repo_global() {
|
||||
let command = detect("glab -R owner/repo mr list").expect("glab command is detected");
|
||||
assert_eq!(command.program, "glab");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("mr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_arm_returns_first_positional_for_unknown_program() {
|
||||
// Regression pin: programs without a dedicated arm fall through to the
|
||||
// default branch, which returns the first non-flag token verbatim.
|
||||
let command = detect("rustc main.rs").expect("rustc command is detected");
|
||||
assert_eq!(command.program, "rustc");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_gt_through_wrappers_and_globals() {
|
||||
let command = detect("env GRAPHITE_TOKEN=x command gt --repo owner/repo submit --stack")
|
||||
@@ -701,4 +804,110 @@ fn npx_workspace_value_is_skipped_in_subcommand_detection() {
|
||||
let command = detect("npx -w my-workspace vitest").expect("npx with workspace is detected");
|
||||
assert_eq!(command.program, "npx");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("vitest"));
|
||||
|
||||
// version-qualified package specs are stripped to the bare tool name so
|
||||
// `npx jest@latest` routes identically to `npx jest`
|
||||
let command = detect("npx jest@latest --runInBand").expect("version-qualified npx detected");
|
||||
assert_eq!(command.program, "npx");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("jest"), "jest@latest must normalise to jest");
|
||||
|
||||
// scoped package leading @ is preserved; only the trailing @version is stripped
|
||||
let command = detect("npx @scope/pkg@1.0.0 --flag").expect("scoped npx detected");
|
||||
assert_eq!(
|
||||
command.subcommand.as_deref(),
|
||||
Some("@scope/pkg"),
|
||||
"@scope/pkg@1.0.0 must normalise to @scope/pkg"
|
||||
);
|
||||
|
||||
// unversioned scoped package is unchanged
|
||||
let command = detect("npx @scope/tool").expect("unversioned scoped npx detected");
|
||||
assert_eq!(
|
||||
command.subcommand.as_deref(),
|
||||
Some("@scope/tool"),
|
||||
"unversioned scoped package must be unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_docker_compose_to_docker() {
|
||||
let command = detect("docker-compose up").expect("docker-compose command is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
|
||||
let command = detect("/usr/local/bin/docker-compose logs")
|
||||
.expect("path-prefixed docker-compose is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("logs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_composer_is_not_normalized() {
|
||||
let command = detect("docker-composer up").expect("docker-composer command is detected");
|
||||
assert_eq!(command.program, "docker-composer");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_with_flags_finds_real_subcommand() {
|
||||
// Value-taking compose flags must be skipped so the real action is found.
|
||||
let command =
|
||||
detect("docker-compose -p myproj up").expect("docker-compose with -p flag is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
|
||||
let command = detect("docker-compose --profile logs up")
|
||||
.expect("docker-compose with --profile flag is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
|
||||
let command = detect("docker-compose -f docker-compose.yml ps")
|
||||
.expect("docker-compose with -f flag is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("ps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_global_value_flags_skip_correctly() {
|
||||
// --log-level consumes its value; the next token is the subcommand.
|
||||
let command = detect("docker-compose --log-level debug up")
|
||||
.expect("docker-compose --log-level is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
|
||||
// -H consumes its value.
|
||||
let command =
|
||||
detect("docker-compose -H tcp://host:2376 ps").expect("docker-compose -H is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("ps"));
|
||||
|
||||
// --host=inline also works.
|
||||
let command = detect("docker-compose --host=tcp://host:2376 logs")
|
||||
.expect("docker-compose --host= is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("logs"));
|
||||
|
||||
// --tlscacert consumes its value.
|
||||
let command = detect("docker-compose --tlscacert /path/ca.pem up")
|
||||
.expect("docker-compose --tlscacert is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
|
||||
// --tlscert consumes its value.
|
||||
let command = detect("docker-compose --tlscert /path/cert.pem ps")
|
||||
.expect("docker-compose --tlscert is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("ps"));
|
||||
|
||||
// --tlskey consumes its value.
|
||||
let command = detect("docker-compose --tlskey /path/key.pem logs")
|
||||
.expect("docker-compose --tlskey is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("logs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_without_flags_still_works() {
|
||||
let command = detect("docker-compose up").expect("docker-compose command is detected");
|
||||
assert_eq!(command.program, "docker");
|
||||
assert_eq!(command.subcommand.as_deref(), Some("up"));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub enum MinimizerMode {
|
||||
}
|
||||
|
||||
/// Return the minimization mode for a command.
|
||||
#[must_use]
|
||||
pub fn mode_for(command: &str, config: &MinimizerConfig) -> MinimizerMode {
|
||||
match plan::analyze(command) {
|
||||
plan::CommandPlan::Single { .. } => {
|
||||
@@ -62,6 +63,7 @@ pub fn mode_for(command: &str, config: &MinimizerConfig) -> MinimizerMode {
|
||||
|
||||
/// Return true when the command should be captured for minimization.
|
||||
#[allow(dead_code, reason = "test-only API surface")]
|
||||
#[must_use]
|
||||
pub fn should_minimize(command: &str, config: &MinimizerConfig) -> bool {
|
||||
!matches!(mode_for(command, config), MinimizerMode::None)
|
||||
}
|
||||
@@ -77,6 +79,7 @@ pub fn should_minimize(command: &str, config: &MinimizerConfig) -> bool {
|
||||
/// `artifact://<id>` reference back into the visible text before showing it
|
||||
/// to the agent. The minimizer itself never formats the reference — ids are
|
||||
/// assigned by the session store, not content-addressed.
|
||||
#[must_use]
|
||||
pub fn apply(
|
||||
command: &str,
|
||||
captured: &str,
|
||||
@@ -370,6 +373,8 @@ fn program_label(program: &str) -> &'static str {
|
||||
program if filters::cpp::is_gtest_binary_name(program) => "gtest",
|
||||
"golangci-lint" => "golangci-lint",
|
||||
"dotnet" => "dotnet",
|
||||
"mvn" | "mvnw" | "mvnw.cmd" => "mvn",
|
||||
"gradle" | "gradlew" | "gradlew.bat" => "gradle",
|
||||
"docker" => "docker",
|
||||
"kubectl" => "kubectl",
|
||||
"helm" => "helm",
|
||||
@@ -508,6 +513,7 @@ fn builtin_pipelines() -> &'static PipelineRegistry {
|
||||
|
||||
/// Expose the built-in registry's inline tests for the verify CLI surface.
|
||||
#[allow(dead_code, reason = "test-only API surface")]
|
||||
#[must_use]
|
||||
pub fn verify_builtin_filters() -> Vec<pipeline::TestOutcome> {
|
||||
pipeline::run_tests(builtin_pipelines())
|
||||
}
|
||||
@@ -515,6 +521,7 @@ pub fn verify_builtin_filters() -> Vec<pipeline::TestOutcome> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
@@ -593,6 +600,71 @@ only_on_exit = [0]
|
||||
assert_ne!(failed.filter, "pipeline+builtin");
|
||||
assert!(failed.text.contains("file changed"));
|
||||
}
|
||||
|
||||
// Regression guards for the builtin npx catch-all def (defs/npx.toml). Its
|
||||
// `match_subcommand` must exclude every subcommand owned/routed elsewhere so
|
||||
// it cannot shadow `nx-wrapped` (standalone path) or overlay the Rust
|
||||
// lint/test filters (overlay path). These tests fail loudly if that
|
||||
// exclusion set ever desyncs from filters::supports's npx routing.
|
||||
#[test]
|
||||
fn npx_def_does_not_shadow_nx_wrapped() {
|
||||
// `npx nx` is NOT routed by filters::supports, so apply_identity falls to
|
||||
// the standalone resolve_pipeline branch. nx-wrapped (nx.toml) must win
|
||||
// and strip the NX banner; the bare npx def must not claim subcommand
|
||||
// `nx`.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "\n > NX Running target build for 3 projects\n\n> nx run app:build\nError: \
|
||||
build failed\n\n > NX Ran target build for 3 projects (2s)\n";
|
||||
let out = apply("npx nx build", input, 0, &cfg);
|
||||
assert!(
|
||||
!out.text.contains("NX Running target"),
|
||||
"npx def shadowed nx-wrapped; NX banner survived: {:?}",
|
||||
out.text
|
||||
);
|
||||
assert!(out.text.contains("Error: build failed"));
|
||||
}
|
||||
#[test]
|
||||
fn npx_def_does_not_overlay_routed_eslint() {
|
||||
// `npx eslint` IS routed by filters::supports, so the Rust lint filter
|
||||
// runs and apply_pipeline_overlay re-selects a matching pipeline. The npx
|
||||
// def must NOT match subcommand `eslint`, so no overlay re-applies its
|
||||
// max_lines/strip on top of the lint diagnostics. Output must equal the
|
||||
// direct `eslint` invocation (same filter label, not "pipeline+builtin").
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let mut input = String::new();
|
||||
for i in 0..120 {
|
||||
let _ = writeln!(input, "/src/file{i}.ts:{i}:1 error Something is wrong rule/name");
|
||||
}
|
||||
let npx = apply("npx eslint src/", &input, 1, &cfg);
|
||||
let direct = apply("eslint src/", &input, 1, &cfg);
|
||||
// Label differs by program token (npx -> "builtin", eslint -> "eslint"),
|
||||
// but it must NOT be the overlay label and the TEXT must be the un-mutated
|
||||
// lint output — same lines, no max_lines truncation.
|
||||
assert_ne!(
|
||||
npx.filter, "pipeline+builtin",
|
||||
"npx def overlaid the routed eslint filter output"
|
||||
);
|
||||
assert_eq!(
|
||||
npx.text, direct.text,
|
||||
"npx eslint output diverged from direct eslint (overlay mutated it)"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn npx_def_fires_for_unknown_tool() {
|
||||
// The def must still strip the install preamble / npm warn/notice noise
|
||||
// for genuinely UNKNOWN tools (its whole purpose). cowsay is not routed
|
||||
// or owned by any other def, so the npx pipeline claims it.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "Need to install the following packages:\ncowsay@1.6.0\nOk to proceed? \
|
||||
(y)\n\nnpm warn deprecated foo@1.0.0: use bar instead\n< Hello >\n";
|
||||
let out = apply("npx cowsay hello", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
assert!(!out.text.contains("Need to install"));
|
||||
assert!(!out.text.contains("Ok to proceed"));
|
||||
assert!(!out.text.contains("npm warn"));
|
||||
assert!(out.text.contains("< Hello >"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_known_filter_minimizes() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -951,6 +1023,146 @@ strip_lines_matching = [".*"]
|
||||
assert_eq!(out.text, "hi\n");
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
// ── Rails def standalone + bundle exec re-dispatch guards ────────
|
||||
|
||||
#[test]
|
||||
fn rails_db_migrate_routes_to_standalone_def() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "== 20240115 CreateUsers: migrating\n-- create_table(:users)\n -> 0.0234s\n== \
|
||||
20240115 CreateUsers: migrated\n\n== 20240116 AddIndexToOrders: migrating\n-- \
|
||||
add_index(:orders)\n -> 0.0123s\n== 20240116 AddIndexToOrders: migrated\n";
|
||||
let out = apply("rails db:migrate", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("✓ CreateUsers"));
|
||||
assert!(out.text.contains("✓ AddIndexToOrders"));
|
||||
assert!(!out.text.contains("-- create_table"));
|
||||
assert!(!out.text.contains("-> 0.0234s"));
|
||||
// Must NOT be an overlay label; standalone pipeline gets "pipeline".
|
||||
assert_eq!(out.filter, "pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rails_routes_routes_to_standalone_def() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = " Prefix Verb URI Pattern Controller#Action\n root GET / home#index\n";
|
||||
let out = apply("rails routes", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
assert!(!out.text.contains("Prefix"));
|
||||
assert!(out.text.contains("root GET"));
|
||||
assert_eq!(out.filter, "pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rake_routes_regression_unminimized() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = " Prefix Verb URI Pattern Controller#Action\n root GET / home#index\n";
|
||||
let out = apply("rake routes", input, 0, &cfg);
|
||||
assert!(out.changed, "rake routes should be minimized by the rails-routes def");
|
||||
assert!(!out.text.contains("Prefix"));
|
||||
assert!(out.text.contains("root GET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_rails_db_migrate_reaches_def() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "== 20240115 CreateUsers: migrating\n-- create_table(:users)\n -> 0.0234s\n== \
|
||||
20240115 CreateUsers: migrated\n";
|
||||
let out = apply("bundle exec rails db:migrate", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("CreateUsers"));
|
||||
assert!(!out.text.contains("-- create_table"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_rails_routes_reaches_def() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = " Prefix Verb URI Pattern Controller#Action\n root GET / home#index\n";
|
||||
let out = apply("bundle exec rails routes", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
assert!(!out.text.contains("Prefix"));
|
||||
assert!(out.text.contains("root GET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rails_db_migrate_no_double_truncation() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let mut input = String::new();
|
||||
for i in 0..100 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"== 20240115 Migration{i}: migrating\n-- step\n -> 0.0s\n== 20240115 Migration{i}: \
|
||||
migrated\n\n"
|
||||
);
|
||||
}
|
||||
let out = apply("rails db:migrate", &input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
// The def's max_lines=40 produces ONE truncation marker.
|
||||
// filter_rake's head_tail marker ("lines omitted") must NOT appear.
|
||||
assert!(!out.text.contains("lines omitted"), "double truncation detected: {:#?}", out.text);
|
||||
assert!(out.text.contains("lines truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rails_db_migrate_keyword_in_name_not_dropped() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "== 20240115 CreateTestResults: migrating\n-- create_table(:test_results)\n \
|
||||
-> 0.0234s\n== 20240115 CreateTestResults: migrated\n\n== 20240116 \
|
||||
AddIndexToOrders: migrating\n-- add_index(:orders)\n -> 0.0123s\n== 20240116 \
|
||||
AddIndexToOrders: migrated\n";
|
||||
let out = apply("rails db:migrate", input, 0, &cfg);
|
||||
assert!(out.changed);
|
||||
// Both migrations must survive; the old overlay-on-rake would keep only
|
||||
// lines containing "test" and silently drop AddIndexToOrders.
|
||||
assert!(out.text.contains("CreateTestResults"), "CreateTestResults was dropped");
|
||||
assert!(out.text.contains("AddIndexToOrders"), "AddIndexToOrders was dropped");
|
||||
}
|
||||
|
||||
// Regression guards for the builtin rails defs (rails-migrate.toml,
|
||||
// rails-routes.toml). Their `match_subcommand` scopes are MANDATORY:
|
||||
// unscoped they would overlay the Rust minitest filter output for
|
||||
// `rails test` / `rake test` and strip failure-detail lines
|
||||
// ('Expected: true'). These tests fail loudly if the scope is removed.
|
||||
#[test]
|
||||
fn rails_def_does_not_overlay_routed_minitest() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "Run options: --seed 1\n\n# Running:\n\n.F\n\n 1) Failure:\nUserTest#test_name \
|
||||
[test/models/user_test.rb:8]:\nExpected: true\n Actual: false\n\n2 runs, 2 \
|
||||
assertions, 1 failures, 0 errors, 0 skips\n";
|
||||
let rails = apply("rails test", input, 1, &cfg);
|
||||
let rake = apply("rake test", input, 1, &cfg);
|
||||
// Must NOT be an overlay label; minitest filter gets its own program label.
|
||||
assert_ne!(
|
||||
rails.filter, "pipeline+builtin",
|
||||
"rails-migrate def overlaid the routed minitest filter output"
|
||||
);
|
||||
assert_ne!(
|
||||
rake.filter, "pipeline+builtin",
|
||||
"rails-migrate def overlaid the routed minitest filter output"
|
||||
);
|
||||
// Failure-detail lines must survive — the rails-migrate def's
|
||||
// keep_lines_matching would drop them.
|
||||
assert!(rails.text.contains("Expected: true"), "rails test dropped minitest failure detail");
|
||||
assert!(rails.text.contains("Actual: false"));
|
||||
assert!(rake.text.contains("Expected: true"), "rake test dropped minitest failure detail");
|
||||
assert!(rake.text.contains("Actual: false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rails_routes_def_does_not_overlay_routed_minitest() {
|
||||
// Same guard for rails-routes.toml: `match_subcommand = "^routes$"`
|
||||
// must not leak to `rails test`.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "Run options: --seed 1\n\n# Running:\n\n.F\n\n 1) Failure:\nUserTest#test_name \
|
||||
[test/models/user_test.rb:8]:\nExpected: true\n Actual: false\n\n2 runs, 2 \
|
||||
assertions, 1 failures, 0 errors, 0 skips\n";
|
||||
let out = apply("rails test", input, 1, &cfg);
|
||||
assert_ne!(
|
||||
out.filter, "pipeline+builtin",
|
||||
"rails-routes def overlaid the routed minitest filter output"
|
||||
);
|
||||
assert!(out.text.contains("Expected: true"), "rails test dropped minitest failure detail");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -979,7 +1191,7 @@ mod pipeline_integration_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_matches_gradle_via_apply() {
|
||||
fn gradle_routes_to_jvm_filter_via_apply() {
|
||||
let cfg = MinimizerConfig::from_options(&MinimizerOptions {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
@@ -990,11 +1202,14 @@ mod pipeline_integration_tests {
|
||||
0,
|
||||
&cfg,
|
||||
);
|
||||
assert!(out.changed, "gradle pipeline should transform");
|
||||
assert!(!out.text.contains("UP-TO-DATE"));
|
||||
// gradle.toml is deleted; gradle now dispatches to the Rust jvm Build
|
||||
// filter, which strips `> Task :…UP-TO-DATE` task-progress noise (the
|
||||
// carried-over defs/gradle.toml behaviour, now as a Rust filter — NOT a
|
||||
// TOML pipeline) and keeps the BUILD status.
|
||||
assert!(out.changed, "gradle build filter strips task-progress noise");
|
||||
assert!(out.text.contains("BUILD SUCCESSFUL"));
|
||||
assert_eq!(out.filter, "pipeline");
|
||||
assert!(out.bytes_saved() > 0);
|
||||
assert!(!out.text.contains("> Task :"), "task lines stripped; got:\n{}", out.text);
|
||||
assert_eq!(out.filter, "gradle", "telemetry label routes through jvm program_label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,10 +14,12 @@ use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
const HEAD_LINES: usize = 50;
|
||||
const TAIL_LINES: usize = 20;
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, _subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "xxd" | "strings" | "od")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
// Kill-switch parity (M2): legacy_filters_active=true skips this
|
||||
// filter so callers can rollback without recompile.
|
||||
@@ -44,6 +46,8 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::*;
|
||||
use crate::minimizer::MinimizerConfig;
|
||||
|
||||
@@ -54,7 +58,7 @@ mod tests {
|
||||
fn build_lines(prefix: &str, count: usize) -> String {
|
||||
let mut s = String::new();
|
||||
for i in 0..count {
|
||||
s.push_str(&format!("{prefix}{i:08x}\n"));
|
||||
let _ = writeln!(s, "{prefix}{i:08x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -107,9 +111,8 @@ mod tests {
|
||||
#[test]
|
||||
fn binary_tools_legacy_filters_active_passes_through() {
|
||||
// Kill-switch parity (M2).
|
||||
let mut cfg = MinimizerConfig::default();
|
||||
cfg.enabled = true;
|
||||
cfg.legacy_filters_active = true;
|
||||
let cfg =
|
||||
MinimizerConfig { enabled: true, legacy_filters_active: true, ..Default::default() };
|
||||
let input = build_lines("00000000: ", 5000);
|
||||
for prog in ["xxd", "strings", "od"] {
|
||||
let context = ctx(prog, "binary-tool", &cfg);
|
||||
|
||||
@@ -13,6 +13,7 @@ const BUN_TOOL_SUBCOMMANDS: &[&str] =
|
||||
&["tsc", "eslint", "biome", "next", "prettier", "prisma", "jest", "vitest", "playwright"];
|
||||
const BUN_CPP_TOOL_SUBCOMMANDS: &[&str] = &["cmake", "ctest", "ninja", "gtest", "gtest-parallel"];
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
match program {
|
||||
"bun" => subcommand.is_some_and(|subcommand| {
|
||||
@@ -30,6 +31,7 @@ pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let subcommand = ctx.subcommand;
|
||||
if matches!((ctx.program, subcommand), ("bun", Some(subcommand)) if is_non_exec_package_subcommand(subcommand))
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{collections::BTreeMap, fmt::Write as _};
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
@@ -22,6 +23,7 @@ pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.subcommand {
|
||||
@@ -61,6 +63,21 @@ fn is_compiling_noise(line: &str) -> bool {
|
||||
|| trimmed.starts_with("Downloaded ")
|
||||
|| trimmed.starts_with("Locking ")
|
||||
|| trimmed.starts_with("Updating ")
|
||||
// `Blocking waiting for file lock on ...` is pure progress noise when a
|
||||
// concurrent cargo holds the lock (snip strips it in cargo-build/clippy).
|
||||
|| trimmed.starts_with("Blocking ")
|
||||
|| is_generated_warnings_rollup(trimmed)
|
||||
}
|
||||
|
||||
/// The per-crate rollup line warning: `crate` (lib) generated N warnings.
|
||||
/// The individual `warning: ...` diagnostic blocks are kept; this redundant
|
||||
/// tally is dropped. Clippy/install paths already skip it explicitly, so
|
||||
/// stripping it here only affects build/check/doc/run condensing.
|
||||
fn is_generated_warnings_rollup(trimmed: &str) -> bool {
|
||||
let Some(rest) = trimmed.strip_prefix("warning: ") else {
|
||||
return false;
|
||||
};
|
||||
rest.contains(" generated ") && (rest.ends_with(" warnings") || rest.ends_with(" warning"))
|
||||
}
|
||||
|
||||
fn failures_only(input: &str, exit_code: i32) -> String {
|
||||
@@ -81,6 +98,12 @@ fn failures_only(input: &str, exit_code: i32) -> String {
|
||||
{
|
||||
keep = true;
|
||||
}
|
||||
// Passing test lines carry no failure signal — drop them unconditionally,
|
||||
// even after the keep flag latches, so a later passing suite in a
|
||||
// multi-suite run does not re-emit its `test <name> ... ok` lines.
|
||||
if is_passing_test_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if keep || trimmed.starts_with("running ") {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
@@ -228,9 +251,16 @@ fn filter_nextest(input: &str) -> String {
|
||||
let mut in_failure = false;
|
||||
let mut summary = None;
|
||||
let mut canceled = false;
|
||||
let mut past_summary = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
// Once the Summary line is seen, nextest re-lists the failing tests as a
|
||||
// recap (duplicate `FAIL [...]` rows + trailing noise). Drop everything
|
||||
// after it; the captured Summary line is re-emitted verbatim at the end.
|
||||
if past_summary {
|
||||
continue;
|
||||
}
|
||||
if is_compiling_noise(trimmed)
|
||||
|| trimmed.starts_with("PASS ")
|
||||
|| trimmed.starts_with("────")
|
||||
@@ -241,6 +271,7 @@ fn filter_nextest(input: &str) -> String {
|
||||
if trimmed.starts_with("Summary [") {
|
||||
summary = Some(trimmed.to_string());
|
||||
in_failure = false;
|
||||
past_summary = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("Cancelling") {
|
||||
@@ -418,11 +449,28 @@ fn extract_lint_rule(line: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let after_note = line.strip_prefix("= note:")?.trim();
|
||||
let rest = after_note
|
||||
// Attribute form: `#[warn(rule)]` / `#[deny(rule)]` / `#[allow(rule)]`.
|
||||
if let Some(rest) = after_note
|
||||
.strip_prefix("`#[warn(")
|
||||
.or_else(|| after_note.strip_prefix("`#[deny("))
|
||||
.or_else(|| after_note.strip_prefix("`#[allow("))?;
|
||||
Some(rest.split(")]`").next()?.to_string())
|
||||
.or_else(|| after_note.strip_prefix("`#[allow("))
|
||||
{
|
||||
return Some(rest.split(")]`").next()?.to_string());
|
||||
}
|
||||
// CLI form: `requested on the command line with `-W <rule>`` (also -D).
|
||||
// Lints enabled this way carry no `#[warn(...)]` note, so without this
|
||||
// branch they fall into the ungrouped bucket instead of grouping by rule.
|
||||
let cli = after_note.strip_prefix("requested on the command line with ")?;
|
||||
let flag = cli.strip_prefix('`')?.split('`').next()?.trim();
|
||||
let rule = flag
|
||||
.strip_prefix("-W ")
|
||||
.or_else(|| flag.strip_prefix("-D "))
|
||||
.or_else(|| flag.strip_prefix("-A "))?
|
||||
.trim();
|
||||
if rule.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(rule.to_string())
|
||||
}
|
||||
|
||||
fn format_clippy_grouped(warnings: &[ClippyWarning], exit_code: i32) -> String {
|
||||
@@ -489,6 +537,46 @@ mod tests {
|
||||
assert!(out.text.contains("error: nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_strips_blocking_lock_and_warning_rollup() {
|
||||
// `Blocking waiting for file lock` is concurrent-cargo progress noise;
|
||||
// the `warning: \`crate\` (lib) generated N warnings` rollup is a
|
||||
// redundant tally of the per-warning blocks, which are kept.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "cargo",
|
||||
subcommand: Some("build"),
|
||||
command: "cargo build",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = concat!(
|
||||
" Blocking waiting for file lock on build directory\n",
|
||||
" Compiling foo v0.1.0\n",
|
||||
"warning: unused variable: `x`\n",
|
||||
" --> src/lib.rs:2:9\n",
|
||||
"warning: `foo` (lib) generated 1 warning\n",
|
||||
" Finished dev [unoptimized + debuginfo] target(s) in 1.2s\n",
|
||||
);
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(
|
||||
!out.text.contains("Blocking"),
|
||||
"blocking lock noise must be stripped: {:?}",
|
||||
out.text
|
||||
);
|
||||
assert!(
|
||||
!out.text.contains("generated 1 warning"),
|
||||
"warning rollup must be stripped: {:?}",
|
||||
out.text
|
||||
);
|
||||
// Per-warning diagnostic block is kept.
|
||||
assert!(
|
||||
out.text.contains("unused variable: `x`"),
|
||||
"warning block must survive: {:?}",
|
||||
out.text
|
||||
);
|
||||
assert!(out.text.contains("src/lib.rs:2:9"), "warning location must survive: {:?}", out.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_passing_test_lines_on_success() {
|
||||
let out =
|
||||
@@ -508,14 +596,25 @@ mod tests {
|
||||
#[test]
|
||||
fn supports_nextest_and_keeps_failures_with_summary() {
|
||||
assert!(supports(Some("nextest")));
|
||||
// nextest re-lists failing tests as a recap AFTER the Summary line. The
|
||||
// recap `FAIL [...]` row and `error: test run failed` trailer must be
|
||||
// dropped (past_summary), while the in-run FAIL block + verbatim Summary
|
||||
// line survive.
|
||||
let out = filter_nextest(
|
||||
"Starting 3 tests across 1 binary\nPASS crate::ok\nFAIL crate::bad\nstdout text\nSummary \
|
||||
[0.2s] 2 tests run: 1 passed, 1 failed\nerror: test run failed\n",
|
||||
[0.2s] 2 tests run: 1 passed, 1 failed\nFAIL [ 0.011s] crate::bad\nerror: test run \
|
||||
failed\n",
|
||||
);
|
||||
assert!(!out.contains("PASS crate::ok"));
|
||||
assert!(out.contains("FAIL crate::bad"));
|
||||
assert!(out.contains("stdout text"));
|
||||
assert!(out.contains("Summary [0.2s] 2 tests run: 1 passed, 1 failed"));
|
||||
// Post-Summary recap row and trailing noise are dropped.
|
||||
assert!(!out.contains("FAIL [ 0.011s]"), "post-Summary recap must be dropped: {out:?}");
|
||||
assert!(
|
||||
!out.contains("error: test run failed"),
|
||||
"post-Summary trailer must be dropped: {out:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn install_strips_noise_keeps_summary() {
|
||||
@@ -649,6 +748,56 @@ mod tests {
|
||||
assert!(unused_pos != clone_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clippy_groups_cli_enabled_lint_via_command_line_note() {
|
||||
// A lint enabled on the command line (`-W clippy::needless_return`) emits
|
||||
// a `requested on the command line with` note instead of `#[warn(...)]`.
|
||||
// It must still GROUP under its rule, not fall into the ungrouped bucket.
|
||||
let input = concat!(
|
||||
"warning: unneeded `return` statement\n",
|
||||
" --> src/lib.rs:3:5\n",
|
||||
" |\n",
|
||||
"3 | return x;\n",
|
||||
" | ^^^^^^^^^\n",
|
||||
" |\n",
|
||||
" = note: requested on the command line with `-W clippy::needless_return`\n",
|
||||
"\n",
|
||||
"warning: `foo` (lib) generated 1 warning\n",
|
||||
);
|
||||
let out = filter_clippy(input, 0);
|
||||
// Grouped renderer prefixes rule-grouped lines with `clippy: <rule>`.
|
||||
assert!(
|
||||
out.contains("clippy: clippy::needless_return"),
|
||||
"CLI-enabled lint must group by rule: {out:?}"
|
||||
);
|
||||
// Not emitted via the ungrouped `clippy warning:` path.
|
||||
assert!(!out.contains("clippy warning:"), "CLI-enabled lint must not be ungrouped: {out:?}");
|
||||
assert!(out.contains("src/lib.rs:3:5"), "location must survive: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_lint_rule_parses_note_forms() {
|
||||
assert_eq!(
|
||||
extract_lint_rule(" = note: `#[warn(unused_variables)]` on by default"),
|
||||
Some("unused_variables".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_lint_rule(
|
||||
"= note: requested on the command line with `-W clippy::needless_return`"
|
||||
),
|
||||
Some("clippy::needless_return".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_lint_rule("= note: requested on the command line with `-D warnings`"),
|
||||
Some("warnings".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_lint_rule("= note: requested on the command line with `-W dead_code`"),
|
||||
Some("dead_code".to_string())
|
||||
);
|
||||
assert_eq!(extract_lint_rule("= note: some other note"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clippy_compile_error_falls_back_to_build_style() {
|
||||
let input = concat!(
|
||||
@@ -747,6 +896,41 @@ mod tests {
|
||||
assert!(!out.text.contains("test ok_two"), "passing test lines must be stripped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_multi_suite_drops_passing_lines_after_keep_latches() {
|
||||
// Suite 1 fails, latching the keep flag. Suite 2 passes — its
|
||||
// `test <name> ... ok` lines must NOT leak through just because keep
|
||||
// is set. Only failure evidence survives.
|
||||
let input = concat!(
|
||||
"running 1 tests\n",
|
||||
"test suite1_bad ... FAILED\n",
|
||||
"\n",
|
||||
"failures:\n",
|
||||
" suite1_bad\n",
|
||||
"\n",
|
||||
"test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured\n",
|
||||
"running 2 tests\n",
|
||||
"test suite2_ok_a ... ok\n",
|
||||
"test suite2_ok_b ... ok\n",
|
||||
"test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured\n",
|
||||
);
|
||||
|
||||
let out = failures_only(input, 101);
|
||||
|
||||
// Failure evidence from suite 1 survives.
|
||||
assert!(out.contains("suite1_bad"), "failing test name must survive: {out:?}");
|
||||
assert!(out.contains("test result: FAILED"), "failed summary must survive: {out:?}");
|
||||
// Suite 2's passing lines must be dropped even though keep is latched.
|
||||
assert!(
|
||||
!out.contains("test suite2_ok_a"),
|
||||
"passing line after keep latch must be dropped: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("test suite2_ok_b"),
|
||||
"passing line after keep latch must be dropped: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_success_via_filter_produces_one_line_summary() {
|
||||
// The token-savings contract: a full passing run must collapse to a
|
||||
|
||||
@@ -28,10 +28,12 @@ const SENSITIVE_AWS_KEYS: &[&str] = &[
|
||||
"ResponseMetadata",
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, _subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "aws" | "curl" | "wget" | "psql")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.program {
|
||||
@@ -708,7 +710,7 @@ fn compact_aws_cloudwatch_events(events: &[&Value]) -> String {
|
||||
for event in events {
|
||||
let ts = event
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.map_or_else(|| "?".to_string(), epoch_ms_to_iso);
|
||||
let msg = event.get("message").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
// Truncate long messages
|
||||
@@ -1479,7 +1481,8 @@ mod tests {
|
||||
assert!(out.text.contains("age=42"));
|
||||
assert!(out.text.contains("active=true"));
|
||||
assert!(out.text.contains("tags=[a,b]"));
|
||||
assert!(out.text.contains("meta={city:Paris}"));
|
||||
const META_ATTR: &str = "meta={city:Paris}";
|
||||
assert!(out.text.contains(META_ATTR));
|
||||
assert!(out.text.contains("1 item(s)"));
|
||||
}
|
||||
|
||||
@@ -1656,7 +1659,7 @@ mod tests {
|
||||
fn sensitive_aws_keys_never_leak_from_generic() {
|
||||
let mut fields = String::new();
|
||||
for key in SENSITIVE_AWS_KEYS {
|
||||
fields.push_str(&format!(r#""{key}":"LEAK_SENTINEL","#));
|
||||
let _ = write!(fields, r#""{key}":"LEAK_SENTINEL","#);
|
||||
}
|
||||
let input = format!(r#"{{"Unknowns":[{{"Name":"safe",{fields}"Status":"ok"}}]}}"#);
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
|
||||
@@ -12,14 +12,17 @@ enum CppTool {
|
||||
GTest,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, _subcommand: Option<&str>) -> bool {
|
||||
direct_tool(program).is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_invocation(command: &str) -> bool {
|
||||
command_tokens(command).any(|token| token_tool(token).is_some())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_gtest_binary_name(program: &str) -> bool {
|
||||
matches!(program, "gtest" | "gtest-parallel")
|
||||
|| program.ends_with("_test")
|
||||
@@ -31,6 +34,7 @@ pub fn is_gtest_binary_name(program: &str) -> bool {
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("test"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let tool = direct_tool(ctx.program).or_else(|| invocation_tool(ctx.command));
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
@@ -21,11 +22,22 @@ pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
| "install"
|
||||
| "upgrade"
|
||||
| "template"
|
||||
| "lint"
|
||||
| "lint" | "apply"
|
||||
| "delete"
|
||||
| "rollout"
|
||||
| "scale"
|
||||
| "create"
|
||||
| "wait" | "label"
|
||||
| "annotate"
|
||||
| "up" | "down"
|
||||
| "start"
|
||||
| "stop" | "restart"
|
||||
| "rm"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.program {
|
||||
@@ -56,6 +68,27 @@ fn filter_docker(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String
|
||||
input.to_string()
|
||||
};
|
||||
}
|
||||
// start/stop/restart/rm output is container names or confirmation messages;
|
||||
// compact_build_or_progress would strip legitimate lines that happen to
|
||||
// contain progress substrings (e.g. a container named "my-Downloading-app").
|
||||
if is_docker_lifecycle_command(ctx) {
|
||||
return head_tail_dedup(input);
|
||||
}
|
||||
// docker-compose up / docker compose up (attached mode) streams container
|
||||
// logs, not build progress. Lines like "Downloading", "Waiting",
|
||||
// "Extracting" that appear here are real application log lines, not
|
||||
// layer-pull status noise. Route through the log filter so they are
|
||||
// preserved.
|
||||
//
|
||||
// Two detection paths:
|
||||
// • legacy `docker-compose up` — detect.rs normalises the binary name to
|
||||
// "docker" and sets subcommand="up" directly.
|
||||
// • Compose v2 `docker compose up` — subcommand="compose", action found
|
||||
// by scanning past compose global options (same pattern as the listing
|
||||
// and lifecycle helpers).
|
||||
if is_compose_up_command(ctx) {
|
||||
return filter_docker_logs(input);
|
||||
}
|
||||
compact_build_or_progress(input)
|
||||
}
|
||||
|
||||
@@ -87,6 +120,9 @@ fn filter_kubectl(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String
|
||||
Some("describe") => {
|
||||
primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), 120, 80)
|
||||
},
|
||||
Some("apply" | "delete" | "rollout" | "scale" | "create" | "wait" | "label" | "annotate") => {
|
||||
head_tail_dedup(input)
|
||||
},
|
||||
_ => compact_build_or_progress(input),
|
||||
}
|
||||
}
|
||||
@@ -305,10 +341,17 @@ fn compute_pod_container_stats(status: &Value) -> (usize, usize, i32) {
|
||||
let mut ready = 0usize;
|
||||
let mut restarts = 0i32;
|
||||
for cs in container_statuses {
|
||||
if cs.get("ready").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
if cs
|
||||
.get("ready")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
ready += 1;
|
||||
}
|
||||
restarts += cs.get("restartCount").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
restarts += cs
|
||||
.get("restartCount")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0) as i32;
|
||||
}
|
||||
(ready, total, restarts)
|
||||
}
|
||||
@@ -374,15 +417,19 @@ fn format_k8s_ports(ports: Option<&Vec<Value>>) -> String {
|
||||
.map(|p| {
|
||||
let port = p
|
||||
.get("port")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.map_or_else(|| "?".to_string(), |v| v.to_string());
|
||||
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("TCP");
|
||||
let node_port = p.get("nodePort").and_then(|v| v.as_i64());
|
||||
let node_port = p.get("nodePort").and_then(serde_json::Value::as_i64);
|
||||
let target_port = p.get("targetPort");
|
||||
let target = target_port
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.map(|v| v.to_string())
|
||||
.or_else(|| target_port.and_then(|v| v.as_str()).map(|s| s.to_string()));
|
||||
.or_else(|| {
|
||||
target_port
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
});
|
||||
match (target, node_port) {
|
||||
(Some(t), Some(np)) => format!("{port}/{t}:{np}->{port}/{proto}"),
|
||||
(Some(t), None) => format!("{port}/{t}:{port}/{proto}"),
|
||||
@@ -398,14 +445,65 @@ fn filter_helm(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String {
|
||||
if exit_code != 0 {
|
||||
return input.to_string();
|
||||
}
|
||||
let cleaned = strip_helm_noise(input);
|
||||
match ctx.subcommand {
|
||||
Some("list" | "ls" | "status") => compact_table(input, 20),
|
||||
Some("install" | "upgrade" | "lint") => compact_build_or_progress(input),
|
||||
Some("list" | "ls" | "status") => compact_table(&cleaned, 20),
|
||||
Some("install" | "upgrade" | "lint") => compact_build_or_progress(&cleaned),
|
||||
Some("template") => input.to_string(),
|
||||
_ => head_tail_dedup(input),
|
||||
_ => head_tail_dedup(&cleaned),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_helm_noise(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if is_glog_prefix(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("WARNING: Kubernetes configuration file is") {
|
||||
continue;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_glog_prefix(line: &str) -> bool {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.len() < 6
|
||||
|| bytes[0] != b'W'
|
||||
|| bytes[5] != b' '
|
||||
|| !bytes[1..5].iter().all(u8::is_ascii_digit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Validate month (01-12) and day (01-31) to avoid stripping legitimate
|
||||
// output that happens to start with W + 4 digits + space.
|
||||
let month = (bytes[1] - b'0') * 10 + (bytes[2] - b'0');
|
||||
let day = (bytes[3] - b'0') * 10 + (bytes[4] - b'0');
|
||||
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return false;
|
||||
}
|
||||
// Require a time stamp (hh:mm...) after the space to distinguish real
|
||||
// glog lines from space-separated table output where a release name
|
||||
// happens to match W + MMDD + space.
|
||||
if bytes.len() < 11 {
|
||||
return false;
|
||||
}
|
||||
if !bytes[6].is_ascii_digit() || !bytes[7].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
if bytes[8] != b':' {
|
||||
return false;
|
||||
}
|
||||
if !bytes[9].is_ascii_digit() || !bytes[10].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` when `tok` is a known docker-compose option that consumes
|
||||
/// the next token as its value (i.e. is space-separated, not `--flag=value`).
|
||||
fn compose_option_consumes_next(tok: &str) -> bool {
|
||||
@@ -419,7 +517,7 @@ fn compose_option_consumes_next(tok: &str) -> bool {
|
||||
| "--progress"
|
||||
| "--project-directory"
|
||||
| "--project-name"
|
||||
| "--workdir"
|
||||
| "-p" | "--workdir"
|
||||
| "-w"
|
||||
)
|
||||
}
|
||||
@@ -501,6 +599,75 @@ fn is_compose_listing_action(command: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_docker_lifecycle_command(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
matches!(ctx.subcommand, Some("start" | "stop" | "restart" | "rm"))
|
||||
|| ctx.subcommand == Some("compose") && is_compose_lifecycle_action(ctx.command)
|
||||
}
|
||||
|
||||
fn is_compose_lifecycle_action(command: &str) -> bool {
|
||||
let mut tokens = command
|
||||
.split_whitespace()
|
||||
.skip_while(|token| *token != "compose");
|
||||
if tokens.next() != Some("compose") {
|
||||
return false;
|
||||
}
|
||||
loop {
|
||||
match tokens.next() {
|
||||
None => return false,
|
||||
Some(tok)
|
||||
if tok.starts_with('-') && !tok.contains('=') && compose_option_consumes_next(tok) =>
|
||||
{
|
||||
tokens.next(); // skip value
|
||||
},
|
||||
Some(tok) if tok.starts_with('-') => {}, // skip boolean flag
|
||||
Some(tok) => return matches!(tok, "start" | "stop" | "restart" | "rm"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the command is an attached `docker compose up` or
|
||||
/// legacy `docker-compose up` that streams container log output.
|
||||
///
|
||||
/// Two forms are handled:
|
||||
/// • `docker-compose up` — detect.rs normalises the binary to "docker" and
|
||||
/// sets `subcommand="up"` directly; the original command still contains
|
||||
/// "compose" so we can tell it apart from plain `docker build`.
|
||||
/// • `docker compose up` — `subcommand="compose"`, action resolved by
|
||||
/// scanning past compose global options (same logic as the listing /
|
||||
/// lifecycle helpers).
|
||||
fn is_compose_up_command(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
// Legacy docker-compose: subcommand is already the action.
|
||||
if ctx.subcommand == Some("up") && ctx.command.contains("compose") {
|
||||
return true;
|
||||
}
|
||||
// Compose v2: subcommand is "compose", scan for first non-option action.
|
||||
if ctx.subcommand == Some("compose") {
|
||||
return is_compose_up_action(ctx.command);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_compose_up_action(command: &str) -> bool {
|
||||
let mut tokens = command
|
||||
.split_whitespace()
|
||||
.skip_while(|token| *token != "compose");
|
||||
if tokens.next() != Some("compose") {
|
||||
return false;
|
||||
}
|
||||
loop {
|
||||
match tokens.next() {
|
||||
None => return false,
|
||||
Some(tok)
|
||||
if tok.starts_with('-') && !tok.contains('=') && compose_option_consumes_next(tok) =>
|
||||
{
|
||||
tokens.next(); // skip value
|
||||
},
|
||||
Some(tok) if tok.starts_with('-') => {}, // skip boolean flag
|
||||
Some(tok) => return tok == "up",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_listing_requests_table(command: &str) -> bool {
|
||||
let mut tokens = command.split_whitespace();
|
||||
while let Some(token) = tokens.next() {
|
||||
@@ -524,58 +691,29 @@ fn docker_format_requests_table(format: &str) -> bool {
|
||||
|
||||
fn filter_logs(input: &str) -> String {
|
||||
let without_empty_runs = drop_repeated_blank_lines(input);
|
||||
let deduped = primitives::dedup_consecutive_lines(&without_empty_runs);
|
||||
primitives::head_tail_lines(&deduped, 120, 80)
|
||||
crate::minimizer::filters::system::compact_log_lines(
|
||||
&without_empty_runs,
|
||||
120,
|
||||
80,
|
||||
crate::minimizer::filters::system::normalize_log_line,
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_docker_logs(input: &str) -> String {
|
||||
let without_empty_runs = drop_repeated_blank_lines(input);
|
||||
let deduped = dedup_consecutive_log_lines(&without_empty_runs);
|
||||
primitives::head_tail_lines(&deduped, 120, 80)
|
||||
crate::minimizer::filters::system::compact_log_lines(&without_empty_runs, 120, 80, log_dedup_key)
|
||||
}
|
||||
|
||||
fn dedup_consecutive_log_lines(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut previous: Option<&str> = None;
|
||||
let mut previous_key: Option<&str> = None;
|
||||
let mut count = 0usize;
|
||||
|
||||
for line in input.lines() {
|
||||
let key = log_dedup_key(line);
|
||||
if previous_key == Some(key) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
flush_repeated_log_line(&mut out, previous, count);
|
||||
previous = Some(line);
|
||||
previous_key = Some(key);
|
||||
count = 1;
|
||||
}
|
||||
flush_repeated_log_line(&mut out, previous, count);
|
||||
out
|
||||
}
|
||||
|
||||
fn flush_repeated_log_line(out: &mut String, line: Option<&str>, count: usize) {
|
||||
let Some(line) = line else {
|
||||
return;
|
||||
};
|
||||
out.push_str(line);
|
||||
if count > 1 {
|
||||
out.push_str(" (×");
|
||||
out.push_str(&count.to_string());
|
||||
out.push(')');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn log_dedup_key(line: &str) -> &str {
|
||||
fn log_dedup_key(line: &str) -> String {
|
||||
if let Some((service, message)) = line.split_once('|') {
|
||||
let service = service.trim();
|
||||
if is_compose_log_service(service) {
|
||||
return message.trim_start();
|
||||
let message = message.trim_start();
|
||||
let normalized = crate::minimizer::filters::system::normalize_log_line(message);
|
||||
return format!("{service}|{normalized}");
|
||||
}
|
||||
}
|
||||
line
|
||||
crate::minimizer::filters::system::normalize_log_line(line)
|
||||
}
|
||||
|
||||
fn is_compose_log_service(value: &str) -> bool {
|
||||
@@ -691,8 +829,11 @@ mod tests {
|
||||
fn dedups_compose_service_prefixed_log_messages() {
|
||||
let input = "api-1 | ready\napi-2 | ready\napi | ready\nworker | busy\n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert!(out.contains("api-1 | ready (×3)"));
|
||||
assert!(out.contains("worker | busy"));
|
||||
// Different services with the same message must NOT be collapsed together.
|
||||
assert!(out.contains("api-1 | ready"), "distinct service must be preserved: {out}");
|
||||
assert!(out.contains("api-2 | ready"), "distinct service must be preserved: {out}");
|
||||
assert!(out.contains("api | ready"), "distinct service must be preserved: {out}");
|
||||
assert!(out.contains("worker | busy"), "distinct service must be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -706,7 +847,10 @@ mod tests {
|
||||
};
|
||||
let input = "api-1 | ready\napi-2 | ready\napi | ready\n";
|
||||
let out = filter(&compose_ctx, input, 0).text;
|
||||
assert!(out.contains("api-1 | ready (×3)"));
|
||||
// Per-service dedup: same message from different services stays distinct.
|
||||
assert!(out.contains("api-1 | ready"), "distinct service line must be preserved: {out}");
|
||||
assert!(out.contains("api-2 | ready"), "distinct service line must be preserved: {out}");
|
||||
assert!(out.contains("api | ready"), "distinct service line must be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,7 +903,7 @@ mod tests {
|
||||
};
|
||||
let mut input = String::from("NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS\n");
|
||||
for idx in 0..20 {
|
||||
input.push_str(&format!("svc-{idx} img command api 1m running 8080/tcp\n"));
|
||||
let _ = writeln!(input, "svc-{idx} img command api 1m running 8080/tcp");
|
||||
}
|
||||
let out = filter(&compose_ctx, &input, 0).text;
|
||||
assert!(out.contains("20 rows"));
|
||||
@@ -1079,7 +1223,7 @@ mod tests {
|
||||
let kubectl_ctx = ctx("kubectl", Some("get"), &cfg);
|
||||
let mut input = String::from("NAME STATUS\n");
|
||||
for i in 0..25 {
|
||||
input.push_str(&format!("pod-{} running\n", i));
|
||||
let _ = writeln!(input, "pod-{i} running");
|
||||
}
|
||||
let out = filter(&kubectl_ctx, &input, 0).text;
|
||||
// Should use table compaction, not crash
|
||||
@@ -1199,9 +1343,360 @@ mod tests {
|
||||
};
|
||||
let mut input = String::from("NAME READY STATUS RESTARTS AGE IP NODE\n");
|
||||
for i in 0..25 {
|
||||
input.push_str(&format!("pod-{i} 1/1 Running 0 1h 10.0.0.{i} node\n"));
|
||||
let _ = writeln!(input, "pod-{i} 1/1 Running 0 1h 10.0.0.{i} node");
|
||||
}
|
||||
let out = filter(&ctx, input.as_str(), 0).text;
|
||||
assert!(out.contains("rows"), "-owide is a table format and must be compacted, got: {out}");
|
||||
}
|
||||
|
||||
// ── Global normalized log dedup tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dedups_non_consecutive_log_lines_globally() {
|
||||
let input = "ready\nstarting\nready\nstarting\nready\ndone\n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert!(out.contains("ready (×3)"), "global dedup should count all occurrences: {out}");
|
||||
assert!(out.contains("starting (×2)"), "global dedup should count all occurrences: {out}");
|
||||
assert!(out.contains("done"), "global dedup should preserve unique lines: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedups_compose_service_prefixed_lines_non_consecutive() {
|
||||
let input = "api-1 | ready\nworker | busy\napi-2 | ready\nworker | busy\n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert!(
|
||||
out.contains("api-1 | ready"),
|
||||
"compose service-prefixed lines should dedup per service: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("api-2 | ready"),
|
||||
"compose service-prefixed lines should preserve distinct services: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("worker | busy (×2)"),
|
||||
"compose same-service lines should dedup globally: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── kubectl uncovered subcommands tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn kubectl_apply_output_condensed() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let kubectl_ctx = ctx("kubectl", Some("apply"), &cfg);
|
||||
let mut input = String::new();
|
||||
for i in 0..250 {
|
||||
let _ = writeln!(input, "deployment.apps/app-{i} unchanged");
|
||||
}
|
||||
let out = filter(&kubectl_ctx, &input, 0).text;
|
||||
assert!(
|
||||
out.contains("lines omitted") || out.contains("lines truncated"),
|
||||
"kubectl apply output should be head/tail condensed: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── helm glog strip tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn helm_list_strips_glog_warning_lines() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let helm_ctx = ctx("helm", Some("list"), &cfg);
|
||||
let input = "W0115 10:30:00 warning message from internal\nNAME: my-release\nLAST DEPLOYED: \
|
||||
Mon Jan 15 10:30:00 2024\nNAMESPACE: default\nSTATUS: deployed\nREVISION: 3\n";
|
||||
let out = filter(&helm_ctx, input, 0).text;
|
||||
assert!(!out.contains("W0115"), "glog W-prefixed lines should be stripped: {out}");
|
||||
assert!(out.contains("NAME: my-release"), "release info should be preserved: {out}");
|
||||
assert!(out.contains("STATUS: deployed"), "release info should be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_list_strips_kubernetes_config_warning() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let helm_ctx = ctx("helm", Some("list"), &cfg);
|
||||
let input = "WARNING: Kubernetes configuration file is group-readable\nNAME: \
|
||||
my-release\nSTATUS: deployed\n";
|
||||
let out = filter(&helm_ctx, input, 0).text;
|
||||
assert!(
|
||||
!out.contains("WARNING: Kubernetes configuration file"),
|
||||
"Kubernetes config warning should be stripped: {out}"
|
||||
);
|
||||
assert!(out.contains("NAME: my-release"), "release info should be preserved: {out}");
|
||||
}
|
||||
|
||||
// ── Blocking-issue regression tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn docker_logs_preserves_blank_lines() {
|
||||
let input = "a\n\nb\n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert_eq!(out, "a\n\nb\n", "blank lines must not be silently dropped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_logs_preserves_non_consecutive_blank_lines() {
|
||||
let input = "a\n\nb\n\nc\n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert_eq!(
|
||||
out, "a\n\nb\n\nc\n",
|
||||
"non-consecutive blank lines must not be globally deduplicated: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_logs_dedups_empty_compose_messages_per_service() {
|
||||
let input = "api | \napi | \napi | \nworker | \nworker | \n";
|
||||
let out = filter_docker_logs(input);
|
||||
assert!(out.contains("api | (×3)"), "same-service empty messages should dedup: {out}");
|
||||
assert!(out.contains("worker | (×2)"), "same-service empty messages should dedup: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_start_preserves_container_names_with_progress_keywords() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
for sub in &["start", "stop", "restart", "rm"] {
|
||||
let ctx = MinimizerCtx {
|
||||
program: "docker",
|
||||
subcommand: Some(sub),
|
||||
command: &format!("docker {sub} my-Downloading-app"),
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "my-Downloading-app\n";
|
||||
let out = filter(&ctx, input, 0).text;
|
||||
assert_eq!(
|
||||
out, input,
|
||||
"docker {sub} must not strip container names matching progress heuristics"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_start_preserves_container_names_with_progress_keywords() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
for action in &["start", "stop", "restart", "rm"] {
|
||||
let ctx = MinimizerCtx {
|
||||
program: "docker",
|
||||
subcommand: Some("compose"),
|
||||
command: &format!("docker compose {action} my-Downloading-app"),
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "my-Downloading-app\n";
|
||||
let out = filter(&ctx, input, 0).text;
|
||||
assert_eq!(
|
||||
out, input,
|
||||
"docker compose {action} must not strip container names matching progress heuristics"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_list_preserves_release_names_starting_with_w_digit() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let helm_ctx = ctx("helm", Some("list"), &cfg);
|
||||
let input = "NAME\tNAMESPACE\nW2024-app\tdefault\nmy-app\tdefault\n";
|
||||
let out = filter(&helm_ctx, input, 0).text;
|
||||
assert!(
|
||||
out.contains("W2024-app"),
|
||||
"release names starting with W+digits must not be stripped: {out}"
|
||||
);
|
||||
assert!(out.contains("my-app"), "other releases must be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glog_prefix_requires_trailing_space() {
|
||||
assert!(is_glog_prefix("W0115 10:30:00 warning"));
|
||||
assert!(!is_glog_prefix("W2024-app"));
|
||||
assert!(!is_glog_prefix("W123"));
|
||||
assert!(!is_glog_prefix("W12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glog_prefix_rejects_invalid_month_day() {
|
||||
// W2024 has month=20 (invalid) — must not be treated as glog.
|
||||
assert!(!is_glog_prefix("W2024 default"));
|
||||
assert!(!is_glog_prefix("W0015 10:30:00 warning"));
|
||||
assert!(!is_glog_prefix("W1301 10:30:00 warning"));
|
||||
assert!(!is_glog_prefix("W1232 10:30:00 warning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glog_prefix_rejects_space_separated_without_time() {
|
||||
// W1231 looks like a valid glog date, but "default" is not a time stamp.
|
||||
assert!(!is_glog_prefix("W1231 default"));
|
||||
// Valid glog with time stamp must still pass.
|
||||
assert!(is_glog_prefix("W1231 10:30:00 warning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_list_preserves_release_names_starting_with_w_digit_space() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let helm_ctx = ctx("helm", Some("list"), &cfg);
|
||||
// W2024 default looks like a glog prefix but month=20 is invalid.
|
||||
let input = "NAME\tNAMESPACE\nW2024 default\tdefault\nmy-app\tdefault\n";
|
||||
let out = filter(&helm_ctx, input, 0).text;
|
||||
assert!(
|
||||
out.contains("W2024 default"),
|
||||
"release names like 'W2024 default' must not be stripped: {out}"
|
||||
);
|
||||
assert!(out.contains("my-app"), "other releases must be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_list_preserves_space_separated_valid_w_digit_release_names() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let helm_ctx = ctx("helm", Some("list"), &cfg);
|
||||
// W1231 default has a valid month/day but lacks a time stamp, so it must
|
||||
// NOT be stripped as a glog line.
|
||||
let input = "NAME NAMESPACE\nW1231 default\nmy-app default\n";
|
||||
let out = filter(&helm_ctx, input, 0).text;
|
||||
assert!(
|
||||
out.contains("W1231 default"),
|
||||
"space-separated release name W1231 must not be stripped: {out}"
|
||||
);
|
||||
assert!(out.contains("my-app default"), "other releases must be preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_p_project_name_routes_logs() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "docker",
|
||||
subcommand: Some("compose"),
|
||||
command: "docker compose -p myproj logs api",
|
||||
config: &cfg,
|
||||
};
|
||||
assert!(is_log_command(&ctx), "docker compose -p myproj logs must be a log command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_p_project_name_routes_ps() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "docker",
|
||||
subcommand: Some("compose"),
|
||||
command: "docker compose -p myproj ps",
|
||||
config: &cfg,
|
||||
};
|
||||
assert!(
|
||||
is_table_command(&ctx),
|
||||
"docker compose -p myproj ps must be classified as a table command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_compose_p_project_name_routes_start() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "docker",
|
||||
subcommand: Some("compose"),
|
||||
command: "docker compose -p myproj start api",
|
||||
config: &cfg,
|
||||
};
|
||||
assert!(
|
||||
is_docker_lifecycle_command(&ctx),
|
||||
"docker compose -p myproj start must be classified as a lifecycle command"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_ctx<'a>(
|
||||
program: &'a str,
|
||||
subcommand: Option<&'a str>,
|
||||
command: &'a str,
|
||||
cfg: &'a MinimizerConfig,
|
||||
) -> MinimizerCtx<'a> {
|
||||
MinimizerCtx { program, subcommand, command, config: cfg }
|
||||
}
|
||||
|
||||
// ── docker-compose up / docker compose up log preservation ──────────────
|
||||
|
||||
#[test]
|
||||
fn test_compose_up_logs_preserved() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
// Legacy docker-compose up: detect.rs sets subcommand="up", command retains
|
||||
// "docker-compose".
|
||||
let input = "web_1 | [2024-01-01 00:00:00] INFO Server started\ndb_1 | 2024-01-01 \
|
||||
00:00:01 [Note] mysqld: ready for connections\n";
|
||||
let ctx = make_ctx("docker", Some("up"), "docker-compose up", &cfg);
|
||||
let out = filter(&ctx, input, 0);
|
||||
// Log output must not be stripped by the build-progress condenser.
|
||||
assert!(
|
||||
out.text.contains("Server started"),
|
||||
"compose up log lines must survive (legacy docker-compose): {}",
|
||||
out.text
|
||||
);
|
||||
assert!(
|
||||
out.text.contains("mysqld: ready for connections"),
|
||||
"compose up db log lines must survive (legacy docker-compose): {}",
|
||||
out.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_v2_up_logs_preserved() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
// Compose v2: subcommand="compose", action resolved by scanning tokens.
|
||||
let input = "web_1 | [2024-01-01 00:00:00] INFO Server started\ndb_1 | 2024-01-01 \
|
||||
00:00:01 [Note] mysqld: ready for connections\n";
|
||||
let ctx = make_ctx("docker", Some("compose"), "docker compose up", &cfg);
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(
|
||||
out.text.contains("Server started"),
|
||||
"compose up log lines must survive (docker compose v2): {}",
|
||||
out.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_up_with_global_flags_logs_preserved() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
// docker compose --progress plain up — global option before action.
|
||||
let input = "app_1 | Downloading something\napp_1 | Waiting for db\n";
|
||||
let ctx = make_ctx("docker", Some("compose"), "docker compose --progress plain up", &cfg);
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(
|
||||
out.text.contains("Downloading something"),
|
||||
"'Downloading' log lines must not be stripped by progress condenser: {}",
|
||||
out.text
|
||||
);
|
||||
assert!(
|
||||
out.text.contains("Waiting for db"),
|
||||
"'Waiting' log lines must not be stripped by progress condenser: {}",
|
||||
out.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_compose_up_command_both_forms() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
// Legacy form
|
||||
assert!(is_compose_up_command(&make_ctx("docker", Some("up"), "docker-compose up", &cfg)));
|
||||
// v2 form
|
||||
assert!(is_compose_up_command(&make_ctx(
|
||||
"docker",
|
||||
Some("compose"),
|
||||
"docker compose up",
|
||||
&cfg
|
||||
)));
|
||||
// v2 with global flags
|
||||
assert!(is_compose_up_command(&make_ctx(
|
||||
"docker",
|
||||
Some("compose"),
|
||||
"docker compose --progress plain up",
|
||||
&cfg
|
||||
)));
|
||||
// Must not match other actions
|
||||
assert!(!is_compose_up_command(&make_ctx(
|
||||
"docker",
|
||||
Some("compose"),
|
||||
"docker compose logs",
|
||||
&cfg
|
||||
)));
|
||||
assert!(!is_compose_up_command(&make_ctx(
|
||||
"docker",
|
||||
Some("compose"),
|
||||
"docker compose ps",
|
||||
&cfg
|
||||
)));
|
||||
// Plain docker build must not be classified as compose up
|
||||
assert!(!is_compose_up_command(&make_ctx("docker", Some("build"), "docker build .", &cfg)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
program == "dotnet" && matches!(subcommand, Some("build" | "test" | "restore" | "format"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.subcommand {
|
||||
@@ -27,17 +29,57 @@ fn filter_build_like(label: &str, input: &str, exit_code: i32) -> String {
|
||||
let mut diagnostics = String::new();
|
||||
let mut summaries = String::new();
|
||||
|
||||
if exit_code == 0 && matches!(label, "dotnet build" | "dotnet restore") {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
// Find the LAST occurrence of "0 Warning(s)" followed by indented "0 Error(s)".
|
||||
let mut last_match = None;
|
||||
for i in 0..lines.len().saturating_sub(1) {
|
||||
if lines[i].trim() == "0 Warning(s)"
|
||||
&& lines[i + 1].trim() == "0 Error(s)"
|
||||
&& lines[i + 1].starts_with(char::is_whitespace)
|
||||
{
|
||||
last_match = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = last_match {
|
||||
// Only short-circuit if no warnings or errors appear after this pair.
|
||||
let has_later_issues = lines[i + 2..].iter().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
let is_count_summary = (lower.ends_with("warning(s)") || lower.ends_with("error(s)"))
|
||||
&& trimmed
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.is_some_and(|first| first.chars().all(|c| c.is_ascii_digit()));
|
||||
let is_later_failure = lower.starts_with("failed! ")
|
||||
|| lower.starts_with("failed ")
|
||||
|| lower.starts_with("error ")
|
||||
|| lower.starts_with("warning ");
|
||||
is_count_summary || is_msbuild_diagnostic(trimmed) || is_later_failure
|
||||
});
|
||||
if !has_later_issues {
|
||||
let noun = if label == "dotnet restore" {
|
||||
"restore"
|
||||
} else {
|
||||
"build"
|
||||
};
|
||||
return format!("ok ({noun} succeeded)\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || is_dotnet_boilerplate(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if is_msbuild_diagnostic(trimmed) || is_failure_line(trimmed) {
|
||||
diagnostics.push_str(trimmed);
|
||||
diagnostics.push('\n');
|
||||
} else if is_dotnet_summary(trimmed) {
|
||||
summaries.push_str(trimmed);
|
||||
let truncated = primitives::truncate_line(trimmed, primitives::CapClass::Errors.lines());
|
||||
if is_dotnet_summary(trimmed) {
|
||||
summaries.push_str(&truncated);
|
||||
summaries.push('\n');
|
||||
} else if is_msbuild_diagnostic(trimmed) || is_failure_line(trimmed) {
|
||||
diagnostics.push_str(&truncated);
|
||||
diagnostics.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +107,11 @@ fn filter_test(input: &str, exit_code: i32) -> String {
|
||||
if trimmed.is_empty() || is_dotnet_boilerplate(trimmed) {
|
||||
continue;
|
||||
}
|
||||
let truncated = primitives::truncate_line(trimmed, primitives::CapClass::Errors.lines());
|
||||
|
||||
if is_failed_test_start(trimmed) {
|
||||
in_failed_test = true;
|
||||
out.push_str(trimmed);
|
||||
out.push_str(&truncated);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
@@ -77,14 +120,14 @@ fn filter_test(input: &str, exit_code: i32) -> String {
|
||||
if is_test_section_boundary(trimmed) {
|
||||
in_failed_test = false;
|
||||
} else {
|
||||
out.push_str(trimmed);
|
||||
out.push_str(&truncated);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if is_msbuild_diagnostic(trimmed) || is_failure_line(trimmed) || is_test_summary(trimmed) {
|
||||
out.push_str(trimmed);
|
||||
out.push_str(&truncated);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
@@ -110,11 +153,12 @@ fn filter_format(input: &str) -> String {
|
||||
if trimmed.is_empty() || is_dotnet_format_noise(trimmed) {
|
||||
continue;
|
||||
}
|
||||
let truncated = primitives::truncate_line(trimmed, primitives::CapClass::Errors.lines());
|
||||
if is_msbuild_diagnostic(trimmed)
|
||||
|| looks_like_path(trimmed)
|
||||
|| contains_format_signal(trimmed)
|
||||
{
|
||||
out.push_str(trimmed);
|
||||
out.push_str(&truncated);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
@@ -136,7 +180,7 @@ fn compact_format_json(input: &str) -> Option<String> {
|
||||
|
||||
let mut out = format!("dotnet format: {} diagnostics\n", rows.len());
|
||||
for row in rows.iter().take(40) {
|
||||
out.push_str(row);
|
||||
out.push_str(&primitives::truncate_line(row, primitives::CapClass::Errors.lines()));
|
||||
out.push('\n');
|
||||
}
|
||||
if rows.len() > 40 {
|
||||
@@ -215,7 +259,7 @@ fn first_string<'a>(
|
||||
fn first_number(map: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> Option<u64> {
|
||||
keys
|
||||
.iter()
|
||||
.find_map(|key| map.get(*key).and_then(|value| value.as_u64()))
|
||||
.find_map(|key| map.get(*key).and_then(serde_json::Value::as_u64))
|
||||
}
|
||||
|
||||
fn compact_general(input: &str) -> String {
|
||||
@@ -229,9 +273,7 @@ fn is_dotnet_boilerplate(line: &str) -> bool {
|
||||
lower.starts_with("determining projects to restore")
|
||||
|| lower.starts_with("all projects are up-to-date for restore")
|
||||
|| lower.starts_with("restored ") && !contains_diagnostic_signal(&lower)
|
||||
|| lower.starts_with(" ") && lower.contains(" -> ")
|
||||
|| lower.starts_with("build started")
|
||||
|| lower.starts_with("build succeeded")
|
||||
|| lower.starts_with("test run for ")
|
||||
|| lower.starts_with("starting test execution")
|
||||
|| lower.starts_with("a total of ") && lower.contains("test files matched")
|
||||
@@ -282,9 +324,12 @@ fn is_dotnet_summary(line: &str) -> bool {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
lower.starts_with("build failed")
|
||||
|| lower.starts_with("restore failed")
|
||||
|| lower.starts_with("build succeeded")
|
||||
|| lower.starts_with("restore succeeded")
|
||||
|| lower.starts_with("time elapsed")
|
||||
|| lower.contains(" error(s)")
|
||||
|| lower.contains(" warning(s)")
|
||||
|| lower.contains(" -> ")
|
||||
}
|
||||
|
||||
fn is_test_summary(line: &str) -> bool {
|
||||
@@ -357,4 +402,181 @@ mod tests {
|
||||
assert!(out.contains("src/App.cs:4:9"));
|
||||
assert!(out.contains("IDE0055"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_build_success_short_circuits() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Microsoft (R) Build Engine version 17.8.3+195e7f5a3\nCopyright (C) Microsoft \
|
||||
Corporation. All rights reserved.\n\nDetermining projects to restore...\nAll \
|
||||
projects are up-to-date for restore.\nMyApp -> \
|
||||
/home/user/MyApp/bin/Debug/net8.0/MyApp.dll\n\nBuild succeeded.\n 0 \
|
||||
Warning(s)\n 0 Error(s)\n\nTime Elapsed 00:00:02.34\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(out.text, "ok (build succeeded)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_restore_success_short_circuits() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("restore"),
|
||||
command: "dotnet restore",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Microsoft (R) Build Engine version 17.8.3+195e7f5a3\nCopyright (C) Microsoft \
|
||||
Corporation. All rights reserved.\n\n Determining projects to restore...\n \
|
||||
All projects are up-to-date for restore.\n\n 0 Warning(s)\n 0 \
|
||||
Error(s)\n\nTime Elapsed 00:00:01.23\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(out.text, "ok (restore succeeded)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_build_unindented_summary_not_short_circuited() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
// Real warnings appear after an early unindented "0 Warning(s)" line;
|
||||
// the def's regex required consecutive lines with the second indented.
|
||||
let input = "pre-build validation: 0 Warning(s)\nSome other line\nsrc/Program.cs(1,1): \
|
||||
warning CS8600: Converting null literal or possible null value to non-nullable \
|
||||
type\nBuild succeeded.\n 1 Warning(s)\n 0 Error(s)\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.text.contains("ok (build succeeded)"));
|
||||
assert!(out.text.contains("1 Warning(s)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_build_consecutive_unindented_summary_not_short_circuited() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
// The def's regex required the second line to be indented (\s+);
|
||||
// consecutive unindented lines must not short-circuit.
|
||||
let input = "Build succeeded.\n0 Warning(s)\n0 Error(s)\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.text.contains("ok (build succeeded)"));
|
||||
assert!(out.text.contains("0 Warning(s)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_build_with_warnings_not_short_circuited() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Microsoft (R) Build Engine version 17.8.3+195e7f5a3\nCopyright (C) Microsoft \
|
||||
Corporation. All rights reserved.\n\nDetermining projects to restore...\nMyApp \
|
||||
-> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll\n\nBuild succeeded.\n3 \
|
||||
Warning(s)\n0 Error(s)\n\nTime Elapsed 00:00:01.87\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(
|
||||
out.text,
|
||||
"MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll\nBuild succeeded.\n3 Warning(s)\n0 \
|
||||
Error(s)\nTime Elapsed 00:00:01.87\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotnet_build_errors_preserved() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Microsoft (R) Build Engine version 17.8.3+195e7f5a3\nCopyright (C) Microsoft \
|
||||
Corporation. All rights reserved.\n\nDetermining projects to \
|
||||
restore...\nsrc/Program.cs(10,5): error CS1002: ; expected \
|
||||
[/home/user/MyApp/MyApp.csproj]\n\nBuild FAILED.\n0 Warning(s)\n1 Error(s)\n";
|
||||
let out = filter(&ctx, input, 1);
|
||||
assert_eq!(
|
||||
out.text,
|
||||
"dotnet build: failed\nsrc/Program.cs(10,5): error CS1002: ; expected \
|
||||
[/home/user/MyApp/MyApp.csproj]\nBuild FAILED.\n0 Warning(s)\n1 Error(s)\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_lines_in_build_output() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "dotnet",
|
||||
subcommand: Some("build"),
|
||||
command: "dotnet build",
|
||||
config: &cfg,
|
||||
};
|
||||
let long_tail = "x".repeat(200);
|
||||
let input = format!(
|
||||
"src/Program.cs(1,1): error CS0000: {long_tail}\nBuild FAILED.\n 0 Warning(s)\n 1 \
|
||||
Error(s)\n"
|
||||
);
|
||||
let out = filter(&ctx, &input, 1);
|
||||
let line = out
|
||||
.text
|
||||
.lines()
|
||||
.find(|l| l.starts_with("src/Program.cs"))
|
||||
.unwrap();
|
||||
let prefix_len = "src/Program.cs(1,1): error CS0000: ".chars().count();
|
||||
let dropped = (prefix_len + long_tail.chars().count())
|
||||
.saturating_sub(primitives::CapClass::Errors.lines());
|
||||
assert!(line.ends_with(&format!("…[+{dropped}]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_lines_in_test_output() {
|
||||
let long_tail = "x".repeat(200);
|
||||
let input = format!("Failed TestMethod\n Stack trace: {long_tail}\nTotal tests: 1\n");
|
||||
let out = filter_test(&input, 1);
|
||||
let line = out.lines().find(|l| l.starts_with("Stack trace:")).unwrap();
|
||||
let prefix_len = "Stack trace: ".chars().count();
|
||||
let dropped = (prefix_len + long_tail.chars().count())
|
||||
.saturating_sub(primitives::CapClass::Errors.lines());
|
||||
assert!(line.ends_with(&format!("…[+{dropped}]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_lines_in_format_text_output() {
|
||||
let long_tail = "x".repeat(200);
|
||||
let input = format!("src/Program.cs(1,1): warning CS8600: {long_tail}\n");
|
||||
let out = filter_format(&input);
|
||||
let line = out.lines().next().unwrap();
|
||||
let prefix_len = "src/Program.cs(1,1): warning CS8600: ".chars().count();
|
||||
let dropped = (prefix_len + long_tail.chars().count())
|
||||
.saturating_sub(primitives::CapClass::Errors.lines());
|
||||
assert!(line.ends_with(&format!("…[+{dropped}]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_lines_in_format_json_output() {
|
||||
let long_tail = "x".repeat(200);
|
||||
let input = format!(
|
||||
r#"{{"FileName":"src/App.cs","Changes":[{{"DiagnosticId":"IDE0055","LineNumber":4,"CharNumber":9,"FormatDescription":"Fix formatting {long_tail}"}}]}}"#
|
||||
);
|
||||
let out = filter_format(&input);
|
||||
let line = out.lines().find(|l| l.starts_with("src/App.cs")).unwrap();
|
||||
let prefix_len = "src/App.cs:4:9: IDE0055 - Fix formatting ".chars().count();
|
||||
let dropped = (prefix_len + long_tail.chars().count())
|
||||
.saturating_sub(primitives::CapClass::Errors.lines());
|
||||
assert!(line.ends_with(&format!("…[+{dropped}]")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
section_start:1711234567:prepare_executor[0K
|
||||
Running with gitlab-runner 16.9.0 (656c1943)
|
||||
on runner-abc123 system ID: r_defGHI456
|
||||
Using Docker executor with image node:20-alpine ...
|
||||
Preparing the "docker" executor
|
||||
Using Docker executor with image node:20-alpine ...
|
||||
Running on runner-abc123-project-42-concurrent-0 via runner-host...
|
||||
section_end:1711234570:prepare_executor[0K
|
||||
section_start:1711234570:get_sources[0K
|
||||
Getting source from Git repository
|
||||
Fetching changes with git depth set to 20...
|
||||
Initialized empty Git repository in /builds/acme/toolkit/.git/
|
||||
Created fresh repository.
|
||||
Checking out abc12345 as main...
|
||||
Skipping Git submodules setup
|
||||
section_end:1711234575:get_sources[0K
|
||||
section_start:1711234575:download_artifacts[0K
|
||||
Downloading artifacts for build (job_id=98765)...
|
||||
Downloading artifacts from coordinator... ok host=runner-host id=98765 responseStatus=200 OK token=abc123
|
||||
section_end:1711234578:download_artifacts[0K
|
||||
section_start:1711234578:build_script[0K
|
||||
$ npm ci
|
||||
added 847 packages in 12s
|
||||
$ npm run build
|
||||
> acme-toolkit@3.2.1 build
|
||||
> tsc && vite build
|
||||
[36;1mvite v5.2.0[0m building for production...
|
||||
[32m✓[0m 142 modules transformed.
|
||||
[33m⚠[0m Some chunks are larger than 500 kB after minification.
|
||||
dist/index.html 0.45 kB │ gzip: 0.29 kB
|
||||
dist/assets/main-a1b2c3.js 156.78 kB │ gzip: 48.23 kB
|
||||
dist/assets/style-d4e5f6.css 12.34 kB │ gzip: 3.45 kB
|
||||
[32m✓[0m built in 4.56s
|
||||
$ npm test
|
||||
> acme-toolkit@3.2.1 test
|
||||
> vitest run
|
||||
[32m✓[0m src/utils.test.ts (3 tests) 45ms
|
||||
[32m✓[0m src/api.test.ts (7 tests) 123ms
|
||||
[31m✗[0m src/auth.test.ts (2 tests) 67ms
|
||||
FAIL src/auth.test.ts > validateToken > should reject expired tokens
|
||||
AssertionError: expected true to be false
|
||||
at src/auth.test.ts:42:18
|
||||
Test Files 1 failed | 2 passed | 3 total
|
||||
Tests 1 failed | 11 passed | 12 total
|
||||
Duration 0.89s
|
||||
section_end:1711234600:build_script[0K
|
||||
section_start:1711234600:upload_artifacts[0K
|
||||
Uploading artifacts...
|
||||
Uploading artifacts as "archive" to coordinator... ok id=98765 responseStatus=201 Created token=abc123
|
||||
section_end:1711234605:upload_artifacts[0K
|
||||
section_start:1711234605:cleanup_file_variables[0K
|
||||
Cleaning up project directory and file based variables
|
||||
section_end:1711234606:cleanup_file_variables[0K
|
||||
[31;1mERROR: Job failed: exit code 1[0m
|
||||
@@ -0,0 +1,13 @@
|
||||
Showing 10 releases on acme/toolkit.
|
||||
|
||||
Name Tag Created
|
||||
v3.2.1 v3.2.1 about 2 days ago
|
||||
v3.2.0 v3.2.0 about 1 week ago
|
||||
v3.1.0 v3.1.0 about 3 weeks ago
|
||||
v3.0.0 v3.0.0 about 1 month ago
|
||||
v2.5.0 v2.5.0 about 3 months ago
|
||||
v2.4.1 v2.4.1 about 5 months ago
|
||||
v2.4.0 v2.4.0 about 6 months ago
|
||||
v2.3.0 v2.3.0 about 9 months ago
|
||||
v2.2.0 v2.2.0 about 1 year ago
|
||||
v2.1.0 v2.1.0 about 2 years ago
|
||||
@@ -0,0 +1,30 @@
|
||||
Test Release v2.0
|
||||
alice_dev released this 3 days ago
|
||||
abc1234 - v2.0.0
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Added widget support
|
||||
- Fixed authentication bug
|
||||
|
||||
### Contributors
|
||||
|
||||
@alice_dev @bob_dev
|
||||
|
||||
--------
|
||||
|
||||
Image: logo → https://example.com/logo.png
|
||||
|
||||
<!-- internal tracking: PROJ-123 -->
|
||||
|
||||
|
||||
ASSETS
|
||||
There are no assets for this release
|
||||
SOURCES
|
||||
https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.zip
|
||||
https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar.gz
|
||||
https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar.bz2
|
||||
https://gitlab.example.com/acme/toolkit/-/archive/v2.0.0/toolkit-v2.0.0.tar
|
||||
|
||||
|
||||
View this release on GitLab at https://gitlab.example.com/acme/toolkit/-/releases/v2.0.0
|
||||
@@ -0,0 +1,33 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] ----------------------< commons-cli:commons-cli >-----------------------
|
||||
[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli ---
|
||||
[INFO] Recompiling the module because of changed source code.
|
||||
[INFO] Compiling 37 source files with javac [debug release 8] to target/classes
|
||||
[INFO] -------------------------------------------------------------
|
||||
[WARNING] COMPILATION WARNING :
|
||||
[INFO] -------------------------------------------------------------
|
||||
[WARNING] source value 8 is obsolete and will be removed in a future release
|
||||
[WARNING] target value 8 is obsolete and will be removed in a future release
|
||||
[WARNING] To suppress warnings about obsolete options, use -Xlint:-options.
|
||||
[INFO] 3 warnings
|
||||
[INFO] -------------------------------------------------------------
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] COMPILATION ERROR :
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol
|
||||
symbol: variable bar
|
||||
location: class org.apache.commons.cli.CompileBreaker
|
||||
[INFO] 1 error
|
||||
[INFO] -------------------------------------------------------------
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 30.631 s
|
||||
[INFO] Finished at: 2026-05-21T14:59:46Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure
|
||||
@@ -0,0 +1,44 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] ----------------------< commons-cli:commons-cli >-----------------------
|
||||
[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli ---
|
||||
[INFO] Recompiling the module because of changed source code.
|
||||
[INFO] Compiling 47 source files with javac [debug parameters release 11] to target/classes
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ commons-cli ---
|
||||
[INFO] Recompiling the module because of changed dependency.
|
||||
[INFO] Compiling 52 source files with javac [debug parameters release 11] to target/test-classes
|
||||
[INFO]
|
||||
[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli ---
|
||||
[INFO]
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] T E S T S
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] Running org.apache.commons.cli.help.UtilTest
|
||||
[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest
|
||||
[INFO] Running org.apache.commons.cli.DefaultParserTest
|
||||
[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest
|
||||
[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest
|
||||
[INFO]
|
||||
[INFO] Results:
|
||||
[INFO]
|
||||
[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61
|
||||
[INFO]
|
||||
[INFO] --- jar:3.5.0:jar (default-jar) @ commons-cli ---
|
||||
[INFO] Building jar: target/commons-cli-1.11.1-SNAPSHOT.jar
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.4:install (default-install) @ commons-cli ---
|
||||
[INFO] Installing pom.xml to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.pom
|
||||
[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.jar
|
||||
[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT-sources.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT-sources.jar
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 01:29 min
|
||||
[INFO] Finished at: 2026-05-21T15:13:00Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
@@ -0,0 +1,15 @@
|
||||
[INFO] Recherche des projets...
|
||||
[INFO]
|
||||
[INFO] -----------------------< com.example:myapp >------------------------
|
||||
[INFO] Construction de myapp 1.0.0
|
||||
[INFO] de pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- clean:3.2.0:clean (default-clean) @ myapp ---
|
||||
[INFO] Suppression de /path/to/project/target
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD ÉCHEC
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Temps total: 0.713 s
|
||||
[INFO] Termine a: 2026-01-15T10:30:00Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
@@ -0,0 +1,8 @@
|
||||
[INFO] Scanning for projects...
|
||||
[ERROR] The goal you specified requires a project to execute but there is no POM in this directory (/tmp). Please verify you invoked Maven from the correct directory. -> [Help 1]
|
||||
[ERROR]
|
||||
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
|
||||
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
|
||||
[ERROR]
|
||||
[ERROR] For more information about the errors and possible solutions, please read the following articles:
|
||||
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MissingProjectException
|
||||
@@ -0,0 +1,27 @@
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.069 s <<< FAILURE! -- in x.FailTest
|
||||
[ERROR] x.FailTest.this_will_fail -- Time elapsed: 0.030 s <<< FAILURE!
|
||||
org.opentest4j.AssertionFailedError: deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2>
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
|
||||
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
|
||||
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
|
||||
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563)
|
||||
at x.FailTest.this_will_fail(FailTest.java:9)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
|
||||
[ERROR] Failures:
|
||||
[ERROR] FailTest.this_will_fail:9 deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2>
|
||||
[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project mvnq: There are test failures.
|
||||
[ERROR]
|
||||
[ERROR] See /tmp/mvnq/target/surefire-reports for the individual test results.
|
||||
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
|
||||
[ERROR] -> [Help 1]
|
||||
[ERROR]
|
||||
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
|
||||
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
|
||||
[ERROR]
|
||||
[ERROR] For more information about the errors and possible solutions, please read the following articles:
|
||||
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
|
||||
@@ -0,0 +1,87 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Reactor Build Order:
|
||||
[INFO]
|
||||
[INFO] multi-module-skeleton [pom]
|
||||
[INFO] child-a [jar]
|
||||
[INFO] child-b [jar]
|
||||
[INFO]
|
||||
[INFO] ---------------< com.example.rtk:multi-module-skeleton >----------------
|
||||
[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3]
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ pom ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton ---
|
||||
[INFO] Installing /tmp/reactor/pom.xml to /home/user/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom
|
||||
[INFO]
|
||||
[INFO] ----------------------< com.example.rtk:child-a >-----------------------
|
||||
[INFO] Building child-a 1.0.0-SNAPSHOT [2/3]
|
||||
[INFO] from child-a/pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:resources (default-resources) @ child-a ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-a/src/main/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a ---
|
||||
[INFO] Nothing to compile - all classes are up to date.
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-a/src/test/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a ---
|
||||
[INFO] No sources to compile
|
||||
[INFO]
|
||||
[INFO] --- surefire:2.17:test (default-test) @ child-a ---
|
||||
[INFO] No tests to run.
|
||||
[INFO]
|
||||
[INFO] --- jar:3.1.2:jar (default-jar) @ child-a ---
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.2:install (default-install) @ child-a ---
|
||||
[INFO] Installing /tmp/reactor/child-a/pom.xml to /home/user/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom
|
||||
[INFO] Installing /tmp/reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/user/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar
|
||||
[INFO]
|
||||
[INFO] ----------------------< com.example.rtk:child-b >-----------------------
|
||||
[INFO] Building child-b 1.0.0-SNAPSHOT [3/3]
|
||||
[INFO] from child-b/pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:resources (default-resources) @ child-b ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-b/src/main/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b ---
|
||||
[INFO] Recompiling the module because of changed source code.
|
||||
[INFO] Compiling 1 source file with javac [debug target 21] to target/classes
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] COMPILATION ERROR :
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] /tmp/reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol
|
||||
symbol: class BogusType
|
||||
location: class Empty
|
||||
[INFO] 1 error
|
||||
[INFO] -------------------------------------------------------------
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:
|
||||
[INFO]
|
||||
[INFO] multi-module-skeleton .............................. SUCCESS [ 0.369 s]
|
||||
[INFO] child-a ............................................ SUCCESS [ 1.657 s]
|
||||
[INFO] child-b ............................................ FAILURE [ 0.940 s]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 3.193 s
|
||||
[INFO] Finished at: 2026-05-25T12:11:41-03:00
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project child-b: Compilation failure
|
||||
[ERROR] /tmp/reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol
|
||||
[ERROR] symbol: class BogusType
|
||||
[ERROR] location: class Empty
|
||||
[ERROR] -> [Help 1]
|
||||
[ERROR]
|
||||
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
|
||||
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
|
||||
[ERROR]
|
||||
[ERROR] For more information about the errors and possible solutions, please read the following articles:
|
||||
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
|
||||
[ERROR]
|
||||
[ERROR] After correcting the problems, you can resume the build with the command
|
||||
[ERROR] mvn <args> -rf :child-b
|
||||
@@ -0,0 +1,79 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Reactor Build Order:
|
||||
[INFO]
|
||||
[INFO] multi-module-skeleton [pom]
|
||||
[INFO] child-a [jar]
|
||||
[INFO] child-b [jar]
|
||||
[INFO]
|
||||
[INFO] ---------------< com.example.rtk:multi-module-skeleton >----------------
|
||||
[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3]
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ pom ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton ---
|
||||
[INFO] Installing /tmp/reactor/pom.xml to /home/user/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom
|
||||
[INFO]
|
||||
[INFO] ----------------------< com.example.rtk:child-a >-----------------------
|
||||
[INFO] Building child-a 1.0.0-SNAPSHOT [2/3]
|
||||
[INFO] from child-a/pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:resources (default-resources) @ child-a ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-a/src/main/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a ---
|
||||
[INFO] Nothing to compile - all classes are up to date.
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-a/src/test/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a ---
|
||||
[INFO] No sources to compile
|
||||
[INFO]
|
||||
[INFO] --- surefire:2.17:test (default-test) @ child-a ---
|
||||
[INFO] No tests to run.
|
||||
[INFO]
|
||||
[INFO] --- jar:3.1.2:jar (default-jar) @ child-a ---
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.2:install (default-install) @ child-a ---
|
||||
[INFO] Installing /tmp/reactor/child-a/pom.xml to /home/user/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom
|
||||
[INFO] Installing /tmp/reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/user/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar
|
||||
[INFO]
|
||||
[INFO] ----------------------< com.example.rtk:child-b >-----------------------
|
||||
[INFO] Building child-b 1.0.0-SNAPSHOT [3/3]
|
||||
[INFO] from child-b/pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:resources (default-resources) @ child-b ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-b/src/main/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b ---
|
||||
[INFO] Nothing to compile - all classes are up to date.
|
||||
[INFO]
|
||||
[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-b ---
|
||||
[INFO] skip non existing resourceDirectory /tmp/reactor/child-b/src/test/resources
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-b ---
|
||||
[INFO] No sources to compile
|
||||
[INFO]
|
||||
[INFO] --- surefire:2.17:test (default-test) @ child-b ---
|
||||
[INFO] No tests to run.
|
||||
[INFO]
|
||||
[INFO] --- jar:3.1.2:jar (default-jar) @ child-b ---
|
||||
[INFO]
|
||||
[INFO] --- install:3.1.2:install (default-install) @ child-b ---
|
||||
[INFO] Installing /tmp/reactor/child-b/pom.xml to /home/user/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.pom
|
||||
[INFO] Installing /tmp/reactor/child-b/target/child-b-1.0.0-SNAPSHOT.jar to /home/user/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.jar
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT:
|
||||
[INFO]
|
||||
[INFO] multi-module-skeleton .............................. SUCCESS [ 0.353 s]
|
||||
[INFO] child-a ............................................ SUCCESS [ 1.676 s]
|
||||
[INFO] child-b ............................................ SUCCESS [ 0.069 s]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 2.284 s
|
||||
[INFO] Finished at: 2026-05-25T12:11:31-03:00
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] ----------------------< commons-cli:commons-cli >-----------------------
|
||||
[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli ---
|
||||
[INFO] Recompiling the module because of changed source code.
|
||||
[INFO] Compiling 37 source files with javac [debug release 8] to target/classes
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] COMPILATION ERROR :
|
||||
[INFO] -------------------------------------------------------------
|
||||
[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol
|
||||
symbol: variable bar
|
||||
location: class org.apache.commons.cli.CompileBreaker
|
||||
[INFO] 1 error
|
||||
[INFO] -------------------------------------------------------------
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 3.214 s
|
||||
[INFO] Finished at: 2026-05-21T15:02:31Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure
|
||||
[ERROR] /home/user/commons-cli/src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol
|
||||
[ERROR] symbol: variable bar
|
||||
[ERROR] location: class org.apache.commons.cli.CompileBreaker
|
||||
[ERROR] -> [Help 1]
|
||||
@@ -0,0 +1,46 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] ----------------------< commons-cli:commons-cli >-----------------------
|
||||
[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli ---
|
||||
[INFO]
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] T E S T S
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] Running org.apache.commons.cli.SolrCliTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in org.apache.commons.cli.SolrCliTest
|
||||
[INFO] Running org.apache.commons.cli.RtkInducedFailTest
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest
|
||||
[ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE!
|
||||
org.opentest4j.AssertionFailedError: expected: <expected> but was: <actual>
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
|
||||
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
|
||||
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182)
|
||||
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177)
|
||||
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145)
|
||||
at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
|
||||
[INFO] Running org.apache.commons.cli.PatternOptionBuilderTest
|
||||
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in org.apache.commons.cli.PatternOptionBuilderTest
|
||||
[INFO]
|
||||
[INFO] Results:
|
||||
[INFO]
|
||||
[ERROR] Failures:
|
||||
[ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: <expected> but was: <actual>
|
||||
[INFO]
|
||||
[ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61
|
||||
[INFO]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 01:05 min
|
||||
[INFO] Finished at: 2026-05-21T14:57:09Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures.
|
||||
@@ -0,0 +1,70 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] -----------------< com.example.rtk:multifail-skeleton >-----------------
|
||||
[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- surefire:3.5.5:test (default-test) @ multifail-skeleton ---
|
||||
[INFO]
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] T E S T S
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] Running com.example.rtk.CalcTest
|
||||
[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.108 s <<< FAILURE! -- in com.example.rtk.CalcTest
|
||||
[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.050 s <<< FAILURE!
|
||||
org.opentest4j.AssertionFailedError: failOne: addition should equal five ==> expected: <5> but was: <4>
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
|
||||
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
|
||||
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
|
||||
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
|
||||
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563)
|
||||
at com.example.rtk.CalcTest.failOne(CalcTest.java:12)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
|
||||
[ERROR] com.example.rtk.CalcTest.failTwo -- Time elapsed: 0.006 s <<< ERROR!
|
||||
java.lang.IllegalStateException: failTwo: induced error for fixture capture
|
||||
at com.example.rtk.CalcTest.failTwo(CalcTest.java:17)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
|
||||
[INFO] Running com.example.rtk.BoomTest
|
||||
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< FAILURE! -- in com.example.rtk.BoomTest
|
||||
[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.004 s <<< ERROR!
|
||||
java.lang.IllegalStateException: boom: errors-only class for fixture capture
|
||||
at com.example.rtk.BoomTest.boom(BoomTest.java:8)
|
||||
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
|
||||
|
||||
[INFO]
|
||||
[INFO] Results:
|
||||
[INFO]
|
||||
[ERROR] Failures:
|
||||
[ERROR] CalcTest.failOne:12 failOne: addition should equal five ==> expected: <5> but was: <4>
|
||||
[ERROR] Errors:
|
||||
[ERROR] BoomTest.boom:8 IllegalState boom: errors-only class for fixture capture
|
||||
[ERROR] CalcTest.failTwo:17 IllegalState failTwo: induced error for fixture capture
|
||||
[INFO]
|
||||
[ERROR] Tests run: 4, Failures: 1, Errors: 2, Skipped: 0
|
||||
[INFO]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD FAILURE
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 3.171 s
|
||||
[INFO] Finished at: 2026-06-06T10:53:18-03:00
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project multifail-skeleton: There are test failures.
|
||||
[ERROR]
|
||||
[ERROR] See /home/user/projetos/rtk/tests/fixtures/multifail-skeleton/target/surefire-reports for the individual test results.
|
||||
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
|
||||
[ERROR] -> [Help 1]
|
||||
[ERROR]
|
||||
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
|
||||
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
|
||||
[ERROR]
|
||||
[ERROR] For more information about the errors and possible solutions, please read the following articles:
|
||||
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
|
||||
@@ -0,0 +1,33 @@
|
||||
[INFO] Scanning for projects...
|
||||
[INFO]
|
||||
[INFO] ----------------------< commons-cli:commons-cli >-----------------------
|
||||
[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT
|
||||
[INFO] from pom.xml
|
||||
[INFO] --------------------------------[ jar ]---------------------------------
|
||||
[INFO]
|
||||
[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli ---
|
||||
[INFO]
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] T E S T S
|
||||
[INFO] -------------------------------------------------------
|
||||
[INFO] Running org.apache.commons.cli.help.UtilTest
|
||||
[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest
|
||||
[INFO] Running org.apache.commons.cli.help.TextStyleTest
|
||||
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.240 s -- in org.apache.commons.cli.help.TextStyleTest
|
||||
[INFO] Running org.apache.commons.cli.DefaultParserTest
|
||||
[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest
|
||||
[INFO] Running org.apache.commons.cli.ConverterTests
|
||||
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in org.apache.commons.cli.ConverterTests
|
||||
[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest
|
||||
[INFO]
|
||||
[INFO] Results:
|
||||
[INFO]
|
||||
[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61
|
||||
[INFO]
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] Total time: 01:14 min
|
||||
[INFO] Finished at: 2026-05-21T14:54:30Z
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> MinimizerOutput {
|
||||
let stripped = primitives::strip_ansi(input);
|
||||
let deduped = primitives::dedup_consecutive_lines(&stripped);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! GitHub CLI output filters.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
@@ -18,6 +21,7 @@ pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if preserves_raw_mode(ctx) {
|
||||
return MinimizerOutput::passthrough(input);
|
||||
@@ -25,9 +29,15 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.subcommand {
|
||||
Some("pr") if primitives::command_has_ordered_tokens(ctx.command, "pr", "checks") => {
|
||||
match filter_pr_checks(&cleaned) {
|
||||
Some(summary) => summary,
|
||||
None => filter_pr_issue(&cleaned, exit_code),
|
||||
}
|
||||
},
|
||||
Some("pr" | "issue") => filter_pr_issue(&cleaned, exit_code),
|
||||
Some("run" | "workflow") => filter_run(&cleaned, exit_code),
|
||||
_ => head_tail_dedup(&cleaned),
|
||||
_ => primitives::head_tail_dedup(&cleaned),
|
||||
};
|
||||
|
||||
if text == input {
|
||||
@@ -41,51 +51,122 @@ fn preserves_raw_mode(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
match ctx.subcommand {
|
||||
Some("api") => true,
|
||||
Some("run") => {
|
||||
command_has_ordered_tokens(ctx.command, "run", "view")
|
||||
&& command_has_any_token(ctx.command, &["--log", "--log-failed", "--json"])
|
||||
primitives::command_has_ordered_tokens(ctx.command, "run", "view")
|
||||
&& primitives::command_has_any_token(ctx.command, &["--log", "--log-failed", "--json"])
|
||||
},
|
||||
Some("pr") if command_has_ordered_tokens(ctx.command, "pr", "diff") => true,
|
||||
Some("pr") if command_has_ordered_tokens(ctx.command, "pr", "status") => {
|
||||
command_has_any_token(ctx.command, &["--web", "--jq", "--template"])
|
||||
Some("pr") if primitives::command_has_ordered_tokens(ctx.command, "pr", "checks") => {
|
||||
// `--watch` re-renders the whole check table each `--interval`/-i
|
||||
// (default 10s) until checks finish; the captured buffer is then
|
||||
// dozens of concatenated frames. `filter_pr_checks` counts one glyph
|
||||
// per row per frame, so it would report counts x frames and let
|
||||
// duplicate failed rows exhaust FAILED_ROW_CAP, hiding distinct later
|
||||
// failures. A watch is an explicit live view -- pass it through raw
|
||||
// instead of summarizing a multi-frame buffer. (--json/-w break the
|
||||
// table shape outright.)
|
||||
primitives::command_has_any_token(ctx.command, &[
|
||||
"--json",
|
||||
"--web",
|
||||
"-w",
|
||||
"--jq",
|
||||
"--template",
|
||||
"--watch",
|
||||
"--interval",
|
||||
"-i",
|
||||
])
|
||||
},
|
||||
Some("pr") if primitives::command_has_ordered_tokens(ctx.command, "pr", "diff") => true,
|
||||
Some("pr") if primitives::command_has_ordered_tokens(ctx.command, "pr", "status") => {
|
||||
primitives::command_has_any_token(ctx.command, &["--web", "--jq", "--template"])
|
||||
},
|
||||
Some(subcommand @ ("pr" | "issue")) => {
|
||||
command_has_ordered_tokens(ctx.command, subcommand, "view")
|
||||
&& command_has_any_token(ctx.command, &["--json", "--jq", "--comments"])
|
||||
primitives::command_has_ordered_tokens(ctx.command, subcommand, "view")
|
||||
&& primitives::command_has_any_token(ctx.command, &["--json", "--jq", "--comments"])
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn command_has_ordered_tokens(command: &str, first: &str, second: &str) -> bool {
|
||||
let mut saw_first = false;
|
||||
for part in command.split_whitespace() {
|
||||
if saw_first && part == second {
|
||||
return true;
|
||||
}
|
||||
if part == first {
|
||||
saw_first = true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn command_has_any_token(command: &str, tokens: &[&str]) -> bool {
|
||||
command.split_whitespace().any(|part| {
|
||||
tokens.iter().any(|token| {
|
||||
part == *token
|
||||
|| part
|
||||
.strip_prefix(*token)
|
||||
.is_some_and(|suffix| suffix.starts_with('='))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_pr_issue(input: &str, exit_code: i32) -> String {
|
||||
if exit_code != 0 {
|
||||
return head_tail_dedup(input);
|
||||
return primitives::head_tail_dedup(input);
|
||||
}
|
||||
let markdown_filtered = filter_markdown_noise(input);
|
||||
head_tail_dedup(&markdown_filtered)
|
||||
primitives::head_tail_dedup(&markdown_filtered)
|
||||
}
|
||||
|
||||
/// Summarize the DEFAULT (non-JSON) `gh pr checks` table.
|
||||
///
|
||||
/// Default human output is a tab-separated table:
|
||||
/// `<symbol>\t<name>\t<duration>\t<url>` where the leading status glyph is
|
||||
/// `✓`/`X`/`*`/`-` (pass/fail/pending/skipping). We re-derive against this real
|
||||
/// layout — NOT rtk's `[ok]`/`[x]` strings, which only appear on the injected
|
||||
/// `-F json` path. Failed rows stay verbatim (they carry the actionable URL);
|
||||
/// passed/pending/skipping collapse to counts. Returns `None` when no
|
||||
/// recognizable check rows are found so the caller can fall back to the generic
|
||||
/// path.
|
||||
fn filter_pr_checks(input: &str) -> Option<String> {
|
||||
let mut passed = 0usize;
|
||||
let mut pending = 0usize;
|
||||
let mut skipping = 0usize;
|
||||
let mut failed_rows = Vec::new();
|
||||
let mut saw_row = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
// A real check row is `<symbol>\t<name>\t<duration>\t<url>`: a status glyph
|
||||
// followed by at least one TAB-delimited field. Requiring the tab shape
|
||||
// keyed off the row's first glyph — NOT the leading char alone — rejects
|
||||
// separators (`---`), blank-glyph lines, and bulleted annotation detail
|
||||
// (`- ...`, `* ...`) that gh/CI tools emit, any of which would otherwise be
|
||||
// miscounted as a phantom skipping/pending check and inflate the summary.
|
||||
if !trimmed.contains('\t') {
|
||||
continue;
|
||||
}
|
||||
let Some(symbol) = trimmed.chars().next() else {
|
||||
continue;
|
||||
};
|
||||
match symbol {
|
||||
'✓' => {
|
||||
saw_row = true;
|
||||
passed += 1;
|
||||
},
|
||||
'X' | '✗' | '×' => {
|
||||
saw_row = true;
|
||||
failed_rows.push(line.trim_end().to_string());
|
||||
},
|
||||
'*' => {
|
||||
saw_row = true;
|
||||
pending += 1;
|
||||
},
|
||||
'-' => {
|
||||
saw_row = true;
|
||||
skipping += 1;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_row {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let failed = failed_rows.len();
|
||||
let _ = write!(out, "checks: {passed} passed, {failed} failed");
|
||||
if pending > 0 {
|
||||
let _ = write!(out, ", {pending} pending");
|
||||
}
|
||||
if skipping > 0 {
|
||||
let _ = write!(out, ", {skipping} skipping");
|
||||
}
|
||||
out.push('\n');
|
||||
// Cap the verbatim failed rows: a PR with hundreds of failing checks would
|
||||
// otherwise emit one line each, uncapped (this path bypasses head_tail_dedup
|
||||
// that every sibling arm ends in). Keep the first FAILED_ROW_CAP failures —
|
||||
// they carry the actionable URLs — and append an omission marker.
|
||||
const FAILED_ROW_CAP: usize = 40;
|
||||
out.push_str(&primitives::head_lines_only(&failed_rows.join("\n"), FAILED_ROW_CAP));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn filter_run(input: &str, exit_code: i32) -> String {
|
||||
@@ -100,22 +181,32 @@ fn filter_markdown_noise(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_html_comment = false;
|
||||
let mut previous_blank = false;
|
||||
let mut comment_lines = 0usize;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if in_html_comment {
|
||||
if trimmed.contains("-->") {
|
||||
in_html_comment = false;
|
||||
comment_lines = 0;
|
||||
} else {
|
||||
comment_lines += 1;
|
||||
if comment_lines > 50 {
|
||||
in_html_comment = false;
|
||||
comment_lines = 0;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("<!--") {
|
||||
if !trimmed.contains("-->") {
|
||||
in_html_comment = true;
|
||||
comment_lines = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_markdown_badge_or_image(trimmed) || is_horizontal_rule(trimmed) {
|
||||
if primitives::is_markdown_badge_or_image(trimmed) || primitives::is_horizontal_rule(trimmed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
@@ -132,14 +223,6 @@ fn filter_markdown_noise(input: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn is_markdown_badge_or_image(line: &str) -> bool {
|
||||
line.starts_with("![") || line.starts_with("[![") || line.contains("img.shields.io")
|
||||
}
|
||||
|
||||
fn is_horizontal_rule(line: &str) -> bool {
|
||||
line.len() >= 3 && line.chars().all(|ch| matches!(ch, '-' | '*' | '_' | ' '))
|
||||
}
|
||||
|
||||
fn contains_failure_signal(input: &str) -> bool {
|
||||
input.lines().any(|line| {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
@@ -150,10 +233,6 @@ fn contains_failure_signal(input: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn head_tail_dedup(input: &str) -> String {
|
||||
primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), 120, 80)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -198,6 +277,137 @@ mod tests {
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_summarizes_default_table_and_keeps_failures() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123", &cfg);
|
||||
// DEFAULT `gh pr checks` table: <symbol>\t<name>\t<duration>\t<url>.
|
||||
let input = "✓\tbuild\t1m2s\thttps://ci.test/build\n\
|
||||
X\ttest\t3m4s\thttps://ci.test/test\n\
|
||||
*\tlint\t0s\thttps://ci.test/lint\n\
|
||||
-\tdocs\t0s\thttps://ci.test/docs\n";
|
||||
|
||||
let out = filter(&ctx, input, 1);
|
||||
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("checks: 1 passed, 1 failed"));
|
||||
assert!(out.text.contains("1 pending"));
|
||||
assert!(out.text.contains("1 skipping"));
|
||||
// Failed row stays verbatim with its actionable URL.
|
||||
assert!(out.text.contains("X\ttest\t3m4s\thttps://ci.test/test"));
|
||||
// Passed/pending/skipping rows collapse to counts only.
|
||||
assert!(!out.text.contains("https://ci.test/build"));
|
||||
assert!(!out.text.contains("https://ci.test/lint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_caps_unbounded_failed_rows() {
|
||||
// A PR with hundreds of failing checks must not emit one verbatim line
|
||||
// each: the failed rows are capped (with an omission marker) like every
|
||||
// sibling path, not passed through unbounded.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123", &cfg);
|
||||
let mut input = String::new();
|
||||
for idx in 0..500 {
|
||||
let _ = writeln!(input, "X\tcheck{idx}\t1s\thttps://ci.test/{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&ctx, &input, 1);
|
||||
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("checks: 0 passed, 500 failed"));
|
||||
// First failures stay verbatim with their actionable URLs.
|
||||
assert!(out.text.contains("X\tcheck0\t1s\thttps://ci.test/0"));
|
||||
// The output is capped, not 501 lines of raw rows.
|
||||
assert!(out.text.contains("lines omitted"));
|
||||
assert!(out.text.lines().count() < 60);
|
||||
assert!(!out.text.contains("https://ci.test/499"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_web_flag_is_passthrough() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123 --web", &cfg);
|
||||
let input = "Opening https://github.com/owner/repo/pull/123/checks in your browser.\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_watch_session_is_passthrough() {
|
||||
// `--watch` re-renders the whole table each interval; the captured buffer
|
||||
// is many concatenated frames. Summarizing it would count every row once
|
||||
// per frame (counts x frames) and let duplicate failed rows exhaust the
|
||||
// FAILED_ROW_CAP, hiding distinct later failures. A watch must pass through
|
||||
// raw so the live frames survive intact.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123 --watch", &cfg);
|
||||
// Three frames of the same 1-pass/1-fail PR (what a watch concatenates).
|
||||
let frame =
|
||||
"\u{2713}\tbuild\t1m2s\thttps://ci.test/build\nX\ttest\t3m4s\thttps://ci.test/test\n";
|
||||
let input = format!("{frame}{frame}{frame}");
|
||||
|
||||
let out = filter(&ctx, &input, 1);
|
||||
|
||||
// Raw passthrough: no summary line, no inflated counts.
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
assert!(!out.text.contains("checks:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_watch_interval_flags_are_passthrough() {
|
||||
// `-i`/`--interval` only take effect alongside `--watch`; recognizing
|
||||
// them (long and short forms, attached or spaced) keeps any watch variant
|
||||
// on the raw path.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let input = "\u{2713}\tbuild\t1m2s\thttps://ci.test/build\n";
|
||||
for command in ["gh pr checks 123 --watch -i 5", "gh pr checks 123 --watch --interval=5"] {
|
||||
let ctx = test_ctx(Some("pr"), command, &cfg);
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.changed, "expected passthrough for {command}");
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_ignores_non_tab_glyph_lines() {
|
||||
// Only `<symbol>\t...` rows count. A separator (`---`) and bulleted CI
|
||||
// annotation detail (`- ...`, `* ...`) whose first char matches a status
|
||||
// glyph must NOT be miscounted as phantom skipping/pending checks.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123", &cfg);
|
||||
let input = "✓\tbuild\t1m\thttps://ci.test/build\nX\ttest\t2m\thttps://ci.test/test\n---\n- \
|
||||
annotation detail one\n- annotation detail two\n* note about the failure\n";
|
||||
|
||||
let out = filter(&ctx, input, 1);
|
||||
|
||||
assert!(out.changed);
|
||||
// Exactly 1 passed + 1 failed; no phantom pending/skipping from the
|
||||
// separator or the bulleted detail lines.
|
||||
assert!(out.text.contains("checks: 1 passed, 1 failed"));
|
||||
assert!(!out.text.contains("pending"));
|
||||
assert!(!out.text.contains("skipping"));
|
||||
// The failing row (with its actionable URL) is still kept verbatim.
|
||||
assert!(out.text.contains("X\ttest\t2m\thttps://ci.test/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_checks_falls_back_when_no_rows() {
|
||||
// No recognizable glyph rows -> generic pr/issue path, not a bogus summary.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("pr"), "gh pr checks 123", &cfg);
|
||||
let input = "no checks reported on this pull request\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.text.contains("checks: 0 passed"));
|
||||
assert!(out.text.contains("no checks reported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_diff_preserves_diff() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
@@ -29,6 +30,7 @@ pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if is_show_path_content(ctx.command) || is_stash_patch(ctx.command) {
|
||||
return MinimizerOutput::passthrough(input);
|
||||
@@ -66,7 +68,16 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
Some("tag") if is_tag_non_listing(ctx.command) => cleaned,
|
||||
Some("tag") => primitives::compact_listing(&cleaned, 40),
|
||||
Some("stash") => condense_stash(ctx.command, &cleaned, exit_code),
|
||||
Some("worktree") => cleaned,
|
||||
Some("worktree") => {
|
||||
if has_token(ctx.command, "--porcelain")
|
||||
|| has_token(ctx.command, "-z")
|
||||
|| has_token(ctx.command, "--null")
|
||||
{
|
||||
cleaned
|
||||
} else {
|
||||
condense_worktree(&cleaned)
|
||||
}
|
||||
},
|
||||
Some("push") if has_token(ctx.command, "--porcelain") => cleaned,
|
||||
Some("push") => condense_push(&cleaned, exit_code),
|
||||
Some("pull") => condense_pull(&cleaned, exit_code),
|
||||
@@ -266,7 +277,15 @@ fn condense_status(input: &str) -> String {
|
||||
summary.clean = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(detected) = detect_status_state(trimmed) {
|
||||
// Only detect in-progress state headers OUTSIDE the "Untracked files:"
|
||||
// section. Real `git status` prints these blocks before/after the file
|
||||
// listings, never as untracked entries; but several progress phrases
|
||||
// ("Last command done", "Next command to do", "No commands remaining")
|
||||
// are plausible filenames, so an untracked file so named would otherwise
|
||||
// be mis-read as `state: rebasing` and swallowed via the `continue`
|
||||
// below, losing the real untracked count. Gating on `!in_untracked`
|
||||
// lets such filenames fall through to the untracked path handling.
|
||||
if !in_untracked && let Some(detected) = detect_status_state(trimmed) {
|
||||
if state.is_none() {
|
||||
state = Some(detected);
|
||||
}
|
||||
@@ -320,7 +339,20 @@ fn condense_status(input: &str) -> String {
|
||||
}
|
||||
}
|
||||
fn detect_status_state(line: &str) -> Option<&str> {
|
||||
if line.starts_with("You are currently rebasing") {
|
||||
if line.starts_with("You are currently rebasing")
|
||||
// Interactive-rebase sub-states all print under the same in-progress
|
||||
// rebase header in default long-format `git status`; collapse them to
|
||||
// the single `rebasing` label so an interactive-rebase edit/split is not
|
||||
// mistaken for a clean tree. "You are currently editing a commit"/"…
|
||||
// splitting the commit" are the rebase-edit/-split phase lines, and the
|
||||
// "Last command done"/"Next command to do"/"No commands remaining"
|
||||
// progress lines accompany them.
|
||||
|| line.starts_with("You are currently editing")
|
||||
|| line.starts_with("You are currently splitting")
|
||||
|| line.starts_with("Last command done")
|
||||
|| line.starts_with("Next command to do")
|
||||
|| line.starts_with("No commands remaining")
|
||||
{
|
||||
Some("rebasing")
|
||||
} else if line.starts_with("You are currently cherry-picking") {
|
||||
Some("cherry-pick")
|
||||
@@ -332,6 +364,11 @@ fn detect_status_state(line: &str) -> Option<&str> {
|
||||
Some("am")
|
||||
} else if line.starts_with("You are in a sparse checkout") {
|
||||
Some("sparse-checkout")
|
||||
} else if line.starts_with("All conflicts fixed but you are still merging") {
|
||||
// Distinct from `merge-conflict`: the merge is staged and ready to
|
||||
// conclude with `git commit`, so surface a separate "ready to commit"
|
||||
// label rather than implying unresolved conflicts.
|
||||
Some("merge (ready to commit)")
|
||||
} else if line == "You have unmerged paths." {
|
||||
Some("merge-conflict")
|
||||
} else {
|
||||
@@ -528,20 +565,33 @@ struct LogEntry {
|
||||
hash: String,
|
||||
subject: String,
|
||||
body: Vec<String>,
|
||||
/// Body lines dropped past the per-commit body cap, surfaced as an explicit
|
||||
/// `[+N lines omitted]` marker instead of being silently lost.
|
||||
omitted: usize,
|
||||
}
|
||||
|
||||
/// Soft cap on rendered subject/body line width. Long commit subjects and body
|
||||
/// lines are truncated through `truncate_line`, which appends a `…[+N]`
|
||||
/// dropped-char marker so the elision is visible rather than silent.
|
||||
const LOG_LINE_WIDTH: usize = 160;
|
||||
|
||||
fn push_log_entry(out: &mut String, entry: &LogEntry) {
|
||||
out.push_str(&entry.hash);
|
||||
if !entry.subject.is_empty() {
|
||||
out.push(' ');
|
||||
out.push_str(&entry.subject);
|
||||
out.push_str(&primitives::truncate_line(&entry.subject, LOG_LINE_WIDTH));
|
||||
}
|
||||
out.push('\n');
|
||||
for line in &entry.body {
|
||||
out.push_str(" ");
|
||||
out.push_str(line);
|
||||
out.push_str(&primitives::truncate_line(line, LOG_LINE_WIDTH));
|
||||
out.push('\n');
|
||||
}
|
||||
if entry.omitted > 0 {
|
||||
out.push_str(" [+");
|
||||
out.push_str(&entry.omitted.to_string());
|
||||
out.push_str(" lines omitted]\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_log_entries(input: &str) -> Vec<LogEntry> {
|
||||
@@ -561,6 +611,7 @@ fn parse_log_entries(input: &str) -> Vec<LogEntry> {
|
||||
hash: short_hash(hash),
|
||||
subject: subject.to_string(),
|
||||
body: Vec::new(),
|
||||
omitted: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -574,8 +625,15 @@ fn parse_log_entries(input: &str) -> Vec<LogEntry> {
|
||||
}
|
||||
if entry.subject.is_empty() {
|
||||
entry.subject = trimmed.to_string();
|
||||
} else if entry.body.len() < 3 && !is_git_trailer(trimmed) {
|
||||
entry.body.push(trimmed.to_string());
|
||||
} else if !is_git_trailer(trimmed) {
|
||||
// Real (non-trailer) body lines past the 3-line cap are tallied so
|
||||
// `push_log_entry` can emit an explicit `[+N lines omitted]` marker
|
||||
// rather than dropping them silently.
|
||||
if entry.body.len() < 3 {
|
||||
entry.body.push(trimmed.to_string());
|
||||
} else {
|
||||
entry.omitted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,11 +1109,31 @@ fn condense_noisy_output(input: &str) -> String {
|
||||
|
||||
fn condense_commit(input: &str, exit_code: i32) -> String {
|
||||
if exit_code == 0 {
|
||||
let mut hash = None;
|
||||
let mut stat = None;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(hash) = parse_commit_hash(trimmed) {
|
||||
return format!("ok {hash}\n");
|
||||
if hash.is_none()
|
||||
&& let Some(found) = parse_commit_hash(trimmed)
|
||||
{
|
||||
hash = Some(found);
|
||||
continue;
|
||||
}
|
||||
// Default `git commit` success prints a "N files changed, …" stat line
|
||||
// below the "[branch hash] msg" line; fold it back into the summary so
|
||||
// the files/insertions signal survives the condense.
|
||||
if stat.is_none() {
|
||||
stat = parse_stat_summary(trimmed);
|
||||
}
|
||||
}
|
||||
if let Some(hash) = hash {
|
||||
return match stat {
|
||||
Some((files, added, deleted)) => {
|
||||
format!("ok {hash} ({files} files +{added} -{deleted})\n")
|
||||
},
|
||||
// `--quiet` (or otherwise stat-less) success keeps the bare hash.
|
||||
None => format!("ok {hash}\n"),
|
||||
};
|
||||
}
|
||||
// No commit hash found — likely a `--dry-run` invocation that exits 0
|
||||
// but prints a status-style listing instead of a "[branch hash]" line.
|
||||
@@ -1435,6 +1513,90 @@ fn stash_subcommand(command: &str) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
const WORKTREE_LIMIT: usize = 20;
|
||||
|
||||
fn condense_worktree(input: &str) -> String {
|
||||
// Home is re-derived from the environment (never shelled out) so a leading
|
||||
// `$HOME` in worktree paths can be abbreviated to `~`. Falls back to no
|
||||
// abbreviation when `HOME` is unset.
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
condense_worktree_with_home(input, &home)
|
||||
}
|
||||
|
||||
/// Condense `git worktree` output, shape-detected from the OUTPUT rather than
|
||||
/// the args so it covers both `worktree list` and bare confirmations.
|
||||
///
|
||||
/// Listing-shaped lines (`<abs-path> <hash> [<branch>]`, plus the `(bare)` and
|
||||
/// `(detached HEAD)` variants) get a leading `$HOME` abbreviated to `~` and are
|
||||
/// capped with an omitted-count marker. Any non-listing output (`add`'s
|
||||
/// "Preparing worktree…"/"HEAD is now at …" confirmations, errors) is left to
|
||||
/// `condense_noisy_output`/passthrough so its meaning is preserved.
|
||||
fn condense_worktree_with_home(input: &str, home: &str) -> String {
|
||||
let mut entries = Vec::new();
|
||||
for line in input.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_worktree_listing_line(line) {
|
||||
// Not a listing: confirmation / error / progress — hand off untouched.
|
||||
return condense_noisy_output(input);
|
||||
}
|
||||
entries.push(abbreviate_worktree_home(line, home));
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for entry in entries.iter().take(WORKTREE_LIMIT) {
|
||||
out.push_str(entry);
|
||||
out.push('\n');
|
||||
}
|
||||
if entries.len() > WORKTREE_LIMIT {
|
||||
out.push_str("… ");
|
||||
out.push_str(&(entries.len() - WORKTREE_LIMIT).to_string());
|
||||
out.push_str(" worktrees omitted …\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `git worktree list` row is `<abs-path> <hash> [<branch>]`, with the
|
||||
/// trailing column being `(bare)` for a bare repo or `(detached HEAD)` for a
|
||||
/// detached worktree. The discriminator: an absolute first token followed by a
|
||||
/// hex hash or the literal `(bare)`.
|
||||
fn is_worktree_listing_line(line: &str) -> bool {
|
||||
let mut parts = line.split_whitespace();
|
||||
let Some(path) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
if !path.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
let Some(second) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
second == "(bare)" || is_short_hex(second)
|
||||
}
|
||||
|
||||
fn is_short_hex(token: &str) -> bool {
|
||||
token.len() >= 7 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn abbreviate_worktree_home(line: &str, home: &str) -> String {
|
||||
if home.is_empty() {
|
||||
return line.to_string();
|
||||
}
|
||||
// Only abbreviate a leading `$HOME` path prefix; a bare `$HOME` exactly is
|
||||
// rendered as `~`. Mid-line occurrences are left untouched.
|
||||
if let Some(rest) = line.strip_prefix(home)
|
||||
&& (rest.is_empty() || rest.starts_with(['/', ' ', '\t']))
|
||||
{
|
||||
return format!("~{rest}");
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1579,9 +1741,10 @@ mod tests {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx =
|
||||
test_ctx(Some("tag"), "git tag --format=%(refname:short)|%(taggerdate:short)", &cfg);
|
||||
let input = (0..45)
|
||||
.map(|idx| format!("v1.{idx}|2026-06-06\n"))
|
||||
.collect::<String>();
|
||||
let input = (0..45).fold(String::new(), |mut s, idx| {
|
||||
let _ = writeln!(s, "v1.{idx}|2026-06-06");
|
||||
s
|
||||
});
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
@@ -1593,9 +1756,10 @@ mod tests {
|
||||
fn tag_delete_output_is_passthrough() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("tag"), "git tag -d v1.0 v1.1", &cfg);
|
||||
let input = (0..45)
|
||||
.map(|idx| format!("Deleted tag 'v1.{idx}' (was abc1234)\n"))
|
||||
.collect::<String>();
|
||||
let input = (0..45).fold(String::new(), |mut s, idx| {
|
||||
let _ = writeln!(s, "Deleted tag 'v1.{idx}' (was abc1234)");
|
||||
s
|
||||
});
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
@@ -1607,7 +1771,10 @@ mod tests {
|
||||
fn tag_listing_is_compacted() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("tag"), "git tag --list", &cfg);
|
||||
let input = (0..45).map(|idx| format!("v1.{idx}\n")).collect::<String>();
|
||||
let input = (0..45).fold(String::new(), |mut s, idx| {
|
||||
let _ = writeln!(s, "v1.{idx}");
|
||||
s
|
||||
});
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
@@ -1796,6 +1963,41 @@ mod tests {
|
||||
assert!(!out.text.contains("Co-authored-by"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_long_subject_is_truncated_with_dropped_marker() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("log"), "git log", &cfg);
|
||||
let long_subject = "x".repeat(200);
|
||||
let input = format!(
|
||||
"commit abcdef1234567890\nAuthor: A <a@x.com>\nDate: today\n\n {long_subject}\n"
|
||||
);
|
||||
let out = filter(&ctx, &input, 0);
|
||||
// 200 chars → first 160 kept, 40 dropped, surfaced by truncate_line's marker.
|
||||
assert!(
|
||||
out.text
|
||||
.contains(&format!("abcdef1 {}…[+40]", "x".repeat(160))),
|
||||
"{:?}",
|
||||
out.text
|
||||
);
|
||||
assert!(!out.text.contains(&long_subject), "full subject must not survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_body_over_cap_shows_omitted_marker() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("log"), "git log", &cfg);
|
||||
// Subject + 5 body lines: 3 kept, 2 past the cap surfaced as a marker.
|
||||
let input = "commit abcdef1234567890\nAuthor: A <a@x.com>\nDate: today\n\n feat: add \
|
||||
API\n\n body line one\n body line two\n body line three\n body \
|
||||
line four\n body line five\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(out.text.contains("abcdef1 feat: add API"), "{:?}", out.text);
|
||||
assert!(out.text.contains("body line one"));
|
||||
assert!(out.text.contains("body line three"));
|
||||
assert!(!out.text.contains("body line four"), "{:?}", out.text);
|
||||
assert!(out.text.contains("[+2 lines omitted]"), "{:?}", out.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_condenses_unified_patch_to_stat_and_hunk_samples() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -1878,10 +2080,8 @@ mod tests {
|
||||
if idx % 3 == 0 {
|
||||
input.push_str(".rs\tnew-");
|
||||
input.push_str(&idx.to_string());
|
||||
input.push_str(".rs\n");
|
||||
} else {
|
||||
input.push_str(".rs\n");
|
||||
}
|
||||
input.push_str(".rs\n");
|
||||
}
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
@@ -1965,7 +2165,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_success_compacts_to_hash_only() {
|
||||
fn commit_success_compacts_to_hash_with_stat_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("commit"), "git commit -m msg", &cfg);
|
||||
let input = "\
|
||||
@@ -1976,11 +2176,32 @@ mod tests {
|
||||
";
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert_eq!(out.text, "ok 5f490f764\n");
|
||||
// Best-of: the change summary from the stat line is folded into `ok <hash>`.
|
||||
assert_eq!(out.text, "ok 5f490f764 (70 files +3081 -403)\n");
|
||||
assert!(!out.text.contains("files changed"));
|
||||
assert!(!out.text.contains("create mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_success_with_stat_appends_change_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("commit"), "git commit -m msg", &cfg);
|
||||
// Default `git commit` success body: "[branch hash] msg" + stat line.
|
||||
let input = "[main 1a2b3c4] msg\n 3 files changed, 10 insertions(+), 2 deletions(-)\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(out.text, "ok 1a2b3c4 (3 files +10 -2)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_success_without_stat_keeps_bare_hash() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("commit"), "git commit -m msg --quiet", &cfg);
|
||||
// `--quiet` suppresses the stat line; the bare hash summary is preserved.
|
||||
let input = "[main 1a2b3c4] msg\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(out.text, "ok 1a2b3c4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_nothing_to_commit_is_compacted() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -2161,6 +2382,39 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
assert!(out.text.contains("conflicts 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_detects_interactive_rebase_edit() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("status"), "git status", &cfg);
|
||||
// Realistic default long-format body for an interactive-rebase `edit` stop.
|
||||
let input = "On branch feature\n\ninteractive rebase in progress; onto abc1234\nLast \
|
||||
command done (1 command done):\n edit abc123 some message\nNo commands \
|
||||
remaining.\nYou are currently editing a commit while rebasing branch 'feature' \
|
||||
on 'abc1234'.\n (use \"git commit --amend\" to amend the current commit)\n \
|
||||
(use \"git rebase --continue\" once you are satisfied with your \
|
||||
changes)\n\nnothing to commit, working tree clean\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.starts_with("state: rebasing\n"), "{:?}", out.text);
|
||||
assert!(out.text.contains("branch feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_detects_merge_all_conflicts_fixed() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("status"), "git status", &cfg);
|
||||
// Realistic default long-format body after all merge conflicts are resolved.
|
||||
let input = "On branch main\n\nAll conflicts fixed but you are still merging.\n (use \"git \
|
||||
commit\" to conclude merge)\n\nChanges to be committed:\n modified: \
|
||||
src/main.rs\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.starts_with("state: merge (ready to commit)\n"), "{:?}", out.text);
|
||||
// Distinct from the unresolved-conflict label.
|
||||
assert!(!out.text.contains("merge-conflict"));
|
||||
assert!(out.text.contains("M src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_state_not_emitted_when_no_state() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -2173,6 +2427,25 @@ hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
assert_eq!(out.text, "branch main\nclean\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_untracked_file_named_like_rebase_progress_not_misread_as_state() {
|
||||
// A clean tree whose only untracked file is named like a rebase
|
||||
// progress line must NOT be reported as `state: rebasing`, and the
|
||||
// untracked file must still be counted. Regression for the
|
||||
// detect_status_state false positive inside the untracked section.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("status"), "git status", &cfg);
|
||||
let input = "On branch main\nUntracked files:\n (use \"git add <file>...\" to include in \
|
||||
what will be committed)\n\tNext command to do.md\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(out.changed);
|
||||
// Pre-fix this body was reported as `state: rebasing` with the filename
|
||||
// swallowed; the fix keeps state detection out of the untracked section.
|
||||
assert!(!out.text.contains("state:"), "{:?}", out.text);
|
||||
assert!(out.text.contains("untracked 1"), "{:?}", out.text);
|
||||
assert!(out.text.contains("?? Next command to do.md"), "{:?}", out.text);
|
||||
}
|
||||
|
||||
// --- Pull summaries ---
|
||||
|
||||
#[test]
|
||||
@@ -2484,6 +2757,90 @@ error: could not apply abc1234... fix: something\nhint: Resolve all conflicts ma
|
||||
|
||||
// --- Push porcelain passthrough ---
|
||||
|
||||
// --- Worktree condense ---
|
||||
|
||||
#[test]
|
||||
fn worktree_list_caps_and_abbreviates_home() {
|
||||
let home = "/home/alice";
|
||||
let mut input = String::new();
|
||||
// 22 listing entries under $HOME, including a bare and a detached-HEAD row.
|
||||
input.push_str("/home/alice/repo abc1234 (bare)\n");
|
||||
input.push_str("/home/alice/repo-detached def5678 (detached HEAD)\n");
|
||||
for idx in 0..20 {
|
||||
let _ = writeln!(
|
||||
input,
|
||||
"/home/alice/wt-{idx:02} aaaaaaa{idx:02} [wt-{idx}]"
|
||||
);
|
||||
}
|
||||
let out = condense_worktree_with_home(&input, home);
|
||||
|
||||
// Leading $HOME abbreviated to '~'.
|
||||
assert!(out.starts_with("~/repo "), "{out:?}");
|
||||
assert!(out.contains("~/repo-detached "));
|
||||
assert!(out.contains("(bare)"));
|
||||
assert!(out.contains("(detached HEAD)"));
|
||||
assert!(!out.contains("/home/alice"), "no absolute $HOME paths should survive: {out:?}");
|
||||
// 22 entries → capped at 20 with a 2-omitted marker.
|
||||
assert!(out.contains("… 2 worktrees omitted …"), "{out:?}");
|
||||
assert_eq!(out.lines().filter(|l| l.starts_with('~')).count(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_add_confirmation_passes_through() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("worktree"), "git worktree add ../wt feature", &cfg);
|
||||
// `worktree add` confirmation is not listing-shaped; it must survive.
|
||||
let input = "Preparing worktree (new branch 'feature')\nHEAD is now at abc1234 commit msg\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.changed, "{:?}", out.text);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_porcelain_passthrough() {
|
||||
// `git worktree list --porcelain` emits machine-readable records;
|
||||
// the minimizer must not insert "… N lines omitted …" markers.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("worktree"), "git worktree list --porcelain", &cfg);
|
||||
let input = "worktree /home/user/project\nHEAD abc1234def\nbranch \
|
||||
refs/heads/main\n\nworktree /home/user/other\nHEAD 567890ab\nbranch \
|
||||
refs/heads/feat\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.changed, "porcelain worktree listing must pass through unchanged");
|
||||
assert_eq!(out.text, input, "porcelain worktree listing must pass through");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_null_passthrough() {
|
||||
// `git worktree list -z` also produces machine-readable NUL-delimited output.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("worktree"), "git worktree list -z", &cfg);
|
||||
let input = "worktree /home/user/project\0HEAD abc1234def\0branch refs/heads/main\0";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(!out.changed, "-z worktree output must pass through unchanged");
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_non_porcelain_still_condensed() {
|
||||
// Normal (non-porcelain) listing should still go through the condenser.
|
||||
// Build 22 listing-shaped entries so the cap (20) is exceeded; the condenser
|
||||
// would insert an "… N worktrees omitted …" marker that a passthrough would
|
||||
// never emit.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("worktree"), "git worktree list", &cfg);
|
||||
let mut input = String::new();
|
||||
for idx in 0..22usize {
|
||||
let _ = writeln!(input, "/repo/wt-{idx:02} aaaaaaa{idx:02} [branch-{idx}]");
|
||||
}
|
||||
let out = filter(&ctx, &input, 0);
|
||||
assert!(
|
||||
out.text.contains("worktrees omitted"),
|
||||
"non-porcelain output over the cap must show omitted marker; got: {:?}",
|
||||
out.text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_porcelain_output_is_passthrough() {
|
||||
// Scripts that parse `git push --porcelain` rely on the exact byte
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
//! GitLab CLI (glab) output filters.
|
||||
|
||||
use std::{fmt::Write as _, sync::LazyLock};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
/// Match GitLab CI section markers: `section_start/end:timestamp:name` followed
|
||||
/// by bracket code.
|
||||
static SECTION_MARKER_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"section_(?:start|end):\d+:[a-z0-9_]+\[[\d;]*[A-Za-z]").unwrap());
|
||||
|
||||
/// Match bare bracket ANSI-like codes without ESC prefix: `[0K`, `[0;m`,
|
||||
/// `[36;1m`, etc.
|
||||
static BARE_ANSI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[\d;]+[A-Za-z]").unwrap());
|
||||
|
||||
/// Multiple consecutive blank lines (3+ newlines) collapsed to double newline.
|
||||
static MULTI_BLANK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(subcommand, Some("mr" | "issue" | "ci" | "pipeline" | "release"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if preserves_raw_mode(ctx) {
|
||||
return MinimizerOutput::passthrough(input);
|
||||
}
|
||||
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ctx.subcommand {
|
||||
Some("ci") if primitives::command_has_ordered_tokens(ctx.command, "ci", "trace") => {
|
||||
filter_ci_trace(&cleaned)
|
||||
},
|
||||
Some("release") if primitives::command_has_ordered_tokens(ctx.command, "release", "list") => {
|
||||
match filter_release_list(&cleaned) {
|
||||
Some(summary) => summary,
|
||||
None => primitives::head_tail_dedup(&cleaned),
|
||||
}
|
||||
},
|
||||
Some("release") if primitives::command_has_ordered_tokens(ctx.command, "release", "view") => {
|
||||
filter_release_view(&cleaned)
|
||||
},
|
||||
Some("mr" | "issue")
|
||||
if primitives::command_has_ordered_tokens(ctx.command, "mr", "view")
|
||||
|| primitives::command_has_ordered_tokens(ctx.command, "issue", "view") =>
|
||||
{
|
||||
filter_mr_issue_view(&cleaned, exit_code)
|
||||
},
|
||||
_ => primitives::head_tail_dedup(&cleaned),
|
||||
};
|
||||
|
||||
if text == input {
|
||||
MinimizerOutput::passthrough(input)
|
||||
} else {
|
||||
MinimizerOutput::transformed(text, input.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the command should be passed through unmodified.
|
||||
fn preserves_raw_mode(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
// -F, --output, --json anywhere -> passthrough (user chose output format)
|
||||
if primitives::command_has_any_token(ctx.command, &["-F", "--output", "--json"]) {
|
||||
return true;
|
||||
}
|
||||
// --web anywhere -> passthrough (opens browser)
|
||||
if primitives::command_has_any_token(ctx.command, &["--web"]) {
|
||||
return true;
|
||||
}
|
||||
// api subcommand -> passthrough (advanced user)
|
||||
if ctx.subcommand == Some("api") {
|
||||
return true;
|
||||
}
|
||||
// --comments for mr view / issue view -> passthrough
|
||||
if primitives::command_has_any_token(ctx.command, &["--comments"])
|
||||
&& (primitives::command_has_ordered_tokens(ctx.command, "mr", "view")
|
||||
|| primitives::command_has_ordered_tokens(ctx.command, "issue", "view"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// mr diff -> passthrough (raw unified diff must be un-modified)
|
||||
if primitives::command_has_ordered_tokens(ctx.command, "mr", "diff") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ── CI trace filter ────────────────────────────────────────────────────
|
||||
|
||||
/// Filter `glab ci trace` output: strip section markers, bare ANSI codes,
|
||||
/// and runner boilerplate. Keep warnings, errors, and build output.
|
||||
fn filter_ci_trace(input: &str) -> String {
|
||||
// Strip section markers first (they contain bracket codes), then bare ANSI
|
||||
// codes
|
||||
let cleaned = SECTION_MARKER_RE.replace_all(input, "");
|
||||
let cleaned = BARE_ANSI_RE.replace_all(&cleaned, "");
|
||||
|
||||
let mut filtered = String::new();
|
||||
let mut previous_blank = false;
|
||||
|
||||
for line in cleaned.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
if !previous_blank {
|
||||
filtered.push('\n');
|
||||
}
|
||||
previous_blank = true;
|
||||
continue;
|
||||
}
|
||||
previous_blank = false;
|
||||
|
||||
// Skip runner boilerplate
|
||||
if trimmed.starts_with("Running with gitlab-runner")
|
||||
|| (trimmed.starts_with("on ") && trimmed.contains("system ID:"))
|
||||
|| trimmed.starts_with("Using Docker executor")
|
||||
|| trimmed.starts_with("Running on runner-")
|
||||
|| trimmed.starts_with("Preparing the")
|
||||
|| trimmed.starts_with("Preparing environment")
|
||||
|| trimmed.starts_with("Getting source from")
|
||||
|| trimmed.starts_with("Resolving secrets")
|
||||
|| trimmed.starts_with("Cleaning up")
|
||||
|| trimmed.starts_with("Uploading artifacts")
|
||||
|| trimmed.starts_with("Downloading artifacts")
|
||||
|| trimmed.starts_with("Runtime platform")
|
||||
|| trimmed.starts_with("Fetching changes with git")
|
||||
|| trimmed.starts_with("Initialized empty Git")
|
||||
|| trimmed.starts_with("Created fresh repository")
|
||||
|| trimmed.starts_with("Checking out ")
|
||||
|| trimmed.starts_with("Skipping Git submodules")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push_str(trimmed);
|
||||
filtered.push('\n');
|
||||
}
|
||||
|
||||
primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&filtered), 120, 80)
|
||||
}
|
||||
|
||||
// ── Release list filter ───────────────────────────────────────────────
|
||||
|
||||
/// Parse `glab release list` tab-separated output into compact form.
|
||||
/// Returns `None` if no TAB-separated rows are found (caller falls through
|
||||
/// to `head_tail_dedup`).
|
||||
fn filter_release_list(input: &str) -> Option<String> {
|
||||
let mut lines = input.lines().peekable();
|
||||
let mut filtered = String::new();
|
||||
|
||||
// Skip "Showing N releases..." preamble and blank lines until header.
|
||||
// Parse the total count from the preamble line if present.
|
||||
let mut total: Option<usize> = None;
|
||||
while let Some(line) = lines.peek() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Name\t") || trimmed.starts_with("NAME\t") {
|
||||
lines.next(); // consume header
|
||||
break;
|
||||
}
|
||||
// Parse "Showing N releases on owner/repo." or similar
|
||||
if total.is_none()
|
||||
&& let Some(rest) = trimmed.strip_prefix("Showing ")
|
||||
&& let Some(n_str) = rest.split_whitespace().next()
|
||||
&& let Ok(n) = n_str.parse::<usize>()
|
||||
{
|
||||
total = Some(n);
|
||||
}
|
||||
lines.next();
|
||||
}
|
||||
|
||||
filtered.push_str("Releases\n");
|
||||
|
||||
let mut count = 0;
|
||||
let mut has_more = false;
|
||||
for line in &mut lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = trimmed.split('\t').collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if count >= 20 {
|
||||
// We've already emitted 20 rows and found a 21st valid row.
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let name = parts[0].trim();
|
||||
let tag = parts[1].trim();
|
||||
let created = parts[2].trim();
|
||||
|
||||
if name == tag {
|
||||
let _ = writeln!(filtered, " {name} ({created})");
|
||||
} else {
|
||||
let _ = writeln!(filtered, " {name} [{tag}] ({created})");
|
||||
}
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Append omission marker when there are more releases than shown.
|
||||
let omitted = total.map_or(0, |t| t.saturating_sub(count));
|
||||
if omitted > 0 {
|
||||
let _ = writeln!(filtered, "… {omitted} releases omitted …");
|
||||
} else if has_more {
|
||||
// Total not parsed from preamble but a 21st row was observed; signal
|
||||
// truncation.
|
||||
filtered.push_str("… releases omitted (showing first 20) …\n");
|
||||
}
|
||||
|
||||
Some(filtered)
|
||||
}
|
||||
|
||||
// ── Release view filter ───────────────────────────────────────────────
|
||||
|
||||
/// Filter `glab release view` output: strip SOURCES block, image-only lines,
|
||||
/// `Image: name -> url` lines, HTML comments, horizontal rules, and collapse
|
||||
/// multiple blank lines.
|
||||
fn filter_release_view(input: &str) -> String {
|
||||
let mut filtered = String::new();
|
||||
let mut in_sources = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Strip trailing "View this release on GitLab" link
|
||||
if trimmed.starts_with("View this release on GitLab") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip "ASSETS" / "There are no assets..." section
|
||||
if trimmed == "ASSETS" {
|
||||
in_sources = true; // reuse state machine; next non-empty line is "There are no assets..."
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip SOURCES section (archive download URLs)
|
||||
if trimmed == "SOURCES" {
|
||||
in_sources = true;
|
||||
continue;
|
||||
}
|
||||
if in_sources {
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
in_sources = false;
|
||||
}
|
||||
|
||||
// Strip image-only lines: 
|
||||
if trimmed.starts_with(" {
|
||||
continue;
|
||||
}
|
||||
// Strip glab's "Image: name -> url" rendering (Unicode or ASCII arrow)
|
||||
if trimmed.starts_with("Image:") && (trimmed.contains('\u{2192}') || trimmed.contains(" -> "))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip single-line HTML comments
|
||||
if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip horizontal rules (--- or --------)
|
||||
if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push_str(line);
|
||||
filtered.push('\n');
|
||||
}
|
||||
|
||||
// Collapse multiple blank lines
|
||||
MULTI_BLANK_RE.replace_all(&filtered, "\n\n").to_string()
|
||||
}
|
||||
|
||||
// ── MR/issue view filter ─────────────────────────────────────────────
|
||||
|
||||
/// On error (non-zero exit), skip markdown filtering to preserve error context.
|
||||
/// On success, apply markdown body noise filtering.
|
||||
fn filter_mr_issue_view(input: &str, exit_code: i32) -> String {
|
||||
if exit_code != 0 {
|
||||
return primitives::head_tail_dedup(input);
|
||||
}
|
||||
filter_markdown_body_view(input)
|
||||
}
|
||||
|
||||
// ── Markdown body filter (mr view / issue view) ──────────────────────
|
||||
|
||||
/// Filter markdown body noise: HTML comments, badges, image-only lines,
|
||||
/// horizontal rules. Collapse multiple blank lines. Apply `head_tail_dedup`.
|
||||
fn filter_markdown_body_view(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_html_comment = false;
|
||||
let mut previous_blank = false;
|
||||
let mut comment_lines = 0usize;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if in_html_comment {
|
||||
if trimmed.contains("-->") {
|
||||
in_html_comment = false;
|
||||
comment_lines = 0;
|
||||
} else {
|
||||
comment_lines += 1;
|
||||
// Safety: cap unclosed comment consumption at 50 lines to
|
||||
// prevent data loss from malformed/truncated markdown.
|
||||
if comment_lines > 50 {
|
||||
in_html_comment = false;
|
||||
comment_lines = 0;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("<!--") {
|
||||
if !trimmed.contains("-->") {
|
||||
in_html_comment = true;
|
||||
comment_lines = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if primitives::is_markdown_badge_or_image(trimmed) || primitives::is_horizontal_rule(trimmed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
if !previous_blank {
|
||||
out.push('\n');
|
||||
}
|
||||
previous_blank = true;
|
||||
continue;
|
||||
}
|
||||
previous_blank = false;
|
||||
out.push_str(line.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
primitives::head_tail_dedup(&out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::minimizer::MinimizerConfig;
|
||||
|
||||
fn test_ctx<'a>(
|
||||
subcommand: Option<&'a str>,
|
||||
command: &'a str,
|
||||
config: &'a MinimizerConfig,
|
||||
) -> MinimizerCtx<'a> {
|
||||
MinimizerCtx { program: "glab", subcommand, command, config }
|
||||
}
|
||||
|
||||
// ── CI trace tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ci_trace_strips_section_markers_and_runner_boilerplate() {
|
||||
let input = include_str!("fixtures/glab/ci-trace.txt");
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("ci"), "glab ci trace 123", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 1);
|
||||
|
||||
assert!(out.changed);
|
||||
// Section markers stripped
|
||||
assert!(!out.text.contains("section_start:"));
|
||||
assert!(!out.text.contains("section_end:"));
|
||||
// Runner boilerplate stripped
|
||||
assert!(!out.text.contains("Running with gitlab-runner"));
|
||||
assert!(!out.text.contains("Using Docker executor"));
|
||||
assert!(!out.text.contains("Fetching changes with git"));
|
||||
assert!(!out.text.contains("Checking out"));
|
||||
assert!(!out.text.contains("Uploading artifacts"));
|
||||
assert!(!out.text.contains("Downloading artifacts"));
|
||||
assert!(!out.text.contains("Cleaning up"));
|
||||
assert!(!out.text.contains("Runtime platform"));
|
||||
assert!(!out.text.contains("Preparing the"));
|
||||
assert!(!out.text.contains("Getting source from"));
|
||||
assert!(!out.text.contains("Resolving secrets"));
|
||||
// Bare ANSI codes stripped
|
||||
assert!(!out.text.contains("[0K"));
|
||||
assert!(!out.text.contains("[36;1m"));
|
||||
assert!(!out.text.contains("[32m"));
|
||||
assert!(!out.text.contains("[31m"));
|
||||
assert!(!out.text.contains("[33m"));
|
||||
assert!(!out.text.contains("[0m"));
|
||||
// Build output preserved
|
||||
assert!(out.text.contains("npm ci"));
|
||||
assert!(out.text.contains("npm run build"));
|
||||
assert!(out.text.contains("npm test"));
|
||||
// Test results preserved
|
||||
assert!(out.text.contains("FAIL"));
|
||||
assert!(out.text.contains("AssertionError"));
|
||||
// Final error line preserved
|
||||
assert!(out.text.contains("Job failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ci_trace_preserves_build_errors() {
|
||||
let input = "\
|
||||
section_start:1711234578:build_script[0K
|
||||
$ npm test
|
||||
[31mFAIL[0m src/auth.test.ts
|
||||
Error: expected true to be false
|
||||
section_end:1711234600:build_script[0K
|
||||
[31;1mERROR: Job failed: exit code 1[0K
|
||||
";
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("ci"), "glab ci trace 456", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 1);
|
||||
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("$ npm test"));
|
||||
assert!(out.text.contains("FAIL"));
|
||||
assert!(out.text.contains("Error: expected true to be false"));
|
||||
assert!(out.text.contains("ERROR: Job failed: exit code 1"));
|
||||
// Boilerplate gone
|
||||
assert!(!out.text.contains("section_start"));
|
||||
assert!(!out.text.contains("section_end"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ci_trace_passthrough_on_web_flag() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("ci"), "glab ci trace 123 --web", &cfg);
|
||||
let input = "Opening in browser...\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
// ── Release list tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn release_list_compacts_tab_separated() {
|
||||
let input = include_str!("fixtures/glab/release-list.txt");
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release list", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("Releases"));
|
||||
assert!(out.text.contains("v3.2.1"));
|
||||
assert!(out.text.contains("about 2 days ago"));
|
||||
// Preamble stripped
|
||||
assert!(!out.text.contains("Showing 10 releases"));
|
||||
// Header stripped
|
||||
assert!(!out.text.contains("Name\tTag\tCreated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_list_falls_through_on_no_tabs() {
|
||||
let input = "No releases available on owner/repo.\n";
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release list", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
// Falls through to head_tail_dedup; output should still contain the text
|
||||
assert!(out.text.contains("No releases available"));
|
||||
}
|
||||
|
||||
// ── Release view tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn release_view_strips_sources_and_noise() {
|
||||
let input = include_str!("fixtures/glab/release-view.txt");
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release view v2.0.0", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
// SOURCES section stripped
|
||||
assert!(!out.text.contains("SOURCES"));
|
||||
assert!(!out.text.contains("toolkit-v2.0.0.zip"));
|
||||
assert!(!out.text.contains("toolkit-v2.0.0.tar.gz"));
|
||||
// Horizontal rules stripped
|
||||
assert!(!out.text.contains("--------"));
|
||||
// Image lines stripped
|
||||
assert!(!out.text.contains("Image:"));
|
||||
// HTML comments stripped
|
||||
assert!(!out.text.contains("<!-- internal"));
|
||||
// Footer stripped (noise)
|
||||
assert!(!out.text.contains("View this release"));
|
||||
// ASSETS/SOURCES sections stripped
|
||||
assert!(!out.text.contains("ASSETS"));
|
||||
assert!(!out.text.contains("SOURCES"));
|
||||
assert!(!out.text.contains("archive/v2.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_view_preserves_description() {
|
||||
let input = include_str!("fixtures/glab/release-view.txt");
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release view v2.0.0", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
// Description preserved
|
||||
assert!(out.text.contains("Test Release v2.0"));
|
||||
assert!(out.text.contains("Added widget support"));
|
||||
assert!(out.text.contains("Fixed authentication bug"));
|
||||
assert!(out.text.contains("@alice_dev @bob_dev"));
|
||||
// Footer stripped (noise)
|
||||
assert!(!out.text.contains("View this release"));
|
||||
// ASSETS/SOURCES sections stripped
|
||||
assert!(!out.text.contains("ASSETS"));
|
||||
assert!(!out.text.contains("SOURCES"));
|
||||
assert!(!out.text.contains("archive/v2.0.0"));
|
||||
}
|
||||
|
||||
// ── MR/issue view tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mr_view_strips_markdown_noise() {
|
||||
let input = [
|
||||
"<!-- template comment -->",
|
||||
"# MR Title",
|
||||
"[](url)",
|
||||
"## Description",
|
||||
"Some description here.",
|
||||
"---",
|
||||
"More content.",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
.join("\n");
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("mr"), "glab mr view 42", &cfg);
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
// HTML comments stripped
|
||||
assert!(!out.text.contains("template comment"));
|
||||
// Badges stripped
|
||||
assert!(!out.text.contains("img.shields.io"));
|
||||
// Horizontal rules stripped
|
||||
assert!(!out.text.contains("---"));
|
||||
// Content preserved
|
||||
assert!(out.text.contains("# MR Title"));
|
||||
assert!(out.text.contains("## Description"));
|
||||
assert!(out.text.contains("Some description here."));
|
||||
assert!(out.text.contains("More content."));
|
||||
}
|
||||
|
||||
// ── Passthrough tests ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mr_list_passthrough_on_json_flag() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("mr"), "glab mr list --json", &cfg);
|
||||
let input = "[{\"iid\": 1, \"title\": \"test\"}]\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_subcommand_is_passthrough() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("api"), "glab api projects/1", &cfg);
|
||||
let input = "{\"id\": 1, \"name\": \"test\"}\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
// ── Passthrough flag variations ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn passthrough_on_output_flag() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("mr"), "glab mr list -F json", &cfg);
|
||||
let input = "[]\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_on_web_flag_anywhere() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release view --web v1.0", &cfg);
|
||||
let input = "Opening...\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_on_comments_for_mr_view() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("mr"), "glab mr view 42 --comments", &cfg);
|
||||
let input = "comments...\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn release_list_name_differs_from_tag() {
|
||||
let input = "Showing 1 releases\n\nName\tTag\tCreated\nMy Release\tv1.0.0\t2 days ago\n";
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release list", &cfg);
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.text.contains("My Release [v1.0.0]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ci_trace_empty_input() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("ci"), "glab ci trace 123", &cfg);
|
||||
let input = "";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
// Empty input passes through (no change)
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_list_marks_omitted_when_over_cap() {
|
||||
// Build input with 25 releases so the cap of 20 is exceeded.
|
||||
let mut input = String::from("Showing 25 releases on owner/repo.\n\nName\tTag\tCreated\n");
|
||||
for i in 1..=25 {
|
||||
let _ = writeln!(input, "Release {i}\tv{i}.0.0\t{i} days ago");
|
||||
}
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("release"), "glab release list", &cfg);
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
// Output must signal that releases were omitted.
|
||||
assert!(out.text.contains("omitted"), "expected omission marker, got: {}", out.text);
|
||||
// First release present, 21st not shown verbatim in the list.
|
||||
assert!(out.text.contains("Release 1"));
|
||||
assert!(!out.text.contains("Release 21 ["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mr_diff_passes_through_unmodified() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("mr"), "glab mr diff 42", &cfg);
|
||||
let input =
|
||||
"diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.changed);
|
||||
assert_eq!(out.text, input);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Go toolchain output filters.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
match program {
|
||||
"go" => matches!(subcommand, Some("test" | "build" | "vet" | "tool")),
|
||||
@@ -10,6 +13,7 @@ pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = if ctx.program == "golangci-lint" || is_go_tool_golangci_lint(ctx) {
|
||||
@@ -49,6 +53,13 @@ fn is_go_tool_golangci_lint(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
}
|
||||
|
||||
fn filter_go_test(input: &str, exit_code: i32) -> String {
|
||||
// On success, no per-test/per-package detail carries signal: re-derive rtk's
|
||||
// aggregation against DEFAULT text (and opportunistic JSON), counting package
|
||||
// and test markers into a single summary line instead of echoing every PASS/ok.
|
||||
if exit_code == 0 {
|
||||
return aggregate_go_test_success(input);
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let mut kept = 0usize;
|
||||
let mut keep_next_after_location = false;
|
||||
@@ -87,6 +98,67 @@ fn filter_go_test(input: &str, exit_code: i32) -> String {
|
||||
primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 140, 80)
|
||||
}
|
||||
|
||||
/// Success-path aggregation: count package and test markers (re-derived for
|
||||
/// DEFAULT text, with opportunistic JSON rendering) and emit one summary line.
|
||||
fn aggregate_go_test_success(input: &str) -> String {
|
||||
// Benchmark output is signal — don't collapse it into a count.
|
||||
if input
|
||||
.lines()
|
||||
.any(|l| l.trim_start().starts_with("Benchmark"))
|
||||
{
|
||||
return primitives::head_tail_lines(input, 140, 80);
|
||||
}
|
||||
|
||||
let mut packages_ok = 0usize;
|
||||
let mut no_tests = 0usize;
|
||||
let mut tests_skipped = 0usize;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Opportunistic JSON: render to the same text shape, then count.
|
||||
// Also check for JSON-wrapped benchmark output — those lines start with
|
||||
// "{" so the raw-line guard above misses them. Bail to head_tail
|
||||
// early so benchmark results are never collapsed into a package count.
|
||||
if trimmed.starts_with('{')
|
||||
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
|
||||
&& let Some(output) = value.get("Output").and_then(|v| v.as_str())
|
||||
&& output.trim_start().starts_with("Benchmark")
|
||||
{
|
||||
return primitives::head_tail_lines(input, 140, 80);
|
||||
}
|
||||
|
||||
let candidate = render_go_test_json_line(trimmed);
|
||||
let line_to_count = candidate.as_deref().unwrap_or(trimmed);
|
||||
let lower = line_to_count.trim().to_ascii_lowercase();
|
||||
|
||||
if lower.starts_with("ok\t") || lower.starts_with("ok ") {
|
||||
packages_ok += 1;
|
||||
} else if lower.starts_with("?\t") || lower.starts_with("? ") {
|
||||
no_tests += 1;
|
||||
} else if lower.starts_with("--- skip") {
|
||||
tests_skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if packages_ok == 0 && no_tests == 0 && tests_skipped == 0 {
|
||||
return compact_general(input);
|
||||
}
|
||||
|
||||
let mut summary = format!("go test: {packages_ok} packages ok");
|
||||
if no_tests > 0 {
|
||||
let _ = write!(summary, ", {no_tests} no tests");
|
||||
}
|
||||
if tests_skipped > 0 {
|
||||
let _ = write!(summary, ", {tests_skipped} tests skipped");
|
||||
}
|
||||
summary.push('\n');
|
||||
summary
|
||||
}
|
||||
|
||||
fn render_go_test_json_line(line: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
let action = value
|
||||
@@ -113,7 +185,7 @@ fn render_go_test_json_line(line: &str) -> Option<String> {
|
||||
match action {
|
||||
"fail" if !test.is_empty() => Some(format!("--- FAIL: {test}")),
|
||||
"fail" if !package.is_empty() => Some(format!("FAIL\t{package}")),
|
||||
"pass" if !package.is_empty() && test.is_empty() => Some(format!("ok\t{package}")),
|
||||
"pass" if !package.is_empty() && test.is_empty() => None,
|
||||
"skip" if !test.is_empty() => Some(format!("--- SKIP: {test}")),
|
||||
_ => None,
|
||||
}
|
||||
@@ -242,12 +314,12 @@ fn summarize_golangci_json(line: &str) -> Option<String> {
|
||||
let line_no = issue
|
||||
.get("Pos")
|
||||
.and_then(|pos| pos.get("Line"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map_or(0, |value| value);
|
||||
let col_no = issue
|
||||
.get("Pos")
|
||||
.and_then(|pos| pos.get("Column"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map_or(0, |value| value);
|
||||
let linter = issue
|
||||
.get("FromLinter")
|
||||
@@ -301,10 +373,16 @@ fn looks_like_go_error(line: &str) -> bool {
|
||||
|| lower.starts_with("no required module provides package ")
|
||||
|| lower.starts_with("missing go.sum entry")
|
||||
|| lower.starts_with("found packages ")
|
||||
|| lower.starts_with("pattern ")
|
||||
|| lower.starts_with("no go files in ")
|
||||
|| lower.starts_with("go: cannot load module ")
|
||||
|| lower.starts_with("go: updates to go.mod needed")
|
||||
|| lower.starts_with("go: inconsistent vendoring")
|
||||
|| lower.starts_with("go: ")
|
||||
&& (lower.contains("error") || lower.contains("failed") || lower.contains("not found"))
|
||||
|| lower.contains("import cycle not allowed")
|
||||
|| lower.contains("build constraints exclude all go files")
|
||||
|| lower.contains("function main is undeclared in the main package")
|
||||
}
|
||||
|
||||
fn is_go_noise(line: &str) -> bool {
|
||||
@@ -318,7 +396,19 @@ fn is_go_noise(line: &str) -> bool {
|
||||
|
||||
fn is_golangci_noise(line: &str) -> bool {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
lower.starts_with("level=") && lower.contains("msg=\"[linters_context]")
|
||||
// Strip runner-log info/warn chatter (snip strips `^level=` wholesale), but
|
||||
// DELIBERATELY KEEP `level=error` — those lines carry config/typecheck failures
|
||||
// that would otherwise vanish silently.
|
||||
// `level=error` carries config/typecheck failures (incl. the canonical
|
||||
// `level=error msg="[linters_context]…"` typecheck headline) — never strip it,
|
||||
// even when it routes through the linters_context component.
|
||||
if lower.starts_with("level=error") {
|
||||
return false;
|
||||
}
|
||||
lower.starts_with("level=info")
|
||||
|| lower.starts_with("level=warning")
|
||||
|| lower.starts_with("level=warn")
|
||||
|| lower.starts_with("level=") && lower.contains("msg=\"[linters_context]")
|
||||
|| lower.starts_with("golangci-lint has version")
|
||||
|| lower.starts_with("running ") && lower.contains("linters")
|
||||
}
|
||||
@@ -373,7 +463,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_test_verbose_success_drops_run_and_ginkgo_success_noise() {
|
||||
fn go_test_verbose_success_aggregates_to_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "go",
|
||||
@@ -386,13 +476,30 @@ mod tests {
|
||||
kubecraft.ai/.../controller 6.610s\n=== RUN TestNewClient\n--- PASS: \
|
||||
TestNewClient (0.00s)\nPASS\nok kubecraft.ai/.../llm 0.776s\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert!(out.text.contains("--- PASS: TestControllers (6.04s)"));
|
||||
assert!(out.text.contains("ok kubecraft.ai/.../controller 6.610s"));
|
||||
assert!(out.text.contains("--- PASS: TestNewClient (0.00s)"));
|
||||
// On success the two `ok` packages collapse to one summary line; the per-test
|
||||
// PASS lines and `=== RUN`/ginkgo banner noise disappear.
|
||||
assert!(out.text.contains("go test: 2 packages ok"));
|
||||
assert!(!out.text.contains("--- PASS"));
|
||||
assert!(!out.text.contains("=== RUN"));
|
||||
assert!(!out.text.contains("SUCCESS!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_test_success_default_text_counts_no_tests_and_skips() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "go",
|
||||
subcommand: Some("test"),
|
||||
command: "go test ./...",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "ok \texample.com/a\t0.10s\n? \texample.com/b\t[no test files]\nok \
|
||||
\texample.com/c\t0.20s\n--- SKIP: TestSkipped (0.00s)\nok \
|
||||
\texample.com/d\t0.30s\n";
|
||||
let out = filter(&ctx, input, 0);
|
||||
assert_eq!(out.text.trim(), "go test: 3 packages ok, 1 no tests, 1 tests skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_golangci_json_issues() {
|
||||
let input = r#"{"Issues":[{"FromLinter":"govet","Text":"unreachable code","Pos":{"Filename":"main.go","Line":7,"Column":2}}]}"#;
|
||||
@@ -416,6 +523,100 @@ mod tests {
|
||||
assert!(out.text.contains("main.go:7:2: bad (govet)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_go_error_recognizes_non_location_error_shapes() {
|
||||
// Ported from rtk go_cmd inline inputs: module/compiler failures that carry
|
||||
// no file.go:line:col location must still register as errors.
|
||||
assert!(looks_like_go_error("undefined: missingFunc"));
|
||||
assert!(looks_like_go_error("cannot find package \"foo/bar\""));
|
||||
assert!(looks_like_go_error(
|
||||
"found packages a (a.go) and b (b.go) in /tmp/rtk-go-build-probe-mix"
|
||||
));
|
||||
assert!(looks_like_go_error("imports example.com/cycle/a: import cycle not allowed"));
|
||||
assert!(looks_like_go_error(
|
||||
"package example.com/buildtag: build constraints exclude all Go files in /tmp/x"
|
||||
));
|
||||
assert!(looks_like_go_error("no Go files in /tmp/example"));
|
||||
assert!(looks_like_go_error(
|
||||
"go: cannot load module missing listed in go.work file: open missing/go.mod: no such \
|
||||
file or directory"
|
||||
));
|
||||
assert!(looks_like_go_error("go: updates to go.mod needed; to update it: go mod tidy"));
|
||||
assert!(looks_like_go_error(
|
||||
"go: inconsistent vendoring in /tmp/example: run 'go mod vendor' to sync"
|
||||
));
|
||||
assert!(looks_like_go_error(
|
||||
"runtime.main_main·f: function main is undeclared in the main package"
|
||||
));
|
||||
assert!(looks_like_go_error(
|
||||
"pattern ./...: directory prefix . does not contain main module or its selected \
|
||||
dependencies"
|
||||
));
|
||||
// go.mod-not-found is already covered via the `go: ... not found` arm.
|
||||
assert!(looks_like_go_error(
|
||||
"go: go.mod file not found in current directory or any parent directory; see 'go help \
|
||||
modules'"
|
||||
));
|
||||
// NOTE: `go: downloading …/errors …` trips the broad `go: …error…` arm,
|
||||
// but `is_go_noise` strips those lines upstream in filter_go_build
|
||||
// before this helper runs, so the build-level test below is the real
|
||||
// guard.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_build_failure_preserves_non_location_error_shapes() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "go",
|
||||
subcommand: Some("build"),
|
||||
command: "go build ./...",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "pattern ./...: directory prefix . does not contain main module or its selected \
|
||||
dependencies\nno Go files in /tmp/example\ngo: inconsistent vendoring in \
|
||||
/tmp/x: run 'go mod vendor'\nruntime.main_main·f: function main is undeclared \
|
||||
in the main package\n";
|
||||
let out = filter(&ctx, input, 1);
|
||||
assert!(out.text.contains("does not contain main module"));
|
||||
assert!(out.text.contains("no Go files in /tmp/example"));
|
||||
assert!(out.text.contains("inconsistent vendoring"));
|
||||
assert!(
|
||||
out.text
|
||||
.contains("function main is undeclared in the main package")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golangci_strips_info_warn_but_keeps_level_error() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "golangci-lint",
|
||||
subcommand: Some("run"),
|
||||
command: "golangci-lint run ./...",
|
||||
config: &cfg,
|
||||
};
|
||||
// Real default (non-json) golangci run: the typecheck headline is emitted by
|
||||
// the logrus logger in its canonical `level=error msg="[linters_context]…"`
|
||||
// shape — the exact format that must survive. Surrounding runner chatter
|
||||
// (incl. a warn-level linters_context line) is stripped.
|
||||
let input = "level=info Active 5 linters\nlevel=warning The linter 'deadcode' is \
|
||||
deprecated\nlevel=warning msg=\"[linters_context] stale cache\"\nlevel=error \
|
||||
msg=\"[linters_context] typechecking error: cannot find \
|
||||
package\"\nmain.go:10:2: undefined: Foo (typecheck)\n";
|
||||
let out = filter(&ctx, input, 1);
|
||||
assert!(out.text.contains("level=error"));
|
||||
assert!(out.text.contains("typechecking error"));
|
||||
// `group_by_file` regroups the per-issue line under a `main.go:` header,
|
||||
// so assert on the surviving location + linter, not the joined string.
|
||||
assert!(out.text.contains("main.go"));
|
||||
assert!(out.text.contains("undefined: Foo (typecheck)"));
|
||||
assert!(!out.text.contains("level=info"));
|
||||
assert!(!out.text.contains("level=warning"));
|
||||
// The warn-level linters_context chatter is still dropped — the keep is
|
||||
// scoped to error level, not to every linters_context line.
|
||||
assert!(!out.text.contains("stale cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_go_tool_is_passthrough() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -430,4 +631,34 @@ mod tests {
|
||||
assert_eq!(out.text, input);
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_json_no_double_count() {
|
||||
// A -json stream has both an Output "ok\t{pkg}" line AND a pass event.
|
||||
// We must count only once.
|
||||
let json = "{\"Action\":\"output\",\"Package\":\"example/pkg\",\"Output\":\"ok\\texample/\
|
||||
pkg\\n\"}\n{\"Action\":\"pass\",\"Package\":\"example/pkg\",\"Elapsed\":0.123}\n";
|
||||
let result = aggregate_go_test_success(json);
|
||||
assert!(result.contains("1 packages ok"), "got: {result}");
|
||||
assert!(!result.contains("2 packages ok"), "double-counted: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_benchmark_preserved_on_success() {
|
||||
let input = "BenchmarkFoo-8\t1000000\t1234 ns/op\nok\texample/pkg\t1.234s\n";
|
||||
let result = filter_go_test(input, 0);
|
||||
assert!(result.contains("BenchmarkFoo"), "benchmark stripped: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_benchmark_preserved() {
|
||||
let input = r#"{"Action":"run","Test":"BenchmarkFoo"}
|
||||
{"Action":"output","Output":"BenchmarkFoo-8 1000 1234 ns/op\n"}
|
||||
{"Action":"output","Output":"ok example.com/pkg 1.234s\n"}
|
||||
{"Action":"pass","Elapsed":1.234}"#;
|
||||
let result = aggregate_go_test_success(input);
|
||||
// Should NOT produce "packages ok" summary — should return head_tail of input
|
||||
assert!(!result.contains("packages ok"), "benchmark json run must not be collapsed");
|
||||
assert!(result.contains("BenchmarkFoo"), "benchmark lines must survive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Graphite (`gt`) output filters.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::git;
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
@@ -8,10 +10,12 @@ const GT_SUBCOMMANDS: &[&str] = &[
|
||||
"fetch", "stash", "worktree",
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
program == "gt" && subcommand.is_some_and(|subcommand| GT_SUBCOMMANDS.contains(&subcommand))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if ctx.subcommand == Some("log") && is_log_short(ctx.command) {
|
||||
return MinimizerOutput::passthrough(input);
|
||||
@@ -21,7 +25,11 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
let text = match ctx.subcommand {
|
||||
Some("log") => compact_log(&cleaned),
|
||||
Some("branch") => primitives::compact_listing(&cleaned, 40),
|
||||
Some("submit" | "sync" | "restack" | "create") => compact_noisy_command(&cleaned, exit_code),
|
||||
Some("sync") => dense_sync_summary(&cleaned, exit_code)
|
||||
.unwrap_or_else(|| compact_noisy_command(&cleaned, exit_code)),
|
||||
Some("restack") => dense_restack_summary(&cleaned, exit_code)
|
||||
.unwrap_or_else(|| compact_noisy_command(&cleaned, exit_code)),
|
||||
Some("submit" | "create") => compact_noisy_command(&cleaned, exit_code),
|
||||
Some("diff" | "show" | "add" | "push" | "pull" | "fetch" | "stash" | "worktree") => {
|
||||
let git_ctx = MinimizerCtx {
|
||||
program: "git",
|
||||
@@ -116,6 +124,125 @@ fn compact_noisy_command(input: &str, exit_code: i32) -> String {
|
||||
primitives::head_tail_lines(&candidate, 80, 40)
|
||||
}
|
||||
|
||||
/// FAST PATH: collapse a clean `gt sync` to one dense line.
|
||||
///
|
||||
/// Re-derived against DEFAULT `gt sync` output (no injection): `Synced ...
|
||||
/// branch` lines and `Deleted branch <name>` lines. Returns `None` (so the
|
||||
/// caller falls back to `compact_noisy_command`) on a non-zero exit, on any
|
||||
/// error line, or when nothing recognizable was synced/deleted — error
|
||||
/// visibility is preserved.
|
||||
fn dense_sync_summary(input: &str, exit_code: i32) -> Option<String> {
|
||||
if exit_code != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut synced = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
let mut deleted_names = Vec::new();
|
||||
|
||||
for raw in input.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if is_error_line(line) {
|
||||
return None;
|
||||
}
|
||||
// Real `gt sync` emits the top-level confirmation `Synced with remote`
|
||||
// (no "branch" token); per-branch forms read `Synced ... branch`. Count
|
||||
// both so a successful sync never reports `0 synced`.
|
||||
if line.starts_with("Synced with remote")
|
||||
|| (line.contains("Synced") && line.contains("branch"))
|
||||
{
|
||||
synced += 1;
|
||||
} else if let Some(name) = deleted_branch_name(line) {
|
||||
deleted += 1;
|
||||
deleted_names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if synced == 0 && deleted == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut summary = format!("ok sync: {synced} synced, {deleted} deleted");
|
||||
if !deleted_names.is_empty() {
|
||||
// Cap the inline name list: a long-lived stack cleanup can delete hundreds
|
||||
// of merged branches at once, and this fast path bypasses the
|
||||
// head_tail_lines bound that compact_noisy_command (the fallback) applies.
|
||||
// Without a cap every name lands on one unbounded line, defeating the
|
||||
// minimizer's bounding guarantee. Show the first DELETED_NAME_CAP names and
|
||||
// summarize the rest as `+N more` (the count above stays exact).
|
||||
const DELETED_NAME_CAP: usize = 20;
|
||||
let shown = deleted_names.len().min(DELETED_NAME_CAP);
|
||||
let names = deleted_names[..shown].join(", ");
|
||||
let _ = write!(summary, " ({names}");
|
||||
if deleted_names.len() > DELETED_NAME_CAP {
|
||||
let _ = write!(summary, ", +{} more", deleted_names.len() - DELETED_NAME_CAP);
|
||||
}
|
||||
summary.push(')');
|
||||
}
|
||||
summary.push('\n');
|
||||
Some(summary)
|
||||
}
|
||||
|
||||
/// FAST PATH: collapse a clean `gt restack` to one dense line.
|
||||
///
|
||||
/// Re-derived against DEFAULT `gt restack` output: lines reporting a restacked
|
||||
/// branch. Returns `None` (caller falls back to `compact_noisy_command`) on a
|
||||
/// non-zero exit, on any error line, or when no branch was restacked.
|
||||
fn dense_restack_summary(input: &str, exit_code: i32) -> Option<String> {
|
||||
if exit_code != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut restacked = 0usize;
|
||||
for raw in input.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if is_error_line(line) {
|
||||
return None;
|
||||
}
|
||||
if (line.contains("Restacked") || line.contains("Rebased")) && line.contains("branch") {
|
||||
restacked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if restacked == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("ok restacked {restacked} branches\n"))
|
||||
}
|
||||
|
||||
/// Extract the branch name from a `Deleted branch <name>` / `deleted branch
|
||||
/// <name>` line.
|
||||
fn deleted_branch_name(line: &str) -> Option<String> {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let marker = "deleted branch ";
|
||||
let pos = lower.find(marker)?;
|
||||
let rest = line[pos + marker.len()..].trim_start();
|
||||
let name = rest
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.trim_matches(|ch: char| matches!(ch, '`' | '"' | '\'' | ','));
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_error_line(line: &str) -> bool {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
lower.starts_with("error")
|
||||
|| lower.starts_with("fatal")
|
||||
|| lower.starts_with("conflict")
|
||||
|| lower.contains("failed to")
|
||||
|| lower.contains("merge conflict")
|
||||
}
|
||||
|
||||
fn is_graph_node(line: &str) -> bool {
|
||||
let stripped = line
|
||||
.trim_start_matches('│')
|
||||
@@ -279,6 +406,89 @@ mod tests {
|
||||
assert!(!out.text.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dense_summary_counts_synced_and_deleted() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("sync"), &cfg);
|
||||
let input = "Synced branch feat/a with remote\nSynced branch feat/b with remote\nDeleted \
|
||||
branch feat/merged-feature\nDeleted branch fix/old-hotfix\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
assert_eq!(out.text, "ok sync: 2 synced, 2 deleted (feat/merged-feature, fix/old-hotfix)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dense_summary_counts_synced_with_remote_form() {
|
||||
// Donor real-output fixture (gt_cmd.rs): the top-level confirmation is
|
||||
// `Synced with remote` (no "branch" token), followed by deletions. The
|
||||
// synced tally must reflect this real form, not report `0 synced`.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("sync"), &cfg);
|
||||
let input =
|
||||
"Synced with remote\nDeleted branch feat/merged-feature\nDeleted branch fix/old-hotfix\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
assert_eq!(out.text, "ok sync: 1 synced, 2 deleted (feat/merged-feature, fix/old-hotfix)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dense_summary_caps_deleted_name_list() {
|
||||
// A long-lived stack cleanup can delete hundreds of merged branches at
|
||||
// once. The dense summary must stay bounded: cap the inline name list and
|
||||
// summarize the remainder as `+N more`, never emit one unbounded line.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("sync"), &cfg);
|
||||
let mut input = String::from("Synced with remote\n");
|
||||
for idx in 0..500 {
|
||||
let _ = writeln!(input, "Deleted branch feat/merged-{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
// Exact deleted count is preserved.
|
||||
assert!(out.text.contains("ok sync: 1 synced, 500 deleted"));
|
||||
// First names stay visible; the rest collapse to a `+N more` marker.
|
||||
assert!(out.text.contains("feat/merged-0"));
|
||||
assert!(out.text.contains("+480 more"));
|
||||
// Bounded: a single short line, not 500 names concatenated.
|
||||
assert!(!out.text.contains("feat/merged-499"));
|
||||
let longest = out.text.lines().map(str::len).max().unwrap_or(0);
|
||||
assert!(longest < 600, "summary line not bounded: {longest} bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restack_dense_summary_counts_branches() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("restack"), &cfg);
|
||||
let input = "Restacked branch feat/add-auth on main\nRestacked branch feat/add-db on \
|
||||
feat/add-auth\nRestacked branch fix/parsing on feat/add-db\n";
|
||||
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(out.changed);
|
||||
assert_eq!(out.text, "ok restacked 3 branches\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_with_error_falls_back_to_compact() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = test_ctx(Some("sync"), &cfg);
|
||||
let input = "Synced branch feat/a with remote\nerror: failed to rebase feat/b\n";
|
||||
|
||||
// Error line present -> fast path declines, compact_noisy_command keeps the
|
||||
// error.
|
||||
let out = filter(&ctx, input, 0);
|
||||
|
||||
assert!(!out.text.starts_with("ok sync:"));
|
||||
assert!(out.text.contains("error: failed to rebase feat/b"));
|
||||
assert!(out.text.contains("Synced branch feat/a with remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_noise_is_stripped_and_errors_remain() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
const SUPPORTED_TOOLS: &[&str] = &["next", "prettier", "prisma"];
|
||||
const NPX_ROUTABLE_TOOLS: &[&str] = &["tsc", "eslint", "prisma", "prettier", "next"];
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
effective_tool(program, subcommand).is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let tool = effective_tool(ctx.program, ctx.subcommand)
|
||||
@@ -31,6 +33,7 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn effective_tool<'a>(program: &'a str, subcommand: Option<&'a str>) -> Option<&'a str> {
|
||||
if SUPPORTED_TOOLS.contains(&program) {
|
||||
return Some(program);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,44 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
supports_program("", subcommand)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_program(program: &str, subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "ruff" | "mypy" | "rubocop" | "pyright" | "basedpyright")
|
||||
|| matches!(
|
||||
subcommand,
|
||||
None | Some("check" | "lint" | "run" | "format" | "fmt" | "typecheck")
|
||||
)
|
||||
// Program-claim the JS type-checker/linters too: without this, a path-arg
|
||||
// invocation (`tsc --project x`, `eslint src/`, `biome ci app/`,
|
||||
// `oxlint src/`) resolves its subcommand to the path token, which is not in
|
||||
// the subcommand allowlist below, so the invocation would route UNFILTERED.
|
||||
// detect.rs yields these exact program tokens (see its
|
||||
// `detects_direct_lint_tools` test). Claiming the program makes the engine
|
||||
// pick the Rust path first; the residual defs/biome.toml & defs/oxlint.toml
|
||||
// remain as fallback for any unclaimed subcommand only.
|
||||
matches!(
|
||||
program,
|
||||
"ruff"
|
||||
| "mypy"
|
||||
| "rubocop"
|
||||
| "pyright"
|
||||
| "basedpyright"
|
||||
| "tsc"
|
||||
| "eslint"
|
||||
| "biome"
|
||||
| "oxlint"
|
||||
) || matches!(subcommand, None | Some("check" | "lint" | "run" | "format" | "fmt" | "typecheck"))
|
||||
}
|
||||
|
||||
/// JS type-checker/linter programs whose human (non-JSON) output carries
|
||||
/// code-frame body lines, underline rows, and tool-specific success/progress
|
||||
/// chatter. The frame/noise strips below are gated to these programs so the
|
||||
/// shared ruff/mypy/rubocop/pyright paths keep their existing behavior.
|
||||
fn is_js_lint_program(program: &str) -> bool {
|
||||
matches!(program, "tsc" | "eslint" | "biome" | "oxlint")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if preserves_machine_readable_output(ctx) {
|
||||
return MinimizerOutput::passthrough(input);
|
||||
@@ -29,9 +55,22 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn condense_lint_output(program: &str, input: &str, exit_code: i32) -> String {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let stripped = strip_lint_noise(program, &cleaned, exit_code);
|
||||
if program == "eslint" {
|
||||
// eslint's default "stylish" output puts the file path on its own
|
||||
// non-indented header line and the diagnostics on the following indented
|
||||
// `L:C severity message rule-id` rows. group_diagnostics expects the
|
||||
// file on the SAME line as the location, so reshape stylish into
|
||||
// per-diagnostic `file:line:col: …` lines first, then derive a Top-rules
|
||||
// summary from the trailing rule-id column (rtk's idea, re-derived from
|
||||
// DEFAULT text output, not JSON).
|
||||
if let Some(rendered) = render_eslint_stylish(&stripped) {
|
||||
return primitives::head_tail_lines(&rendered, 180, 100);
|
||||
}
|
||||
}
|
||||
let grouped = group_diagnostics(&stripped);
|
||||
primitives::head_tail_lines(&grouped, 180, 100)
|
||||
}
|
||||
@@ -50,14 +89,58 @@ fn strip_lint_noise(program: &str, input: &str, exit_code: i32) -> String {
|
||||
}
|
||||
|
||||
fn preserves_machine_readable_output(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
matches!(ctx.program, "pyright" | "basedpyright")
|
||||
// pyright/basedpyright --outputjson
|
||||
if matches!(ctx.program, "pyright" | "basedpyright")
|
||||
&& ctx
|
||||
.command
|
||||
.split_whitespace()
|
||||
.any(|part| part == "--outputjson" || part.starts_with("--outputjson="))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// eslint with an explicit non-default formatter (-f / --format with value !=
|
||||
// stylish)
|
||||
if ctx.program == "eslint" {
|
||||
let tokens: Vec<&str> = ctx.command.split_whitespace().collect();
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
if (*t == "-f" || *t == "--format") && tokens.get(i + 1).is_some_and(|v| *v != "stylish") {
|
||||
return true;
|
||||
}
|
||||
if let Some(val) = t.strip_prefix("--format=")
|
||||
&& val != "stylish"
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lint_noise(program: &str, line: &str, exit_code: i32) -> bool {
|
||||
// eslint's `N error(s) … potentially fixable with the --fix option.` hint is
|
||||
// actionable chatter, not a diagnostic; drop it while keeping the
|
||||
// `✖ N problems (…)` summary line. Checked ahead of the diagnostic-signal
|
||||
// guard below because the hint itself contains the word "error".
|
||||
// (snip strips on `potentially fixable`.)
|
||||
if program == "eslint" && line.contains("potentially fixable") {
|
||||
return true;
|
||||
}
|
||||
// Code-frame / underline / box-drawing / progress chatter is stripped even at
|
||||
// exit!=0: these rows carry no diagnostic of their own, and oxlint's
|
||||
// `Found N warning…` / biome's `Fixed N file…` summaries match the
|
||||
// diagnostic-signal guard below only incidentally (the word "warning"). The
|
||||
// `× message` diagnostic rows are never matched here, so they survive.
|
||||
if is_js_lint_program(program) && is_js_frame_noise(program, line) {
|
||||
return true;
|
||||
}
|
||||
// pyright/basedpyright emit a version banner plus source-discovery progress
|
||||
// before the diagnostics. None of these rows carry a diagnostic, so strip them
|
||||
// even at exit!=0. Scoped to pyright/basedpyright so the other linters'
|
||||
// equivalent-looking lines (e.g. an oxlint `Found N warnings` summary) are not
|
||||
// caught here. (Ported from rtk/src/filters/basedpyright.toml strip patterns.)
|
||||
if matches!(program, "pyright" | "basedpyright") && is_pyright_banner_noise(line) {
|
||||
return true;
|
||||
}
|
||||
if exit_code != 0 && contains_diagnostic_signal(line) {
|
||||
return false;
|
||||
}
|
||||
@@ -77,6 +160,335 @@ fn is_lint_noise(program: &str, line: &str, exit_code: i32) -> bool {
|
||||
|| lower.ends_with(" files inspected, no offenses detected"))
|
||||
}
|
||||
|
||||
/// pyright/basedpyright banner/progress noise: the version banner and the
|
||||
/// source-file-discovery progress lines that precede the diagnostics. `line`
|
||||
/// arrives already trimmed (see `strip_lint_noise`). Re-derived from
|
||||
/// rtk/src/filters/basedpyright.toml's `strip_lines_matching` patterns:
|
||||
/// `^Searching for source files`, `^Found \d+ source file`,
|
||||
/// `^Pyright \d+\.\d+`, `^basedpyright \d+\.\d+`.
|
||||
/// The blank-line pattern (`^\s*$`) is already handled by `strip_lint_noise`.
|
||||
fn is_pyright_banner_noise(line: &str) -> bool {
|
||||
if line.starts_with("Searching for source files") {
|
||||
return true;
|
||||
}
|
||||
// `Pyright 1.1.0` / `basedpyright 1.22.0`: program token then a `MAJOR.MINOR`
|
||||
// version. Matched by a literal prefix plus a dotted-digit check on the rest so
|
||||
// a stray diagnostic message beginning with the word is not swept up.
|
||||
if let Some(rest) = line
|
||||
.strip_prefix("Pyright ")
|
||||
.or_else(|| line.strip_prefix("basedpyright "))
|
||||
&& starts_with_dotted_version(rest)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// `Found 42 source files`: literal prefix, then a count, then `source file`.
|
||||
if let Some(rest) = line.strip_prefix("Found ") {
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
if !digits.is_empty() {
|
||||
let after = rest[digits.len()..].trim_start();
|
||||
if after.starts_with("source file") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True when `s` begins with `MAJOR.MINOR` (e.g. `1.22.0`, `1.1`): one or more
|
||||
/// digits, a `.`, then one or more digits.
|
||||
fn starts_with_dotted_version(s: &str) -> bool {
|
||||
let mut chars = s.chars().peekable();
|
||||
let mut saw_major = false;
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_ascii_digit() {
|
||||
saw_major = true;
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !saw_major || chars.next() != Some('.') {
|
||||
return false;
|
||||
}
|
||||
matches!(chars.next(), Some(c) if c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Reshape eslint "stylish" output and render it through the shared grouped
|
||||
/// renderer plus an eslint-specific Top-rules summary.
|
||||
///
|
||||
/// Returns `None` when no stylish diagnostic rows were recognized, so the
|
||||
/// caller falls back to the generic grouped renderer (e.g. when eslint emitted
|
||||
/// an already-flat or non-stylish shape). The reshape attributes each indented
|
||||
/// `L:C severity message rule-id` row to the most recent non-indented file
|
||||
/// header, synthesizing a `file:line:col: severity message` line that
|
||||
/// `group_diagnostics` can parse. Ungrouped lines (notably the
|
||||
/// `✖ N problems (…)` summary) flow through `group_diagnostics` untouched.
|
||||
fn render_eslint_stylish(stripped: &str) -> Option<String> {
|
||||
let mut current_header: Option<&str> = None;
|
||||
let mut synthesized = String::new();
|
||||
let mut passthrough = String::new();
|
||||
let mut rule_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut matched_any = false;
|
||||
|
||||
for raw in stripped.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let indented = raw.starts_with(' ') || raw.starts_with('\t');
|
||||
if !indented {
|
||||
let trimmed = raw.trim_end();
|
||||
if looks_like_eslint_header(trimmed) {
|
||||
current_header = Some(trimmed);
|
||||
} else {
|
||||
// Summary lines (`✖ 3 problems (…)`) and any other non-indented,
|
||||
// non-path text are preserved verbatim for the renderer.
|
||||
passthrough.push_str(trimmed);
|
||||
passthrough.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((loc, severity, message, rule)) = parse_eslint_row(raw.trim()) {
|
||||
matched_any = true;
|
||||
let header = current_header.unwrap_or("<unknown>");
|
||||
synthesized.push_str(header);
|
||||
synthesized.push(':');
|
||||
synthesized.push_str(loc);
|
||||
synthesized.push_str(": ");
|
||||
synthesized.push_str(severity);
|
||||
synthesized.push(' ');
|
||||
synthesized.push_str(message);
|
||||
synthesized.push('\n');
|
||||
if let Some(rule) = rule {
|
||||
*rule_counts.entry(rule.to_string()).or_default() += 1;
|
||||
}
|
||||
} else {
|
||||
passthrough.push_str(raw.trim_end());
|
||||
passthrough.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !matched_any {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut combined = synthesized;
|
||||
combined.push_str(&passthrough);
|
||||
let grouped = group_diagnostics(&combined);
|
||||
|
||||
let rule_summary = format_code_summary(&rule_counts);
|
||||
if rule_summary.is_empty() {
|
||||
return Some(grouped);
|
||||
}
|
||||
let mut out = String::with_capacity(grouped.len() + rule_summary.len() + 12);
|
||||
// group_diagnostics prints `N diagnostics in M files` as the first line;
|
||||
// keep that header, then splice the eslint Top-rules summary right after it.
|
||||
let mut lines = grouped.splitn(2, '\n');
|
||||
if let Some(first) = lines.next() {
|
||||
out.push_str(first);
|
||||
out.push('\n');
|
||||
out.push_str("Top rules: ");
|
||||
out.push_str(&rule_summary);
|
||||
out.push('\n');
|
||||
if let Some(rest) = lines.next() {
|
||||
out.push_str(rest);
|
||||
}
|
||||
} else {
|
||||
out.push_str(&grouped);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A non-indented eslint stylish header is the file path preceding its
|
||||
/// diagnostic rows. Reject summary/keyword lines so they are not mistaken for
|
||||
/// file headers.
|
||||
fn looks_like_eslint_header(line: &str) -> bool {
|
||||
if line.is_empty() || !looks_like_path(line) {
|
||||
return false;
|
||||
}
|
||||
let lower = line.to_ascii_lowercase();
|
||||
// Drop summary noise that also "looks like a path" (contains a dot) such as
|
||||
// `✖ 3 problems (2 errors, 1 warning)`.
|
||||
!line.starts_with('✖')
|
||||
&& !line.starts_with('×')
|
||||
&& !lower.contains("problem")
|
||||
&& !lower.contains(" error")
|
||||
&& !lower.contains(" warning")
|
||||
}
|
||||
|
||||
/// Parse an eslint stylish diagnostic row (already trimmed of indentation):
|
||||
/// `1:10 error Unexpected var, use let or const instead no-var`.
|
||||
///
|
||||
/// Returns `(location, severity, message, rule_id)`. The rule-id is the final
|
||||
/// whitespace-separated token when it is a plain rule slug (`no-var`,
|
||||
/// `@typescript-eslint/no-unused-vars`); rows without a trailing rule (e.g.
|
||||
/// `Parsing error: Unexpected token`) yield `rule_id == None` and fold the
|
||||
/// trailing text into the message.
|
||||
fn parse_eslint_row(row: &str) -> Option<(&str, &str, &str, Option<&str>)> {
|
||||
let (loc, after_loc) = row.split_once(char::is_whitespace)?;
|
||||
if !is_line_col(loc) {
|
||||
return None;
|
||||
}
|
||||
let after_loc = after_loc.trim_start();
|
||||
let (severity, after_sev) = after_loc.split_once(char::is_whitespace)?;
|
||||
if !matches!(severity, "error" | "warning") {
|
||||
return None;
|
||||
}
|
||||
let body = after_sev.trim_start();
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// eslint stylish separates the message from the trailing rule-id with a run of
|
||||
// >=2 spaces (`message␣␣rule-id`). Recognize the rule-id by that STRUCTURAL
|
||||
// position — the token after the last >=2-space gap — not by requiring a
|
||||
// hyphen, so hyphenless core rules (`semi`, `eqeqeq`, `camelcase`, `curly`,
|
||||
// `radix`, `complexity`) are counted and not glued onto the message text.
|
||||
// Rows with no >=2-space gap (`Parsing error: Unexpected token`) yield no
|
||||
// rule-id and keep their whole body as the message.
|
||||
if let Some((message, candidate)) = split_message_and_rule(body)
|
||||
&& is_eslint_rule_id(candidate)
|
||||
{
|
||||
return Some((loc, severity, message.trim_end(), Some(candidate)));
|
||||
}
|
||||
Some((loc, severity, body, None))
|
||||
}
|
||||
|
||||
/// Split an eslint stylish body into `(message, rule-candidate)` at the LAST
|
||||
/// run of two-or-more spaces. The rule-id column is right-aligned/space-padded
|
||||
/// by eslint, so the final >=2-space gap precedes the rule slug. Returns `None`
|
||||
/// when the body contains no such gap (the row carries no rule-id column).
|
||||
fn split_message_and_rule(body: &str) -> Option<(&str, &str)> {
|
||||
let bytes = body.as_bytes();
|
||||
let mut idx = bytes.len();
|
||||
// Walk back to the last `" "` (>=2 spaces) boundary; the tail after it is the
|
||||
// rule-candidate, which itself contains no internal space.
|
||||
while idx >= 2 {
|
||||
if bytes[idx - 1] == b' ' && bytes[idx - 2] == b' ' {
|
||||
let candidate = body[idx..].trim_start();
|
||||
if candidate.is_empty() || candidate.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
return Some((&body[..idx - 2], candidate));
|
||||
}
|
||||
idx -= 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `line:col` location token: digits, a colon, digits.
|
||||
fn is_line_col(token: &str) -> bool {
|
||||
let Some((line, col)) = token.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
!line.is_empty()
|
||||
&& !col.is_empty()
|
||||
&& line.chars().all(|ch| ch.is_ascii_digit())
|
||||
&& col.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// An eslint rule slug: lowercase letters/digits, hyphens, optional `@scope/`
|
||||
/// prefix and `/` separators (`no-var`, `@typescript-eslint/no-unused-vars`,
|
||||
/// and hyphenless core rules `semi`, `eqeqeq`, `camelcase`, `curly`, `radix`,
|
||||
/// `complexity`). NO hyphen is required — recognition relies on the structural
|
||||
/// >=2-space column split in `parse_eslint_row` plus this character-class. A
|
||||
/// > leading lowercase letter excludes prose tokens that survived the split
|
||||
/// > (uppercase-initial words, punctuation, trailing periods).
|
||||
fn is_eslint_rule_id(token: &str) -> bool {
|
||||
let mut chars = token.chars();
|
||||
matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase())
|
||||
&& chars
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '/' | '@'))
|
||||
}
|
||||
|
||||
/// Code-frame / underline / tool-chatter noise specific to the JS lint family.
|
||||
///
|
||||
/// `line` arrives already trimmed (see `strip_lint_noise`), so leading-column
|
||||
/// whitespace is gone; patterns are matched against the trimmed form. These
|
||||
/// strips are deliberately gated to `tsc`/`eslint`/`biome`/`oxlint` so the
|
||||
/// shared ruff/mypy/rubocop/pyright paths are unaffected.
|
||||
fn is_js_frame_noise(program: &str, trimmed: &str) -> bool {
|
||||
// tsc pretty + biome/oxlint share the same caret/tilde underline rows and
|
||||
// gutter-numbered code-frame bodies; handle them for every JS lint program.
|
||||
//
|
||||
// Underline rows: only `~` (tsc) or `^` (biome/oxlint carets), optionally
|
||||
// with interior spaces, e.g. `~~~`, `^^^`, `~ ~`.
|
||||
if !trimmed.is_empty()
|
||||
&& trimmed
|
||||
.chars()
|
||||
.all(|ch| ch == '~' || ch == '^' || ch == ' ')
|
||||
&& trimmed.chars().any(|ch| ch == '~' || ch == '^')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Code-frame body line: a leading line-number gutter followed by source.
|
||||
// biome/oxlint emit `3 │ interface Props {`; tsc pretty emits `3 foo = 1;`.
|
||||
//
|
||||
// The biome/oxlint `│`-bar gutter is unambiguous (no real summary line carries
|
||||
// a leading number then a box-drawing bar), so strip it unconditionally. The
|
||||
// tsc-pretty BARE form (`N source`, no bar) collides with genuine summary /
|
||||
// content lines that legitimately begin with a number — `7 errors and 2
|
||||
// warnings found`, `5 warnings`, `2 problems (2 errors)`, `3 files checked` —
|
||||
// so only strip the bare form when the line carries NO diagnostic signal. This
|
||||
// guards the exact information the exit!=0 diagnostic-signal gate was written
|
||||
// to protect (is_js_frame_noise runs ahead of that gate in is_lint_noise).
|
||||
if is_gutter_bar_line(trimmed) {
|
||||
return true;
|
||||
}
|
||||
if is_bare_gutter_numbered_line(trimmed) && !contains_diagnostic_signal(trimmed) {
|
||||
return true;
|
||||
}
|
||||
match program {
|
||||
"biome" => {
|
||||
// `│ ...` continuation rows (no leading number) and the post-fix
|
||||
// success summary. `Checked N files` is already covered by the
|
||||
// lowercase `checked ` rule in is_lint_noise.
|
||||
trimmed.starts_with('│') || trimmed.to_ascii_lowercase().starts_with("fixed ")
|
||||
},
|
||||
"oxlint" => {
|
||||
// Box-drawing closers and progress/summary chatter that carries no
|
||||
// diagnostic. `× rule: message` and `╭─[file:line]` are KEPT.
|
||||
trimmed.starts_with('╰')
|
||||
|| trimmed.starts_with("Finished in")
|
||||
|| trimmed.starts_with("Found ") && trimmed.contains("warning")
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `trimmed` is a biome/oxlint `│`-bar code-frame gutter row: a run
|
||||
/// of ASCII digits, optional spaces, then the box-drawing bar `│` (`3 │
|
||||
/// interface`, `12 │ items.forEach(...)`). The bar makes this form unambiguous,
|
||||
/// so it is stripped unconditionally — no genuine summary line matches it.
|
||||
fn is_gutter_bar_line(trimmed: &str) -> bool {
|
||||
let mut rest = trimmed;
|
||||
let digits = rest
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.unwrap_or(rest.len());
|
||||
if digits == 0 {
|
||||
return false;
|
||||
}
|
||||
rest = rest[digits..].trim_start_matches(' ');
|
||||
rest.starts_with('│')
|
||||
}
|
||||
|
||||
/// True when `trimmed` begins with a tsc-pretty BARE line-number gutter: a run
|
||||
/// of ASCII digits immediately followed by an ASCII space/tab then source
|
||||
/// (`3 foo = 1;`, `10 const x: number = "hello";`). This form overlaps with
|
||||
/// real summary lines that start with a number, so callers MUST additionally
|
||||
/// exclude lines carrying a diagnostic signal before stripping.
|
||||
fn is_bare_gutter_numbered_line(trimmed: &str) -> bool {
|
||||
let mut chars = trimmed.char_indices();
|
||||
let mut saw_digit = false;
|
||||
for (idx, ch) in chars.by_ref() {
|
||||
if ch.is_ascii_digit() {
|
||||
saw_digit = true;
|
||||
continue;
|
||||
}
|
||||
return saw_digit && idx > 0 && (ch == ' ' || ch == '\t');
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_diagnostics(input: &str) -> String {
|
||||
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let mut ungrouped = Vec::new();
|
||||
@@ -311,4 +723,364 @@ mod tests {
|
||||
let out = condense_lint_output("basedpyright", "0 errors, 0 warnings, 0 notes\n", 0);
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basedpyright_banner_and_progress_noise_is_stripped() {
|
||||
// Re-derived from rtk/src/filters/basedpyright.toml's first inline test,
|
||||
// rendered through the minimizer's grouped per-file output. The version
|
||||
// banner, `Searching for source files`, and `Found N source files`
|
||||
// progress lines are dropped; the diagnostics survive and group by file.
|
||||
let input = "basedpyright 1.22.0\nSearching for source files\nFound 42 source \
|
||||
files\n\n/home/user/app/main.py:10:5 - error: \"foo\" is not defined \
|
||||
(reportUndefinedVariable)\n/home/user/app/main.py:25:1 - error: Type \"str\" \
|
||||
is not assignable to type \"int\" \
|
||||
(reportAssignmentType)\n/home/user/app/utils.py:8:9 - warning: Variable \"x\" \
|
||||
is not accessed (reportUnusedVariable)\n";
|
||||
let out = condense_lint_output("basedpyright", input, 1);
|
||||
assert!(!out.contains("basedpyright 1.22.0"), "version banner must be stripped: {out}");
|
||||
assert!(!out.contains("Searching for source files"), "progress must be stripped: {out}");
|
||||
assert!(!out.contains("Found 42 source files"), "progress must be stripped: {out}");
|
||||
assert!(out.contains("3 diagnostics in 2 files"), "got: {out}");
|
||||
assert!(out.contains("reportUndefinedVariable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyright_banner_strips_are_scoped_off_other_linters() {
|
||||
// The pyright banner/progress strips must not touch other linters: an
|
||||
// oxlint diagnostic that legitimately mentions `Found`/`source files`-style
|
||||
// text, or a version-like token, stays put.
|
||||
assert!(is_pyright_banner_noise("Found 3 source files")); // sanity: helper itself
|
||||
// Helper is scoped at the call site to pyright/basedpyright; confirm a
|
||||
// non-pyright program never reaches it via is_lint_noise.
|
||||
assert!(!is_lint_noise("oxlint", "Found 3 source files referenced", 1));
|
||||
assert!(!is_lint_noise("tsc", "Pyright 1.1 is mentioned here", 2));
|
||||
// And confirm the helper fires for pyright/basedpyright through is_lint_noise.
|
||||
assert!(is_lint_noise("pyright", "Searching for source files", 1));
|
||||
assert!(is_lint_noise("basedpyright", "Found 42 source files", 1));
|
||||
assert!(is_lint_noise("basedpyright", "basedpyright 1.22.0", 1));
|
||||
assert!(is_lint_noise("pyright", "Pyright 1.1.0", 1));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// CONCERN 1: tsc program-claim + code-frame strips
|
||||
// (ported from snip/filters/tsc.yaml inline tests, re-rendered through
|
||||
// the minimizer's grouped per-file + Top-codes output instead of snip's
|
||||
// flat keep_lines)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tsc_eslint_biome_oxlint_are_program_claimed() {
|
||||
// Path-arg invocations resolve the subcommand to a path token that is not
|
||||
// in the subcommand allowlist; the program claim is what routes them.
|
||||
for program in ["tsc", "eslint", "biome", "oxlint"] {
|
||||
assert!(
|
||||
supports_program(program, Some("src/foo.ts")),
|
||||
"{program} path-arg invocation must be program-claimed"
|
||||
);
|
||||
assert!(supports_program(program, None), "{program} bare invocation must be claimed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_pretty_strips_code_frames_and_groups_by_file() {
|
||||
// snip's "pretty format errors with context" fixture.
|
||||
let input = "src/index.ts:3:1 - error TS2304: Cannot find name 'foo'.\n\n3 foo = 1;\n \
|
||||
~~~\n\nsrc/utils.ts:10:5 - error TS2322: Type 'string' is not assignable to \
|
||||
type 'number'.\n\n10 const x: number = \"hello\";\n ~\n\nFound 2 errors \
|
||||
in 2 files.\n";
|
||||
let out = condense_lint_output("tsc", input, 2);
|
||||
assert!(out.contains("2 diagnostics in 2 files"), "got: {out}");
|
||||
assert!(out.contains("TS2304"));
|
||||
assert!(out.contains("TS2322"));
|
||||
assert!(out.contains("Top codes:"));
|
||||
// Code-frame body lines and underline rows are stripped.
|
||||
assert!(!out.contains("foo = 1;"), "code-frame body must be stripped: {out}");
|
||||
assert!(!out.contains("const x: number"), "code-frame body must be stripped: {out}");
|
||||
assert!(!out.contains('~'), "underline rows must be stripped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_classic_groups_by_file() {
|
||||
// snip's "classic format errors" fixture (no code frames).
|
||||
let input = "src/index.ts(3,1): error TS2304: Cannot find name 'foo'.\nsrc/utils.ts(10,5): \
|
||||
error TS2322: Type 'string' is not assignable to type 'number'.\nFound 2 \
|
||||
errors in 2 files.\n";
|
||||
let out = condense_lint_output("tsc", input, 2);
|
||||
assert!(out.contains("2 diagnostics in 2 files"), "got: {out}");
|
||||
assert!(out.contains("src/index.ts"));
|
||||
assert!(out.contains("src/utils.ts"));
|
||||
assert!(out.contains("TS2304"));
|
||||
assert!(out.contains("TS2322"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_empty_input_condenses_to_clean() {
|
||||
// snip emits "ok (no type errors)"; the minimizer renders empty input as
|
||||
// empty (its own clean-build signal), so assert that behavior.
|
||||
assert_eq!(condense_lint_output("tsc", "", 0), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// CONCERN 2: eslint stylish-block parsing
|
||||
// (ported from snip/filters/eslint.yaml inline tests)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn eslint_stylish_groups_rows_under_headers_with_top_rules() {
|
||||
// snip's "errors with summary" fixture (incl. a Parsing error row and the
|
||||
// fixable hint). Codepoints normalized: × is U+00D7-free here — eslint's
|
||||
// summary marker is the heavy ballot ✖ (U+2716), kept verbatim.
|
||||
let input = "/home/user/project/src/file.js\n 1:10 error Unexpected var, use let or \
|
||||
const instead no-var\n 2:5 warning Missing semicolon \
|
||||
semi\n\n/home/user/project/src/other.js\n 3:1 error Parsing error: \
|
||||
Unexpected token\n\n✖ 3 problems (2 errors, 1 warning)\n 1 error and 0 \
|
||||
warnings potentially fixable with the `--fix` option.\n";
|
||||
let out = condense_lint_output("eslint", input, 1);
|
||||
// Grouped per-file rendering with the shared diagnostics header.
|
||||
assert!(out.contains("3 diagnostics in 2 files"), "got: {out}");
|
||||
assert!(out.contains("file.js"));
|
||||
assert!(out.contains("other.js"));
|
||||
// Top-rules summary derived from the trailing rule-id column.
|
||||
assert!(out.contains("Top rules:"), "got: {out}");
|
||||
assert!(out.contains("no-var"));
|
||||
assert!(out.contains("semi"));
|
||||
// The Parsing error row (no rule-id) is preserved as a diagnostic.
|
||||
assert!(out.contains("Parsing error: Unexpected token"), "got: {out}");
|
||||
// Summary kept, fixable hint stripped.
|
||||
assert!(out.contains("✖ 3 problems (2 errors, 1 warning)"), "got: {out}");
|
||||
assert!(!out.contains("potentially fixable"), "fixable hint must be stripped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_stylish_single_file_keeps_summary() {
|
||||
// snip's "single file error" fixture.
|
||||
let input = "/app/src/index.js\n 5:3 error 'x' is defined but never used \
|
||||
no-unused-vars\n\n✖ 1 problem (1 error, 0 warnings)\n";
|
||||
let out = condense_lint_output("eslint", input, 1);
|
||||
assert!(out.contains("1 diagnostics in 1 files"), "got: {out}");
|
||||
assert!(out.contains("index.js"));
|
||||
assert!(out.contains("no-unused-vars"));
|
||||
assert!(out.contains("Top rules:"));
|
||||
assert!(out.contains("✖ 1 problem (1 error, 0 warnings)"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_clean_run_condenses_to_clean() {
|
||||
// snip's "no errors produces ok": empty eslint output renders as empty
|
||||
// (the minimizer's clean signal). Reshape finds no rows and falls back to
|
||||
// the empty grouped output.
|
||||
assert_eq!(condense_lint_output("eslint", "", 0), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// CONCERN 3: biome / oxlint split-brain alignment
|
||||
// (synthetic inline fixtures; × is U+00D7 — biome/oxlint emit U+00D7,
|
||||
// NOT snip's ✖/✔ keep-pattern codepoints)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn biome_ci_keeps_diagnostics_strips_code_frames() {
|
||||
assert!(supports_program("biome", Some("ci")));
|
||||
// biome ci default output: grouped header line + `× message` rows +
|
||||
// numbered code-frame + caret underline. (× == U+00D7.)
|
||||
let input = "Checked 42 files in 0.5s\n\nsrc/app.tsx:5:3 lint/suspicious/noExplicitAny \
|
||||
\u{2501}\u{2501}\u{2501}\n \u{00d7} Unexpected any. Specify a different \
|
||||
type.\n 3 \u{2502} interface Props {\n 4 \u{2502} data: any;\n 5 \u{2502} \
|
||||
\u{0020} ^^^\n\nsrc/utils.ts:12:1 lint/complexity/noForEach \
|
||||
\u{2501}\u{2501}\u{2501}\n \u{00d7} Prefer for...of instead of forEach.\n 12 \
|
||||
\u{2502} items.forEach(item => process(item));\n\nFound 2 errors.\n";
|
||||
let out = condense_lint_output("biome", input, 1);
|
||||
// Grouped header lines survive (file:line:col …), × rows survive.
|
||||
assert!(out.contains("src/app.tsx"), "got: {out}");
|
||||
assert!(out.contains("src/utils.ts"));
|
||||
assert!(out.contains('\u{00d7}'), "× diagnostic rows must survive: {out}");
|
||||
assert!(out.contains("Unexpected any"));
|
||||
assert!(out.contains("Prefer for...of"));
|
||||
// Code-frame body rows (`N │ …`) are stripped; success chatter gone.
|
||||
assert!(!out.contains("interface Props"), "code-frame body must be stripped: {out}");
|
||||
assert!(!out.contains("Checked 42 files"), "checked chatter must be stripped: {out}");
|
||||
assert!(!out.contains('^'), "caret underline must be stripped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_strips_fixed_files_success() {
|
||||
// `Fixed N files` post-fix summary is chatter; stripped at success.
|
||||
let out = condense_lint_output("biome", "Fixed 3 files in 0.1s\n", 0);
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxlint_keeps_rule_and_location_strips_frames() {
|
||||
assert!(supports_program("oxlint", Some("src/")));
|
||||
// oxlint default output: `× rule: message`, `╭─[file:line]` location,
|
||||
// numbered code-frame, `╰────` closer, Finished/Found chatter.
|
||||
let input = " \u{00d7} eslint(no-console): Unexpected console statement.\n \
|
||||
\u{256d}\u{2500}[src/app.ts:5:3]\n 5 \u{2502} console.log(\"debug\");\n \
|
||||
\u{2502} ^^^^^^^^^^^\n \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\n\n \
|
||||
\u{00d7} eslint(no-unused-vars): 'x' is defined but never used.\n \
|
||||
\u{256d}\u{2500}[src/utils.ts:2:7]\n 2 \u{2502} let x = 42;\n \u{2502} \
|
||||
^\n \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\n\nFound 2 warnings on 2 \
|
||||
files.\nFinished in 12ms on 100 files.\n";
|
||||
let out = condense_lint_output("oxlint", input, 1);
|
||||
// Diagnostic rows and location markers survive.
|
||||
assert!(out.contains('\u{00d7}'), "× diagnostic rows must survive: {out}");
|
||||
assert!(out.contains("no-console"));
|
||||
assert!(out.contains("no-unused-vars"));
|
||||
assert!(
|
||||
out.contains("\u{256d}\u{2500}[src/app.ts:5:3]"),
|
||||
"location marker must survive: {out}"
|
||||
);
|
||||
// Code-frame rows, closers, and progress chatter are stripped.
|
||||
assert!(!out.contains("console.log"), "code-frame body must be stripped: {out}");
|
||||
assert!(!out.contains('\u{2570}'), "box closer must be stripped: {out}");
|
||||
assert!(!out.contains("Finished in"), "Finished chatter must be stripped: {out}");
|
||||
assert!(!out.contains("Found 2 warnings"), "Found chatter must be stripped: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxlint_clean_run_condenses_to_clean() {
|
||||
// Only progress chatter, no diagnostics -> empty.
|
||||
let out = condense_lint_output("oxlint", "Finished in 5ms on 100 files.\n", 0);
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_python_ruby_paths_unaffected_by_js_strips() {
|
||||
// Regression pin for the SHARED renderer: ruff (file:line) and mypy
|
||||
// (bracketed code) diagnostics still group, and a leading-number gutter
|
||||
// line in their output is NOT mistaken for a JS code-frame (the strip is
|
||||
// gated to tsc/eslint/biome/oxlint only).
|
||||
let ruff = "src/app.py:1:1: F401 imported but unused\nFound 1 error.\n";
|
||||
let out = condense_lint_output("ruff", ruff, 1);
|
||||
assert!(out.contains("F401"), "got: {out}");
|
||||
|
||||
let mypy = "lib/b.py:4: error: no attr [attr-defined]\n";
|
||||
let out = condense_lint_output("mypy", mypy, 1);
|
||||
assert!(out.contains("attr-defined") || out.contains("b.py"), "got: {out}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Regression: blocking-issue fixes
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn js_lint_numeric_summary_line_is_not_gutter_stripped() {
|
||||
// BLOCKING 1 regression: a JS-lint summary/content line that BEGINS with a
|
||||
// number (`7 errors and 2 warnings found`) must survive — the bare-gutter
|
||||
// strip only applies to code-frame body rows that carry no diagnostic
|
||||
// signal, so this line (it contains "error"/"warning") is preserved.
|
||||
let input = "/app/x.js\n 1:1 error bad no-var\n\n7 errors and 2 warnings \
|
||||
found\n\u{2716} 9 problems (9 errors, 0 warnings)\n";
|
||||
let out = condense_lint_output("eslint", input, 1);
|
||||
assert!(
|
||||
out.contains("7 errors and 2 warnings found"),
|
||||
"numeric summary line must survive the gutter strip: {out}"
|
||||
);
|
||||
assert!(out.contains("\u{2716} 9 problems (9 errors, 0 warnings)"), "got: {out}");
|
||||
|
||||
// Helper-level pins: the BARE tsc form only strips when no diagnostic signal
|
||||
// is present, while the biome/oxlint `│`-bar form always strips.
|
||||
assert!(is_bare_gutter_numbered_line("3 foo = 1;"));
|
||||
assert!(contains_diagnostic_signal("7 errors and 2 warnings found"));
|
||||
assert!(contains_diagnostic_signal("5 warnings"));
|
||||
assert!(contains_diagnostic_signal("2 problems (2 errors)"));
|
||||
assert!(!is_gutter_bar_line("10 errors found"), "bare numeric is not a bar gutter");
|
||||
assert!(is_gutter_bar_line("3 \u{2502} interface Props {"), "biome bar gutter strips");
|
||||
assert!(is_gutter_bar_line("12 \u{2502} items.forEach(...)"), "oxlint bar gutter strips");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// CONCERN: eslint explicit formatter passthrough
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn eslint_json_format_passes_through() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "eslint",
|
||||
subcommand: None,
|
||||
command: "eslint -f json src/",
|
||||
config: &cfg,
|
||||
};
|
||||
// should passthrough — preserves_machine_readable_output returns true
|
||||
let dummy = r#"[{"filePath":"src/foo.js","messages":[]}]"#;
|
||||
let out = filter(&ctx, dummy, 0);
|
||||
assert!(!out.changed, "eslint -f json must passthrough unchanged");
|
||||
assert_eq!(out.text, dummy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_format_long_flag_passes_through() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
for command in [
|
||||
"eslint --format json src/",
|
||||
"eslint --format=json src/",
|
||||
"eslint --format compact src/",
|
||||
"eslint -f junit src/",
|
||||
] {
|
||||
let ctx = MinimizerCtx { program: "eslint", subcommand: None, command, config: &cfg };
|
||||
let dummy = r#"[{"filePath":"src/foo.js","messages":[]}]"#;
|
||||
let out = filter(&ctx, dummy, 0);
|
||||
assert!(!out.changed, "{command} must passthrough unchanged");
|
||||
assert_eq!(out.text, dummy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_stylish_still_condensed() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "eslint",
|
||||
subcommand: None,
|
||||
command: "eslint --format stylish src/",
|
||||
config: &cfg,
|
||||
};
|
||||
let dummy = "\nsrc/foo.js\n 1:1 error bad no-var\n\n✖ 1 problem\n";
|
||||
let out = filter(&ctx, dummy, 1);
|
||||
// stylish goes through the condenser, result should be changed
|
||||
assert!(out.changed, "eslint --format stylish must go through the condenser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_no_format_flag_still_condensed() {
|
||||
// no -f/--format flag at all → default stylish → condenser runs
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = MinimizerCtx {
|
||||
program: "eslint",
|
||||
subcommand: None,
|
||||
command: "eslint src/",
|
||||
config: &cfg,
|
||||
};
|
||||
let dummy = "\nsrc/foo.js\n 1:1 error bad no-var\n\n✖ 1 problem\n";
|
||||
let out = filter(&ctx, dummy, 1);
|
||||
assert!(out.changed, "eslint without -f must go through the condenser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_hyphenless_rules_counted_under_top_rules_and_unglued() {
|
||||
// BLOCKING 2 regression: hyphenless core rules (`semi`, `eqeqeq`) are
|
||||
// recognized by their structural >=2-space column position, counted under
|
||||
// `Top rules:`, and NOT glued onto the message body.
|
||||
assert!(is_eslint_rule_id("semi"));
|
||||
assert!(is_eslint_rule_id("eqeqeq"));
|
||||
assert!(is_eslint_rule_id("camelcase"));
|
||||
assert!(is_eslint_rule_id("curly"));
|
||||
assert!(is_eslint_rule_id("radix"));
|
||||
assert!(is_eslint_rule_id("complexity"));
|
||||
// Prose tokens (uppercase-initial / punctuation) are still rejected.
|
||||
assert!(!is_eslint_rule_id("Unexpected"));
|
||||
assert!(!is_eslint_rule_id("token."));
|
||||
|
||||
let input = "/app/x.js\n 1:1 error Missing semicolon semi\n 2:1 error Expected === \
|
||||
eqeqeq\n\n\u{2716} 2 problems (2 errors, 0 warnings)\n";
|
||||
let out = condense_lint_output("eslint", input, 1);
|
||||
assert!(out.contains("Top rules:"), "hyphenless rules must produce a Top rules line: {out}");
|
||||
assert!(out.contains("semi (1x)"), "semi must be counted: {out}");
|
||||
assert!(out.contains("eqeqeq (1x)"), "eqeqeq must be counted: {out}");
|
||||
// The rule slug must NOT be glued onto the diagnostic message body.
|
||||
assert!(
|
||||
!out.contains("Missing semicolon semi") && !out.contains("Missing semicolon semi"),
|
||||
"rule slug must be split from the message, not glued: {out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Filesystem listing and search filters.
|
||||
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
use std::{collections::BTreeMap, path::Path, sync::LazyLock};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, config::OutlineLevel, primitives};
|
||||
|
||||
@@ -37,6 +39,7 @@ fn find_outputs_paths_only(command: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let legacy = ctx.config.legacy_filters_active();
|
||||
@@ -67,7 +70,7 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
},
|
||||
"cat" | "read" => compact_cat_output(ctx, &cleaned),
|
||||
"stat" | "du" | "df" | "wc" => compact_summary_output(&cleaned),
|
||||
"stat" | "du" | "df" | "wc" => compact_summary_output(&cleaned, ctx.program),
|
||||
"jq" | "json" => cleaned,
|
||||
_ => cleaned,
|
||||
}
|
||||
@@ -79,6 +82,14 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
}
|
||||
|
||||
/// Date/time anchor in `ls -l` output: month-name day time-or-year.
|
||||
/// E.g.: " Apr 28 10:00 " or " Dec 25 2024 " or " 2 feb 21:35 ".
|
||||
/// The `(?i)` flag makes month names case-insensitive for non-C locales.
|
||||
/// The trailing space ensures we do not match partial tokens.
|
||||
static LS_DATE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\s+(?:(?:\p{L}{3,}\.?\s+\d{1,2}|\d{1,2}\.?\s+\p{L}{3,}\.?)\s+(?:\d{4}|\d{2}:\d{2})|\d{4}[-/]\d{2}(?:[-/]\d{2})?\s+\d{2}:\d{2})\s+").unwrap()
|
||||
});
|
||||
|
||||
fn compact_listing_output(input: &str) -> String {
|
||||
primitives::compact_listing(input, 80)
|
||||
}
|
||||
@@ -471,24 +482,41 @@ fn parse_ls_long_line(line: &str) -> Option<LsEntry> {
|
||||
if trimmed.starts_with("total ") || trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let kind = trimmed.chars().next()?;
|
||||
if !matches!(kind, 'd' | '-' | 'l') {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let name = parts[8..].join(" ");
|
||||
|
||||
let date_match = LS_DATE_RE.find(line)?;
|
||||
let name = line[date_match.end()..].to_string();
|
||||
if matches!(name.as_str(), "." | "..") {
|
||||
return None;
|
||||
}
|
||||
Some(LsEntry {
|
||||
name,
|
||||
is_dir: kind == 'd',
|
||||
size: parts.get(4).and_then(|value| value.parse().ok()),
|
||||
is_file: kind == '-',
|
||||
})
|
||||
|
||||
let before_date = &line[..date_match.start()];
|
||||
let before_parts: Vec<&str> = before_date.split_whitespace().collect();
|
||||
if before_parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = before_parts[0].chars().next()?;
|
||||
if !matches!(kind, '-' | 'd' | 'l' | 'c' | 'b' | 's' | 'p' | 'D') {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Size is the rightmost parseable u64 before the date, but skip
|
||||
// comma-separated device major/minor pairs.
|
||||
let mut size = None;
|
||||
if let Some((i, part)) = before_parts.iter().enumerate().next_back() {
|
||||
if part.ends_with(',') {
|
||||
// Major number of a device file — skip.
|
||||
} else if let Ok(s) = part.parse::<u64>() {
|
||||
if i > 0 && before_parts[i - 1].ends_with(',') {
|
||||
// This numeric field is the minor number of a device file.
|
||||
} else {
|
||||
size = Some(s);
|
||||
}
|
||||
}
|
||||
// Non-numeric field (e.g. owner/group word) before size: no size.
|
||||
}
|
||||
|
||||
Some(LsEntry { name, is_dir: kind == 'd', size, is_file: kind == '-' })
|
||||
}
|
||||
|
||||
fn format_human_size(size: u64) -> String {
|
||||
@@ -1123,13 +1151,34 @@ fn strip_python_bodies(input: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn compact_summary_output(input: &str) -> String {
|
||||
fn compact_summary_output(input: &str, program: &str) -> String {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
if lines.len() <= 30 {
|
||||
return input.to_string();
|
||||
}
|
||||
|
||||
let windowed = primitives::head_tail_lines(input, 12, 12);
|
||||
let input = if program == "df" {
|
||||
let kept: Vec<&str> = lines
|
||||
.into_iter()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("overlay") || trimmed.starts_with("none") {
|
||||
// Keep container root: overlay/none mounted at "/"
|
||||
return trimmed.split_whitespace().last() == Some("/");
|
||||
}
|
||||
!trimmed.starts_with("tmpfs")
|
||||
&& !trimmed.starts_with("devtmpfs")
|
||||
&& !trimmed.starts_with("udev")
|
||||
&& !trimmed.starts_with("shm")
|
||||
})
|
||||
.collect();
|
||||
kept.join("\n")
|
||||
} else {
|
||||
lines.join("\n")
|
||||
};
|
||||
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
let windowed = primitives::head_tail_lines(&input, 12, 12);
|
||||
let mut out = String::new();
|
||||
for line in lines.iter().copied().filter(|line| is_summary_line(line)) {
|
||||
if !windowed.lines().any(|existing| existing == line)
|
||||
@@ -1160,6 +1209,8 @@ fn has_content(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::*;
|
||||
use crate::minimizer::MinimizerConfig;
|
||||
|
||||
@@ -1441,6 +1492,39 @@ mod tests {
|
||||
assert!(out.text.contains("/dev/disk35"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn df_keeps_overlay_root_drops_inner_overlays() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = ctx("df", &cfg);
|
||||
let header = "Filesystem 1K-blocks Used Available Use% Mounted on\n";
|
||||
let overlay_root = "overlay 52428800 12345678 40083122 24% /\n";
|
||||
let overlay_inner =
|
||||
"overlay 52428800 1234 52427566 0% /var/lib/docker/overlay2/abc/merged\n";
|
||||
let real_fs = "sda1 52428800 5000000 47428800 10% /data\n";
|
||||
let tmpfs_line = "tmpfs 8192000 0 8192000 0% /dev\n";
|
||||
let padding: String = (0..28).fold(String::new(), |mut s, i| {
|
||||
let _ = writeln!(s, "sda{i} 1000 500 500 50% /mnt/disk{i}");
|
||||
s
|
||||
});
|
||||
let input = format!("{header}{overlay_root}{overlay_inner}{real_fs}{tmpfs_line}{padding}");
|
||||
|
||||
let out = filter(&ctx, &input, 0);
|
||||
// Container root overlay must be kept
|
||||
assert!(
|
||||
out.text.contains("24%") || (out.text.contains("overlay") && out.text.contains("/ ")),
|
||||
"container root overlay must be kept; got:\n{}",
|
||||
out.text
|
||||
);
|
||||
// Inner overlay must be dropped
|
||||
assert!(
|
||||
!out.text.contains("/var/lib/docker"),
|
||||
"inner overlay must be dropped; got:\n{}",
|
||||
out.text
|
||||
);
|
||||
// tmpfs must be dropped
|
||||
assert!(!out.text.contains("tmpfs"), "tmpfs must be dropped; got:\n{}", out.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_only_strips_ansi_when_short() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -1626,23 +1710,21 @@ mod tests {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn legacy_cfg() -> MinimizerConfig {
|
||||
let mut cfg = MinimizerConfig::default();
|
||||
cfg.enabled = true;
|
||||
cfg.legacy_filters_active = true;
|
||||
cfg
|
||||
MinimizerConfig { enabled: true, legacy_filters_active: true, ..Default::default() }
|
||||
}
|
||||
|
||||
fn synthesize_grep(matches_per_file: usize, files: usize) -> String {
|
||||
let mut out = String::new();
|
||||
for f in 0..files {
|
||||
for m in 0..matches_per_file {
|
||||
out.push_str(&format!(
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"src/module{f}/file{f}.rs:{ln}: pub fn handler_{f}_{m}(req: Request) -> \
|
||||
Result<Response, AppError> {{ /* body */ }}\n",
|
||||
Result<Response, AppError> {{ /* body */ }}",
|
||||
ln = m * 7 + 1,
|
||||
f = f,
|
||||
m = m,
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -1652,10 +1734,11 @@ mod tests {
|
||||
let mut out = String::new();
|
||||
for d in 0..dirs {
|
||||
for f in 0..per_dir {
|
||||
out.push_str(&format!(
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"./crates/pi-shell/src/minimizer/filters/category{d}/\
|
||||
handler_{f}_with_descriptive_name.rs\n",
|
||||
));
|
||||
handler_{f}_with_descriptive_name.rs",
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -1817,4 +1900,133 @@ mod tests {
|
||||
assert_eq!(out.text, input);
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CONCERN 1: ls date-anchored parse
|
||||
// -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_handles_spaced_filename() {
|
||||
let entry = parse_ls_long_line("drwxr-xr-x 2 user staff 192 Feb 2 21:35 my dir").unwrap();
|
||||
assert_eq!(entry.name, "my dir");
|
||||
assert_eq!(entry.size, Some(192));
|
||||
assert!(entry.is_dir);
|
||||
assert!(!entry.is_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_handles_year_format_date() {
|
||||
let entry =
|
||||
parse_ls_long_line("-rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar").unwrap();
|
||||
assert_eq!(entry.name, "archive.tar");
|
||||
assert_eq!(entry.size, Some(5678));
|
||||
assert!(!entry.is_dir);
|
||||
assert!(entry.is_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_ls_with_spaced_filename_and_year_date() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = ctx("ls", &cfg);
|
||||
let mut input = String::from("total 8\n");
|
||||
input.push_str("drwxr-xr-x 2 user staff 192 Feb 2 21:35 my dir\n");
|
||||
input.push_str("-rw-r--r-- 1 user staff 1234 Feb 2 21:35 my file.txt\n");
|
||||
input.push_str("-rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n");
|
||||
for idx in 0..20 {
|
||||
input.push_str("-rw-r--r-- 1 user staff ");
|
||||
input.push_str(&(1024 * (idx + 1)).to_string());
|
||||
input.push_str(" Feb 2 21:35 file");
|
||||
input.push_str(&idx.to_string());
|
||||
input.push_str(".rs\n");
|
||||
}
|
||||
let out = filter(&ctx, &input, 0);
|
||||
assert!(out.text.contains("my dir/"), "spaced dir name missing: {}", out.text);
|
||||
assert!(out.text.contains("my file.txt"), "spaced file name missing: {}", out.text);
|
||||
assert!(out.text.contains("archive.tar"), "year-format file missing: {}", out.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_handles_non_english_month() {
|
||||
let entry =
|
||||
parse_ls_long_line("-rw-r--r-- 1 user staff 1234 25 Mär 10:00 file.txt").unwrap();
|
||||
assert_eq!(entry.name, "file.txt");
|
||||
assert_eq!(entry.size, Some(1234));
|
||||
assert!(!entry.is_dir);
|
||||
assert!(entry.is_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_handles_dotted_month_abbreviation() {
|
||||
let entry =
|
||||
parse_ls_long_line("-rw-r--r-- 1 user staff 5678 févr. 2 21:35 file.txt").unwrap();
|
||||
assert_eq!(entry.name, "file.txt");
|
||||
assert_eq!(entry.size, Some(5678));
|
||||
assert!(!entry.is_dir);
|
||||
assert!(entry.is_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_device_file_returns_none_size() {
|
||||
let entry =
|
||||
parse_ls_long_line("crw-rw---- 1 root dialout 1, 3 Jan 1 10:00 console").unwrap();
|
||||
assert_eq!(entry.name, "console");
|
||||
assert_eq!(entry.size, None);
|
||||
assert!(!entry.is_dir);
|
||||
assert!(!entry.is_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_long_line_iso_date_format() {
|
||||
let entry =
|
||||
parse_ls_long_line("-rw-r--r-- 1 user staff 1234 2024-12-25 10:00 file.txt").unwrap();
|
||||
assert_eq!(entry.name, "file.txt");
|
||||
assert_eq!(entry.size, Some(1234));
|
||||
assert!(!entry.is_dir);
|
||||
assert!(entry.is_file);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CONCERN 2: df virtual-filesystem row drop
|
||||
// -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn long_df_drops_virtual_fs_rows() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = ctx("df", &cfg);
|
||||
let mut input = String::from("Filesystem 1K-blocks Used Available Use% Mounted on\n");
|
||||
for idx in 0..18 {
|
||||
input.push_str("tmpfs 100 50 50 50% /mnt/t");
|
||||
input.push_str(&idx.to_string());
|
||||
input.push('\n');
|
||||
}
|
||||
for idx in 0..30 {
|
||||
input.push_str("/dev/disk");
|
||||
input.push_str(&idx.to_string());
|
||||
input.push_str(" 100 50 50 50% /mnt/d");
|
||||
input.push_str(&idx.to_string());
|
||||
input.push('\n');
|
||||
}
|
||||
let out = filter(&ctx, &input, 0);
|
||||
assert!(
|
||||
out.text
|
||||
.contains("Filesystem 1K-blocks Used Available Use% Mounted on"),
|
||||
"header must be kept: {}",
|
||||
out.text
|
||||
);
|
||||
assert!(!out.text.contains("tmpfs"), "tmpfs must be dropped: {}", out.text);
|
||||
assert!(out.text.contains("/dev/disk"), "real disk rows must be kept: {}", out.text);
|
||||
assert!(out.text.contains("…"), "summary ellipsis expected: {}", out.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_df_passes_through_verbatim() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = ctx("df", &cfg);
|
||||
let mut input = String::from("Filesystem 1K-blocks Used Available Use% Mounted on\n");
|
||||
input.push_str("tmpfs 100 50 50 50% /mnt/tmp\n");
|
||||
input.push_str("/dev/disk0 100 50 50 50% /\n");
|
||||
let out = filter(&ctx, &input, 0);
|
||||
assert_eq!(out.text, input, "short df must pass through verbatim");
|
||||
assert!(!out.changed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod dotnet;
|
||||
|
||||
pub mod generic;
|
||||
pub mod gh;
|
||||
pub mod glab;
|
||||
|
||||
pub mod go;
|
||||
pub mod gt;
|
||||
@@ -23,6 +24,8 @@ pub mod git;
|
||||
|
||||
pub mod js_tools;
|
||||
|
||||
pub mod jvm;
|
||||
|
||||
pub mod lint;
|
||||
pub mod listing;
|
||||
pub mod node_tests;
|
||||
@@ -33,6 +36,7 @@ pub mod ruby;
|
||||
pub mod rust_tools;
|
||||
pub mod system;
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
match program {
|
||||
"git" | "yadm" => git::supports(subcommand),
|
||||
@@ -45,11 +49,19 @@ pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
},
|
||||
program if cpp::is_gtest_binary_name(program) => cpp::supports(program, subcommand),
|
||||
"dotnet" => dotnet::supports(program, subcommand),
|
||||
// JVM build tools: phase is decided inside jvm::filter (never by
|
||||
// ctx.subcommand, which mis-reports `mvn clean install` as `clean`), so
|
||||
// supports() claims every subcommand. Defensive `.cmd`/`.bat` arms cover
|
||||
// the case where normalize_program is bypassed.
|
||||
"mvn" | "mvnw" | "mvnw.cmd" | "gradle" | "gradlew" | "gradlew.bat" => {
|
||||
jvm::supports(program, subcommand)
|
||||
},
|
||||
"ls" | "tree" | "find" | "grep" | "rg" | "wc" | "cat" | "read" | "stat" | "du" | "df"
|
||||
| "jq" | "json" => true,
|
||||
"aws" | "curl" | "wget" | "psql" => cloud::supports(program, subcommand),
|
||||
"docker" | "kubectl" | "helm" => docker::supports(subcommand),
|
||||
"gh" => gh::supports(subcommand),
|
||||
"glab" => glab::supports(subcommand),
|
||||
"pytest" | "ruff" | "mypy" | "python" | "python3" | "py" => {
|
||||
python::supports(program, subcommand)
|
||||
},
|
||||
@@ -130,6 +142,7 @@ fn is_lint_script_token(token: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Apply the matching built-in filter.
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let _ = ctx.command;
|
||||
let _ = ctx.config.per_command(ctx.program);
|
||||
@@ -140,6 +153,9 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
"cargo" => cargo::filter(ctx, input, exit_code),
|
||||
"go" | "golangci-lint" => go::filter(ctx, input, exit_code),
|
||||
"dotnet" => dotnet::filter(ctx, input, exit_code),
|
||||
"mvn" | "mvnw" | "mvnw.cmd" | "gradle" | "gradlew" | "gradlew.bat" => {
|
||||
jvm::filter(ctx, input, exit_code)
|
||||
},
|
||||
"cmake" | "ctest" | "ninja" | "gtest" | "gtest-parallel" => {
|
||||
cpp::filter(ctx, input, exit_code)
|
||||
},
|
||||
@@ -149,6 +165,7 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
"aws" | "curl" | "wget" | "psql" => cloud::filter(ctx, input, exit_code),
|
||||
"docker" | "kubectl" | "helm" => docker::filter(ctx, input, exit_code),
|
||||
"gh" => gh::filter(ctx, input, exit_code),
|
||||
"glab" => glab::filter(ctx, input, exit_code),
|
||||
"pytest" | "ruff" | "mypy" | "python" | "python3" | "py" => {
|
||||
python::filter(ctx, input, exit_code)
|
||||
},
|
||||
@@ -164,6 +181,9 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
"uv" if matches!(ctx.subcommand, Some("run" | "pytest" | "ruff" | "mypy" | "-m")) => {
|
||||
filter_uv_wrapper(ctx, input, exit_code)
|
||||
},
|
||||
"bundle" if matches!(ctx.subcommand, Some("exec")) => {
|
||||
filter_bundle_wrapper(ctx, input, exit_code)
|
||||
},
|
||||
"npm" | "pnpm" | "yarn" => {
|
||||
if is_pkg_test_invocation(ctx) {
|
||||
node_tests::filter(ctx, input, exit_code)
|
||||
@@ -183,10 +203,22 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
|
||||
fn filter_js_wrapper(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if wrapper_invokes(ctx, &["tsc", "eslint", "biome"]) {
|
||||
lint::filter(ctx, input, exit_code)
|
||||
} else if wrapper_invokes(ctx, &["jest", "vitest", "playwright"]) {
|
||||
node_tests::filter(ctx, input, exit_code)
|
||||
if let Some(tool) = wrapper_invoked_tool(ctx, &["tsc", "eslint", "biome"]) {
|
||||
let routed = MinimizerCtx {
|
||||
program: tool,
|
||||
subcommand: Some(tool),
|
||||
command: ctx.command,
|
||||
config: ctx.config,
|
||||
};
|
||||
lint::filter(&routed, input, exit_code)
|
||||
} else if let Some(tool) = wrapper_invoked_tool(ctx, &["jest", "vitest", "playwright"]) {
|
||||
let routed = MinimizerCtx {
|
||||
program: tool,
|
||||
subcommand: Some(tool),
|
||||
command: ctx.command,
|
||||
config: ctx.config,
|
||||
};
|
||||
node_tests::filter(&routed, input, exit_code)
|
||||
} else if js_tools::supports(ctx.program, ctx.subcommand) {
|
||||
js_tools::filter(ctx, input, exit_code)
|
||||
} else {
|
||||
@@ -252,6 +284,40 @@ fn filter_uv_wrapper(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> Min
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_bundle_wrapper(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let Some(inner_command) = bundle_wrapped_command(ctx.command) else {
|
||||
return pkg::filter(ctx, input, exit_code);
|
||||
};
|
||||
crate::minimizer::apply(&inner_command, input, exit_code, ctx.config)
|
||||
}
|
||||
|
||||
/// Extract the inner command from a `bundle exec <cmd> …` invocation so the
|
||||
/// engine can re-dispatch through the wrapped tool's filter/def. Returns
|
||||
/// `None` when the command does not follow the `bundle exec` pattern.
|
||||
fn bundle_wrapped_command(command: &str) -> Option<String> {
|
||||
let tokens: Vec<&str> = command
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&'))
|
||||
.filter(|tok| !tok.is_empty())
|
||||
.collect();
|
||||
let bundle_pos = tokens.iter().position(|t| {
|
||||
t.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("bundle"))
|
||||
})?;
|
||||
let mut iter = tokens.iter().skip(bundle_pos + 1).copied();
|
||||
let word = next_command_word(&mut iter)?;
|
||||
if word != "exec" {
|
||||
return None;
|
||||
}
|
||||
let tool = next_command_word(&mut iter)?;
|
||||
let mut inner = String::from(tool);
|
||||
for tok in iter {
|
||||
inner.push(' ');
|
||||
inner.push_str(tok);
|
||||
}
|
||||
Some(inner)
|
||||
}
|
||||
|
||||
/// Normalize uv invocation forms into a routable tool name (B1 / m4).
|
||||
///
|
||||
/// Resolution order:
|
||||
@@ -340,6 +406,11 @@ const WRAPPER_VALUE_OPTIONS: &[&str] = &[
|
||||
"--workspace",
|
||||
"-w",
|
||||
"--node-arg",
|
||||
// bundle exec
|
||||
"--gemfile",
|
||||
"--path",
|
||||
"--jobs",
|
||||
"--retry",
|
||||
];
|
||||
|
||||
/// Advance `tokens` to the next invoked-command word, skipping flag tokens and
|
||||
@@ -397,10 +468,6 @@ fn wrapper_command_word(command: &str) -> Option<&str> {
|
||||
Some(word)
|
||||
}
|
||||
|
||||
fn wrapper_invokes(ctx: &MinimizerCtx<'_>, tools: &[&str]) -> bool {
|
||||
wrapper_invoked_tool(ctx, tools).is_some()
|
||||
}
|
||||
|
||||
fn wrapper_invoked_tool<'a>(ctx: &'a MinimizerCtx<'_>, tools: &[&'a str]) -> Option<&'a str> {
|
||||
// Prefer wrapper_command_word over ctx.subcommand: it properly skips
|
||||
// value-taking option values (e.g. -w, --workspace, --with) that
|
||||
@@ -453,6 +520,16 @@ mod tests {
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_eslint_json_format_preserved() {
|
||||
// Simulates: npx eslint -f json src/
|
||||
let config = MinimizerConfig::default();
|
||||
let input = r#"[{"filePath":"a.ts","messages":[],"errorCount":0}]"#;
|
||||
let context = ctx("npx", Some("eslint"), "npx eslint -f json src/", &config);
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("filePath"), "eslint -f json via npx must not be condensed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_invoked_word_picks_script_not_arguments() {
|
||||
assert_eq!(run_invoked_word("npm run build -- test"), Some("build"));
|
||||
@@ -846,12 +923,96 @@ mod tests {
|
||||
fn pytest_legacy_filters_active_passes_through() {
|
||||
// Kill-switch parity (M2): legacy_filters_active=true skips the
|
||||
// pytest state machine even when invoked via `uv pytest`.
|
||||
let mut config = MinimizerConfig::default();
|
||||
config.enabled = true;
|
||||
config.legacy_filters_active = true;
|
||||
let config =
|
||||
MinimizerConfig { enabled: true, legacy_filters_active: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("pytest"), "uv pytest tests/", &config);
|
||||
let out = filter(&context, PYTEST_FAILURE_INPUT, 1);
|
||||
assert_eq!(out.text, PYTEST_FAILURE_INPUT);
|
||||
assert!(!out.changed);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Tier 2b: bundle exec wrapper re-dispatch
|
||||
// -------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bundle_wrapped_command_extracts_inner_command() {
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("bundle exec rails db:migrate"),
|
||||
Some("rails db:migrate".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("bundle exec rake db:migrate"),
|
||||
Some("rake db:migrate".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("bundle exec rails routes"),
|
||||
Some("rails routes".to_string())
|
||||
);
|
||||
assert_eq!(super::bundle_wrapped_command("bundle exec rspec"), Some("rspec".to_string()));
|
||||
// Skips bundle-specific value-taking options
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("bundle exec --gemfile foo/Gemfile rails db:migrate"),
|
||||
Some("rails db:migrate".to_string())
|
||||
);
|
||||
// Non-exec subcommands return None
|
||||
assert_eq!(super::bundle_wrapped_command("bundle install"), None);
|
||||
// No bundle token returns None
|
||||
assert_eq!(super::bundle_wrapped_command("gem install"), None);
|
||||
// Path-prefixed bundle binary should still be recognized
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("/usr/local/bin/bundle exec rails db:migrate"),
|
||||
Some("rails db:migrate".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
super::bundle_wrapped_command("bin/bundle exec rake db:migrate"),
|
||||
Some("rake db:migrate".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_rails_db_migrate_routes_to_def() {
|
||||
let config = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("exec"), "bundle exec rails db:migrate", &config);
|
||||
let input = "== 20240115 CreateUsers: migrating\n-- create_table(:users)\n -> 0.0234s\n== \
|
||||
20240115 CreateUsers: migrated\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("CreateUsers"));
|
||||
assert!(!out.text.contains("-- create_table"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_rails_routes_routes_to_def() {
|
||||
let config = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("exec"), "bundle exec rails routes", &config);
|
||||
let input = " Prefix Verb URI Pattern Controller#Action\n root GET / home#index\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.changed);
|
||||
assert!(!out.text.contains("Prefix"));
|
||||
assert!(out.text.contains("root GET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_rspec_routes_to_ruby_filter() {
|
||||
let config = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("exec"), "bundle exec rspec", &config);
|
||||
let input = "Randomized with seed 12345\n\nUserController\n GET /users\n returns a list \
|
||||
of users\n\nFinished in 0.45 seconds\n5 examples, 0 failures\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.changed);
|
||||
assert!(out.text.contains("5 examples, 0 failures"));
|
||||
assert!(!out.text.contains("returns a list of users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_exec_unknown_tool_uses_pkg_filter() {
|
||||
let config = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("exec"), "bundle exec ruby script.rb", &config);
|
||||
let input = "hello from ruby\n";
|
||||
let out = filter(&context, input, 0);
|
||||
// No filter matches ruby script.rb, so it passes through
|
||||
assert_eq!(out.text, input);
|
||||
assert!(!out.changed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = if exit_code == 0 {
|
||||
@@ -27,6 +28,11 @@ fn drop_passed_lines(input: &str) -> String {
|
||||
push_line(&mut out, line);
|
||||
continue;
|
||||
}
|
||||
// snip jest.yaml strips: console.log noise and the zero-information
|
||||
// "Ran all test suites" line carry no signal even on success.
|
||||
if is_noise_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if is_pass_noise(trimmed) {
|
||||
continue;
|
||||
}
|
||||
@@ -50,6 +56,13 @@ fn failures_only(input: &str) -> String {
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
// snip jest.yaml strips: drop console.log noise and the zero-information
|
||||
// "Ran all test suites" line transparently, without breaking the kept
|
||||
// failure block around them.
|
||||
if is_noise_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_summary_line(trimmed) {
|
||||
keeping_block = false;
|
||||
trailing_context = 0;
|
||||
@@ -105,7 +118,6 @@ fn is_summary_line(trimmed: &str) -> bool {
|
||||
|| trimmed.starts_with("Tests:")
|
||||
|| trimmed.starts_with("Snapshots:")
|
||||
|| trimmed.starts_with("Time:")
|
||||
|| trimmed.starts_with("Ran all test suites")
|
||||
|| trimmed.starts_with("Test Files")
|
||||
|| trimmed.starts_with("Duration")
|
||||
|| trimmed.starts_with("Start at")
|
||||
@@ -127,6 +139,13 @@ fn starts_count_summary(trimmed: &str) -> bool {
|
||||
matches!(parts.next(), Some("failed" | "passed" | "skipped" | "flaky" | "pass" | "fail"))
|
||||
}
|
||||
|
||||
/// Zero-information lines worth dropping regardless of pass/fail context.
|
||||
/// Ported from snip/filters/jest.yaml's `remove_lines` strips: jest console.log
|
||||
/// echoes (`^\s+console\.`) and the trailing `Ran all test suites.` banner.
|
||||
fn is_noise_line(trimmed: &str) -> bool {
|
||||
trimmed.starts_with("console.") || trimmed.starts_with("Ran all test suites")
|
||||
}
|
||||
|
||||
fn is_pass_noise(trimmed: &str) -> bool {
|
||||
trimmed.starts_with("PASS ")
|
||||
|| trimmed.starts_with("✓")
|
||||
@@ -184,7 +203,7 @@ fn is_playwright_numbered_failure(trimmed: &str) -> bool {
|
||||
saw_digit = true;
|
||||
continue;
|
||||
}
|
||||
return saw_digit && ch == ')' && chars.next().is_some_and(|next| next.is_whitespace());
|
||||
return saw_digit && ch == ')' && chars.next().is_some_and(char::is_whitespace);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -342,4 +361,85 @@ Time: 2.345s
|
||||
assert!(filtered.contains("Tests: 32 passed, 32 total"));
|
||||
assert!(filtered.contains("Time: 2.345s"));
|
||||
}
|
||||
|
||||
// Ported from snip/filters/jest.yaml's "all passing" inline test. snip keeps
|
||||
// PASS lines; the minimizer collapses pass runs to the count summary, which is
|
||||
// strictly less noisy, so the expectation is adjusted to drop the PASS lines.
|
||||
#[test]
|
||||
fn jest_all_passing_collapses_to_summary() {
|
||||
let input = "PASS src/__tests__/utils.test.js\nPASS src/__tests__/main.test.js\n\nTest \
|
||||
Suites: 2 passed, 2 total\nTests: 5 passed, 5 total\nSnapshots: 0 \
|
||||
total\nTime: 1.234 s\n";
|
||||
let filtered = drop_passed_lines(input);
|
||||
|
||||
assert!(!filtered.contains("utils.test.js"));
|
||||
assert!(!filtered.contains("main.test.js"));
|
||||
assert!(filtered.contains("Test Suites: 2 passed, 2 total"));
|
||||
assert!(filtered.contains("Tests: 5 passed, 5 total"));
|
||||
assert!(filtered.contains("Time: 1.234 s"));
|
||||
}
|
||||
|
||||
// Ported from snip/filters/jest.yaml's "with failures and stack traces" inline
|
||||
// test. snip strips the code frame and `at` stack lines; the minimizer keeps
|
||||
// that richer failure context. The new strips fold in here: the trailing "Ran
|
||||
// all test suites." banner is dropped and the PASS line collapses.
|
||||
#[test]
|
||||
fn jest_failures_drop_ran_banner_and_keep_rich_context() {
|
||||
let input =
|
||||
"PASS src/__tests__/utils.test.js\nFAIL src/__tests__/main.test.js\n ● Main > should \
|
||||
return correct value\n\n expect(received).toBe(expected)\n\n Expected: 4\n \
|
||||
Received: 3\n\n 5 | test('should return correct value', () => {\n 6 | \
|
||||
expect(calculate(2, 2)).toBe(4);\n | ^\n 7 | });\n\n at \
|
||||
Object.<anonymous> (src/__tests__/main.test.js:6:29)\n\nTest Suites: 1 failed, 1 \
|
||||
passed, 2 total\nTests: 1 failed, 3 passed, 4 total\nSnapshots: 0 total\nTime: \
|
||||
1.234 s\nRan all test suites.\n";
|
||||
let filtered = failures_only(input);
|
||||
|
||||
// PASS line collapses; failure context survives.
|
||||
assert!(!filtered.contains("PASS src/__tests__/utils.test.js"));
|
||||
assert!(filtered.contains("FAIL src/__tests__/main.test.js"));
|
||||
assert!(filtered.contains("● Main > should return correct value"));
|
||||
assert!(filtered.contains("Expected: 4"));
|
||||
assert!(filtered.contains("Received: 3"));
|
||||
assert!(filtered.contains("Test Suites: 1 failed, 1 passed, 2 total"));
|
||||
// New snip strip: the zero-information banner is gone.
|
||||
assert!(!filtered.contains("Ran all test suites"));
|
||||
}
|
||||
|
||||
// New jest console.log strip (snip jest.yaml `^\s+console\.`): echoed
|
||||
// console output is dropped on both the pass and fail paths.
|
||||
#[test]
|
||||
fn jest_console_log_noise_is_dropped() {
|
||||
let pass_input = "PASS src/a.test.ts\n console.log\n debugging value 42\n at \
|
||||
log.ts:3:9\n\nTests: 1 passed, 1 total\n";
|
||||
let pass_filtered = drop_passed_lines(pass_input);
|
||||
assert!(!pass_filtered.contains("console.log"));
|
||||
assert!(pass_filtered.contains("Tests: 1 passed, 1 total"));
|
||||
|
||||
let fail_input = "FAIL src/a.test.ts\n ● breaks\n console.error\n noisy log \
|
||||
line\n Expected: 1\n Received: 2\n\nTests: 1 failed, 1 total\n";
|
||||
let fail_filtered = failures_only(fail_input);
|
||||
assert!(!fail_filtered.contains("console.error"));
|
||||
assert!(fail_filtered.contains("Expected: 1"));
|
||||
assert!(fail_filtered.contains("Received: 2"));
|
||||
assert!(fail_filtered.contains("Tests: 1 failed, 1 total"));
|
||||
}
|
||||
|
||||
// Vitest default-reporter regression (keep-minimizer; pure lock). A failing
|
||||
// file's multi-line `AssertionError: expected X to be Y` block plus the
|
||||
// `❯ file:line` pointer must survive failures_only.
|
||||
#[test]
|
||||
fn vitest_assertion_error_block_and_pointer_survive() {
|
||||
let input = " ✓ src/passing.test.ts (3)\n ❯ src/math.test.ts (1)\n × adds numbers\n\n \
|
||||
AssertionError: expected 3 to be 4 // Object.is equality\n\n - Expected\n + \
|
||||
Received\n\n - 4\n + 3\n\n ❯ src/math.test.ts:7:23\n\n Test Files 1 failed \
|
||||
| 1 passed (2)\n Tests 1 failed | 1 passed (2)\n";
|
||||
let filtered = failures_only(input);
|
||||
|
||||
assert!(!filtered.contains("src/passing.test.ts"));
|
||||
assert!(filtered.contains("× adds numbers"));
|
||||
assert!(filtered.contains("AssertionError: expected 3 to be 4"));
|
||||
assert!(filtered.contains("❯ src/math.test.ts:7:23"));
|
||||
assert!(filtered.contains("Test Files 1 failed | 1 passed (2)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{collections::HashSet, fmt::Write as _};
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
const PACKAGE_TREE_HEAD_LINES: usize = 80;
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
matches!(
|
||||
subcommand,
|
||||
@@ -39,9 +40,12 @@ pub fn supports(subcommand: Option<&str>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
if exit_code == 0
|
||||
&& (command_contains_any(ctx.command, &["--json"])
|
||||
|| primitives::command_has_any_token(ctx.command, &["--format=json"])
|
||||
|| primitives::command_has_ordered_tokens(ctx.command, "--format", "json")
|
||||
|| ctx.program == "uv"
|
||||
&& matches!(ctx.subcommand, Some("pip"))
|
||||
&& command_contains_any(ctx.command, &["freeze"]))
|
||||
@@ -50,6 +54,18 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
|
||||
// Success no-op short-circuits, moved here from defs/poetry-install.toml and
|
||||
// defs/uv-sync.toml so they fire regardless of overlay ordering. Each is
|
||||
// scoped per (program, subcommand) so unrelated package managers are
|
||||
// untouched. A non-empty message means ensure_success_visible leaves it as-is
|
||||
// (no bare 'OK' rewrite).
|
||||
if exit_code == 0
|
||||
&& let Some(message) = success_up_to_date_short_circuit(ctx, &cleaned)
|
||||
{
|
||||
return MinimizerOutput::transformed(message.to_string(), input.len());
|
||||
}
|
||||
|
||||
let text = if exit_code == 0 && is_package_lock_command(ctx) {
|
||||
compact_package_lock_output(ctx, &cleaned)
|
||||
} else {
|
||||
@@ -60,6 +76,8 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
} else if exit_code == 0
|
||||
&& (is_package_tree_command(ctx) || is_package_export_command(ctx))
|
||||
&& !command_contains_any(ctx.command, &["--json"])
|
||||
&& !primitives::command_has_any_token(ctx.command, &["--format=json"])
|
||||
&& !primitives::command_has_ordered_tokens(ctx.command, "--format", "json")
|
||||
{
|
||||
compact_package_tree_output(&deduped)
|
||||
} else {
|
||||
@@ -79,9 +97,65 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-(program, subcommand) success no-op detection. Returns the one-line
|
||||
/// summary to emit when the raw output says nothing changed, replacing the
|
||||
/// `match_output` overlays that previously lived in defs/poetry-install.toml
|
||||
/// and defs/uv-sync.toml. Callers must gate on `exit_code` == 0.
|
||||
fn success_up_to_date_short_circuit(ctx: &MinimizerCtx<'_>, cleaned: &str) -> Option<&'static str> {
|
||||
// poetry install/lock/update no-op: 'No dependencies to install or update'
|
||||
// (poetry 1.x) or 'No changes.' (poetry 2.x). Scoped to program=poetry.
|
||||
if ctx.program == "poetry"
|
||||
&& matches!(ctx.subcommand, Some("install" | "lock" | "update"))
|
||||
&& cleaned.lines().any(|line| {
|
||||
let lower = line.trim().to_ascii_lowercase();
|
||||
lower.starts_with("no dependencies to install or update") || lower == "no changes."
|
||||
}) {
|
||||
return Some("ok (up to date)");
|
||||
}
|
||||
// uv sync/add/remove no-op: 'Audited N packages in Xms' with no installs.
|
||||
// Scoped to those subcommands so npm/brew/composer 'Audited' is unaffected.
|
||||
// Only collapse when the run is a CLEAN no-op: uv co-prints actionable
|
||||
// diagnostics (e.g. 'warning: VIRTUAL_ENV=… does not match the project
|
||||
// environment path …') alongside the Audited line on exit 0, and eagerly
|
||||
// collapsing to 'ok (up to date)' would destroy them. On HEAD the global
|
||||
// is_noise_line stripped the Audited line but the surviving warning kept the
|
||||
// output non-empty, so it was reported; preserve that by short-circuiting only
|
||||
// when no warning/error line co-occurs. (poetry's branch above deliberately
|
||||
// collapses warnings too — its deleted overlay did the same, so it stays.)
|
||||
if ctx.program == "uv"
|
||||
&& matches!(ctx.subcommand, Some("sync" | "add" | "remove"))
|
||||
&& !uv_has_actionable_diagnostic(cleaned)
|
||||
&& cleaned.lines().any(|line| {
|
||||
let lower = line.trim().to_ascii_lowercase();
|
||||
lower.starts_with("audited ") && lower.contains("package")
|
||||
}) {
|
||||
return Some("ok (up to date)");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True when any line carries an actionable diagnostic ('warning'/'error'/
|
||||
/// 'failed') that must survive a uv no-op short-circuit. Kept deliberately
|
||||
/// narrow: the no-op summary lines themselves ('Resolved …', 'Audited …') carry
|
||||
/// none of these tokens, so a clean no-op still collapses to 'ok (up to date)'.
|
||||
/// Do NOT reuse `is_error_or_summary` here — it also matches 'audited'/'found'/
|
||||
/// 'success'/'complete', which would suppress the short-circuit on every no-op.
|
||||
fn uv_has_actionable_diagnostic(cleaned: &str) -> bool {
|
||||
cleaned.lines().any(|line| {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
lower.contains("warning") || lower.contains("error") || lower.contains("failed")
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_package_noise(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String {
|
||||
let mut out = String::new();
|
||||
let mut previous_blank = false;
|
||||
// snip keeps exactly one JS install-summary line ('added N packages…',
|
||||
// 'up to date', pnpm 'Done in Xs'/'Packages: +N', yarn 'Done in Xs'): the
|
||||
// count confirms lockfile/node_modules state. Keep the first such line and
|
||||
// treat later duplicates as noise. This check precedes is_noise_line so the
|
||||
// 'audited N packages' strip cannot eat the combined 'added…audited' summary.
|
||||
let mut kept_install_summary = false;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -93,6 +167,16 @@ fn strip_package_noise(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> S
|
||||
}
|
||||
previous_blank = false;
|
||||
|
||||
if is_js_program(ctx.program) && is_js_install_summary(&trimmed.to_ascii_lowercase()) {
|
||||
if kept_install_summary {
|
||||
continue;
|
||||
}
|
||||
kept_install_summary = true;
|
||||
out.push_str(line.trim_end());
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_noise_line(ctx, trimmed, exit_code) {
|
||||
continue;
|
||||
}
|
||||
@@ -117,6 +201,11 @@ fn is_package_tree_command(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
|| matches!(ctx.subcommand, Some("pip"))
|
||||
&& command_contains_any(ctx.command, &["list", "ls", "tree"])
|
||||
},
|
||||
// Default tabular `pip list` / `pip list --outdated` are inventory dumps
|
||||
// like `uv pip list`; give them the same tree/list compaction. `--json`
|
||||
// already passes through earlier in filter(), so only text output lands.
|
||||
// `pip3` is not normalized to `pip`, so claim both spellings.
|
||||
"pip" | "pip3" => matches!(ctx.subcommand, Some("list")),
|
||||
"poetry" => {
|
||||
matches!(ctx.subcommand, Some("tree"))
|
||||
|| matches!(ctx.subcommand, Some("show"))
|
||||
@@ -287,7 +376,16 @@ fn is_noise_line(ctx: &MinimizerCtx<'_>, line: &str, exit_code: i32) -> bool {
|
||||
if lower.contains("found 0 vulnerabilities") {
|
||||
return true;
|
||||
}
|
||||
// Strip: "audited X packages" timing summaries (non-actionable)
|
||||
// Strip: npm funding nags ("N packages are looking for funding" / "run `npm
|
||||
// fund` for details"). snip drops both; removing 'funding' from
|
||||
// is_audit_or_security_summary keeps real audit findings protected.
|
||||
if lower.contains("looking for funding") || lower.contains("npm fund") {
|
||||
return true;
|
||||
}
|
||||
// Strip: "audited X packages" timing summaries (non-actionable). This global
|
||||
// rule still governs npm/brew/composer. uv sync/add/remove never reach here
|
||||
// for an 'Audited' no-op — success_up_to_date_short_circuit() in filter()
|
||||
// consumes that line into 'ok (up to date)' on the raw output first.
|
||||
if lower.contains("audited") && lower.contains("package") {
|
||||
return true;
|
||||
}
|
||||
@@ -304,7 +402,7 @@ fn is_noise_line(ctx: &MinimizerCtx<'_>, line: &str, exit_code: i32) -> bool {
|
||||
}
|
||||
is_generic_progress(line, &lower)
|
||||
|| is_js_package_noise(ctx.program, line, &lower)
|
||||
|| is_python_package_noise(ctx.program, line, &lower)
|
||||
|| is_python_package_noise(ctx, line, &lower)
|
||||
|| is_ruby_php_brew_noise(ctx.program, line, &lower)
|
||||
}
|
||||
|
||||
@@ -322,7 +420,7 @@ fn compact_package_lock_output(ctx: &MinimizerCtx<'_>, input: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
if is_generic_progress(trimmed, &lower)
|
||||
|| is_python_package_noise(ctx.program, trimmed, &lower)
|
||||
|| is_python_package_noise(ctx, trimmed, &lower)
|
||||
|| is_js_package_noise(ctx.program, trimmed, &lower)
|
||||
{
|
||||
continue;
|
||||
@@ -360,14 +458,65 @@ fn is_generic_progress(line: &str, lower: &str) -> bool {
|
||||
.all(|ch| matches!(ch, '⠁' | '⠂' | '⠄' | '⡀' | '⢀' | '⠠' | '⠐' | '⠈' | ' '))
|
||||
}
|
||||
|
||||
fn is_js_program(program: &str) -> bool {
|
||||
matches!(program, "npm" | "pnpm" | "yarn" | "bun")
|
||||
}
|
||||
|
||||
/// JS package-manager success-summary lines worth keeping exactly once. snip
|
||||
/// retains these so the count confirms `lockfile/node_modules` state. Callers
|
||||
/// must gate on `is_js_program` first.
|
||||
fn is_js_install_summary(lower: &str) -> bool {
|
||||
lower.starts_with("added ") && lower.contains("package")
|
||||
|| lower.starts_with("removed ") && lower.contains("package")
|
||||
|| lower.starts_with("changed ") && lower.contains("package")
|
||||
|| lower.starts_with("up to date")
|
||||
|| lower.contains("already up-to-date")
|
||||
|| lower.starts_with("done in ")
|
||||
|| lower.starts_with("packages:")
|
||||
|| lower.starts_with("dependencies:")
|
||||
}
|
||||
|
||||
/// Classic yarn step markers: `[N/4] Resolving|Fetching|Linking|Building …`.
|
||||
fn is_yarn_step_marker(line: &str) -> bool {
|
||||
let Some(rest) = line.strip_prefix('[') else {
|
||||
return false;
|
||||
};
|
||||
let Some((counter, tail)) = rest.split_once(']') else {
|
||||
return false;
|
||||
};
|
||||
let Some((num, denom)) = counter.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
if num.is_empty()
|
||||
|| denom.is_empty()
|
||||
|| !num.bytes().all(|b| b.is_ascii_digit())
|
||||
|| !denom.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let step = tail.trim_start();
|
||||
step.starts_with("Resolving")
|
||||
|| step.starts_with("Fetching")
|
||||
|| step.starts_with("Linking")
|
||||
|| step.starts_with("Building")
|
||||
}
|
||||
|
||||
fn is_js_package_noise(program: &str, line: &str, lower: &str) -> bool {
|
||||
if !matches!(program, "npm" | "pnpm" | "yarn" | "bun") {
|
||||
if !is_js_program(program) {
|
||||
return false;
|
||||
}
|
||||
line.starts_with('>') && line.contains('@')
|
||||
|| lower.starts_with("npm notice")
|
||||
|| lower.starts_with("npm http fetch")
|
||||
|| lower.starts_with("pnpm: progress")
|
||||
// pnpm progress bars are runs of plus signs; anchor on 3+ so a bare '+'
|
||||
// diff line (a real change) still passes through.
|
||||
|| line.starts_with("+++")
|
||||
// yarn classic step markers and berry's structural YN0000 info lines
|
||||
// (box-drawing, section headers). Actionable codes (YN0002 peer warnings,
|
||||
// YN0060 incompatibilities) carry other YNxxxx codes and are kept.
|
||||
|| is_yarn_step_marker(line)
|
||||
|| lower.contains("yn0000")
|
||||
|| lower.starts_with("packages:")
|
||||
|| lower.starts_with("resolved ")
|
||||
|| lower.starts_with("reused ")
|
||||
@@ -377,8 +526,9 @@ fn is_js_package_noise(program: &str, line: &str, lower: &str) -> bool {
|
||||
|| lower.contains("up to date")
|
||||
}
|
||||
|
||||
fn is_python_package_noise(program: &str, _line: &str, lower: &str) -> bool {
|
||||
if !matches!(program, "pip" | "uv" | "poetry") {
|
||||
fn is_python_package_noise(ctx: &MinimizerCtx<'_>, _line: &str, lower: &str) -> bool {
|
||||
let program = ctx.program;
|
||||
if !matches!(program, "pip" | "pip3" | "uv" | "poetry") {
|
||||
return false;
|
||||
}
|
||||
lower.starts_with("collecting ")
|
||||
@@ -389,10 +539,44 @@ fn is_python_package_noise(program: &str, _line: &str, lower: &str) -> bool {
|
||||
|| lower.starts_with("resolving dependencies")
|
||||
|| lower.starts_with("writing lock file")
|
||||
|| lower.starts_with("package operations:")
|
||||
|| program == "uv" && is_uv_progress_noise(lower)
|
||||
// pip prints an upgrade nag as '[notice] A new release of pip is
|
||||
// available' plus a '[notice] To update, run: …' follow-up — non-actionable
|
||||
// for the wrapped command. Scoped to pip/uv/poetry by the gate above.
|
||||
|| lower.starts_with("[notice]")
|
||||
|| program == "uv" && is_uv_progress_noise(lower, uv_keeps_install_summary(ctx))
|
||||
|| program == "poetry" && is_poetry_bullet_progress(lower)
|
||||
}
|
||||
|
||||
fn is_uv_progress_noise(lower: &str) -> bool {
|
||||
/// poetry prefixes per-package progress with a `- ` or `• ` bullet, e.g.
|
||||
/// ` - Downloading requests-2.31.0…` / ` • Installing certifi (2023.11.17)`,
|
||||
/// plus virtualenv-setup chatter. These are the `strip_lines` that previously
|
||||
/// lived in defs/poetry-install.toml; the bullet prefix means the bare
|
||||
/// `downloading `/`installing ` checks above never reached them. `lower` is the
|
||||
/// already-trimmed, lowercased line.
|
||||
fn is_poetry_bullet_progress(lower: &str) -> bool {
|
||||
let bullet = lower
|
||||
.strip_prefix("- ")
|
||||
.or_else(|| lower.strip_prefix("• "));
|
||||
if let Some(rest) = bullet
|
||||
&& (rest.starts_with("downloading ") || rest.starts_with("installing ") && rest.contains('('))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
lower.starts_with("creating virtualenv") || lower.starts_with("using virtualenv")
|
||||
}
|
||||
|
||||
/// uv sync/add/remove keep the one-line 'Installed/Uninstalled N packages in
|
||||
/// Xms' summary alongside the +/- delta rows (the count is the install signal).
|
||||
/// uv lock/tree and every other subcommand still strip those rows as progress.
|
||||
fn uv_keeps_install_summary(ctx: &MinimizerCtx<'_>) -> bool {
|
||||
ctx.program == "uv" && matches!(ctx.subcommand, Some("sync" | "add" | "remove"))
|
||||
}
|
||||
|
||||
fn is_uv_progress_noise(lower: &str, keep_install_summary: bool) -> bool {
|
||||
if keep_install_summary && (lower.starts_with("installed ") || lower.starts_with("uninstalled "))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
lower.starts_with("resolved ")
|
||||
|| lower.starts_with("prepared ")
|
||||
|| lower.starts_with("installed ")
|
||||
@@ -406,9 +590,23 @@ fn is_ruby_php_brew_noise(program: &str, _line: &str, lower: &str) -> bool {
|
||||
if !matches!(program, "bundle" | "brew" | "composer") {
|
||||
return false;
|
||||
}
|
||||
if program == "bundle" {
|
||||
// Keep 'Bundle complete! … N gems now installed' / 'Bundle updated!' — the
|
||||
// one-line gem-count signal (replaces the defs/bundle-install.toml
|
||||
// short-circuit). Strip the 'Use `bundle info [gemname]`…' follow-up hint.
|
||||
// Using/Fetching/Installing rows are still per-gem progress noise.
|
||||
if lower.starts_with("bundle complete") || lower.starts_with("bundle updated") {
|
||||
return false;
|
||||
}
|
||||
if lower.starts_with("use `bundle info") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
lower.starts_with("fetching ")
|
||||
|| lower.starts_with("installing ") && !lower.contains("error")
|
||||
|| lower.starts_with("using ")
|
||||
// brew/composer never emit 'Bundle complete'; this strip is preserved for
|
||||
// them but bundle now keeps the line (handled above).
|
||||
|| lower.starts_with("bundle complete")
|
||||
|| lower.starts_with("==> downloading")
|
||||
|| lower.starts_with("==> pouring")
|
||||
@@ -422,11 +620,13 @@ fn contains_audit_or_security_summary(input: &str) -> bool {
|
||||
|
||||
fn is_audit_or_security_summary(line: &str) -> bool {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
// 'funding' deliberately excluded: funding nags are stripped as noise and
|
||||
// must NOT bypass head_tail_cap. Real audit findings ('audit'/'vulnerab'/
|
||||
// 'security') still trip the bypass.
|
||||
lower.contains("audit")
|
||||
|| lower.contains("audited")
|
||||
|| lower.contains("vulnerab")
|
||||
|| lower.contains("security")
|
||||
|| lower.contains("funding")
|
||||
}
|
||||
|
||||
fn is_error_or_summary(line: &str) -> bool {
|
||||
@@ -460,14 +660,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_success_noise_audited_and_zero_vulnerabilities() {
|
||||
fn keeps_one_success_summary_strips_funding_and_zero_vulnerabilities() {
|
||||
// snip keeps the install summary (the count confirms node_modules state)
|
||||
// while dropping progress, funding nags, and 'found 0 vulnerabilities'.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let ctx = ctx("npm", Some("install"), "npm install", &cfg);
|
||||
let input = "Resolving: total 10\nadded 3 packages, and audited 4 packages in 1s\n2 \
|
||||
packages are looking for funding\nfound 0 vulnerabilities\n";
|
||||
packages are looking for funding\n run `npm fund` for details\nfound 0 \
|
||||
vulnerabilities\n";
|
||||
let out = strip_package_noise(&ctx, input, 0);
|
||||
assert!(!out.contains("Resolving:"));
|
||||
assert!(!out.contains("audited 4 packages"));
|
||||
assert!(out.contains("added 3 packages, and audited 4 packages in 1s"));
|
||||
assert!(!out.contains("looking for funding"));
|
||||
assert!(!out.contains("npm fund"));
|
||||
assert!(!out.contains("found 0 vulnerabilities"));
|
||||
}
|
||||
|
||||
@@ -504,6 +709,67 @@ mod tests {
|
||||
assert!(out.contains("error: failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_ci_keeps_one_added_summary_and_strips_funding() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("npm", Some("ci"), "npm ci", &cfg);
|
||||
let input = "npm warn deprecated glob@7.2.3: no longer supported\nadded 245 packages, and \
|
||||
audited 246 packages in 12s\n\n29 packages are looking for funding\n run `npm \
|
||||
fund` for details\n\nfound 0 vulnerabilities\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(
|
||||
out.text
|
||||
.contains("added 245 packages, and audited 246 packages in 12s")
|
||||
);
|
||||
assert_eq!(out.text.matches("added 245 packages").count(), 1);
|
||||
assert!(!out.text.contains("looking for funding"));
|
||||
assert!(!out.text.contains("npm fund"));
|
||||
assert!(!out.text.contains("found 0 vulnerabilities"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_audit_surfaces_real_vulnerabilities() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("npm", Some("install"), "npm install", &cfg);
|
||||
let input = "added 10 packages, and audited 11 packages in 2s\n3 packages are looking for \
|
||||
funding\n run `npm fund` for details\n\n2 vulnerabilities (1 moderate, 1 \
|
||||
high)\n\nTo address all issues, run:\n npm audit fix\n\nfound 2 \
|
||||
vulnerabilities\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("2 vulnerabilities (1 moderate, 1 high)"));
|
||||
assert!(out.text.contains("found 2 vulnerabilities"));
|
||||
assert!(out.text.contains("npm audit fix"));
|
||||
assert!(!out.text.contains("looking for funding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pnpm_install_strips_plus_bars_keeps_packages_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("pnpm", Some("install"), "pnpm install", &cfg);
|
||||
let input = "Progress: resolved 120, reused 120, downloaded 0, added \
|
||||
0\n+++++++++++++++++\nPackages: +183\n+ left-pad 1.3.0\nDone in 4.2s\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(!out.text.contains("+++"));
|
||||
assert!(out.text.contains("Packages: +183"));
|
||||
// A bare '+' diff line (a real change) survives the 3+ plus anchor.
|
||||
assert!(out.text.contains("+ left-pad 1.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yarn_berry_keeps_actionable_codes_strips_yn0000() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("yarn", Some("install"), "yarn install", &cfg);
|
||||
let input = "➤ YN0000: ┌ Resolution step\n➤ YN0002: │ react is listed by your project but \
|
||||
missing peer dependency\n➤ YN0060: │ incompatible peer dependency\n➤ YN0000: └ \
|
||||
Completed\n[2/4] Fetching packages...\nDone in 5.31s\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("YN0002"));
|
||||
assert!(out.text.contains("YN0060"));
|
||||
assert!(!out.text.contains("YN0000"));
|
||||
assert!(!out.text.contains("[2/4] Fetching"));
|
||||
assert!(out.text.contains("Done in 5.31s"));
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
program: &'a str,
|
||||
subcommand: Option<&'a str>,
|
||||
@@ -519,7 +785,7 @@ mod tests {
|
||||
let context = ctx("npm", Some("list"), "npm list --all", &cfg);
|
||||
let mut input = String::from("app@1.0.0\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("├── dep{idx:03}@1.0.0\n"));
|
||||
let _ = writeln!(input, "├── dep{idx:03}@1.0.0");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -536,7 +802,7 @@ mod tests {
|
||||
let context = ctx("npm", Some("ls"), "npm ls --depth=0", &cfg);
|
||||
let mut input = String::from("app@1.0.0\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("├── dep{idx:03}@1.0.0\n"));
|
||||
let _ = writeln!(input, "├── dep{idx:03}@1.0.0");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -552,7 +818,7 @@ mod tests {
|
||||
let mut input =
|
||||
String::from("Legend: production dependency, optional only, dev only\nreact 19.0.0\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("└─ dependent{idx:03}\n"));
|
||||
let _ = writeln!(input, "└─ dependent{idx:03}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -612,7 +878,7 @@ mod tests {
|
||||
"Resolved 91 packages in 12ms\nPrepared 2 packages in 3ms\nPackage Version\n",
|
||||
);
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("pkg{idx:03} 1.0.{idx}\n"));
|
||||
let _ = writeln!(input, "pkg{idx:03} 1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -632,7 +898,7 @@ mod tests {
|
||||
let context = ctx("uv", Some("tree"), "uv tree", &cfg);
|
||||
let mut input = String::from("project v1.0.0\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("├── pkg{idx:03} v1.0.{idx}\n"));
|
||||
let _ = writeln!(input, "├── pkg{idx:03} v1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -648,7 +914,7 @@ mod tests {
|
||||
let context = ctx("poetry", Some("show"), "poetry show --tree", &cfg);
|
||||
let mut input = String::from("requests 2.32.0 Python HTTP for Humans.\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("├── dep{idx:03} 1.0.{idx}\n"));
|
||||
let _ = writeln!(input, "├── dep{idx:03} 1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -663,7 +929,7 @@ mod tests {
|
||||
let context = ctx("uv", Some("pip"), "uv pip freeze", &cfg);
|
||||
let mut input = String::new();
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("pkg{idx:03}==1.0.{idx}\n"));
|
||||
let _ = writeln!(input, "pkg{idx:03}==1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -679,7 +945,7 @@ mod tests {
|
||||
let context = ctx("uv", Some("export"), "uv export -f requirements-txt", &cfg);
|
||||
let mut input = String::from("# generated by uv\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("pkg{idx:03}==1.0.{idx}\n"));
|
||||
let _ = writeln!(input, "pkg{idx:03}==1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -694,7 +960,7 @@ mod tests {
|
||||
let context = ctx("poetry", Some("export"), "poetry export -f requirements.txt", &cfg);
|
||||
let mut input = String::from("# generated by poetry\n");
|
||||
for idx in 0..90 {
|
||||
input.push_str(&format!("dep{idx:03}==2.0.{idx}\n"));
|
||||
let _ = writeln!(input, "dep{idx:03}==2.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
@@ -703,6 +969,36 @@ mod tests {
|
||||
assert!(out.text.contains("… 11 package entries omitted …"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pip_install_strips_upgrade_notice_nag() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("pip", Some("install"), "pip install requests", &cfg);
|
||||
let input = "Collecting requests\n Downloading requests-2.31.0-py3-none-any.whl (62 \
|
||||
kB)\nInstalling collected packages: requests\nSuccessfully installed \
|
||||
requests-2.31.0\n[notice] A new release of pip is available: 23.0 -> \
|
||||
24.0\n[notice] To update, run: pip install --upgrade pip\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("Successfully installed requests-2.31.0"));
|
||||
assert!(!out.text.contains("[notice]"));
|
||||
assert!(!out.text.contains("new release of pip"));
|
||||
assert!(!out.text.contains("Downloading requests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_pip_list_output() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("pip", Some("list"), "pip list", &cfg);
|
||||
let mut input = String::from("Package Version\n---------- -------\n");
|
||||
for idx in 0..90 {
|
||||
let _ = writeln!(input, "pkg{idx:03} 1.0.{idx}");
|
||||
}
|
||||
|
||||
let out = filter(&context, &input, 0);
|
||||
assert!(out.text.starts_with("package tree/list: 92 entries\n"));
|
||||
assert!(out.text.contains("pkg000"));
|
||||
assert!(out.text.contains("… 12 package entries omitted …"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_uv_lock_output() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
@@ -725,4 +1021,195 @@ mod tests {
|
||||
assert!(out.text.contains("Installing dependencies from lock file"));
|
||||
assert!(out.text.contains("Writing lock file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_install_no_changes_short_circuits_to_up_to_date() {
|
||||
// Carried over from defs/poetry-install.toml's match_output overlay; now
|
||||
// lives in pkg.rs so it fires regardless of overlay ordering. poetry 1.x
|
||||
// prints 'No dependencies to install or update'.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("poetry", Some("install"), "poetry install", &cfg);
|
||||
let input =
|
||||
"Installing dependencies from lock file\n\nNo dependencies to install or update\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_eq!(out.text, "ok (up to date)");
|
||||
assert!(out.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_update_no_changes_bullet_short_circuits() {
|
||||
// poetry 2.x prints 'No changes.' after bullet rows; still a no-op.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("poetry", Some("update"), "poetry update", &cfg);
|
||||
let input =
|
||||
"• Installing requests (2.31.0)\n• Installing certifi (2023.11.17)\n\nNo changes.\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_eq!(out.text, "ok (up to date)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_lock_no_changes_short_circuits() {
|
||||
// The short-circuit covers lock too (the deleted def matched
|
||||
// install|lock|update).
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("poetry", Some("lock"), "poetry lock", &cfg);
|
||||
let input = "Resolving dependencies...\nNo changes.\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_eq!(out.text, "ok (up to date)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_install_with_real_work_is_not_short_circuited() {
|
||||
// A genuine install (no 'No changes'/'No dependencies…' no-op marker) must
|
||||
// NOT collapse to 'ok (up to date)'. An actionable warning survives the
|
||||
// progress strip and proves the short-circuit did not fire.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("poetry", Some("install"), "poetry install", &cfg);
|
||||
let input = "Installing dependencies from lock file\n\n - Downloading \
|
||||
requests-2.31.0-py3-none-any.whl (62.6 kB)\nWarning: the lock file is not up \
|
||||
to date\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_ne!(out.text, "ok (up to date)");
|
||||
assert!(
|
||||
out.text
|
||||
.contains("Warning: the lock file is not up to date")
|
||||
);
|
||||
assert!(!out.text.contains("Downloading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_audited_no_op_short_circuits_to_up_to_date() {
|
||||
// Replaces defs/uv-sync.toml's 'Audited' overlay; lands as 'ok (up to
|
||||
// date)' (not bare 'OK'). Scoped to uv sync/add/remove.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("sync"), "uv sync", &cfg);
|
||||
let input = "Resolved 42 packages in 123ms\nAudited 42 packages in 0.05ms\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_eq!(out.text, "ok (up to date)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_audited_no_op_with_warning_keeps_warning() {
|
||||
// Regression: the 'Audited' no-op short-circuit must NOT swallow a
|
||||
// co-printed actionable diagnostic. uv on exit 0 can print
|
||||
// 'warning: VIRTUAL_ENV=… does not match the project environment path …'
|
||||
// before the Resolved/Audited summary; collapsing to 'ok (up to date)'
|
||||
// would hide that the sync may have targeted the wrong environment. On HEAD
|
||||
// the global is_noise_line stripped 'Audited' but the surviving warning
|
||||
// kept the output, so it was reported — preserve that.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("sync"), "uv sync", &cfg);
|
||||
let input = "warning: VIRTUAL_ENV=.venv does not match the project environment path \
|
||||
.venv-other\nResolved 42 packages in 123ms\nAudited 42 packages in 0.05ms\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert_ne!(out.text, "ok (up to date)");
|
||||
assert!(
|
||||
out.text
|
||||
.contains("warning: VIRTUAL_ENV=.venv does not match the project environment path")
|
||||
);
|
||||
assert!(!out.text.contains("Audited 42 packages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_keeps_installed_summary_and_delta_rows() {
|
||||
// uv sync/add/remove keep the one-line 'Installed N packages' summary
|
||||
// alongside the +/- delta rows; Resolved/Prepared progress is stripped.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("sync"), "uv sync", &cfg);
|
||||
let input = "Resolved 5 packages in 12ms\nPrepared 5 packages in 100ms\nInstalled 5 \
|
||||
packages in 23ms\n + certifi==2023.11.17\n + idna==3.6\n + requests==2.31.0\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("Installed 5 packages in 23ms"));
|
||||
assert!(out.text.contains("+ certifi==2023.11.17"));
|
||||
assert!(out.text.contains("+ requests==2.31.0"));
|
||||
assert!(!out.text.contains("Resolved 5 packages"));
|
||||
assert!(!out.text.contains("Prepared 5 packages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_add_keeps_installed_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("add"), "uv add requests", &cfg);
|
||||
let input = "Resolved 5 packages in 9ms\nInstalled 1 package in 4ms\n + requests==2.31.0\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("Installed 1 package in 4ms"));
|
||||
assert!(out.text.contains("+ requests==2.31.0"));
|
||||
assert!(!out.text.contains("Resolved 5 packages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_remove_keeps_uninstalled_summary() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("uv", Some("remove"), "uv remove requests", &cfg);
|
||||
let input = "Resolved 4 packages in 6ms\nUninstalled 1 package in 2ms\n - requests==2.31.0\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("Uninstalled 1 package in 2ms"));
|
||||
assert!(out.text.contains("- requests==2.31.0"));
|
||||
assert!(!out.text.contains("Resolved 4 packages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_install_keeps_complete_line_strips_info_hint() {
|
||||
// Replaces defs/bundle-install.toml; keep the 'Bundle complete!' gem-count
|
||||
// signal, strip Using/Fetching/Installing rows and the 'Use `bundle info`'
|
||||
// hint. Scoped to program=bundle.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("install"), "bundle install", &cfg);
|
||||
let input = "Fetching gem metadata from https://rubygems.org/.........\nResolving \
|
||||
dependencies...\nUsing rake 13.1.0\nFetching rspec 3.13.0\nInstalling rspec \
|
||||
3.13.0\nBundle complete! 85 Gemfile dependencies, 200 gems now installed.\nUse \
|
||||
`bundle info [gemname]` to see where a bundled gem is installed.\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(
|
||||
out.text
|
||||
.contains("Bundle complete! 85 Gemfile dependencies, 200 gems now installed.")
|
||||
);
|
||||
assert!(!out.text.contains("Use `bundle info"));
|
||||
assert!(!out.text.contains("Using rake"));
|
||||
assert!(!out.text.contains("Installing rspec"));
|
||||
assert!(!out.text.contains("Fetching"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_update_keeps_updated_line() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("bundle", Some("update"), "bundle update", &cfg);
|
||||
let input = "Fetching gem metadata from https://rubygems.org/.........\nResolving \
|
||||
dependencies...\nUsing rake 13.1.0\nFetching rspec 3.14.0 (was \
|
||||
3.13.0)\nInstalling rspec 3.14.0 (was 3.13.0)\nBundle updated!\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(out.text.contains("Bundle updated!"));
|
||||
assert!(!out.text.contains("Using rake"));
|
||||
assert!(!out.text.contains("Installing rspec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brew_install_still_strips_pour_and_download_noise() {
|
||||
// Concern 4 scoping guard: brew is unaffected by the bundle-only keep.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = ctx("brew", Some("install"), "brew install jq", &cfg);
|
||||
let input =
|
||||
"==> Downloading https://example.com/jq.tar.gz\n==> Pouring jq--1.7.bottle.tar.gz\n";
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(!out.text.contains("==> Downloading"));
|
||||
assert!(!out.text.contains("==> Pouring"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pip_list_format_json_passthrough() {
|
||||
let input = r#"[{"name":"pip","version":"23.0"},{"name":"requests","version":"2.28.0"}]"#;
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
|
||||
// --format=json form: output must be byte-identical to input (no JSON rewrite)
|
||||
let context = ctx("pip", Some("list"), "pip list --format=json", &cfg);
|
||||
let out = filter(&context, input, 0);
|
||||
assert!(!out.changed, "pip list --format=json must not be modified");
|
||||
assert_eq!(out.text, input, "pip list --format=json must pass through as JSON");
|
||||
|
||||
// --format json form (two separate tokens): same contract
|
||||
let context2 = ctx("pip", Some("list"), "pip list --format json", &cfg);
|
||||
let out2 = filter(&context2, input, 0);
|
||||
assert!(!out2.changed, "pip list --format json must not be modified");
|
||||
assert_eq!(out2.text, input, "pip list --format json must pass through as JSON");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
use super::lint;
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
/// Cap on rendered verbose failure blocks (the `___ test ___` traceback
|
||||
/// sections). Mirrors RTK's `MAX_PYTEST_FAILURES` (== `CAP_WARNINGS` == 10),
|
||||
/// re-derived for the minimizer's streaming, line-based renderer: once this
|
||||
/// many traceback blocks have been emitted, further blocks are suppressed and
|
||||
/// a single `+N more failures` overflow marker stands in for the remainder.
|
||||
/// The compact `FAILED …` short-summary one-liners are NOT capped here — they
|
||||
/// name every failed test cheaply and stay intact.
|
||||
///
|
||||
/// The `=== ERRORS ===` section (collection / fixture-setup errors) is capped
|
||||
/// by a SEPARATE counter (see `filter_pytest`): pytest renders ERRORS *before*
|
||||
/// FAILURES, and those banners (`___ ERROR collecting … ___`) contain the
|
||||
/// substring `test` via their path, so a single shared counter would let a
|
||||
/// burst of low-value collection banners exhaust the budget and evict the
|
||||
/// real assertion tracebacks that follow. Two counters keep each section's
|
||||
/// overflow independent.
|
||||
const MAX_PYTEST_FAILURES: usize = 10;
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "pytest" | "ruff" | "mypy")
|
||||
|| matches!(
|
||||
@@ -24,6 +42,7 @@ pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
// Kill-switch parity (M2): when `legacy_filters_active`, fall back to
|
||||
// the pre-PR passthrough so callers can rollback an RTK-port regression
|
||||
@@ -66,23 +85,70 @@ fn filter_pytest(input: &str, exit_code: i32) -> String {
|
||||
|
||||
let mut out = String::new();
|
||||
let mut in_failure = false;
|
||||
// Verbose traceback blocks seen so far, counted SEPARATELY per pytest
|
||||
// section. `=== ERRORS ===` (collection / fixture-setup) banners render
|
||||
// before `=== FAILURES ===`, so a shared counter would let collection
|
||||
// noise exhaust the budget and evict real assertion tracebacks; two
|
||||
// counters give each section an independent cap + overflow marker.
|
||||
let mut failure_blocks = 0usize;
|
||||
let mut error_blocks = 0usize;
|
||||
let mut suppressing = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if is_pytest_summary_header(trimmed) {
|
||||
in_failure = false;
|
||||
suppressing = false;
|
||||
push_line(&mut out, line);
|
||||
continue;
|
||||
}
|
||||
if is_pytest_summary_line(trimmed) {
|
||||
in_failure = false;
|
||||
suppressing = false;
|
||||
push_pytest_summary_line(&mut out, trimmed);
|
||||
continue;
|
||||
}
|
||||
// XFAIL/XPASS short-summary report lines (emitted under `-r` flags)
|
||||
// must survive verbatim: XPASS in particular signals a behavior change
|
||||
// (something expected-to-fail now passes). Handled before the
|
||||
// in_failure block so the verbose-pass-noise filter can't eat them.
|
||||
if trimmed.starts_with("XFAIL ") || trimmed.starts_with("XPASS ") {
|
||||
in_failure = false;
|
||||
suppressing = false;
|
||||
push_line(&mut out, line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// `=== ERRORS ===` banners (`___ ERROR collecting … ___`,
|
||||
// `___ ERROR at setup of … ___`) are checked FIRST and counted under
|
||||
// their own cap. They precede the FAILURES section, so counting them
|
||||
// here keeps the failure budget reserved for real assertion blocks.
|
||||
let is_error_banner = is_pytest_error_banner(trimmed);
|
||||
if is_error_banner || is_pytest_failure_header(trimmed) {
|
||||
in_failure = true;
|
||||
// The overflow count for each kind is rendered once after the loop.
|
||||
if is_error_banner {
|
||||
error_blocks += 1;
|
||||
suppressing = error_blocks > MAX_PYTEST_FAILURES;
|
||||
} else {
|
||||
failure_blocks += 1;
|
||||
suppressing = failure_blocks > MAX_PYTEST_FAILURES;
|
||||
}
|
||||
if !suppressing {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if starts_pytest_failure(trimmed) {
|
||||
in_failure = true;
|
||||
push_line(&mut out, line);
|
||||
// While a capped traceback block is being suppressed, its `E ` /
|
||||
// `ERROR at ` continuation lines belong to that block and are
|
||||
// dropped too. (`FAILED ` short-summary lines only appear after the
|
||||
// summary header has cleared `suppressing`, so they stay.)
|
||||
if !suppressing {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -91,7 +157,7 @@ fn filter_pytest(input: &str, exit_code: i32) -> String {
|
||||
in_failure = false;
|
||||
continue;
|
||||
}
|
||||
if !is_pytest_pass_noise(trimmed) {
|
||||
if !suppressing && !is_pytest_pass_noise(trimmed) {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
continue;
|
||||
@@ -102,6 +168,21 @@ fn filter_pytest(input: &str, exit_code: i32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Errors render before failures in pytest; mirror that order for the
|
||||
// overflow markers so the compact output reads top-to-bottom.
|
||||
let error_overflow = error_blocks.saturating_sub(MAX_PYTEST_FAILURES);
|
||||
if error_overflow > 0 {
|
||||
out.push_str("… +");
|
||||
out.push_str(&error_overflow.to_string());
|
||||
out.push_str(" more errors\n");
|
||||
}
|
||||
let overflow = failure_blocks.saturating_sub(MAX_PYTEST_FAILURES);
|
||||
if overflow > 0 {
|
||||
out.push_str("… +");
|
||||
out.push_str(&overflow.to_string());
|
||||
out.push_str(" more failures\n");
|
||||
}
|
||||
|
||||
if has_content(&out) {
|
||||
out
|
||||
} else {
|
||||
@@ -141,12 +222,35 @@ fn pytest_success(input: &str) -> String {
|
||||
}
|
||||
|
||||
fn starts_pytest_failure(trimmed: &str) -> bool {
|
||||
(trimmed.starts_with('_') && trimmed.ends_with('_') && trimmed.contains("test"))
|
||||
is_pytest_failure_header(trimmed)
|
||||
|| trimmed.starts_with("E ")
|
||||
|| trimmed.starts_with("ERROR at ")
|
||||
|| trimmed.starts_with("FAILED ")
|
||||
}
|
||||
|
||||
/// A `___ test ___`-style banner that opens one verbose traceback block in the
|
||||
/// `=== FAILURES ===` section. These are the units capped by
|
||||
/// `MAX_PYTEST_FAILURES`; the `E `/`FAILED `/`ERROR at ` forms are
|
||||
/// continuation or short-summary lines, not block boundaries.
|
||||
fn is_pytest_failure_header(trimmed: &str) -> bool {
|
||||
trimmed.starts_with('_') && trimmed.ends_with('_') && trimmed.contains("test")
|
||||
}
|
||||
|
||||
/// A `___ ERROR … ___` banner that opens an `=== ERRORS ===`-section block:
|
||||
/// `___ ERROR collecting <path> ___` (import/collection failure) and
|
||||
/// `___ ERROR at setup of <test> ___` / `___ ERROR at teardown of <test> ___`
|
||||
/// (fixture errors). These render BEFORE the FAILURES section and their inner
|
||||
/// text contains the substring `test` via the path/test name, so without this
|
||||
/// distinction they would count against `MAX_PYTEST_FAILURES` and crowd out
|
||||
/// the real assertion tracebacks. They are capped by their own counter
|
||||
/// instead.
|
||||
fn is_pytest_error_banner(trimmed: &str) -> bool {
|
||||
if !(trimmed.starts_with('_') && trimmed.ends_with('_')) {
|
||||
return false;
|
||||
}
|
||||
trimmed.trim_matches('_').trim().starts_with("ERROR ")
|
||||
}
|
||||
|
||||
fn is_pytest_summary_header(trimmed: &str) -> bool {
|
||||
trimmed.contains("short test summary info") || trimmed.contains("warnings summary")
|
||||
}
|
||||
@@ -227,6 +331,14 @@ fn is_pytest_verbose_pass_line(trimmed: &str) -> bool {
|
||||
if !trimmed.contains("::") {
|
||||
return false;
|
||||
}
|
||||
// `XFAIL …`/`XPASS …` short-summary report lines lead with the status
|
||||
// token (e.g. `XFAIL test.py::case - reason`) and must NOT be treated as
|
||||
// pass-noise — XPASS signals a behavior change. Verbose per-test rows carry
|
||||
// the status token in a trailing column (`test.py::case XFAIL [100%]`), so
|
||||
// keying on the leading token cleanly separates the two.
|
||||
if trimmed.starts_with("XFAIL ") || trimmed.starts_with("XPASS ") {
|
||||
return false;
|
||||
}
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
parts.any(|part| matches!(part, "PASSED" | "SKIPPED" | "XPASS" | "XFAIL"))
|
||||
}
|
||||
@@ -299,6 +411,8 @@ fn has_content(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::*;
|
||||
use crate::minimizer::MinimizerConfig;
|
||||
|
||||
@@ -427,4 +541,177 @@ mod tests {
|
||||
assert!(out.text.contains("3 files left unchanged"));
|
||||
assert!(!out.text.contains("diagnostics"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_caps_failure_blocks_with_overflow_marker() {
|
||||
// Default (non -q) failing output with 12 verbose traceback blocks.
|
||||
let mut input = String::from(
|
||||
"============================= test session starts \
|
||||
=============================\ncollected 12 items\n\ntests/test_many.py \
|
||||
FFFFFFFFFFFF [100%]\n\n\
|
||||
=================================== FAILURES \
|
||||
===================================\n",
|
||||
);
|
||||
for i in 0..12 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"____________________________ test_case_{i} ____________________________\n\n def \
|
||||
test_case_{i}():\n> assert False\nE assert \
|
||||
False\n\ntests/test_many.py:{}: AssertionError\n",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
input.push_str(
|
||||
"=========================== short test summary info ===========================\n",
|
||||
);
|
||||
for i in 0..12 {
|
||||
let _ = writeln!(input, "FAILED tests/test_many.py::test_case_{i} - assert False");
|
||||
}
|
||||
input.push_str("========================= 12 failed in 0.30s =========================\n");
|
||||
|
||||
let out = filter_pytest(&input, 1);
|
||||
|
||||
// First 10 traceback blocks render; blocks 11 and 12 are suppressed.
|
||||
assert!(out.contains("test_case_0"), "got: {out}");
|
||||
assert!(out.contains("test_case_9"), "got: {out}");
|
||||
// The overflow marker accounts for the remaining 2 verbose blocks.
|
||||
assert!(out.contains("… +2 more failures"), "got: {out}");
|
||||
// The traceback bodies for the capped blocks are gone, but the compact
|
||||
// short-summary one-liners for every test survive uncapped.
|
||||
let assert_false_lines = out.matches("E assert False").count();
|
||||
assert_eq!(assert_false_lines, 10, "capped traceback bodies: {out}");
|
||||
assert!(
|
||||
out.contains("FAILED tests/test_many.py::test_case_11"),
|
||||
"short-summary one-liners must stay uncapped: {out}"
|
||||
);
|
||||
assert!(out.contains("pytest: 12 failed in 0.30s"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_xfail_xpass_summary_lines_survive() {
|
||||
// `-rxX` surfaces XFAIL/XPASS entries in the short summary; XPASS is a
|
||||
// behavior-change signal and must never be stripped as pass-noise.
|
||||
let input = "============================= test session starts \
|
||||
=============================\ncollected 3 items\n\ntests/test_x.py \
|
||||
.xX [100%]\n\n\
|
||||
=========================== short test summary info \
|
||||
===========================\nXFAIL tests/test_x.py::test_known_bug - \
|
||||
known issue #42\nXPASS tests/test_x.py::test_unexpected_pass - should \
|
||||
fail but passes\n========================= 1 passed, 1 xfailed, 1 xpassed \
|
||||
in 0.04s =========================\n";
|
||||
|
||||
let out = filter_pytest(input, 1);
|
||||
|
||||
assert!(out.contains("XFAIL tests/test_x.py::test_known_bug"), "got: {out}");
|
||||
assert!(out.contains("XPASS tests/test_x.py::test_unexpected_pass"), "got: {out}");
|
||||
assert!(out.contains("pytest: 1 passed, 1 xfailed, 1 xpassed"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_collection_errors_do_not_evict_real_failure_tracebacks() {
|
||||
// Constructed from real default pytest output (`--tb=short -q`,
|
||||
// collection errors coexisting with failures): the `=== ERRORS ===`
|
||||
// section renders 9 `___ ERROR collecting … ___` banners BEFORE the
|
||||
// `=== FAILURES ===` section's 5 real assertion blocks. Those banners
|
||||
// contain the substring `test` via their path, so a single shared cap
|
||||
// would let them consume 9 of 10 slots and suppress 4 of the 5 real
|
||||
// payment-bug tracebacks. The separate error cap must keep every real
|
||||
// assertion traceback intact.
|
||||
let mut input = String::from(
|
||||
"============================= test session starts \
|
||||
=============================\ncollected 5 items / 9 \
|
||||
errors\n\n==================================== ERRORS \
|
||||
====================================\n",
|
||||
);
|
||||
for i in 0..9 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"_____________________ ERROR collecting tests/test_imp_{i}.py \
|
||||
_____________________\nImportError while importing test module \
|
||||
'tests/test_imp_{i}.py'.\nE ImportError: boom\n"
|
||||
);
|
||||
}
|
||||
input.push_str(
|
||||
"=================================== FAILURES ===================================\n",
|
||||
);
|
||||
for i in 0..5 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"_______________________________ test_critical_{i} \
|
||||
________________________________\ntests/pay.py:{}: in test_critical_{i}\n assert \
|
||||
0 == 100\nE assert 0 == 100\ntests/pay.py:{}: AssertionError\n",
|
||||
i + 1,
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
input.push_str(
|
||||
"=========================== short test summary info ===========================\n",
|
||||
);
|
||||
for i in 0..5 {
|
||||
let _ = writeln!(input, "FAILED tests/pay.py::test_critical_{i} - assert 0 == 100");
|
||||
}
|
||||
input.push_str(
|
||||
"========================= 5 failed, 9 errors in 0.30s =========================\n",
|
||||
);
|
||||
|
||||
let out = filter_pytest(&input, 1);
|
||||
|
||||
// Every real assertion traceback survives — none evicted by the
|
||||
// collection-error banners that precede them.
|
||||
for i in 0..5 {
|
||||
assert!(out.contains(&format!("test_critical_{i}")), "lost failure {i}: {out}");
|
||||
}
|
||||
let assert_lines = out.matches("E assert 0 == 100").count();
|
||||
assert_eq!(assert_lines, 5, "all 5 assertion bodies must survive: {out}");
|
||||
// 5 < cap, so there is NO failure overflow stealing real tracebacks.
|
||||
assert!(!out.contains("more failures"), "no failures should be capped: {out}");
|
||||
// Collection errors are still surfaced (under their own cap of 10).
|
||||
assert!(out.contains("ERROR collecting tests/test_imp_0.py"), "got: {out}");
|
||||
assert!(!out.contains("more errors"), "9 errors < cap, no error overflow: {out}");
|
||||
assert!(out.contains("pytest: 5 failed, 9 errors"), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_caps_error_blocks_independently_of_failures() {
|
||||
// 12 collection-error banners (ERRORS section) exceed the cap and yield
|
||||
// their OWN `+N more errors` marker, while the 2 real failure tracebacks
|
||||
// in the FAILURES section are untouched (their counter is separate).
|
||||
let mut input = String::from(
|
||||
"============================= test session starts \
|
||||
=============================\ncollected 2 items / 12 \
|
||||
errors\n\n==================================== ERRORS \
|
||||
====================================\n",
|
||||
);
|
||||
for i in 0..12 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"_____________________ ERROR collecting tests/test_imp_{i}.py \
|
||||
_____________________\nE ImportError: boom\n"
|
||||
);
|
||||
}
|
||||
input.push_str(
|
||||
"=================================== FAILURES ===================================\n",
|
||||
);
|
||||
for i in 0..2 {
|
||||
let _ = write!(
|
||||
input,
|
||||
"_______________________________ test_real_{i} ________________________________\n \
|
||||
assert False\nE assert False\n"
|
||||
);
|
||||
}
|
||||
input.push_str(
|
||||
"========================= 2 failed, 12 errors in 0.10s =========================\n",
|
||||
);
|
||||
|
||||
let out = filter_pytest(&input, 1);
|
||||
|
||||
// Error overflow marker fires for the 2 capped collection banners…
|
||||
assert!(out.contains("… +2 more errors"), "got: {out}");
|
||||
// …but NOT a failure overflow — both real tracebacks render in full.
|
||||
assert!(!out.contains("more failures"), "failures must be uncapped here: {out}");
|
||||
assert!(out.contains("test_real_0"), "got: {out}");
|
||||
assert!(out.contains("test_real_1"), "got: {out}");
|
||||
let assert_false = out.matches("E assert False").count();
|
||||
assert_eq!(assert_false, 2, "both failure bodies survive: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,29 @@
|
||||
use super::lint;
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "rspec" | "rubocop")
|
||||
|| matches!((program, subcommand), ("rake" | "rails", Some("test")))
|
||||
match program {
|
||||
"rspec" | "rubocop" => true,
|
||||
"rake" | "rails" => !is_def_scoped_subcommand(subcommand),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcommands that have dedicated TOML defs and must run standalone instead
|
||||
/// of being claimed by the generic ruby filter (which would overlay them).
|
||||
fn is_def_scoped_subcommand(subcommand: Option<&str>) -> bool {
|
||||
matches!(subcommand, Some("db:migrate" | "db:rollback" | "routes"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let text = match ruby_tool(ctx.program, ctx.subcommand) {
|
||||
Some("rspec") => filter_rspec(&cleaned, exit_code),
|
||||
Some("minitest") => filter_minitest(&cleaned, exit_code),
|
||||
Some("rubocop") => lint::condense_lint_output("rubocop", &cleaned, exit_code),
|
||||
Some("rubocop") => filter_rubocop(&cleaned, exit_code),
|
||||
Some("rake") => filter_rake(&cleaned, exit_code),
|
||||
_ => cleaned,
|
||||
};
|
||||
|
||||
@@ -28,25 +40,54 @@ fn ruby_tool<'a>(program: &'a str, subcommand: Option<&'a str>) -> Option<&'a st
|
||||
match (program, subcommand) {
|
||||
("rspec", _) => Some("rspec"),
|
||||
("rubocop", _) => Some("rubocop"),
|
||||
("rake" | "rails", Some("test")) => Some("minitest"),
|
||||
("rake" | "rails", Some(sub)) if is_minitest_subcommand(sub) => Some("minitest"),
|
||||
("rake" | "rails", Some(sub)) if is_def_scoped_subcommand(Some(sub)) => None,
|
||||
("rake" | "rails", _) => Some("rake"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rake/rails subcommand that runs minitest: the bare `test` task, a scoped
|
||||
/// `test:<scope>` task (`test:models`, `test:system`, `test:all`), or a
|
||||
/// namespaced `<ns>:test` task (`app:test`).
|
||||
fn is_minitest_subcommand(sub: &str) -> bool {
|
||||
sub == "test" || sub.starts_with("test:") || sub.ends_with(":test")
|
||||
}
|
||||
|
||||
fn filter_rspec(input: &str, exit_code: i32) -> String {
|
||||
if let Some(text) = compact_rspec_json(input) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Strip rtk noise (Spring preloader, SimpleCov coverage block, DEPRECATION
|
||||
// warnings, `Finished in …` timing, Capybara screenshot detail) before both
|
||||
// the success and failure text paths. Ported from
|
||||
// rtk/src/cmds/ruby/rspec_cmd.rs::strip_noise, re-derived against the
|
||||
// minimizer's DEFAULT (non-JSON) output.
|
||||
let stripped = strip_rspec_noise(input);
|
||||
|
||||
if exit_code == 0 {
|
||||
return ruby_test_success(input);
|
||||
// snip behavior: on success collapse the doc-format tree to the
|
||||
// `N examples, 0 failures` summary (plus any pending lines), discarding
|
||||
// the per-example descriptions.
|
||||
return rspec_success_summary(&stripped);
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
// Each rendered failure block is buffered separately so the total can be
|
||||
// capped at MAX_RENDERED_FAILURES with a `+N more failures` marker. The
|
||||
// buffered blocks are rendered into `out` the moment the `Failures:` section
|
||||
// ends — at the summary line or the `Failed examples:` boundary — so real
|
||||
// rspec ordering (`Failures:` details, then summary, then `Failed examples:`)
|
||||
// keeps each detail block under its own `Failures:` header instead of being
|
||||
// appended after the `Failed examples:` list.
|
||||
let mut blocks: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut rendered_blocks = false;
|
||||
let mut in_failure = false;
|
||||
let mut in_failed_examples = false;
|
||||
|
||||
for line in input.lines() {
|
||||
for line in stripped.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "Failures:" {
|
||||
in_failure = true;
|
||||
@@ -55,12 +96,16 @@ fn filter_rspec(input: &str, exit_code: i32) -> String {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "Failed examples:" {
|
||||
flush_rspec_block(&mut blocks, &mut current);
|
||||
render_rspec_blocks(&mut out, &mut blocks, &mut rendered_blocks);
|
||||
in_failure = false;
|
||||
in_failed_examples = true;
|
||||
push_line(&mut out, line);
|
||||
continue;
|
||||
}
|
||||
if is_rspec_summary_line(trimmed) {
|
||||
flush_rspec_block(&mut blocks, &mut current);
|
||||
render_rspec_blocks(&mut out, &mut blocks, &mut rendered_blocks);
|
||||
in_failure = false;
|
||||
in_failed_examples = false;
|
||||
push_line(&mut out, line);
|
||||
@@ -70,13 +115,21 @@ fn filter_rspec(input: &str, exit_code: i32) -> String {
|
||||
if is_gem_backtrace(trimmed) || is_rspec_noise(trimmed) {
|
||||
continue;
|
||||
}
|
||||
push_line(&mut out, line);
|
||||
if is_numbered_failure(trimmed) {
|
||||
flush_rspec_block(&mut blocks, &mut current);
|
||||
}
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
continue;
|
||||
}
|
||||
if in_failed_examples && !trimmed.is_empty() {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
}
|
||||
// Any failure blocks not yet rendered (no summary / `Failed examples:`
|
||||
// boundary was seen) are flushed at the end.
|
||||
flush_rspec_block(&mut blocks, &mut current);
|
||||
render_rspec_blocks(&mut out, &mut blocks, &mut rendered_blocks);
|
||||
|
||||
if has_content(&out) {
|
||||
out
|
||||
@@ -85,6 +138,151 @@ fn filter_rspec(input: &str, exit_code: i32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the buffered `Failures:` detail blocks into `out`, capped at
|
||||
/// `MAX_RENDERED_FAILURES` with a `+N more failures` marker. Runs at most once
|
||||
/// — guarded by `rendered` — so the section is emitted exactly where the
|
||||
/// `Failures:` block ends (the summary line or `Failed examples:` boundary),
|
||||
/// preserving real rspec section ordering.
|
||||
fn render_rspec_blocks(out: &mut String, blocks: &mut Vec<String>, rendered: &mut bool) {
|
||||
if *rendered {
|
||||
blocks.clear();
|
||||
return;
|
||||
}
|
||||
let total = blocks.len();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
*rendered = true;
|
||||
for block in blocks.iter().take(MAX_RENDERED_FAILURES) {
|
||||
out.push_str(block);
|
||||
}
|
||||
if total > MAX_RENDERED_FAILURES {
|
||||
push_line(out, &format!("+{} more failures", total - MAX_RENDERED_FAILURES));
|
||||
}
|
||||
blocks.clear();
|
||||
}
|
||||
|
||||
/// Cap on rendered failure blocks in non-JSON rspec output. rtk uses the same
|
||||
/// limit (`MAX_RSPEC_FAILURES = 5`) — failure blocks carry full context, so a
|
||||
/// handful is enough before collapsing to a `+N more failures` marker.
|
||||
const MAX_RENDERED_FAILURES: usize = 5;
|
||||
|
||||
fn flush_rspec_block(blocks: &mut Vec<String>, current: &mut String) {
|
||||
if has_content(current) {
|
||||
blocks.push(std::mem::take(current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered failure header like `1) User validates name`.
|
||||
fn is_numbered_failure(trimmed: &str) -> bool {
|
||||
let Some(pos) = trimmed.find(')') else {
|
||||
return false;
|
||||
};
|
||||
let prefix = &trimmed[..pos];
|
||||
!prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Drop rtk-style rspec noise before the text paths. Mirrors
|
||||
/// `rtk/src/cmds/ruby/rspec_cmd.rs::strip_noise`: Spring preloader, `SimpleCov`
|
||||
/// coverage block (until the next blank line), `DEPRECATION WARNING:` lines,
|
||||
/// the `Finished in …` timing line, and Capybara screenshot detail (kept as a
|
||||
/// compact `[screenshot: path]`).
|
||||
fn strip_rspec_noise(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_simplecov = false;
|
||||
// The SimpleCov / coverage block only ever appears in the TRAILING region
|
||||
// (after the run completes), never inside a numbered `Failures:` block. The
|
||||
// donor's patterns (`simplecov`, `coverage/`, `.simplecov`) are unscoped
|
||||
// substring/prefix tests, so a failure whose description or assertion path
|
||||
// mentions SimpleCov coverage (e.g. `1) SimpleCov configuration loads`, or an
|
||||
// assertion about a `coverage/index.html` artifact) would falsely enter the
|
||||
// strip and swallow the entire failure block up to the next blank line. Gate
|
||||
// the strip to OUTSIDE the `Failures:` section so failure diagnostics survive.
|
||||
let mut in_failures = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
|
||||
if trimmed == "Failures:" {
|
||||
in_failures = true;
|
||||
} else if trimmed == "Failed examples:"
|
||||
|| is_rspec_summary_line(trimmed)
|
||||
|| trimmed.starts_with("Finished in ")
|
||||
{
|
||||
// rspec always prints the `Finished in …` timing line AFTER the failure
|
||||
// details and BEFORE the trailing coverage block, so it reliably closes
|
||||
// the `Failures:` region even when the summary line trails the coverage
|
||||
// block (SimpleCov prints between timing and summary).
|
||||
in_failures = false;
|
||||
}
|
||||
|
||||
if lower.contains("running via spring preloader") {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("DEPRECATION WARNING:") {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("Finished in ") {
|
||||
continue;
|
||||
}
|
||||
// Only strip the SimpleCov/coverage block when NOT inside a `Failures:`
|
||||
// section. The `coverage report` banner is anchored to its actual shape
|
||||
// (`Coverage report generated …`) rather than a bare `coverage/` prefix, so
|
||||
// a failure-detail line asserting on a `coverage/`-prefixed artifact path is
|
||||
// not mistaken for the banner.
|
||||
if !in_failures
|
||||
&& (is_coverage_banner(&lower)
|
||||
|| lower.contains("simplecov")
|
||||
|| lower.contains(".simplecov"))
|
||||
{
|
||||
in_simplecov = true;
|
||||
continue;
|
||||
}
|
||||
if in_simplecov {
|
||||
if trimmed.is_empty() {
|
||||
in_simplecov = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("saved screenshot to ") {
|
||||
push_line(&mut out, &format!("[screenshot: {}]", rest.trim()));
|
||||
continue;
|
||||
}
|
||||
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// The `SimpleCov` coverage-report banner that opens the trailing coverage
|
||||
/// block, e.g. `Coverage report generated for RSpec to /app/coverage`. Anchored
|
||||
/// to the banner shape so a failure-detail line merely mentioning a `coverage/`
|
||||
/// path is not misread as the banner.
|
||||
fn is_coverage_banner(lower: &str) -> bool {
|
||||
lower.starts_with("coverage report")
|
||||
}
|
||||
|
||||
/// On a passing rspec run, keep only the `N examples, …` summary line plus any
|
||||
/// pending lines; discard the doc-format example tree (snip behavior).
|
||||
fn rspec_success_summary(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if is_rspec_summary_line(trimmed) || trimmed.starts_with("Pending") {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
}
|
||||
if has_content(&out) {
|
||||
out
|
||||
} else {
|
||||
ruby_test_success(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_rspec_json(input: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(input).ok()?;
|
||||
let map = value.as_object()?;
|
||||
@@ -216,7 +414,7 @@ fn first_json_string<'a>(
|
||||
fn first_json_u64(map: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> Option<u64> {
|
||||
keys
|
||||
.iter()
|
||||
.find_map(|key| map.get(*key).and_then(|value| value.as_u64()))
|
||||
.find_map(|key| map.get(*key).and_then(serde_json::Value::as_u64))
|
||||
}
|
||||
|
||||
fn filter_minitest(input: &str, exit_code: i32) -> String {
|
||||
@@ -257,6 +455,143 @@ fn filter_minitest(input: &str, exit_code: i32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `RuboCop` output: keep lint's grouped offense rendering as primary, but
|
||||
/// compact the `-a`/`-A` autocorrect run-summary line
|
||||
/// (`N files inspected, M offenses detected, K offenses autocorrected`) — which
|
||||
/// the grouped output otherwise keeps verbatim and buried — to a single `ok`
|
||||
/// line. Ported from `rtk/src/cmds/ruby/rubocop_cmd.rs` autocorrect handling,
|
||||
/// re-derived against the minimizer's DEFAULT text output.
|
||||
fn filter_rubocop(input: &str, exit_code: i32) -> String {
|
||||
let condensed = lint::condense_lint_output("rubocop", input, exit_code);
|
||||
|
||||
let mut out = String::new();
|
||||
let mut replaced = false;
|
||||
for line in condensed.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !replaced
|
||||
&& trimmed.contains("inspected")
|
||||
&& trimmed.contains("autocorrected")
|
||||
&& let Some(compact) = compact_rubocop_autocorrect(trimmed)
|
||||
{
|
||||
push_line(&mut out, &compact);
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
|
||||
if replaced { out } else { condensed }
|
||||
}
|
||||
|
||||
/// Build `ok rubocop -A (N files, K autocorrected)` from an autocorrect summary
|
||||
/// line. Returns `None` when the counts cannot be parsed (keep the line as-is).
|
||||
fn compact_rubocop_autocorrect(line: &str) -> Option<String> {
|
||||
let files = leading_number(line)?;
|
||||
let corrected = line
|
||||
.split(',')
|
||||
.rev()
|
||||
.find(|part| part.contains("autocorrected"))
|
||||
.and_then(leading_number)?;
|
||||
Some(format!("ok rubocop -A ({files} files, {corrected} autocorrected)"))
|
||||
}
|
||||
|
||||
/// First whitespace-delimited token of `s` parsed as a count.
|
||||
fn leading_number(s: &str) -> Option<usize> {
|
||||
s.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Generic rake/rails task condenser for NON-test tasks. Keeps result/status
|
||||
/// lines (mirrors snip's rake keep-lines pattern) plus the HEAD of a
|
||||
/// `rake aborted!` traceback (the first few frames, which carry the real
|
||||
/// cause). `rake test` / `rails test` never reach here — they route to
|
||||
/// `filter_minitest`.
|
||||
fn filter_rake(input: &str, exit_code: i32) -> String {
|
||||
// On a FAILING non-test task that prints no `rake aborted!` header, the
|
||||
// keep-lines pass below would retain only individually keyword-matching lines
|
||||
// and silently drop the keyword-less diagnostic body (offending records,
|
||||
// `Expected positive integer, got -3`-style value detail) — exactly the
|
||||
// failure-detail the task warns must survive. The head/tail safety net only
|
||||
// fires when `out` is empty, which a single keyword line defeats. So when the
|
||||
// task failed and produced no `rake aborted!` traceback, preserve the full
|
||||
// diagnostic via head/tail rather than the lossy keep-lines pass.
|
||||
if exit_code != 0 && !input.lines().any(|line| line.trim() == "rake aborted!") {
|
||||
return primitives::head_tail_lines(input, 80, 80);
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let mut aborted_frames = 0usize;
|
||||
let mut in_aborted = false;
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == "rake aborted!" {
|
||||
in_aborted = true;
|
||||
aborted_frames = 0;
|
||||
push_line(&mut out, line);
|
||||
continue;
|
||||
}
|
||||
if in_aborted {
|
||||
// Keep the head of the traceback — the first few frames pinpoint the
|
||||
// cause — then drop the long tail.
|
||||
if aborted_frames < MAX_ABORTED_FRAMES {
|
||||
push_line(&mut out, line);
|
||||
aborted_frames += 1;
|
||||
continue;
|
||||
}
|
||||
// A blank line ends the traceback block; resume normal keep-lines.
|
||||
if trimmed.is_empty() {
|
||||
in_aborted = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_rake_keep_line(trimmed) {
|
||||
push_line(&mut out, line);
|
||||
}
|
||||
}
|
||||
|
||||
if has_content(&out) {
|
||||
out
|
||||
} else {
|
||||
primitives::head_tail_lines(input, 80, 80)
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of `rake aborted!` traceback frames kept (HEAD): enough to localize
|
||||
/// the failure without dumping the full Rake/Ruby internal stack.
|
||||
const MAX_ABORTED_FRAMES: usize = 5;
|
||||
|
||||
/// snip rake.yaml keep-lines pattern, re-derived: task result / status / error
|
||||
/// keywords worth surfacing from a non-test rake task.
|
||||
fn is_rake_keep_line(trimmed: &str) -> bool {
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// rake aborted! always surfaces
|
||||
if trimmed.contains("rake aborted") {
|
||||
return true;
|
||||
}
|
||||
// warning: lines from asset compilation, migrations, etc. are signal
|
||||
if trimmed.to_ascii_lowercase().starts_with("warning:") {
|
||||
return true;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
let words: Vec<&str> = lower.split_whitespace().collect();
|
||||
words.iter().any(|w| {
|
||||
matches!(
|
||||
*w,
|
||||
"passed"
|
||||
| "failed"
|
||||
| "error"
|
||||
| "fail" | "ok"
|
||||
| "finished"
|
||||
| "assertion"
|
||||
| "test" | "failure"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ruby_test_success(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut summary = String::new();
|
||||
@@ -293,7 +628,9 @@ fn is_rspec_summary_line(trimmed: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_minitest_summary_line(trimmed: &str) -> bool {
|
||||
trimmed.contains(" runs, ")
|
||||
// Minitest prints `N runs, …`; minitest-reporters prints `N tests, …`.
|
||||
// Accept either head form.
|
||||
(trimmed.contains(" runs, ") || trimmed.contains(" tests, "))
|
||||
&& trimmed.contains(" assertions, ")
|
||||
&& trimmed.contains(" failures, ")
|
||||
&& trimmed.contains(" errors")
|
||||
@@ -309,6 +646,9 @@ fn is_ruby_pass_noise(trimmed: &str) -> bool {
|
||||
|| trimmed.starts_with("Running:")
|
||||
|| trimmed.starts_with("Randomized with seed")
|
||||
|| trimmed.starts_with("Finished in ")
|
||||
// minitest-reporters banner / progress lines.
|
||||
|| trimmed.starts_with("Started with run options")
|
||||
|| trimmed.starts_with("Progress:")
|
||||
}
|
||||
|
||||
fn is_rspec_noise(trimmed: &str) -> bool {
|
||||
@@ -333,6 +673,8 @@ fn has_content(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::*;
|
||||
use crate::minimizer::MinimizerConfig;
|
||||
|
||||
@@ -342,7 +684,55 @@ mod tests {
|
||||
assert!(supports("rake", Some("test")));
|
||||
assert!(supports("rails", Some("test")));
|
||||
assert!(supports("rubocop", None));
|
||||
// rake/rails are claimed GENERICALLY except for def-scoped subcommands
|
||||
// (db:migrate, db:rollback, routes) which run standalone via TOML defs.
|
||||
assert!(!supports("rake", Some("db:migrate")));
|
||||
assert!(!supports("rails", Some("db:migrate")));
|
||||
assert!(!supports("rails", Some("db:rollback")));
|
||||
assert!(!supports("rails", Some("routes")));
|
||||
assert!(supports("rake", Some("db:seed")));
|
||||
// test subcommand still routes to the minitest filter.
|
||||
assert_eq!(ruby_tool("rake", Some("test")), Some("minitest"));
|
||||
assert_eq!(ruby_tool("rails", Some("test")), Some("minitest"));
|
||||
assert_eq!(ruby_tool("rake", Some("db:seed")), Some("rake"));
|
||||
// Scoped (`test:<scope>`) and namespaced (`<ns>:test`) minitest tasks
|
||||
// must ALSO route to the minitest filter — the generic condenser would
|
||||
// strip 'Expected:'/'Actual:' failure-detail lines.
|
||||
assert_eq!(ruby_tool("rake", Some("test:models")), Some("minitest"));
|
||||
assert_eq!(ruby_tool("rails", Some("test:system")), Some("minitest"));
|
||||
assert_eq!(ruby_tool("rake", Some("test:all")), Some("minitest"));
|
||||
assert_eq!(ruby_tool("rake", Some("app:test")), Some("minitest"));
|
||||
// Def-scoped subcommands return None so the engine runs the TOML def
|
||||
// standalone instead of overlaying the generic rake condenser.
|
||||
assert_eq!(ruby_tool("rake", Some("db:migrate")), None);
|
||||
assert_eq!(ruby_tool("rails", Some("db:migrate")), None);
|
||||
assert_eq!(ruby_tool("rails", Some("routes")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_minitest_task_keeps_failure_detail() {
|
||||
// `rake test:models` yields subcommand="test:models" (detect.rs default
|
||||
// arm lowercases the first positional). It must route to filter_minitest
|
||||
// and KEEP the Expected/Actual assertion payload — the generic rake
|
||||
// condenser would drop those keyword-less lines.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = MinimizerCtx {
|
||||
program: "rake",
|
||||
subcommand: Some("test:models"),
|
||||
command: "rake test:models",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Run options: --seed 1\n\n# Running:\n\n.F\n\n 1) Failure:\nUserTest#test_name \
|
||||
[test/models/user_test.rb:8]:\nExpected: true\n Actual: false\n\n2 runs, 2 \
|
||||
assertions, 1 failures, 0 errors, 0 skips\n";
|
||||
let out = filter(&context, input, 1);
|
||||
|
||||
assert!(out.text.contains("1) Failure"));
|
||||
assert!(out.text.contains("test/models/user_test.rb:8"));
|
||||
// The diagnostic payload survives (the generic condenser would drop it).
|
||||
assert!(out.text.contains("Expected: true"));
|
||||
assert!(out.text.contains("Actual: false"));
|
||||
assert!(out.text.contains("2 runs, 2 assertions, 1 failures"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,4 +857,281 @@ mod tests {
|
||||
assert!(out.text.contains("2 diagnostics in 1 files"));
|
||||
assert!(out.text.contains("app/models/user.rb (2 diagnostics)"));
|
||||
}
|
||||
|
||||
// ── CONCERN 1: rspec noise strip + failure cap + success collapse ──────
|
||||
|
||||
#[test]
|
||||
fn rspec_strips_spring_simplecov_deprecation_and_timing_noise() {
|
||||
let input = "Running via Spring preloader in process 12345\n\nFailures:\n\n 1) User \
|
||||
validates name\n Failure/Error: expect(user).to be_valid\n expected \
|
||||
true got false\n # ./spec/models/user_spec.rb:12\n\nFinished in 0.45 \
|
||||
seconds (files took 1.2 seconds to load)\nDEPRECATION WARNING: thing is \
|
||||
deprecated.\nCoverage report generated for RSpec to /app/coverage.\n142 / 200 \
|
||||
LOC (71.0%) covered.\n\n3 examples, 1 failure\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
assert!(!out.contains("Spring preloader"));
|
||||
assert!(!out.contains("Finished in"));
|
||||
assert!(!out.contains("DEPRECATION"));
|
||||
assert!(!out.contains("Coverage report"));
|
||||
assert!(!out.contains("LOC"));
|
||||
assert!(out.contains("User validates name"));
|
||||
assert!(out.contains("expected true got false"));
|
||||
assert!(out.contains("3 examples, 1 failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_caps_failure_blocks_at_five() {
|
||||
let mut input = String::from("Failures:\n\n");
|
||||
for i in 1..=7 {
|
||||
let _ = write!(
|
||||
input,
|
||||
" {i}) Example number {i} fails\n Failure/Error: expect(true).to eq(false)\n \
|
||||
# ./spec/a_spec.rb:{i}\n\n"
|
||||
);
|
||||
}
|
||||
input.push_str("7 examples, 7 failures\n");
|
||||
let out = filter_rspec(&input, 1);
|
||||
|
||||
assert!(out.contains("Example number 1 fails"));
|
||||
assert!(out.contains("Example number 5 fails"));
|
||||
assert!(!out.contains("Example number 6 fails"));
|
||||
assert!(out.contains("+2 more failures"));
|
||||
assert!(out.contains("7 examples, 7 failures"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_exit_zero_collapses_doc_tree_to_summary() {
|
||||
// snip rspec_raw.txt-style doc-format run, but all passing: the doc tree
|
||||
// must collapse to the summary line only.
|
||||
let input = "Randomized with seed 12345\n\nUserController\n GET /users\n returns a list \
|
||||
of users\n POST /users\n creates a new user\n\nOrderService\n #process\n \
|
||||
processes payment\n\nFinished in 0.45 seconds\n5 examples, 0 failures\n";
|
||||
let out = filter_rspec(input, 0);
|
||||
|
||||
assert!(out.contains("5 examples, 0 failures"));
|
||||
assert!(!out.contains("UserController"));
|
||||
assert!(!out.contains("processes payment"));
|
||||
assert!(!out.contains("Finished in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_failure_real_order_keeps_detail_under_failures_header() {
|
||||
// Real rspec text emits sections in this order: `Failures:` numbered
|
||||
// detail blocks, then the summary line, then `Failed examples:` list (see
|
||||
// snip/tests/fixtures/rspec_raw.txt). The detail block must render under
|
||||
// the `Failures:` header (before the summary), not be appended after the
|
||||
// `Failed examples:` list.
|
||||
let input = "Failures:\n\n 1) User validates name\n Failure/Error: expect(user).to \
|
||||
be_valid\n expected valid? to return true, got false\n # \
|
||||
./spec/models/user_spec.rb:12\n\n1 example, 1 failure\n\nFailed \
|
||||
examples:\n\nrspec ./spec/models/user_spec.rb:12 # User validates name\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
let failures_at = out.find("Failures:").expect("Failures header present");
|
||||
let detail_at = out.find("expected valid?").expect("detail present");
|
||||
let summary_at = out.find("1 example, 1 failure").expect("summary present");
|
||||
let failed_examples_at = out
|
||||
.find("Failed examples:")
|
||||
.expect("failed-examples header");
|
||||
|
||||
// Detail block sits under `Failures:` and before the summary; the
|
||||
// `Failed examples:` header comes after the summary — not collided with
|
||||
// the `Failures:` header.
|
||||
assert!(failures_at < detail_at, "detail must follow Failures header: {out}");
|
||||
assert!(detail_at < summary_at, "detail must precede summary: {out}");
|
||||
assert!(summary_at < failed_examples_at, "summary must precede Failed examples list: {out}");
|
||||
// The two section headers must not be adjacent (the collision symptom).
|
||||
assert!(detail_at < failed_examples_at, "detail must not land under Failed examples: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_failure_doc_run_keeps_failed_examples_and_summary() {
|
||||
// The donor snip/tests/fixtures/rspec_raw.txt: a 3-failure doc run with no
|
||||
// `Failures:` detail block, only the doc tree + `Failed examples:` list.
|
||||
let input = "Randomized with seed 12345\n\nUserController\n GET /users\n returns a list \
|
||||
of users\n\nFinished in 0.45623 seconds (files took 1.23 seconds to load)\n42 \
|
||||
examples, 3 failures, 2 pending\n\nFailed examples:\n\nrspec \
|
||||
./spec/controllers/users_controller_spec.rb:25 # UserController POST /users \
|
||||
with invalid params returns errors\nrspec \
|
||||
./spec/services/order_service_spec.rb:45 # OrderService #process with invalid \
|
||||
order raises error\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
assert!(out.contains("Failed examples:"));
|
||||
assert!(out.contains("users_controller_spec.rb:25"));
|
||||
assert!(out.contains("42 examples, 3 failures, 2 pending"));
|
||||
assert!(!out.contains("returns a list of users"));
|
||||
assert!(!out.contains("Finished in"));
|
||||
}
|
||||
|
||||
// ── CONCERN 2: rubocop autocorrect summary compaction ──────────────────
|
||||
|
||||
#[test]
|
||||
fn rubocop_autocorrect_summary_compacts_to_ok_line() {
|
||||
// A default `rubocop -A` run: grouped offenses stay primary, the
|
||||
// `N files inspected, M offenses detected, K offenses autocorrected`
|
||||
// run-summary line collapses to one ok line.
|
||||
let input = "app/models/user.rb:1:1: C: [Corrected] Style/FrozenStringLiteralComment: \
|
||||
Missing frozen string literal comment.\napp/models/user.rb:10:5: C: \
|
||||
[Corrected] Layout/TrailingWhitespace: Trailing whitespace detected.\n\n15 \
|
||||
files inspected, 3 offenses detected, 3 offenses autocorrected\n";
|
||||
let out = filter_rubocop(input, 1);
|
||||
|
||||
assert!(out.contains("ok rubocop -A (15 files, 3 autocorrected)"));
|
||||
assert!(!out.contains("offenses detected, 3 offenses autocorrected"));
|
||||
// Grouped offense output stays primary.
|
||||
assert!(out.contains("app/models/user.rb"));
|
||||
}
|
||||
|
||||
// ── CONCERN 3: minitest `tests,` summary + reporter noise ──────────────
|
||||
|
||||
#[test]
|
||||
fn minitest_accepts_tests_summary_and_strips_reporter_noise() {
|
||||
let input = "Started with run options --seed 37764\n\nProgress: \
|
||||
|====================|\n\n.F\n\nFinished in 5.79938s\n\n 1) \
|
||||
Failure:\nUserTest#test_name [test/models/user_test.rb:8]:\nExpected: true\n \
|
||||
Actual: false\n\n57 tests, 378 assertions, 1 failures, 0 errors, 0 skips\n";
|
||||
let out = filter_minitest(input, 1);
|
||||
|
||||
assert!(!out.contains("Started with run options"));
|
||||
assert!(!out.contains("Progress:"));
|
||||
assert!(out.contains("1) Failure"));
|
||||
assert!(out.contains("test/models/user_test.rb:8"));
|
||||
assert!(out.contains("Expected: true"));
|
||||
assert!(out.contains("57 tests, 378 assertions, 1 failures"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minitest_tests_summary_passes_through_success() {
|
||||
let input = "Started with run options --seed 1\n\nProgress: |==========|\n\nFinished in \
|
||||
5.7s\n57 tests, 378 assertions, 0 failures, 0 errors, 0 skips\n";
|
||||
let out = filter_minitest(input, 0);
|
||||
|
||||
assert!(out.contains("57 tests, 378 assertions, 0 failures"));
|
||||
assert!(!out.contains("Started with run options"));
|
||||
assert!(!out.contains("Progress:"));
|
||||
}
|
||||
|
||||
// ── CONCERN 4: generic rake/rails condensation ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rake_generic_task_condensed_keeps_status_drops_chatter() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = MinimizerCtx {
|
||||
program: "rake",
|
||||
subcommand: Some("db:seed"),
|
||||
command: "rake db:seed",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Seeding users...\nLoading fixtures from db/seeds.rb\ncreating record \
|
||||
1\ncreating record 2\nSeed finished successfully\nDone in 1.2s\n";
|
||||
let out = filter(&context, input, 0);
|
||||
|
||||
assert!(out.text.contains("Seed finished successfully"));
|
||||
// Pure progress chatter without a status keyword is dropped.
|
||||
assert!(!out.text.contains("creating record 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_simplecov_named_failure_block_is_not_swallowed() {
|
||||
// A failure whose header/detail mentions SimpleCov (or a `coverage/`
|
||||
// artifact path) must NOT trigger the trailing-coverage strip: the whole
|
||||
// numbered block (header, Failure/Error, diff, file:line) must survive.
|
||||
let input = "Failures:\n\n 1) SimpleCov configuration loads correctly\n Failure/Error: \
|
||||
expect(config).to be_present\n expected present\n # \
|
||||
./spec/simplecov_spec.rb:5\n\n3 examples, 1 failure\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
assert!(out.contains("SimpleCov configuration loads correctly"));
|
||||
assert!(out.contains("expected present"));
|
||||
assert!(out.contains("spec/simplecov_spec.rb:5"));
|
||||
assert!(out.contains("3 examples, 1 failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_coverage_path_assertion_in_failure_survives() {
|
||||
// A failure-detail line asserting on a `coverage/`-prefixed path must not be
|
||||
// mistaken for the SimpleCov coverage banner and swallowed.
|
||||
let input = "Failures:\n\n 1) Report generates coverage artifact\n Failure/Error: \
|
||||
expect(File).to exist\n coverage/index.html should exist\n # \
|
||||
./spec/report_spec.rb:9\n\n1 example, 1 failure\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
assert!(out.contains("Report generates coverage artifact"));
|
||||
assert!(out.contains("coverage/index.html should exist"));
|
||||
assert!(out.contains("spec/report_spec.rb:9"));
|
||||
assert!(out.contains("1 example, 1 failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rspec_trailing_simplecov_block_still_stripped() {
|
||||
// The trailing SimpleCov/coverage block (outside any `Failures:` section)
|
||||
// must STILL be stripped — the failure-block gate must not disable it.
|
||||
let input = "Failures:\n\n 1) User validates name\n Failure/Error: expect(user).to \
|
||||
be_valid\n expected true got false\n # ./spec/user_spec.rb:5\n\n3 \
|
||||
examples, 1 failure\n\nCoverage report generated for RSpec to \
|
||||
/app/coverage.\n142 / 200 LOC (71.0%) covered.\n";
|
||||
let out = filter_rspec(input, 1);
|
||||
|
||||
assert!(out.contains("User validates name"));
|
||||
assert!(!out.contains("Coverage report"));
|
||||
assert!(!out.contains("LOC"));
|
||||
assert!(out.contains("3 examples, 1 failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rake_failing_task_without_aborted_keeps_diagnostic_body() {
|
||||
// A FAILING non-test rake task that prints no `rake aborted!` header but
|
||||
// emits one keyword line plus keyword-less diagnostic body: the body lines
|
||||
// (offending records + `Expected positive integer, got -3` value detail)
|
||||
// must survive — the keep-lines pass alone would drop them.
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = MinimizerCtx {
|
||||
program: "rake",
|
||||
subcommand: Some("import:run"),
|
||||
command: "rake import:run",
|
||||
config: &cfg,
|
||||
};
|
||||
let input = "Starting import...\nImportError occurred during processing\nRecord 4821: \
|
||||
invalid value '<<>>' in column total\nRecord 4822: foreign key 9912 \
|
||||
missing\nExpected positive integer, got -3\n";
|
||||
let out = filter(&context, input, 1);
|
||||
|
||||
assert!(out.text.contains("ImportError occurred during processing"));
|
||||
assert!(out.text.contains("Record 4821"));
|
||||
assert!(out.text.contains("Record 4822"));
|
||||
assert!(out.text.contains("Expected positive integer, got -3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rake_keep_warning_lines() {
|
||||
assert!(is_rake_keep_line("warning: constant Foo::Bar is deprecated"));
|
||||
assert!(is_rake_keep_line("Warning: could not find JAVA_HOME"));
|
||||
// non-warning task chatter still dropped
|
||||
assert!(!is_rake_keep_line("Compiling assets..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rake_aborted_keeps_traceback_head() {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let context = MinimizerCtx {
|
||||
program: "rake",
|
||||
subcommand: Some("db:seed"),
|
||||
command: "rake db:seed",
|
||||
config: &cfg,
|
||||
};
|
||||
let mut input = String::from("rake aborted!\nNameError: undefined local variable\n");
|
||||
for i in 0..20 {
|
||||
let _ = writeln!(input, "/app/lib/task_{i}.rb:{i}:in `block'");
|
||||
}
|
||||
input.push_str("\nTasks: TOP => db:seed\n");
|
||||
let out = filter(&context, &input, 1);
|
||||
|
||||
assert!(out.text.contains("rake aborted!"));
|
||||
assert!(out.text.contains("NameError: undefined local variable"));
|
||||
// HEAD frames kept, deep tail dropped.
|
||||
assert!(out.text.contains("task_0.rb"));
|
||||
assert!(!out.text.contains("task_15.rb"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ use std::fmt::Write;
|
||||
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str, _subcommand: Option<&str>) -> bool {
|
||||
matches!(program, "rustfmt")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
// Kill-switch parity (M2): legacy_filters_active=true skips this
|
||||
// filter so callers can rollback without recompile.
|
||||
@@ -112,7 +114,7 @@ mod tests {
|
||||
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
|
||||
let mut input = String::new();
|
||||
for i in 0..50 {
|
||||
input.push_str(&format!("Diff in src/file_{i}.rs at line 10:\n"));
|
||||
let _ = writeln!(input, "Diff in src/file_{i}.rs at line 10:");
|
||||
for _ in 0..8 {
|
||||
input.push_str("- old line\n");
|
||||
input.push_str("+ new line\n");
|
||||
@@ -147,7 +149,7 @@ mod tests {
|
||||
// Simulate a long usage/error dump with no `Diff in ` headers.
|
||||
let mut input = String::new();
|
||||
for i in 0..400 {
|
||||
input.push_str(&format!("error: usage line {i}\n"));
|
||||
let _ = writeln!(input, "error: usage line {i}");
|
||||
}
|
||||
let out = filter(&context, &input, 1);
|
||||
assert!(out.changed);
|
||||
@@ -158,9 +160,8 @@ mod tests {
|
||||
#[test]
|
||||
fn rustfmt_legacy_filters_active_passes_through() {
|
||||
// Kill-switch parity (M2).
|
||||
let mut cfg = MinimizerConfig::default();
|
||||
cfg.enabled = true;
|
||||
cfg.legacy_filters_active = true;
|
||||
let cfg =
|
||||
MinimizerConfig { enabled: true, legacy_filters_active: true, ..Default::default() };
|
||||
let context = ctx("rustfmt", "rustfmt --check src/", &cfg);
|
||||
let input = "Diff in src/a.rs at line 1:\n-old\n+new\n";
|
||||
let out = filter(&context, input, 1);
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use super::git;
|
||||
use crate::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
|
||||
|
||||
#[must_use]
|
||||
pub fn supports(program: &str) -> bool {
|
||||
matches!(
|
||||
program,
|
||||
@@ -23,6 +24,7 @@ pub fn supports(program: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
|
||||
let cleaned = primitives::strip_ansi(input);
|
||||
let command = ctx.program;
|
||||
@@ -223,7 +225,7 @@ struct LogLine {
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn normalize_log_line(line: &str) -> String {
|
||||
pub(super) fn normalize_log_line(line: &str) -> String {
|
||||
let without_timestamp = strip_leading_timestamp(line.trim());
|
||||
let mut out = String::new();
|
||||
for token in without_timestamp.split_whitespace() {
|
||||
@@ -310,6 +312,41 @@ fn flush_digits(out: &mut String, digits: &mut String) {
|
||||
digits.clear();
|
||||
}
|
||||
|
||||
pub(super) fn compact_log_lines(
|
||||
input: &str,
|
||||
head: usize,
|
||||
tail: usize,
|
||||
key_fn: impl Fn(&str) -> String,
|
||||
) -> String {
|
||||
let mut unique: Vec<LogLine> = Vec::new();
|
||||
let mut by_key: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for (idx, line) in input.lines().enumerate() {
|
||||
let key = if line.trim().is_empty() {
|
||||
// Blank lines are section separators; do not globally deduplicate them.
|
||||
// drop_repeated_blank_lines already collapsed consecutive blanks.
|
||||
format!("<blank-{idx}>")
|
||||
} else {
|
||||
let key = key_fn(line);
|
||||
if key.is_empty() {
|
||||
line.to_string()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
};
|
||||
if let Some(index) = by_key.get(&key).copied() {
|
||||
if let Some(entry) = unique.get_mut(index) {
|
||||
entry.count += 1;
|
||||
}
|
||||
} else {
|
||||
by_key.insert(key, unique.len());
|
||||
unique.push(LogLine { original: line.to_string(), count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
render_counted_lines(&unique, head, tail)
|
||||
}
|
||||
|
||||
fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String {
|
||||
let mut out = String::new();
|
||||
if lines.len() <= head + tail {
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
//! 2. `replace` — ordered regex substitutions, line-by-line
|
||||
//! 3. `match_output` — short-circuit to a one-line summary when the
|
||||
//! full output blob matches, honoring an optional `unless` anti-pattern
|
||||
//! 4. `strip_lines_matching` / `keep_lines_matching` (mutually exclusive)
|
||||
//! 5. `truncate_lines_at` — per-line Unicode-safe char cap
|
||||
//! 6. `head_lines` / `tail_lines` — keep first/last N lines with a marker
|
||||
//! 7. `max_lines` — hard cap after head/tail
|
||||
//! 8. `on_empty` — replace an empty result with a sentinel
|
||||
//! 4. `strip_lines_matching` / `keep_lines_matching` — a line survives iff it
|
||||
//! matches the keep set (when present) and does not match the strip set
|
||||
//! (when present)
|
||||
//! 5. `replace_after` — ordered regex substitutions, line-by-line,
|
||||
//! applied after line filtering and before truncation so substitutions that
|
||||
//! shorten lines (e.g. path compaction) see the full, untruncated text
|
||||
//! 6. `truncate_lines_at` — per-line Unicode-safe char cap
|
||||
//! 7. `head_lines` / `tail_lines` — keep first/last N lines with a marker
|
||||
//! 8. `max_lines` — hard cap after head/tail
|
||||
//! 9. `on_empty` — replace an empty result with a sentinel
|
||||
//!
|
||||
//! Pipelines never panic for the caller: regex compilation errors are
|
||||
//! surfaced when the pipeline is loaded, and runtime application is total.
|
||||
@@ -51,6 +56,11 @@ pub struct PipelineDef {
|
||||
pub strip_lines_matching: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub keep_lines_matching: Vec<String>,
|
||||
/// Ordered regex substitutions applied after the strip/keep line filter
|
||||
/// and before `truncate_lines_at`, so substitutions that shorten lines
|
||||
/// (e.g. path compaction) see the full, untruncated text.
|
||||
#[serde(default)]
|
||||
pub replace_after: Vec<ReplaceDef>,
|
||||
pub truncate_lines_at: Option<usize>,
|
||||
pub head_lines: Option<usize>,
|
||||
pub tail_lines: Option<usize>,
|
||||
@@ -119,13 +129,6 @@ pub struct CompiledMatchOutput {
|
||||
unless: Option<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CompiledLineFilter {
|
||||
None,
|
||||
Strip(RegexSet),
|
||||
Keep(RegexSet),
|
||||
}
|
||||
|
||||
/// A pipeline with every regex pre-compiled.
|
||||
#[derive(Debug)]
|
||||
pub struct CompiledPipeline {
|
||||
@@ -138,7 +141,9 @@ pub struct CompiledPipeline {
|
||||
pub strip_ansi: bool,
|
||||
pub replace: Vec<CompiledReplace>,
|
||||
pub match_output: Vec<CompiledMatchOutput>,
|
||||
pub line_filter: CompiledLineFilter,
|
||||
pub strip_lines: Option<RegexSet>,
|
||||
pub keep_lines: Option<RegexSet>,
|
||||
pub replace_after: Vec<CompiledReplace>,
|
||||
pub truncate_lines_at: Option<usize>,
|
||||
pub head_lines: Option<usize>,
|
||||
pub tail_lines: Option<usize>,
|
||||
@@ -148,13 +153,22 @@ pub struct CompiledPipeline {
|
||||
pub except_on_exit: Vec<i32>,
|
||||
}
|
||||
|
||||
/// Compile a raw TOML definition. Returns a descriptive error on regex
|
||||
/// issues or mutually-exclusive-field conflicts.
|
||||
pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, String> {
|
||||
if !def.strip_lines_matching.is_empty() && !def.keep_lines_matching.is_empty() {
|
||||
return Err("strip_lines_matching and keep_lines_matching are mutually exclusive".into());
|
||||
}
|
||||
/// Compile an ordered regex-substitution list; `label` names the TOML key
|
||||
/// (`replace` or `replace_after`) in error messages.
|
||||
fn compile_replaces(rules: Vec<ReplaceDef>, label: &str) -> Result<Vec<CompiledReplace>, String> {
|
||||
rules
|
||||
.into_iter()
|
||||
.map(|rule| {
|
||||
let pattern = Regex::new(&rule.pattern)
|
||||
.map_err(|e| format!("invalid {label} pattern '{}': {e}", rule.pattern))?;
|
||||
Ok(CompiledReplace { pattern, replacement: rule.replacement })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compile a raw TOML definition. Returns a descriptive error on regex
|
||||
/// issues.
|
||||
pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, String> {
|
||||
let match_command =
|
||||
Regex::new(&def.match_command).map_err(|e| format!("invalid match_command: {e}"))?;
|
||||
let match_subcommand = def
|
||||
@@ -163,15 +177,8 @@ pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, Strin
|
||||
.map(|p| Regex::new(p).map_err(|e| format!("invalid match_subcommand: {e}")))
|
||||
.transpose()?;
|
||||
|
||||
let replace = def
|
||||
.replace
|
||||
.into_iter()
|
||||
.map(|rule| {
|
||||
let pattern = Regex::new(&rule.pattern)
|
||||
.map_err(|e| format!("invalid replace pattern '{}': {e}", rule.pattern))?;
|
||||
Ok(CompiledReplace { pattern, replacement: rule.replacement })
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let replace = compile_replaces(def.replace, "replace")?;
|
||||
let replace_after = compile_replaces(def.replace_after, "replace_after")?;
|
||||
|
||||
let match_output = def
|
||||
.match_output
|
||||
@@ -188,16 +195,21 @@ pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, Strin
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
|
||||
let line_filter = if !def.strip_lines_matching.is_empty() {
|
||||
let set = RegexSet::new(&def.strip_lines_matching)
|
||||
.map_err(|e| format!("invalid strip_lines_matching: {e}"))?;
|
||||
CompiledLineFilter::Strip(set)
|
||||
} else if !def.keep_lines_matching.is_empty() {
|
||||
let set = RegexSet::new(&def.keep_lines_matching)
|
||||
.map_err(|e| format!("invalid keep_lines_matching: {e}"))?;
|
||||
CompiledLineFilter::Keep(set)
|
||||
let strip_lines = if def.strip_lines_matching.is_empty() {
|
||||
None
|
||||
} else {
|
||||
CompiledLineFilter::None
|
||||
Some(
|
||||
RegexSet::new(&def.strip_lines_matching)
|
||||
.map_err(|e| format!("invalid strip_lines_matching: {e}"))?,
|
||||
)
|
||||
};
|
||||
let keep_lines = if def.keep_lines_matching.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
RegexSet::new(&def.keep_lines_matching)
|
||||
.map_err(|e| format!("invalid keep_lines_matching: {e}"))?,
|
||||
)
|
||||
};
|
||||
|
||||
Ok(CompiledPipeline {
|
||||
@@ -208,7 +220,9 @@ pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, Strin
|
||||
strip_ansi: def.strip_ansi,
|
||||
replace,
|
||||
match_output,
|
||||
line_filter,
|
||||
strip_lines,
|
||||
keep_lines,
|
||||
replace_after,
|
||||
truncate_lines_at: def.truncate_lines_at,
|
||||
head_lines: def.head_lines,
|
||||
tail_lines: def.tail_lines,
|
||||
@@ -221,6 +235,7 @@ pub fn compile(name: String, def: PipelineDef) -> Result<CompiledPipeline, Strin
|
||||
|
||||
impl CompiledPipeline {
|
||||
/// Whether this pipeline claims the given `(program, subcommand)` pair.
|
||||
#[must_use]
|
||||
pub fn matches(&self, program: &str, subcommand: Option<&str>) -> bool {
|
||||
if !self.match_command.is_match(program) {
|
||||
return false;
|
||||
@@ -235,6 +250,7 @@ impl CompiledPipeline {
|
||||
}
|
||||
|
||||
/// Whether this pipeline is gated off for the supplied exit code.
|
||||
#[must_use]
|
||||
pub fn skipped_by_exit(&self, exit_code: i32) -> bool {
|
||||
if !self.only_on_exit.is_empty() && !self.only_on_exit.contains(&exit_code) {
|
||||
return true;
|
||||
@@ -245,7 +261,8 @@ impl CompiledPipeline {
|
||||
false
|
||||
}
|
||||
|
||||
/// Apply the full 8-stage pipeline to `input`.
|
||||
/// Apply the full 9-stage pipeline to `input`.
|
||||
#[must_use]
|
||||
pub fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> {
|
||||
// Stage 1: strip_ansi
|
||||
let stage1: Cow<'_, str> = if self.strip_ansi {
|
||||
@@ -255,25 +272,7 @@ impl CompiledPipeline {
|
||||
};
|
||||
|
||||
// Stage 2: replace (ordered, line-by-line)
|
||||
let stage2: Cow<'_, str> = if self.replace.is_empty() {
|
||||
stage1
|
||||
} else {
|
||||
let mut out = String::with_capacity(stage1.len());
|
||||
for line in stage1.lines() {
|
||||
let mut current = Cow::Borrowed(line);
|
||||
for rule in &self.replace {
|
||||
let replaced = rule
|
||||
.pattern
|
||||
.replace_all(¤t, rule.replacement.as_str());
|
||||
if let Cow::Owned(s) = replaced {
|
||||
current = Cow::Owned(s);
|
||||
}
|
||||
}
|
||||
out.push_str(¤t);
|
||||
out.push('\n');
|
||||
}
|
||||
Cow::Owned(out)
|
||||
};
|
||||
let stage2 = apply_replaces(stage1, &self.replace);
|
||||
|
||||
// Stage 3: match_output short-circuit
|
||||
if !self.match_output.is_empty() {
|
||||
@@ -290,51 +289,84 @@ impl CompiledPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4: strip/keep lines
|
||||
let stage4: Cow<'_, str> = match &self.line_filter {
|
||||
CompiledLineFilter::None => stage2,
|
||||
CompiledLineFilter::Strip(set) => Cow::Owned(primitives::strip_lines_regex(&stage2, set)),
|
||||
CompiledLineFilter::Keep(set) => Cow::Owned(primitives::keep_lines_regex(&stage2, set)),
|
||||
// Stage 4: strip/keep lines (keep AND NOT strip; absent set = no
|
||||
// constraint)
|
||||
let stage4: Cow<'_, str> = if self.strip_lines.is_some() || self.keep_lines.is_some() {
|
||||
Cow::Owned(primitives::filter_lines_regex(
|
||||
&stage2,
|
||||
self.strip_lines.as_ref(),
|
||||
self.keep_lines.as_ref(),
|
||||
))
|
||||
} else {
|
||||
stage2
|
||||
};
|
||||
|
||||
// Stage 5: truncate each line
|
||||
let stage5: Cow<'_, str> = if let Some(n) = self.truncate_lines_at {
|
||||
let mut out = String::with_capacity(stage4.len());
|
||||
for line in stage4.lines() {
|
||||
// Stage 5: replace_after (ordered, line-by-line) — post-filter so
|
||||
// only surviving lines are substituted, pre-truncate so shortening
|
||||
// substitutions (path compaction) see the full line.
|
||||
let stage5 = apply_replaces(stage4, &self.replace_after);
|
||||
|
||||
// Stage 6: truncate each line
|
||||
let stage6: Cow<'_, str> = if let Some(n) = self.truncate_lines_at {
|
||||
let mut out = String::with_capacity(stage5.len());
|
||||
for line in stage5.lines() {
|
||||
out.push_str(&primitives::truncate_line(line, n));
|
||||
out.push('\n');
|
||||
}
|
||||
Cow::Owned(out)
|
||||
} else {
|
||||
stage4
|
||||
stage5
|
||||
};
|
||||
|
||||
// Stage 6: head + tail
|
||||
let stage6: Cow<'_, str> = match (self.head_lines, self.tail_lines) {
|
||||
(Some(h), Some(t)) => Cow::Owned(primitives::head_tail_lines(&stage5, h, t)),
|
||||
(Some(h), None) => Cow::Owned(primitives::head_lines_only(&stage5, h)),
|
||||
(None, Some(t)) => Cow::Owned(primitives::tail_lines_only(&stage5, t)),
|
||||
(None, None) => stage5,
|
||||
// Stage 7: head + tail
|
||||
let stage7: Cow<'_, str> = match (self.head_lines, self.tail_lines) {
|
||||
(Some(h), Some(t)) => Cow::Owned(primitives::head_tail_lines(&stage6, h, t)),
|
||||
(Some(h), None) => Cow::Owned(primitives::head_lines_only(&stage6, h)),
|
||||
(None, Some(t)) => Cow::Owned(primitives::tail_lines_only(&stage6, t)),
|
||||
(None, None) => stage6,
|
||||
};
|
||||
|
||||
// Stage 7: max_lines
|
||||
let stage7: Cow<'_, str> = if let Some(m) = self.max_lines {
|
||||
Cow::Owned(primitives::max_lines(&stage6, m))
|
||||
// Stage 8: max_lines
|
||||
let stage8: Cow<'_, str> = if let Some(m) = self.max_lines {
|
||||
Cow::Owned(primitives::max_lines(&stage7, m))
|
||||
} else {
|
||||
stage6
|
||||
stage7
|
||||
};
|
||||
|
||||
// Stage 8: on_empty
|
||||
// Stage 9: on_empty
|
||||
if let Some(msg) = self.on_empty.as_deref()
|
||||
&& stage7.trim().is_empty()
|
||||
&& stage8.trim().is_empty()
|
||||
{
|
||||
return Cow::Owned(msg.to_string());
|
||||
}
|
||||
|
||||
stage7
|
||||
stage8
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an ordered regex-substitution list line-by-line. Returns `input`
|
||||
/// untouched (no allocation) when `rules` is empty.
|
||||
fn apply_replaces<'a>(input: Cow<'a, str>, rules: &[CompiledReplace]) -> Cow<'a, str> {
|
||||
if rules.is_empty() {
|
||||
return input;
|
||||
}
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for line in input.lines() {
|
||||
let mut current = Cow::Borrowed(line);
|
||||
for rule in rules {
|
||||
let replaced = rule
|
||||
.pattern
|
||||
.replace_all(¤t, rule.replacement.as_str());
|
||||
if let Cow::Owned(s) = replaced {
|
||||
current = Cow::Owned(s);
|
||||
}
|
||||
}
|
||||
out.push_str(¤t);
|
||||
out.push('\n');
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Return type of [`parse_file`]: the compiled pipelines alongside their
|
||||
/// inline tests grouped by pipeline name.
|
||||
pub type ParsedPipelineFile = (Vec<CompiledPipeline>, Vec<(String, Vec<PipelineTest>)>);
|
||||
@@ -351,6 +383,7 @@ pub struct PipelineRegistry {
|
||||
|
||||
impl PipelineRegistry {
|
||||
/// Find the first pipeline that claims this `(program, subcommand)` pair.
|
||||
#[must_use]
|
||||
pub fn find(&self, program: &str, subcommand: Option<&str>) -> Option<&CompiledPipeline> {
|
||||
self
|
||||
.pipelines
|
||||
@@ -403,6 +436,7 @@ pub struct TestOutcome {
|
||||
|
||||
/// Run every inline test in `registry` and return the outcomes.
|
||||
#[allow(dead_code, reason = "test-only API surface")]
|
||||
#[must_use]
|
||||
pub fn run_tests(registry: &PipelineRegistry) -> Vec<TestOutcome> {
|
||||
let mut out = Vec::new();
|
||||
for (filter_name, tests) in ®istry.tests {
|
||||
@@ -511,6 +545,59 @@ unless = "(?i)error|fail"
|
||||
assert!(out.as_ref().contains("ERROR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_and_keep_combine_as_keep_and_not_strip() {
|
||||
let src = r#"
|
||||
schema_version = 1
|
||||
[filters.combo]
|
||||
match_command = "^combo$"
|
||||
keep_lines_matching = ["^> Task "]
|
||||
strip_lines_matching = ["UP-TO-DATE$"]
|
||||
"#;
|
||||
let pipeline = compile_one(src);
|
||||
let out = pipeline.apply("> Task :a UP-TO-DATE\n> Task :b\nDownloading dep\n");
|
||||
// Only lines matching the keep set AND not the strip set survive.
|
||||
assert_eq!(out.as_ref(), "> Task :b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_after_substitutes_surviving_lines_post_filter() {
|
||||
let src = r#"
|
||||
schema_version = 1
|
||||
[filters.paths]
|
||||
match_command = "^paths$"
|
||||
keep_lines_matching = ["^src/"]
|
||||
[[filters.paths.replace_after]]
|
||||
pattern = "^src/very/long/prefix/"
|
||||
replacement = ".../"
|
||||
"#;
|
||||
let pipeline = compile_one(src);
|
||||
// The keep filter matches the ORIGINAL line (`^src/`); if the
|
||||
// substitution ran before filtering, the rewritten `.../main.rs`
|
||||
// would no longer match the keep set and be dropped.
|
||||
let out = pipeline.apply("src/very/long/prefix/main.rs\nnoise line\nsrc/lib.rs\n");
|
||||
assert_eq!(out.as_ref(), ".../main.rs\nsrc/lib.rs\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_after_runs_before_truncate_lines_at() {
|
||||
let src = r#"
|
||||
schema_version = 1
|
||||
[filters.shorten]
|
||||
match_command = "^shorten$"
|
||||
truncate_lines_at = 12
|
||||
[[filters.shorten.replace_after]]
|
||||
pattern = "^/workspace/project/deep/"
|
||||
replacement = ""
|
||||
"#;
|
||||
let pipeline = compile_one(src);
|
||||
// The raw line exceeds the 12-char cap; the substitution shortens it
|
||||
// below the cap first, so no truncation marker appears. If truncation
|
||||
// ran first, the pattern would no longer match the clipped line.
|
||||
let out = pipeline.apply("/workspace/project/deep/file.rs\n");
|
||||
assert_eq!(out.as_ref(), "file.rs\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_empty_fires_when_output_is_blank() {
|
||||
let src = r#"
|
||||
|
||||
@@ -64,6 +64,7 @@ pub enum CommandPlan {
|
||||
}
|
||||
|
||||
/// Parse `command` with `brush-parser` and classify its structure.
|
||||
#[must_use]
|
||||
pub fn analyze(command: &str) -> CommandPlan {
|
||||
let trimmed = command.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -202,10 +203,14 @@ fn io_redirect_is_safe(io: &IoRedirect) -> bool {
|
||||
IoFileRedirectTarget::Fd(_) => true,
|
||||
IoFileRedirectTarget::ProcessSubstitution(..) => false,
|
||||
},
|
||||
IoRedirect::HereDocument(_, here_doc) => {
|
||||
!word_has_command_substitution(&here_doc.here_end)
|
||||
&& !word_has_command_substitution(&here_doc.doc)
|
||||
},
|
||||
// Here-docs are never safe to segment. The segmented runner rebuilds
|
||||
// each chain segment from the brush AST via `pipeline.to_string()`, and
|
||||
// that Display impl re-emits a quoted/escaped here-doc's *closing*
|
||||
// delimiter with its quotes intact (`<<'EOF'` … `'EOF'` rather than the
|
||||
// required bare `EOF`). The reconstructed close tag never matches, so
|
||||
// the re-run segment fails with "unterminated here document". Leave any
|
||||
// here-doc-bearing command to the unsegmented single path.
|
||||
IoRedirect::HereDocument(..) => false,
|
||||
IoRedirect::HereString(_, word) => !word_has_command_substitution(word),
|
||||
IoRedirect::OutputAndError(word, _) => !word_has_command_substitution(word),
|
||||
}
|
||||
@@ -417,6 +422,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heredoc_chains_are_not_segmented() {
|
||||
// Regression: a chain whose segment carries a here-doc must not be
|
||||
// split. The segmented runner rebuilds each segment with the brush AST
|
||||
// Display impl, which re-emits a quoted/escaped here-doc's closing
|
||||
// delimiter with quotes (`'EOF'` rather than `EOF`); re-running that
|
||||
// reconstructed segment fails with "unterminated here document". Both
|
||||
// quoted and unquoted delimiters bail so the whole command runs whole.
|
||||
assert_not_chain("cat <<'EOF'\nbody\nEOF\necho done");
|
||||
assert_not_chain("cat <<\"EOF\"\nbody\nEOF\necho done");
|
||||
assert_not_chain("cat <<EOF\nbody\nEOF\necho done");
|
||||
assert_not_chain("cat <<'EOF' && echo done\nbody\nEOF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_legacy_opaque_shapes() {
|
||||
assert_eq!(analyze("foo || bar"), CommandPlan::Compound);
|
||||
|
||||
@@ -11,6 +11,7 @@ pub enum CapClass {
|
||||
}
|
||||
|
||||
impl CapClass {
|
||||
#[must_use]
|
||||
pub const fn lines(self) -> usize {
|
||||
match self {
|
||||
Self::Errors => 160,
|
||||
@@ -21,12 +22,14 @@ impl CapClass {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reduced(cap: usize, by: usize) -> usize {
|
||||
let reduced = cap.saturating_sub(by);
|
||||
if reduced == 0 && cap > 0 { 1 } else { reduced }
|
||||
}
|
||||
|
||||
/// Remove ANSI CSI escape sequences while preserving line endings verbatim.
|
||||
#[must_use]
|
||||
pub fn strip_ansi(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
@@ -46,6 +49,7 @@ pub fn strip_ansi(input: &str) -> String {
|
||||
}
|
||||
|
||||
/// Collapse consecutive identical lines as `line (×N)`.
|
||||
#[must_use]
|
||||
pub fn dedup_consecutive_lines(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut previous: Option<&str> = None;
|
||||
@@ -77,6 +81,7 @@ fn flush_repeated(out: &mut String, line: Option<&str>, count: usize) {
|
||||
}
|
||||
|
||||
/// Keep the first `head` and last `tail` lines with an omission marker.
|
||||
#[must_use]
|
||||
pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
if lines.len() <= head + tail {
|
||||
@@ -99,6 +104,7 @@ pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String {
|
||||
}
|
||||
|
||||
/// Keep head/tail lines using a named cap class.
|
||||
#[must_use]
|
||||
pub fn head_tail_cap(input: &str, class: CapClass) -> String {
|
||||
let cap = class.lines();
|
||||
let head = reduced(cap, cap / 3);
|
||||
@@ -120,6 +126,7 @@ pub fn strip_lines(input: &str, predicates: &[fn(&str) -> bool]) -> String {
|
||||
}
|
||||
|
||||
/// Group `file:line:message` style diagnostics by file.
|
||||
#[must_use]
|
||||
pub fn group_by_file(input: &str, max_per_file: usize) -> String {
|
||||
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let mut ungrouped = Vec::new();
|
||||
@@ -169,7 +176,52 @@ fn split_file_line(line: &str) -> Option<(&str, &str)> {
|
||||
Some((file, rest))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn command_has_ordered_tokens(command: &str, first: &str, second: &str) -> bool {
|
||||
let mut saw_first = false;
|
||||
for part in command.split_whitespace() {
|
||||
if saw_first && part == second {
|
||||
return true;
|
||||
}
|
||||
if part == first {
|
||||
saw_first = true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn command_has_any_token(command: &str, tokens: &[&str]) -> bool {
|
||||
command.split_whitespace().any(|part| {
|
||||
tokens.iter().any(|token| {
|
||||
part == *token
|
||||
|| part
|
||||
.strip_prefix(*token)
|
||||
.is_some_and(|suffix| suffix.starts_with('='))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Dedup consecutive lines then apply a 120-head / 80-tail cap.
|
||||
#[must_use]
|
||||
pub fn head_tail_dedup(input: &str) -> String {
|
||||
head_tail_lines(&dedup_consecutive_lines(input), 120, 80)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_markdown_badge_or_image(line: &str) -> bool {
|
||||
line.starts_with("![") || line.starts_with("[![") || line.contains("img.shields.io")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_horizontal_rule(line: &str) -> bool {
|
||||
line.len() >= 3
|
||||
&& line.chars().all(|ch| matches!(ch, '-' | '*' | '_' | ' '))
|
||||
&& line.chars().any(|ch| matches!(ch, '-' | '*' | '_'))
|
||||
}
|
||||
|
||||
/// Compact a long plain listing to head/tail form.
|
||||
#[must_use]
|
||||
pub fn compact_listing(input: &str, max_lines: usize) -> String {
|
||||
let lines: Vec<&str> = input
|
||||
.lines()
|
||||
@@ -205,6 +257,7 @@ pub fn compact_listing(input: &str, max_lines: usize) -> String {
|
||||
///
|
||||
/// `max_chars == 0` is treated as "drop the line"; no marker is emitted in
|
||||
/// that case since the caller asked for an empty result.
|
||||
#[must_use]
|
||||
pub fn truncate_line(line: &str, max_chars: usize) -> String {
|
||||
if max_chars == 0 {
|
||||
return String::new();
|
||||
@@ -227,6 +280,7 @@ pub fn truncate_line(line: &str, max_chars: usize) -> String {
|
||||
}
|
||||
|
||||
/// Keep only the first `head` lines; append a summary marker when truncated.
|
||||
#[must_use]
|
||||
pub fn head_lines_only(input: &str, head: usize) -> String {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
if lines.len() <= head {
|
||||
@@ -245,6 +299,7 @@ pub fn head_lines_only(input: &str, head: usize) -> String {
|
||||
}
|
||||
|
||||
/// Keep only the last `tail` lines; prepend a summary marker when truncated.
|
||||
#[must_use]
|
||||
pub fn tail_lines_only(input: &str, tail: usize) -> String {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
if lines.len() <= tail {
|
||||
@@ -263,6 +318,7 @@ pub fn tail_lines_only(input: &str, tail: usize) -> String {
|
||||
}
|
||||
|
||||
/// Hard cap: keep at most `max` lines, append a truncation marker otherwise.
|
||||
#[must_use]
|
||||
pub fn max_lines(input: &str, max: usize) -> String {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
if lines.len() <= max {
|
||||
@@ -280,28 +336,24 @@ pub fn max_lines(input: &str, max: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop every line matched by any regex in `set`.
|
||||
pub fn strip_lines_regex(input: &str, set: ®ex::RegexSet) -> String {
|
||||
/// Line filter combining an optional keep set and an optional strip set.
|
||||
///
|
||||
/// A line survives iff it matches the keep set (when present) AND does not
|
||||
/// match the strip set (when present) — i.e. keep is `K AND NOT S`. An
|
||||
/// absent set imposes no constraint, so pure strip and pure keep filtering
|
||||
/// are the degenerate single-set cases.
|
||||
#[must_use]
|
||||
pub fn filter_lines_regex(
|
||||
input: &str,
|
||||
strip: Option<®ex::RegexSet>,
|
||||
keep: Option<®ex::RegexSet>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
if set.is_match(line) {
|
||||
continue;
|
||||
if keep.is_none_or(|set| set.is_match(line)) && !strip.is_some_and(|set| set.is_match(line)) {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep only lines matching any regex in `set`.
|
||||
pub fn keep_lines_regex(input: &str, set: ®ex::RegexSet) -> String {
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
if !set.is_match(line) {
|
||||
continue;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -381,4 +433,72 @@ mod tests {
|
||||
fn truncate_line_max_zero_yields_empty() {
|
||||
assert_eq!(truncate_line("anything", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_lines_regex_combines_keep_and_strip() {
|
||||
let strip = regex::RegexSet::new(["noise"]).unwrap();
|
||||
let keep = regex::RegexSet::new(["^task"]).unwrap();
|
||||
let input = "task ok\ntask noise\nnoise only\nunrelated\n";
|
||||
|
||||
// Combined: survives iff matches keep AND NOT strip.
|
||||
assert_eq!(filter_lines_regex(input, Some(&strip), Some(&keep)), "task ok\n");
|
||||
// Strip only: absent keep set imposes no constraint.
|
||||
assert_eq!(filter_lines_regex(input, Some(&strip), None), "task ok\nunrelated\n");
|
||||
// Keep only: absent strip set imposes no constraint.
|
||||
assert_eq!(filter_lines_regex(input, None, Some(&keep)), "task ok\ntask noise\n");
|
||||
// Neither: identity modulo trailing-newline normalization.
|
||||
assert_eq!(filter_lines_regex(input, None, None), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_has_ordered_tokens_basic() {
|
||||
assert!(command_has_ordered_tokens("glab mr diff 42", "mr", "diff"));
|
||||
assert!(
|
||||
!command_has_ordered_tokens("glab diff mr 42", "mr", "diff"),
|
||||
"wrong order must be false"
|
||||
);
|
||||
assert!(
|
||||
!command_has_ordered_tokens("glab mr", "mr", "diff"),
|
||||
"missing second token must be false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_has_ordered_tokens_first_equals_second() {
|
||||
// edge case: first == second — both must appear in order
|
||||
assert!(command_has_ordered_tokens("git push push", "push", "push"));
|
||||
assert!(
|
||||
!command_has_ordered_tokens("git push", "push", "push"),
|
||||
"only one occurrence — must be false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_has_any_token_equals_form() {
|
||||
// Exact token match and non-match.
|
||||
assert!(command_has_any_token("eslint --format json src", &["json"]));
|
||||
assert!(!command_has_any_token("eslint --format json src", &["xml"]));
|
||||
// Equals-form: --flag=value matches when the search token is the flag prefix.
|
||||
assert!(command_has_any_token("eslint --format=json src", &["--format"]));
|
||||
// Value-only search does NOT match an equals-form part (token is prefix, not
|
||||
// suffix).
|
||||
assert!(
|
||||
!command_has_any_token("eslint --format=json src", &["json"]),
|
||||
"value after = must not match when token is not the flag prefix"
|
||||
);
|
||||
// Substring of a standalone word must not match.
|
||||
assert!(
|
||||
!command_has_any_token("eslint --format foobar src", &["bar"]),
|
||||
"substring of a token must not match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_horizontal_rule_requires_non_space() {
|
||||
assert!(is_horizontal_rule("---"));
|
||||
assert!(is_horizontal_rule("- - -"));
|
||||
assert!(is_horizontal_rule("***"));
|
||||
assert!(!is_horizontal_rule(" "), "whitespace-only must not be a rule");
|
||||
assert!(!is_horizontal_rule(" "), "short whitespace must not be a rule");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,7 +805,7 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
fn as_raw(&self) -> Handle {
|
||||
const fn as_raw(&self) -> Handle {
|
||||
self.raw as Handle
|
||||
}
|
||||
}
|
||||
@@ -932,7 +932,7 @@ mod platform {
|
||||
unsafe { TerminateProcess(self.handle.as_raw(), 1) != 0 }
|
||||
}
|
||||
|
||||
pub const fn group_id(&self) -> Option<i32> {
|
||||
pub const fn group_id() -> Option<i32> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ mod platform {
|
||||
}
|
||||
|
||||
fn read_remote_unicode_string(handle: Handle, value: UnicodeString) -> Option<String> {
|
||||
if value.length == 0 || value.buffer == 0 || value.length % 2 != 0 {
|
||||
if value.length == 0 || value.buffer == 0 || !value.length.is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let code_units = usize::from(value.length) / size_of::<u16>();
|
||||
@@ -1135,7 +1135,7 @@ mod platform {
|
||||
OwnedHandle::from_raw(snapshot)
|
||||
}
|
||||
|
||||
fn process_entry() -> PROCESSENTRY32W {
|
||||
const fn process_entry() -> PROCESSENTRY32W {
|
||||
PROCESSENTRY32W {
|
||||
dwSize: mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
cntUsage: 0,
|
||||
@@ -1259,16 +1259,19 @@ impl Process {
|
||||
}
|
||||
|
||||
/// Operating-system process identifier for this process reference.
|
||||
#[must_use]
|
||||
pub const fn pid(&self) -> i32 {
|
||||
self.inner.pid()
|
||||
}
|
||||
|
||||
/// Parent process id for this process, when available.
|
||||
#[must_use]
|
||||
pub fn ppid(&self) -> Option<i32> {
|
||||
self.inner.parent_pid()
|
||||
}
|
||||
|
||||
/// Launch arguments for this process.
|
||||
#[must_use]
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.inner.args()
|
||||
}
|
||||
@@ -1279,11 +1282,19 @@ impl Process {
|
||||
/// signal abstraction, so the `signal` argument is ignored and the entire
|
||||
/// tree is hard-killed via `TerminateProcess`. Defaults to the POSIX
|
||||
/// hard-kill signal.
|
||||
#[must_use]
|
||||
pub fn kill_tree(&self, signal: Option<i32>) -> u32 {
|
||||
self.signal_tree(signal.unwrap_or(KILL_SIGNAL))
|
||||
}
|
||||
|
||||
/// Process group id for this process, when supported by the platform.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[must_use]
|
||||
pub const fn group_id(&self) -> Option<i32> {
|
||||
platform::Process::group_id()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[must_use]
|
||||
pub fn group_id(&self) -> Option<i32> {
|
||||
self.inner.group_id()
|
||||
}
|
||||
@@ -1299,6 +1310,7 @@ impl Process {
|
||||
}
|
||||
|
||||
/// Current status of this process reference.
|
||||
#[must_use]
|
||||
pub fn status(&self) -> ProcessStatus {
|
||||
self.inner.status()
|
||||
}
|
||||
@@ -1351,7 +1363,7 @@ impl Process {
|
||||
// If self leads its own process group, also signal the group — this catches
|
||||
// grandchildren reparented to init when their immediate parent died inside
|
||||
// the descendant walk.
|
||||
if let Some(pgid) = self.inner.group_id()
|
||||
if let Some(pgid) = self.group_id()
|
||||
&& pgid == self.inner.pid()
|
||||
{
|
||||
let _ = kill_process_group(pgid, signal);
|
||||
@@ -1463,6 +1475,7 @@ async fn wait_for_exit(
|
||||
/// Send `signal` to the process group `pgid`.
|
||||
/// Returns false when process groups are unsupported on the platform.
|
||||
#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")]
|
||||
#[must_use]
|
||||
pub fn kill_process_group(pgid: i32, signal: i32) -> bool {
|
||||
// Defense in depth: refuse to deliver a signal to the harness's own
|
||||
// process group. Doing so terminates the harness along with the targets.
|
||||
@@ -1510,6 +1523,7 @@ pub struct TerminationTargets {
|
||||
|
||||
impl TerminationTargets {
|
||||
/// Create an empty target set.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
@@ -1533,6 +1547,7 @@ impl TerminationTargets {
|
||||
}
|
||||
|
||||
/// True when no targets have been recorded.
|
||||
#[must_use]
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.pgids.is_empty() && self.processes.is_empty()
|
||||
}
|
||||
|
||||
+106
-66
@@ -490,7 +490,10 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
|
||||
}
|
||||
shell.register_builtin("sleep", builtins::builtin::<SleepCommand, _>());
|
||||
shell.register_builtin("timeout", builtins::builtin::<TimeoutCommand, _>());
|
||||
shell.register_builtin("nohup", builtins::builtin::<NohupCommand, _>());
|
||||
shell.register_builtin(
|
||||
"nohup",
|
||||
builtins::builtin::<NohupCommand, _>().transparent_background_wrapper(),
|
||||
);
|
||||
|
||||
let mut merged_path: Option<String> = None;
|
||||
for (key, value) in std::env::vars() {
|
||||
@@ -867,7 +870,7 @@ async fn run_shell_command_segmented_chain(
|
||||
|
||||
async fn run_shell_command_once(
|
||||
session: &mut ShellSessionCore,
|
||||
command: String,
|
||||
mut command: String,
|
||||
mut params: ExecutionParameters,
|
||||
on_chunk: Option<mpsc::UnboundedSender<String>>,
|
||||
cancel_token: CancellationToken,
|
||||
@@ -930,6 +933,7 @@ async fn run_shell_command_once(
|
||||
terminate_new_descendants(&baseline_descendants).await;
|
||||
}
|
||||
});
|
||||
ensure_trailing_newline_for_heredoc(&mut command);
|
||||
let source_info = SourceInfo::from("pi-natives:command");
|
||||
let result = session
|
||||
.shell
|
||||
@@ -1091,10 +1095,12 @@ async fn run_shell_command_streams(
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut command = options.command.clone();
|
||||
ensure_trailing_newline_for_heredoc(&mut command);
|
||||
let source_info = SourceInfo::from("pi-shell:streams");
|
||||
let result = session
|
||||
.shell
|
||||
.run_string(options.command.clone(), &source_info, ¶ms)
|
||||
.run_string(command, &source_info, ¶ms)
|
||||
.await;
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
@@ -1340,10 +1346,17 @@ fn apply_env_fallback(shell: &mut BrushShell) -> Result<()> {
|
||||
.map_err(|err| Error::msg(format!("Failed to set env fallback: {err}")))
|
||||
}
|
||||
|
||||
fn is_macos_malloc_stack_logging_var(key: &str) -> bool {
|
||||
matches!(key, "MallocStackLogging" | "MallocStackLoggingNoCompact")
|
||||
}
|
||||
|
||||
fn should_skip_env_var(key: &str) -> bool {
|
||||
if key.starts_with("BASH_FUNC_") && key.ends_with("%%") {
|
||||
return true;
|
||||
}
|
||||
if is_macos_malloc_stack_logging_var(key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
key,
|
||||
@@ -1398,6 +1411,13 @@ fn should_skip_env_var(key: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_trailing_newline_for_heredoc(command: &mut String) {
|
||||
if command.ends_with('\n') || !command.as_bytes().windows(2).any(|window| window == b"<<") {
|
||||
return;
|
||||
}
|
||||
command.push('\n');
|
||||
}
|
||||
|
||||
const fn session_keepalive(result: &ExecutionResult) -> bool {
|
||||
match result.next_control_flow {
|
||||
ExecutionControlFlow::Normal => true,
|
||||
@@ -2191,6 +2211,32 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert_eq!(minimized.output_bytes, 9);
|
||||
}
|
||||
|
||||
/// Regression: a quoted here-doc followed by another command must execute
|
||||
/// instead of failing with "unterminated here document". The minimizer's
|
||||
/// segmented runner used to rebuild each segment via the brush AST Display
|
||||
/// impl, which re-emitted the `<<'PY'` close tag as the quoted `'PY'` — an
|
||||
/// invalid delimiter that left the body unterminated. Here-doc-bearing
|
||||
/// commands now bail out of segmentation and run whole via the single path.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn quoted_heredoc_in_chain_runs_via_single_path() {
|
||||
let root = unique_temp_dir("heredoc-chain");
|
||||
let minimizer = printf_minimizer(&root.join("minimizer.toml"), None);
|
||||
let (result, output) = run_command_capture(
|
||||
"/bin/cat <<'PY'\nhello $USER\nPY\nprintf 'after\\n'",
|
||||
None,
|
||||
Some(minimizer),
|
||||
CancelToken::default(),
|
||||
)
|
||||
.await;
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
// Quoted delimiter keeps the body literal ($USER unexpanded) and the
|
||||
// trailing command still runs in order.
|
||||
assert_eq!(output, "hello $USER\nafter\n");
|
||||
assert!(!output.contains("unterminated"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn segmented_chain_exceeding_aggregate_capture_cap_stays_raw() {
|
||||
@@ -2491,6 +2537,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_accepts_last_background_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
@@ -2507,6 +2554,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_n_p_records_completed_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
@@ -2526,6 +2574,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_f_accepts_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
@@ -2556,66 +2605,6 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert!(matches!(reason, AbortReason::Signal));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancellation_aborts_internal_background_jobs() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock before epoch")
|
||||
.as_nanos();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("pi-shell-bg-cancel-{}-{unique}", std::process::id()));
|
||||
std::fs::create_dir(&dir).expect("create temp dir");
|
||||
let started = dir.join("started");
|
||||
let release = dir.join("release");
|
||||
let marker = dir.join("marker");
|
||||
|
||||
let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None };
|
||||
let mut session = create_session(&config).await.expect("create session");
|
||||
session
|
||||
.shell
|
||||
.set_working_dir(dir.to_string_lossy().as_ref())
|
||||
.expect("set cwd");
|
||||
|
||||
let mut params = session.shell.default_exec_params();
|
||||
params.set_fd(OpenFiles::STDIN_FD, null_file().expect("null stdin"));
|
||||
params.set_fd(OpenFiles::STDOUT_FD, null_file().expect("null stdout"));
|
||||
params.set_fd(OpenFiles::STDERR_FD, null_file().expect("null stderr"));
|
||||
|
||||
let source_info = SourceInfo::from("pi-shell:test");
|
||||
let result = session
|
||||
.shell
|
||||
.run_string(
|
||||
"{ echo started > started; while [ ! -f release ]; do sleep 0.05; done; echo done > \
|
||||
marker; } &",
|
||||
&source_info,
|
||||
¶ms,
|
||||
)
|
||||
.await
|
||||
.expect("spawn background job");
|
||||
assert_eq!(exit_code(&result), 0);
|
||||
|
||||
let mut background_started = false;
|
||||
for _ in 0..200 {
|
||||
if started.exists() {
|
||||
background_started = true;
|
||||
break;
|
||||
}
|
||||
time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(background_started, "background job did not reach its wait loop");
|
||||
|
||||
terminate_background_jobs(&mut session.shell);
|
||||
std::fs::write(&release, b"").expect("release marker");
|
||||
time::sleep(Duration::from_millis(250)).await;
|
||||
let marker_exists = marker.exists();
|
||||
std::fs::remove_dir_all(&dir).expect("cleanup temp dir");
|
||||
|
||||
assert!(
|
||||
!marker_exists,
|
||||
"internal background job survived cancellation and wrote marker after release",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn read_output_stops_when_cancelled_before_pipe_eof() {
|
||||
@@ -2725,6 +2714,25 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert_eq!(stdout, b"prod:8080");
|
||||
}
|
||||
|
||||
/// Quoted heredoc delimiters at EOF must behave like bash. `brush-parser`
|
||||
/// currently rejects that shape unless the input stream ends with a newline,
|
||||
/// which surfaced as `unterminated here document sequence; tag(s) [...]` for
|
||||
/// normal paste-run Python snippets.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn quoted_heredoc_without_trailing_newline_runs() {
|
||||
let (result, output) = run_command_capture(
|
||||
"/bin/cat <<'PY'\nhello $USER\nPY",
|
||||
None,
|
||||
None,
|
||||
CancelToken::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert_eq!(output, "hello $USER\n");
|
||||
}
|
||||
|
||||
/// Regression for a Windows/macOS deadlock in
|
||||
/// `brush_core::interp::setup_open_file_with_contents`. The body is
|
||||
/// 256 KiB — well past the default pipe buffer on every platform
|
||||
@@ -2757,10 +2765,12 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
/// own exit status — not nohup's (`125`/`126`/`127`) error codes.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nohup_builtin_propagates_command_exit_code() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "nohup /bin/sh -c 'exit 7'".to_string(),
|
||||
..Default::default()
|
||||
let command = if cfg!(windows) {
|
||||
"nohup cmd /C exit 7"
|
||||
} else {
|
||||
"nohup /bin/sh -c 'exit 7'"
|
||||
};
|
||||
let options = ShellExecuteOptions { command: command.to_string(), ..Default::default() };
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
@@ -2769,6 +2779,36 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
|
||||
/// `nohup` is a no-op builtin in this embedded shell, but `nohup cmd &`
|
||||
/// must still behave like a process-launching background command for `$!`.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nohup_background_captures_operand_pid() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let options = ShellExecuteOptions {
|
||||
command: "nohup /bin/sh -c 'exit 0' >/dev/null 2>&1 & pid=$!; printf 'pid=%s\n' \
|
||||
\"$pid\"; test -n \"$pid\""
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = execute_shell(options, Some(tx), CancelToken::default())
|
||||
.await
|
||||
.expect("execute should succeed");
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert!(!result.cancelled);
|
||||
assert!(!result.timed_out);
|
||||
|
||||
let mut out = String::new();
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
out.push_str(&chunk);
|
||||
}
|
||||
let pid = out
|
||||
.trim()
|
||||
.strip_prefix("pid=")
|
||||
.expect("nohup background PID output should include pid= prefix");
|
||||
assert!(pid.parse::<i32>().is_ok_and(|pid| pid > 0), "invalid PID output: {out:?}");
|
||||
}
|
||||
|
||||
/// `nohup` with no operand mirrors coreutils: a `missing operand` diagnostic
|
||||
/// and exit code 125 (a nohup-level error, distinct from any command code).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cargo test --workspace
|
||||
@@ -0,0 +1,48 @@
|
||||
Compiling proc-macro2 v1.0.86
|
||||
Compiling unicode-ident v1.0.12
|
||||
Compiling libc v0.2.155
|
||||
Compiling autocfg v1.3.0
|
||||
Compiling quote v1.0.36
|
||||
Compiling syn v2.0.72
|
||||
Compiling serde_derive v1.0.204
|
||||
Compiling serde v1.0.204
|
||||
Compiling thiserror-impl v1.0.63
|
||||
Compiling thiserror v1.0.63
|
||||
Compiling pi-shell v0.1.0 (/home/alpha/odin/omp/crates/pi-shell)
|
||||
warning: unused variable: `ctx`
|
||||
--> crates/pi-shell/src/minimizer/filters/mod.rs:134:30
|
||||
|
|
||||
134 | pub fn filter(ctx: &MinimizerCtx, input: &str) -> MinimizerOutput {
|
||||
| ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
|
||||
|
|
||||
= note: `#[warn(unused_variables)]` on by default
|
||||
Finished `test` profile [unoptimized + debuginfo] target(s) in 12.84s
|
||||
Running unittests src/lib.rs (target/debug/deps/pi_shell-9a1b2c3d4e5f6a7b)
|
||||
|
||||
running 42 tests
|
||||
test minimizer::detect::tests::detects_git_status ... ok
|
||||
test minimizer::detect::tests::detects_cargo_test ... ok
|
||||
test minimizer::engine::tests::enabled_known_filter_minimizes ... ok
|
||||
test minimizer::engine::tests::unknown_command_is_passthrough ... ok
|
||||
test minimizer::filters::cargo::tests::passing_run_summarizes ... ok
|
||||
test minimizer::filters::go::tests::go_test_pass_condenses ... ok
|
||||
test minimizer::plan::tests::single_command_is_single ... ok
|
||||
test minimizer::plan::tests::chain_splits_on_and ... ok
|
||||
|
||||
test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered; finished in 0.31s
|
||||
|
||||
Running tests/minimizer_fixtures.rs (target/debug/deps/minimizer_fixtures-1122334455667788)
|
||||
|
||||
running 8 tests
|
||||
test fixture_cargo_test_pass ... ok
|
||||
test fixture_npm_install ... ok
|
||||
test fixture_go_test_pass ... ok
|
||||
|
||||
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered; finished in 0.02s
|
||||
|
||||
Doc-tests pi-shell
|
||||
|
||||
running 3 tests
|
||||
test crates/pi-shell/src/minimizer.rs - minimizer (line 12) ... ok
|
||||
|
||||
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered; finished in 0.45s
|
||||
@@ -0,0 +1 @@
|
||||
git log
|
||||
@@ -0,0 +1,5 @@
|
||||
8cb198e (HEAD -> main, tag: v0.22.0)
|
||||
chore(master): release 0.22.0 (#201)
|
||||
Release-As: 0.22.0
|
||||
393fa5b feat: add `rtk wc` command (#175)
|
||||
Adds a compact word-count subcommand.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user