diff --git a/CHANGELOG.md b/CHANGELOG.md index a009278..5a63bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.1] - 2026-04-27 + +Polish release - documentation accuracy, test reliability, and edge-case +observability. No user-visible behavior changes. + +### Changed + +- README Roadmap copy refreshed: clearer framing of the v1.6 stable + CLI/JSON API contract section (abstract guarantees only - concrete + subcommand naming defers to the v1.6 release notes). Domain-folder + negative list updated. +- `_glyphs.ts` doc clarified: `NO_COLOR` is honored when set to a + non-empty value, per the no-color.org spec (code was already + spec-correct; only the doc was stale). + +### Fixed + +- `purge.ts` audit-trail invariant documented: a `manifest_write_failed:` + failure (disk mutation succeeded, journal-write failed) is treated as a + full failure by the all-failed gate. The "if it isn't journaled, it + didn't happen" contract is now part of the file's docstring and locked + by an in-source test. +- `docs/JSON-SCHEMA.md`: fixed an MD028 markdownlint violation (adjacent + blockquotes separated by a blank line) and verified the rest of the + file for similar instances. +- `change-plan.ts`: replaced hard-coded canonical-ID literal strings with + calls to the exported `canonicalItemId(...)` helper, eliminating drift + risk if the format string changes. +- `scan-memory.ts`: added Windows path-normalization regression coverage + (in-source test). The normalization itself was already correct; the + test locks it. + +### Tests + +- Test-helper polish: replaced a dead `void killed` no-op with a real + `if (killed) return;` guard; synced the `graceMs` parameter doc; synced + the `restore --json` envelope test header docstring with the actual + envelope shape; removed a redundant `stripAnsi(raw)` call in the tmux + e2e fixture; replaced a non-null assertion with explicit narrowing in + the corrupt-manifest test; normalized the force-partial banner string + concatenation and added a tightening assertion that the joining space + is exactly one character. Aligned the `commands` row arrow with the + `agents`/`skills` rows via `padEnd(8)`. Added a `formatBytes()` helper + to the bundle-size check and replaced the hard-coded budget string + with the formatted value. Updated the `pagination-500.test.ts` file- + header comment to enumerate the actual tests in the file. + ## [1.5.0] - 2026-04-26 ### Changed diff --git a/README.md b/README.md index ca1d9bb..28324c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ccaudit -**~11% of your Opus 4.7 1M-token context is gone before you type a word.** (~108k tokens of ghost inventory — roughly half a Sonnet 4.6 200k context. See snapshot below.) +**~11% of your Opus 4.7 1M-token context is gone before you type a word.** (~108k tokens of ghost inventory - roughly half a Sonnet 4.6 200k context. See snapshot below.) Unused agents, skills, MCP servers, commands, hooks, and memory files (call them ghosts) load into context every session.
ccaudit finds them, shows you the cost, and moves them to `~/.claude/ccaudit/archived/` in one command.
@@ -13,30 +13,57 @@ ccaudit does. None of them cover the full picture: | Tool | Covers | Doesn't cover | | ----------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `/skills t-sort` | Skills only — list, reorder. | Agents, MCP servers, hooks, memory, commands. No token math. No archive / rollback. | +| `/skills t-sort` | Skills only - list, reorder. | Agents, MCP servers, hooks, memory, commands. No token math. No archive / rollback. | | `/usage` | Aggregate token + cost reporting per session. | Per-component ghost identification. Doesn't tell you what to remove or move anything. | | `claude plugin disable` | Plugin-level on/off. | Granular agent / skill / MCP / hook visibility. No token cost. No manifest of what was disabled. | ccaudit's differentiator: **cross-component scope** (agents + skills + MCP + memory + commands + hooks in one pass), **regime-aware token math** (eager vs deferred-tools once `cc ≥ 2.1.7` flips the ToolSearch threshold), and -**archive-with-rollback** — every action is manifest-logged and reversible +**archive-with-rollback** - every action is manifest-logged and reversible via `ccaudit restore`. ```bash -npx ccaudit-cli@latest # see what's loading vs. what's used -npx ccaudit-cli --dry-run # see the archive plan, no files touched -npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, undo with restore) +npx ccaudit-cli@latest # see what's loading vs. what's used +npx ccaudit-cli --interactive # pick exactly which ghosts to archive (TUI) +npx ccaudit-cli --dry-run # see the archive plan, no files touched +npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, undo with restore) ``` > ccusage tells you what you spent. ccaudit tells you what's wasting it. -Current release: **v1.5.0** — interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). +Current release: **v1.5.1** - interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). ![Image](https://github.com/user-attachments/assets/2afbe339-539b-4a8d-9f73-8d43746c9ace) +The `--interactive` picker (v1.5) lets you tab through every category, toggle individual ghosts, and confirm before any disk write: + +```text +AGENTS │ SKILLS │ MCP SERVERS │ MEMORY │ COMMANDS +AGENTS (9/179 · 743 tok) + ── Ungrouped ── + ◯ brand-guardian 65 tok ~/.claude/agents/design/b… + ◉ image-prompt-engineer 97 tok ~/.claude/agents/design/i… +› ◉ inclusive-visuals-specialist 64 tok ~/.claude/agents/design/i… + ◉ ui-designer 85 tok ~/.claude/agents/design/u… + ◉ ux-architect 64 tok ~/.claude/agents/design/u… + ◉ ux-researcher 84 tok ~/.claude/agents/design/u… + ◉ visual-storyteller 101 tok ~/.claude/agents/design/v… + ◉ whimsy-injector 83 tok ~/.claude/agents/design/w… + ◉ ai-engineer 95 tok ~/.claude/agents/engineer… + ◉ autonomous-optimization-architect 70 tok ~/.claude/agents/engineer… + ◯ backend-architect 82 tok ~/.claude/agents/engineer… + ◯ data-engineer 96 tok ~/.claude/agents/engineer… + ◯ devops-automator 59 tok ~/.claude/agents/engineer… + ◯ embedded-firmware-engineer 69 tok ~/.claude/agents/engineer… + ◯ frontend-developer 66 tok ~/.claude/agents/engineer… +↓ 165 more below +Tab ← → tabs · ↑↓ nav · / search · ? help · Space toggle · a tab-all · Enter → · q cancel +14 of 323 selected across all tabs · ≈ 3k tokens saved +``` + > **"But doesn't `/context` already do this?"** -> `/context` shows what's loaded _right now_ in the current session. ccaudit shows what's been ghost-loading for _weeks_ — agents, skills, MCP servers, and memory files you forgot you installed, silently eating your context budget every session. One is a live snapshot; the other is the longitudinal audit. +> `/context` shows what's loaded _right now_ in the current session. ccaudit shows what's been ghost-loading for _weeks_ - agents, skills, MCP servers, and memory files you forgot you installed, silently eating your context budget every session. One is a live snapshot; the other is the longitudinal audit. --- @@ -74,7 +101,7 @@ A typical audit looks like this (default mode, hooks advisory, not included in t ```text ┌──────────────────────────────────────────────────────────────────────────────┐ │ CCAUDIT - ~64k tokens/session wasted │ -│ 👻 Ghost Inventory — Last 7 days │ +│ 👻 Ghost Inventory - Last 7 days │ ├──────────────┬──────────────┬─────────────┬──────────────────────────────────┤ │ Agents │ Defined: 177 │ Used: 12 │ Ghost: 165 ~24k tokens/session │ │ │ │ │ (70 in frameworks above) │ @@ -93,7 +120,7 @@ A typical audit looks like this (default mode, hooks advisory, not included in t │ Total ghost overhead: ~64k tokens (~32% of 200k context window) │ │ (global: ~62k tokens + worst project ~/projects/my-project: ~2.1k tokens) │ │ Health grade: D (Poor) │ -│ Hooks (advisory — not included in total): ~18k tokens upper-bound (9 hooks) │ +│ Hooks (advisory - not included in total): ~18k tokens upper-bound (9 hooks) │ │ Pass --include-hooks to add to grand total. │ │ 💡 Potential savings after ccaudit --dangerously-bust-ghosts: ~64k │ │ tokens/session reclaimed │ @@ -177,7 +204,7 @@ npx ccaudit-cli ghost --interactive └──────────────────────────────────────────────────────────────────────┘ ``` -> Hook archival deferred — selectable archive coming in a future phase. +> Hook archival deferred - selectable archive coming in a future phase. ### The confirmation screen @@ -214,8 +241,8 @@ npx ccaudit-cli ghost --interactive | `Esc` / `Ctrl+C` / `q` | Cancel with "No changes made." (exit 0) | Framework-protected rows render dimmed with a `[🔒]` glyph and are not -selectable by default — pass `--force-partial` to unlock them for the -current run (a `--force-partial active — partial-framework busts allowed` +selectable by default - pass `--force-partial` to unlock them for the +current run (a `--force-partial active: framework protection DISABLED. Partial framework splits may corrupt dependent setups.` banner appears at the top of the picker). See `CLAUDE.md`'s Safety invariants section for the full rationale behind framework-as-unit protection. @@ -326,10 +353,10 @@ Notes: | _(no args)_ | | Restore all items from **every** bust manifest (deduplicated, newer-wins). | | `` | | Restore a single archived item by name: basename without extension for agents/skills/commands (e.g. `restore code-reviewer`), or server name for MCP entries. | | `--interactive` | `-i` | Open a TUI picker listing every archived item across all manifests. Select a subset to restore. Requires a TTY; may be combined with `--json` for the final result envelope. | -| `--name ` | | Fuzzy single-match restore (case-insensitive substring). Ambiguous patterns error with a candidate list — never auto-resolve. | +| `--name ` | | Fuzzy single-match restore (case-insensitive substring). Ambiguous patterns error with a candidate list - never auto-resolve. | | `--all-matching ` | | Bulk restore of every item matching the fuzzy pattern. Exits `1` with `no archived item matches ""` on stderr if nothing matches. | | `--list` | | List all archived items across all bust manifests (read-only). | -| `--json` | `-j` | Output as JSON with a `meta` envelope. Includes additive `selectionFilter`, `skipped[]`, and `filteredStaleCount` fields — see [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md). | +| `--json` | `-j` | Output as JSON with a `meta` envelope. Includes additive `selectionFilter`, `skipped[]`, and `filteredStaleCount` fields - see [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md). | | `--csv` | | RFC 4180 CSV export. | | `--quiet` | `-q` | Machine-readable TSV only. | | `--verbose` | `-v` | Show detailed output including warnings. | @@ -408,7 +435,7 @@ Everything is reversible: ```bash npx ccaudit-cli restore # restore every archived item across all manifests -npx ccaudit-cli restore --interactive # TUI picker — select a subset to restore (v1.5) +npx ccaudit-cli restore --interactive # TUI picker - select a subset to restore (v1.5) npx ccaudit-cli restore --name code-reviewer # fuzzy single-match restore (v1.5) npx ccaudit-cli restore --all-matching gsd- # bulk-restore everything matching the pattern (v1.5) npx ccaudit-cli restore # restore one archived item by exact canonical id @@ -463,12 +490,12 @@ ccaudit purge-archive --json # structured envelope, combinable with --yes Classification per manifest-union archive op: -- **Reclaim** — archive exists, source path is free. The archive is moved back to source (same shared mover as `reclaim`). -- **Drop / source_occupied** — archive exists, source path is already occupied. The archive is unlinked. The source file is **never** overwritten. -- **Drop / stale_archive_missing** — archive is already gone, source exists (already-restored or test residue). No disk mutation; a follow-up manifest op is still appended so restore listings stop surfacing it. -- **Skip / both_missing** — both paths are absent. Preserved for diagnosis; never auto-resolved. +- **Reclaim** - archive exists, source path is free. The archive is moved back to source (same shared mover as `reclaim`). +- **Drop / source_occupied** - archive exists, source path is already occupied. The archive is unlinked. The source file is **never** overwritten. +- **Drop / stale_archive_missing** - archive is already gone, source exists (already-restored or test residue). No disk mutation; a follow-up manifest op is still appended so restore listings stop surfacing it. +- **Skip / both_missing** - both paths are absent. Preserved for diagnosis; never auto-resolved. -Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP disable ops (the `name → ccaudit-disabled:name` key-rename written by `bust`) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge--.jsonl` manifest — the original archive op stays intact for audit. Real purge requires an **explicit `--yes`**; there is no prompt fallback. Failures on individual items are reported in `purge.failures[]` but do not abort the batch. +Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP disable ops (the `name → ccaudit-disabled:name` key-rename written by `bust`) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge--.jsonl` manifest - the original archive op stays intact for audit. Real purge requires an **explicit `--yes`**; there is no prompt fallback. Failures on individual items are reported in `purge.failures[]` but do not abort the batch. See [docs/JSON-SCHEMA.md § Purge](./docs/JSON-SCHEMA.md) for the `purge.summary` / `purge.failures[]` envelope contract. @@ -683,14 +710,14 @@ wins. Place more-specific entries before more-general ones to avoid shadowing. ## Version -- Current package version: **1.5.0** +- Current package version: **1.5.1** - Build source of truth: `apps/ccaudit/package.json` and `apps/ccaudit/src/_version.ts` --- ## Roadmap -Items planned for upcoming releases. No firm dates — order may shift. +Items planned for upcoming releases. No firm dates - order may shift. ### Interactive process killer @@ -701,21 +728,10 @@ context, lets you multi-select with the same keybindings as the archive picker, and signals selected processes (TERM by default, KILL on confirm). For when one rogue session is pinning context across half your machine. -### v1.6 game CLI API contract - -A separate, stable JSON CLI surface scoped to embedding tools — initial -consumer is the standalone **ccaudit Ghost Town** game. The contract: - -```bash -ccaudit game scan --json -ccaudit game bust --ids --json -ccaudit game vault --json -ccaudit game restore --ids --json -ccaudit game agent-context --id --json -ccaudit game dry-run --ids --json # optional -``` +### v1.6 stable CLI/JSON API contract -Stability guarantees external consumers will rely on: +v1.6 will freeze a public JSON CLI surface scoped to embedding tools and +downstream consumers. Stability guarantees external consumers will rely on: - TUI behavior is **not** part of the contract. - Human-rendered tables are **not** part of the contract. @@ -724,10 +740,11 @@ Stability guarantees external consumers will rely on: - Path-shaped canonical IDs are not public unless explicitly declared. The envelope follows the existing `--json` shape (see -[docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)) with a `command: "game.*"` and -`apiVersion` field for forward compatibility. +[docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)) with an `apiVersion` field for +forward compatibility. Subcommand names and the full contract surface will land +in the v1.6 release notes. -### A new frontend — surprise +### A new frontend - surprise Something visual is in the works. More when it lands. diff --git a/apps/ccaudit/package.json b/apps/ccaudit/package.json index e992f8c..e66c27d 100644 --- a/apps/ccaudit/package.json +++ b/apps/ccaudit/package.json @@ -1,6 +1,6 @@ { "name": "ccaudit-cli", - "version": "1.5.0", + "version": "1.5.1", "description": "Audit Claude Code ghost inventory — agents, skills, MCP servers, and memory files", "keywords": [ "audit", diff --git a/apps/ccaudit/scripts/bundle-size-check.mjs b/apps/ccaudit/scripts/bundle-size-check.mjs index 285707f..c1177d3 100644 --- a/apps/ccaudit/scripts/bundle-size-check.mjs +++ b/apps/ccaudit/scripts/bundle-size-check.mjs @@ -13,6 +13,18 @@ const distPath = resolve(scriptsDir, '../dist/index.js'); const baselinePath = resolve(scriptsDir, 'bundle-baseline.txt'); const BUDGET_BYTES = 15 * 1024; // 15360 bytes per D-04 +/** Format a byte count as a short human string (e.g. 15360 -> 15 KB). */ +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return `${bytes}B`; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) { + const kb = bytes / 1024; + // Prefer integer KB when exact; one decimal otherwise. + return Number.isInteger(kb) ? `${kb} KB` : `${kb.toFixed(1)} KB`; + } + return `${bytes} B`; +} + if (!existsSync(distPath)) { console.error(`[bundle-size] FAIL: dist/index.js not found at ${distPath}`); console.error('[bundle-size] Run `pnpm -w build` first.'); @@ -44,7 +56,9 @@ console.log( ); if (delta > BUDGET_BYTES) { - console.error(`[bundle-size] FAIL: delta exceeds 15 KB budget (${delta} > ${BUDGET_BYTES})`); + console.error( + `[bundle-size] FAIL: delta exceeds ${formatBytes(BUDGET_BYTES)} budget (${delta} > ${BUDGET_BYTES})`, + ); process.exit(1); } diff --git a/apps/ccaudit/src/__tests__/_test-helpers.ts b/apps/ccaudit/src/__tests__/_test-helpers.ts index 19c82fc..5388ca0 100644 --- a/apps/ccaudit/src/__tests__/_test-helpers.ts +++ b/apps/ccaudit/src/__tests__/_test-helpers.ts @@ -9,12 +9,12 @@ import { spawn, type ChildProcess } from 'node:child_process'; /** * Wait for the spawned picker subprocess to reach its blocking read loop. - * Polls until the child exits (error/crash path), or until maxWaitMs elapses, + * Polls until the child exits (error/crash path), or until graceMs elapses, * with exponential backoff — whichever comes first. Then adds a final grace * delay so the TUI is ready for key input. * * Typical path: child never exits during the poll window, loop runs until - * maxWaitMs, then a 300ms grace is added for the render cycle to settle. + * graceMs, then a 300ms grace is added for the render cycle to settle. */ export async function waitForPicker(child: ChildProcess, graceMs = 300): Promise { // Short poll to detect early crashes (child exiting prematurely). @@ -257,8 +257,9 @@ export function runCcauditGhost( }); child.on('close', (code: number | null) => { clearTimeout(timer); - // Resolve in both killed and non-killed cases so callers always get output. - void killed; + // If the process was already killed (timeout path), do not call + // resolve(...) — the timeout already rejected the promise. + if (killed) return; resolve({ stdout, stderr, exitCode: code, durationMs: Date.now() - start }); }); }); diff --git a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts index e2018e8..b25dccc 100644 --- a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts +++ b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts @@ -152,7 +152,10 @@ export class TmuxE2ESession { startLine: opts.startLine ?? -200, ansi: opts.stripAnsi === false, }); - lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw); + // capture() already strips ANSI internally when ansi !== true, and when + // opts.stripAnsi === false we explicitly want the raw bytes. Either way + // `raw` is in the desired shape — no second strip needed. + lastCapture = raw; const matched = typeof needle === 'string' ? lastCapture.includes(needle) : needle.test(lastCapture); if (matched) return lastCapture; diff --git a/apps/ccaudit/src/__tests__/pagination-500.test.ts b/apps/ccaudit/src/__tests__/pagination-500.test.ts index e8c2940..2655490 100644 --- a/apps/ccaudit/src/__tests__/pagination-500.test.ts +++ b/apps/ccaudit/src/__tests__/pagination-500.test.ts @@ -7,13 +7,12 @@ * keystroke decoding — which existing Phase 3.1 / 5 tests already * cover end-to-end. * - * Asserts: - * 1. With 500 ghosts and a bounded viewport, the rendered frame contains - * only a viewport-sized slice of agent rows (no terminal overflow). - * 2. End jumps cursor to last row; an above-indicator is visible. - * 3. The applyScroll reducer round-trips cursor position across filter - * on/off and clamps when the row set narrows below the saved cursor. - * 4. Toggling many rows keeps the rendered frame bounded. + * Asserts (file-header inventory of the actual `it(...)` cases below): + * 1. renders only a viewport-sized slice on a 500-item tab (no overflow) + * 2. End jumps cursor to last row; above-indicator rendered + * 3. applyScroll reducer round-trips cursor across filter on/off and + * clamps on narrowing + * 4. toggling across 500 items keeps the rendered frame bounded */ import { describe, it, expect } from 'vitest'; import { TabbedGhostPicker } from '../../../../packages/terminal/src/tui/tabbed-picker.ts'; diff --git a/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts index fdaa52f..830a0b8 100644 --- a/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts +++ b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts @@ -87,12 +87,15 @@ describe.skipIf(process.platform === 'win32')( // stdout must be a parseable JSON object (structured envelope or error envelope) const stdout = r.stdout.trim(); expect(stdout.length, 'stdout must not be empty for --json').toBeGreaterThan(0); - let parsed: Record; + let parsed: Record | null = null; expect(() => { parsed = JSON.parse(stdout) as Record; }, `stdout must be valid JSON, got:\n${stdout}`).not.toThrow(); + if (parsed === null) { + throw new Error('expected parsed envelope to be non-null at this point'); + } // The envelope must have a meta block (standard ccaudit JSON envelope shape) - expect(parsed!).toHaveProperty('meta'); + expect(parsed).toHaveProperty('meta'); // Must not be a raw stack trace in stdout expect(stdout).not.toContain('at Object.'); }); diff --git a/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts index e3ca7bd..52ae44a 100644 --- a/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts +++ b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts @@ -2,15 +2,19 @@ * Phase 08 Plan 06 — `ccaudit restore --all-matching --json` * envelope contract (D8-16, D8-17). * - * Asserts the v1.5 additive fields land in the envelope: - * - `meta.command === 'restore'`, `meta.exitCode === 0` - * - `status === 'success'` - * - `selectionFilter.mode === 'subset'` - * - `selectionFilter.ids` has exactly 2 entries (pencil-dev + pencil-review) - * - `Array.isArray(skipped)` is true (empty on the happy path, but present) + * Restore JSON envelope shape — what the tests below assert: * - * Source-exists skip + skipped[] contents are covered by - * restore-interactive-source-exists.test.ts. + * - meta.command // e.g. "restore" + * - meta.exitCode // camelCase number + * - status // top-level enum string ("success" on happy path) + * - selectionFilter // top-level object describing applied filter + * // .mode === "subset" + * // .ids has exactly 2 entries (pencil-dev + pencil-review) + * - skipped[] // top-level array of items skipped at restore time + * // (empty on the happy path, but present) + * + * See docs/JSON-SCHEMA.md for the full envelope contract. Source-exists skip + * + skipped[] contents are covered by restore-interactive-source-exists.test.ts. */ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; diff --git a/docs/JSON-SCHEMA.md b/docs/JSON-SCHEMA.md index 8a9883a..bf6e093 100644 --- a/docs/JSON-SCHEMA.md +++ b/docs/JSON-SCHEMA.md @@ -125,6 +125,8 @@ fields surfacing the subset-restore surface landed in v1.5: > compatibility with v1.5 dry-run/bust manifests. Public `--json` envelopes use > camelCase and do not expose the manifest header casing directly. +--- + > **Pre-dispatch validation envelope.** Restore preflight failures such as > mutually-exclusive flags, `CCAUDIT_NO_INTERACTIVE`, TTY refusal, no-match, or > ambiguity emit the standard envelope when `--json` is active: diff --git a/packages/internal/src/remediation/change-plan.ts b/packages/internal/src/remediation/change-plan.ts index 39a9093..d40ea77 100644 --- a/packages/internal/src/remediation/change-plan.ts +++ b/packages/internal/src/remediation/change-plan.ts @@ -311,9 +311,22 @@ if (import.meta.vitest) { it('Test 2: Set with 2 of 3 ids returns only those items', () => { const plan = makePlan3(); - // Build canonical ids for agentA and agentB (category|scope|projectPath|path) - const idA = `agent|global||/tmp/agentA`; - const idB = `agent|global||/tmp/agentB`; + // Build canonical ids via the helper so the test cannot drift if the + // canonical-id format string changes (A3 — re-use `canonicalItemId`). + const idA = canonicalItemId({ + name: 'agentA', + path: '/tmp/agentA', + scope: 'global', + category: 'agent', + projectPath: null, + }); + const idB = canonicalItemId({ + name: 'agentB', + path: '/tmp/agentB', + scope: 'global', + category: 'agent', + projectPath: null, + }); const result = filterChangePlan(plan, new Set([idA, idB])); expect(result.archive).toHaveLength(2); expect(result.disable).toHaveLength(0); @@ -326,10 +339,22 @@ if (import.meta.vitest) { makeResult({ category: 'agent', tier: 'definite-ghost', name: 'a2', tokens: 200 }), makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'm1', tokens: 500 }), ]); - const idA1 = `agent|global||/tmp/a1`; - // mcp-server canonical id: mcp-server|scope|projectPath|name|path - // makeResult produces: name='m1', path='/tmp/m1', scope='global', projectPath=null - const idM1 = `mcp-server|global||m1|/tmp/m1`; + // A3: re-use `canonicalItemId` so the canonical-id shape is sourced from + // the helper rather than mirrored as a literal string here. + const idA1 = canonicalItemId({ + name: 'a1', + path: '/tmp/a1', + scope: 'global', + category: 'agent', + projectPath: null, + }); + const idM1 = canonicalItemId({ + name: 'm1', + path: '/tmp/m1', + scope: 'global', + category: 'mcp-server', + projectPath: null, + }); const result = filterChangePlan(plan, new Set([idA1, idM1])); // counts: 1 agent + 1 mcp expect(result.counts.agents).toBe(1); @@ -351,7 +376,13 @@ if (import.meta.vitest) { it('Test 5: unknown ids in the set are silently ignored (no throw)', () => { const plan = makePlan3(); - const unknownId = 'agent|global||/nonexistent/path'; + const unknownId = canonicalItemId({ + name: 'nonexistent', + path: '/nonexistent/path', + scope: 'global', + category: 'agent', + projectPath: null, + }); // Should not throw; the unknown id just matches nothing expect(() => filterChangePlan(plan, new Set([unknownId]))).not.toThrow(); const result = filterChangePlan(plan, new Set([unknownId])); diff --git a/packages/internal/src/remediation/purge.ts b/packages/internal/src/remediation/purge.ts index 79c07a4..5818ff6 100644 --- a/packages/internal/src/remediation/purge.ts +++ b/packages/internal/src/remediation/purge.ts @@ -17,6 +17,27 @@ // - Every successful mutation produces a single append-only archive_purge op. // - moveArchiveToSource refuses to overwrite existing source (helper // preserves the reclaim INV). +// +// Audit-trail invariant +// --------------------- +// The append-only journal at ~/.claude/ccaudit/manifests/ is the source of +// truth for every mutation purge performs. A purge operation is only +// "complete" when both the disk mutation AND its corresponding op record have +// landed. +// +// If the disk mutation succeeds but writer.writeOp(...) subsequently throws, +// the operation is treated as a FULL FAILURE — the failure reason is +// prefixed with `manifest_write_failed:` and the op is counted in the +// failure tally, not the success tally. The principle: "if it isn't +// journaled, it didn't happen" — restore / reclaim / a future purge cannot +// observe an undocumented mutation, so for the user-visible audit surface +// the mutation effectively did not occur. +// +// That is why the all-failed gate counts manifest_write_failed: reasons +// identically to disk-mutation failures. Carving manifest_write_failed: out +// of the all-failed gate would mean reporting partial success for a state +// the audit trail cannot describe — the exact failure mode this invariant +// forbids. import type { ArchiveOp, ArchivePurgeOp, ManifestOp, ManifestWriter } from './manifest.ts'; import { buildArchivePurgeOp, closePurgeManifestWriter } from './manifest.ts'; @@ -863,6 +884,112 @@ if (import.meta.vitest) { } }); + it('audit-trail invariant — disk mutation succeeds, writeOp throws → failure carries manifest_write_failed: prefix and is counted in failure tally (not success tally)', async () => { + // Locks the "if it isn't journaled, it didn't happen" contract + // documented in the top-of-file block comment. Two-op plan so the + // success channel stays open and we can observe BOTH the prefix on + // the failure record AND the tally separation. The single-op + // all-failed companion case is covered immediately below. + const goodOp = archiveOp({ + op_id: 'audit-trail-good', + archive_path: '/a/audit-good', + source_path: '/s/audit-good', + }); + const badOp = archiveOp({ + op_id: 'audit-trail-bad', + archive_path: '/a/audit-bad', + source_path: '/s/audit-bad', + }); + const unlinkFile = vi.fn(async () => undefined); + + let writeOpCallCount = 0; + const writeOpFn = vi.fn(async () => { + writeOpCallCount += 1; + // First call (goodOp) succeeds; second call (badOp) throws. + if (writeOpCallCount === 2) throw new Error('synthetic writer failure'); + }); + const mockWriter = { + writeOp: writeOpFn, + close: vi.fn(async () => undefined), + filePath: '/fake/manifests/purge-audit-trail.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: mockWriter, + path: '/fake/manifests/purge-audit-trail.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [ + { op: goodOp, reason: 'source_occupied' }, + { op: badOp, reason: 'source_occupied' }, + ], + skip: [], + }; + + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + + // Both disk mutations were attempted (the loop continues after writeOp throw). + expect(unlinkFile).toHaveBeenCalledTimes(2); + // writeOp was invoked twice (once per successful unlink). + expect(writeOpFn).toHaveBeenCalledTimes(2); + + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + // Tally separation: the writeOp-throw op counts in failures, NOT in successes. + expect(result.value.summary.purgedCount).toBe(1); + expect(result.value.failures).toHaveLength(1); + expect(result.value.failures[0]!.op_id).toBe('audit-trail-bad'); + // Exact prefix (case-sensitive, includes the trailing colon). + expect(result.value.failures[0]!.reason.startsWith('manifest_write_failed:')).toBe(true); + // appendedOps reflects only the journaled mutation. + expect(result.value.appendedOps).toHaveLength(1); + expect(result.value.appendedOps[0]!.original_op_id).toBe('audit-trail-good'); + } + }); + + it('audit-trail invariant — single-op plan: writeOp throw fires the all-failed gate (Result.fail)', async () => { + // Companion to the prefix/tally test above. With one op, a + // manifest_write_failed: failure must trip the all-failed gate + // identically to a disk-mutation failure — this is the carve-out + // the invariant forbids. + const op = archiveOp({ op_id: 'audit-trail-solo' }); + const unlinkFile = vi.fn(async () => undefined); + const writeOpFn = vi.fn(async () => { + throw new Error('synthetic writer failure'); + }); + const mockWriter = { + writeOp: writeOpFn, + close: vi.fn(async () => undefined), + filePath: '/fake/manifests/purge-audit-solo.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: mockWriter, + path: '/fake/manifests/purge-audit-solo.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'source_occupied' }], + skip: [], + }; + + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + + expect(unlinkFile).toHaveBeenCalledTimes(1); + expect(writeOpFn).toHaveBeenCalledTimes(1); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + // The all-failed gate counts manifest_write_failed: identically to + // disk-mutation failures (the invariant we are locking). + expect(result.error.message).toMatch(/all 1 purge ops failed/); + } + }); + it('empty plan does not open the writer (no orphan empty manifest file)', async () => { const createPurgeManifestWriter = vi.fn(async () => ({ writer: makeMockWriter().writer, diff --git a/packages/internal/src/scanner/scan-memory.ts b/packages/internal/src/scanner/scan-memory.ts index 9b86fb9..c09e991 100644 --- a/packages/internal/src/scanner/scan-memory.ts +++ b/packages/internal/src/scanner/scan-memory.ts @@ -537,6 +537,51 @@ if (import.meta.vitest) { expect(result[0]!.importDepth).toBeUndefined(); }); + // ── A5: Windows-shape path normalization in import-chain row names ───── + // Locks the backslash→forward-slash normalization at scanImportChain + // (`path.relative(...).replace(/\\/g, '/')`). Production code is correct; + // these tests prevent silent regression if someone reorders the chain or + // drops the `.replace()`. + describe('Windows-path normalization in import-chain row names (A5)', () => { + it('emits forward slashes in the name even when the imported file lives in a nested subdirectory', async () => { + // Build: projRoot/CLAUDE.md → @sub/dir/child.md + // The relative path computed via path.relative() on POSIX is already + // `sub/dir/child.md`. The .replace(/\\/g, '/') pass is a no-op on + // POSIX but the *output* shape is what we lock here: callers must + // be able to assume forward slashes in the name field. + const projPath = path.join(tmpDir3, 'win-shape-proj'); + const subDir = path.join(projPath, 'sub', 'dir'); + await mkdir(subDir, { recursive: true }); + const child = path.join(subDir, 'child.md'); + await writeFile(child, '# Child'); + const root = path.join(projPath, 'CLAUDE.md'); + await writeFile(root, '@sub/dir/child.md\n# Root'); + + const result = await scanMemoryFiles( + { legacy: path.join(tmpDir3, 'legacy'), xdg: path.join(tmpDir3, 'xdg') }, + [projPath], + ); + const chainItem = result.find((r) => r.importDepth !== undefined); + expect(chainItem, 'expected a chain row for the imported child').toBeDefined(); + // Forward slashes only — no backslashes leak into the name. + expect(chainItem!.name).toBe('CLAUDE.md @ sub/dir/child.md'); + expect(chainItem!.name).not.toMatch(/\\/); + }); + + it('the .replace(/\\\\/g, "/") step turns a synthetic backslash-bearing input into forward slashes', () => { + // Direct sanity check on the normalization step, isolated from filesystem + // walking. Mirrors the production expression at scanImportChain. + const winShape = 'C:\\Users\\me\\.claude\\agents\\foo.md'; + expect(winShape.replace(/\\/g, '/')).toBe('C:/Users/me/.claude/agents/foo.md'); + + const posixShape = '/home/me/.claude/agents/foo.md'; + expect(posixShape.replace(/\\/g, '/')).toBe('/home/me/.claude/agents/foo.md'); + + const mixed = 'C:\\Users/me\\.claude/agents\\foo.md'; + expect(mixed.replace(/\\/g, '/')).toBe('C:/Users/me/.claude/agents/foo.md'); + }); + }); + it('import inside fenced code block is NOT followed', async () => { const legacyDir = path.join(tmpDir3, 'legacy'); await mkdir(legacyDir, { recursive: true }); diff --git a/packages/terminal/src/tables/change-plan.ts b/packages/terminal/src/tables/change-plan.ts index d9f4b4a..8fae2d9 100644 --- a/packages/terminal/src/tables/change-plan.ts +++ b/packages/terminal/src/tables/change-plan.ts @@ -74,17 +74,17 @@ export function renderChangePlan(plan: ChangePlan, opts?: ChangePlanRenderOption lines.push(colorize.bold('Will ARCHIVE (reversible via `ccaudit restore `):')); if (plan.counts.agents > 0) { lines.push( - ` ${String(plan.counts.agents).padStart(3)} agents → ~/.claude/ccaudit/archived/agents/`, + ` ${String(plan.counts.agents).padStart(3)} ${'agents'.padEnd(8)} → ~/.claude/ccaudit/archived/agents/`, ); } if (plan.counts.skills > 0) { lines.push( - ` ${String(plan.counts.skills).padStart(3)} skills → ~/.claude/ccaudit/archived/skills/`, + ` ${String(plan.counts.skills).padStart(3)} ${'skills'.padEnd(8)} → ~/.claude/ccaudit/archived/skills/`, ); } if (plan.counts.commands > 0) { lines.push( - ` ${String(plan.counts.commands).padStart(3)} commands → ~/.claude/ccaudit/archived/commands/`, + ` ${String(plan.counts.commands).padStart(3)} ${'commands'.padEnd(8)} → ~/.claude/ccaudit/archived/commands/`, ); } lines.push(''); diff --git a/packages/terminal/src/tui/_force-partial-banner.ts b/packages/terminal/src/tui/_force-partial-banner.ts index 325625f..c9f24fa 100644 --- a/packages/terminal/src/tui/_force-partial-banner.ts +++ b/packages/terminal/src/tui/_force-partial-banner.ts @@ -20,7 +20,7 @@ const BASE_TEXT = '--force-partial active: framework protection DISABLED. ' + 'Partial framework splits may corrupt dependent setups.'; -const ZERO_PROTECTED_SUFFIX = ' (no protected items in this scan)'; +const ZERO_PROTECTED_SUFFIX = '(no protected items in this scan)'; export interface RenderForcePartialBannerOptions { active: boolean; @@ -37,7 +37,7 @@ export function renderForcePartialBanner(opts: RenderForcePartialBannerOptions): if (!opts.active) return ''; const glyph = opts.ascii ? '!' : '⚠'; const suffix = opts.protectedCount === 0 ? ZERO_PROTECTED_SUFFIX : ''; - return `${glyph} ${BASE_TEXT}${suffix}`; + return `${glyph} ${BASE_TEXT}${suffix ? ' ' + suffix : ''}`; } export interface BannerHeightOptions { @@ -103,6 +103,18 @@ if (import.meta.vitest) { expect(out.endsWith('(no protected items in this scan)')).toBe(true); }); + it('joins ZERO_PROTECTED_SUFFIX with exactly one space (no double-space drift)', () => { + // Tightening test (B6): the joining whitespace between BASE_TEXT and the + // suffix must be exactly one character. Asserts that neither BASE_TEXT + // nor the suffix carries an embedded leading/trailing space that would + // double the gap. + const out = renderForcePartialBanner({ active: true, protectedCount: 0, ascii: false }); + const tail = out.split(BASE_TEXT)[1]; + expect(tail).toBe(' (no protected items in this scan)'); + // No "BASE_TEXT suffix" double-space anywhere. + expect(out).not.toContain(' (no protected'); + }); + it('contains no ANSI escape sequences (picker adds color at render time)', () => { const out = renderForcePartialBanner({ active: true, protectedCount: 1, ascii: false }); // eslint-disable-next-line no-control-regex diff --git a/packages/terminal/src/tui/_glyphs.ts b/packages/terminal/src/tui/_glyphs.ts index 3999d82..d61fbcd 100644 --- a/packages/terminal/src/tui/_glyphs.ts +++ b/packages/terminal/src/tui/_glyphs.ts @@ -8,7 +8,7 @@ * * Triggers for the ASCII fallback (first match wins): * 1. `CCAUDIT_ASCII_ONLY=1` env var - * 2. `NO_COLOR` env var set (any value; convention is truthy-presence) + * 2. `NO_COLOR` env var set to a non-empty value (per no-color.org spec) * 3. `TERM=dumb` * 4. `opts.noColor === true` (mirrors the --no-color CLI flag at call sites) * @@ -69,9 +69,10 @@ export function resolveGlyphSet(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOp export function shouldUseAsciiGlyphs(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOpts): boolean { if (opts?.noColor === true) return true; if (env['CCAUDIT_ASCII_ONLY'] === '1') return true; - // NO_COLOR convention: any non-empty value => suppress color (and thus - // glyphs that rely on color for distinction are still safe — we switch - // to ASCII so they're distinguishable structurally). + // NO_COLOR is honored when set to a non-empty value (per no-color.org spec). + // Empty string is NOT "set" — see the dedicated test below. When set, we + // switch to ASCII so glyphs remain distinguishable structurally even with + // color suppressed. // https://no-color.org/ const noColor = env['NO_COLOR']; if (typeof noColor === 'string' && noColor !== '') return true;