diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7e46829..e5bdd3f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -25,7 +25,7 @@ Open a full-screen dashboard displaying Git analytics: churn (commits per file), **Export (all report dashboards).** Each report webview (Git Analytics, Hygiene Analytics, Session Briefing) offers two save paths: **↓ CSV / ↓ JSON** quick-save the report dialog-free to `.meridian/artifacts/` with a timestamped filename (per [ADR 014](./adr/014-dotdir-doctrine.md); the dir self-ignores so artifacts never enter git), and **Save as…** opens a dialog (format + location) for saving anywhere. ### **git.sessionBriefing** -Generate a session-orientation summary. Aggregates git working-tree status, recent commits, run-log activity (`recentRuns`), git analytics (`activityWindow` — including momentum trends and a commit-frequency sparkline showing the shape behind the trend arrow), hygiene scan state (`hygieneSnapshot`), and a pending-change risk preview (`pendingChangeRisk` — each uncommitted file joined against the computed analytics risk model: churn, volatility, and risk tier, with files absent from the analytics window marked `new` (no history) or `cold` (changed but quiet — low, not unknown); a flag is raised when several high-risk files are in flight), and a pending-change companion preview (`pendingChangeCompanions` — files that historically ship in the same commit as your current edits but are not in the dirty set yet, i.e. possibly-forgotten siblings such as tests/types/docs; a flag is raised when several are likely missing) into a deterministic `SessionBriefing` record, then layers optional AI prose on top. Optional slices degrade gracefully when data is unavailable; the prose layer degrades to the raw aggregate when no language model is available. Useful for standup notes, context switching, pre-commit risk triage, or morning orientation. +Generate a session-orientation summary. Aggregates git working-tree status, recent commits, run-log activity (`recentRuns`), git analytics (`activityWindow` — including momentum trends and a commit-frequency sparkline showing the shape behind the trend arrow), hygiene scan state (`hygieneSnapshot`), and a pending-change risk preview (`pendingChangeRisk` — each uncommitted file joined against the computed analytics risk model: churn, volatility, and risk tier, with files absent from the analytics window marked `new` (no history) or `cold` (changed but quiet — low, not unknown); a flag is raised when several high-risk files are in flight), and a pending-change companion preview (`pendingChangeCompanions` — files that historically ship in the same commit as your current edits but are not in the dirty set yet, i.e. possibly-forgotten siblings such as tests/types/docs; a flag is raised when several are likely missing), and a longitudinal pulse slice (`pulse` — movement since your previous briefing plus trend lines over the stored history in `.meridian/pulse/`; snapshots are captured automatically per briefing, throttled to one per 10 minutes, capped and local-only per [ADR 019](./adr/019-pulse-and-retention.md)) into a deterministic `SessionBriefing` record, then layers optional AI prose on top. Optional slices degrade gracefully when data is unavailable; the prose layer degrades to the raw aggregate when no language model is available. Useful for standup notes, context switching, pre-commit risk triage, or morning orientation. --- @@ -41,7 +41,7 @@ Scan the workspace for cleanup candidates: - **Log files**: stale `.log` files above age and size thresholds - **Markdown files**: documentation artifacts for review or archival -Respects `.gitignore` and `.meridian/.meridianignore` patterns. Returns a categorized list with file paths, sizes, ages, and reasons. +Respects `.gitignore` and `.meridian/.meridianignore` patterns. Exclusions are ecosystem-aware ([ADR 018](./adr/018-ecosystem-registry.md)): envs, caches, vendored deps, and build outputs for JS/TS, Python, JVM (Maven/Gradle/Kotlin/Scala), Go, Rust, .NET, Ruby, Elixir, and more derive from a single registry (`src/ecosystems.ts`). Returns a categorized list with file paths, sizes, ages, and reasons. ### **hygiene.cleanup** Delete specified files. Batch removal of candidates surfaced by `hygiene.scan`. Defaults to dry-run: deletion happens only with an explicit `dryRun: false`, and the user-facing path (**Delete File**) requires a modal confirmation first. Not exposed in the command palette. @@ -56,6 +56,9 @@ Open a dashboard displaying Hygiene analytics: prune candidates over time, file- - **Delete File** — remove a file flagged by the last scan (confirmation required). - **Ignore File** — append the file's pattern to `.meridian/.meridianignore`. +### **hygiene.pruneStorage** (Meridian Storage) +Meridian polices its own storage ([ADR 019](./adr/019-pulse-and-retention.md)). The Hygiene view's **Meridian Storage** section shows the footprint of `.meridian/artifacts/` (exported reports), the run log, and the pulse history — including what the current retention policy would prune. **Prune Now** (inline action or `Hygiene: Prune Meridian Storage`) previews the effect, asks for confirmation, then deletes aged/surplus exports and compacts the run log. Retention is also enforced automatically at activation and after each report export. + --- ## Sidebar Views & UI @@ -81,6 +84,9 @@ All features respect workspace settings under the `meridian.*` namespace, includ - `meridian.hygiene.prune.minAgeDays` — Minimum file age (days) before a file is a prune candidate (number, default: 30) - `meridian.hygiene.prune.maxSizeMB` — Files larger than this (MB) are flagged when also older than `minAgeDays` (number, default: 1) - `meridian.hygiene.prune.minLineCount` — Files with this many lines or more are flagged when also older than `minAgeDays`; 0 disables (number, default: 0) +- `meridian.retention.artifacts.maxCount` — Keep at most N exported reports in `.meridian/artifacts/`; 0 disables (number, default: 50) +- `meridian.retention.artifacts.maxAgeDays` — Prune exported reports older than N days; 0 disables (number, default: 30) +- `meridian.retention.runLog.maxEvents` — Compact the run log to its newest N events at activation; 0 disables (number, default: 5000) - `meridian.sessionBriefing.autoLaunch` — Open a Session Briefing on activation (boolean, default: false) - `meridian.startup.enableFileWatchers` — Register file watchers for auto tree/status refresh (boolean, default: true) @@ -90,6 +96,7 @@ Per-workspace Meridian state lives under `.meridian/` at the workspace root (see - `.meridian/.meridianignore` — gitignore-syntax patterns excluded from hygiene scans. Editor syntax highlighting is provided via the built-in `ignore` language association. Legacy `.meridianignore` at the workspace root is auto-relocated on activation. - `.meridian/settings.json` — sparse JSON overrides for `meridian.*` settings. Present keys take precedence over VS Code user/workspace settings; absent keys fall through. Example: `{ "hygiene.prune.minAgeDays": 7 }`. +- `.meridian/pulse/` — local, self-gitignored pulse history (`pulse.v1.jsonl`) behind the session briefing's pulse slice; self-capping, no maintenance needed ([ADR 019](./adr/019-pulse-and-retention.md)). --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1a6abfc..f37cc3a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,14 +13,20 @@ deferred. (See [ADR 012](./adr/012-product-reanchor.md) for the re-anchor.) ## Now -1. **Session-briefing UI polish** — the briefing is the connective tissue that - makes the analytics/hygiene panels get reopened. Richer presentation over - the existing `aggregateSessionBriefing()` record: actionable cards, inline - panel links, quick filters. Prose stays optional and degrades to the raw - aggregate when `vscode.lm` is unavailable. +1. **Session-briefing UI polish (continued)** — richer presentation over the + `aggregateSessionBriefing()` record: actionable cards, inline panel links, + quick filters. The pulse slice (ADR 019) landed; remaining polish is + presentation-only. Prose stays optional and degrades to the raw aggregate + when `vscode.lm` is unavailable. - Primary files: `src/domains/git/session-briefing-ui/`, `src/domains/git/session-aggregator.ts`. +2. **Agent-readable snapshot (stretch, file-shaped)** — a stable, versioned + JSON "latest" convention (e.g. `.meridian/latest/briefing.json`) that + Copilot/Cursor/CLI agents read directly, plus a documented pointer users + paste into their agent rules. No runtime integration, no LM-tool surface — + the inversion of the pre-ADR-012 approach. + --- ## Deferred @@ -38,6 +44,13 @@ deferred. (See [ADR 012](./adr/012-product-reanchor.md) for the re-anchor.) ## Done +- **Ecosystem registry** (ADR 018) — single source for language/toolchain + semantics; JVM (Maven/Gradle/Kotlin/Scala) first-class; exclusion/bucket/ + categorization drift structurally eliminated. +- **Pulse history + retention + storage surface** (ADR 019) — longitudinal + pulse slice in the session briefing; self-policing retention for + `.meridian/artifacts/`, the run log, and pulse history; "Meridian Storage" + tree section with Prune Now. - **2.0 re-anchor** (ADR 012) — retired the LLM-commodity, chat-participant, LM-tool, workflow, and agent surfaces; kept the computed-insight core. - **Foundations** — run log (ADR 009), dispatch lifecycle (ADR 008), diff --git a/docs/adr/018-ecosystem-registry.md b/docs/adr/018-ecosystem-registry.md new file mode 100644 index 0000000..2650e66 --- /dev/null +++ b/docs/adr/018-ecosystem-registry.md @@ -0,0 +1,127 @@ +# ADR 018 — Ecosystem Registry: Single Source for Language/Toolchain Semantics + +**Date:** 2026-07-03 +**Status:** Accepted + +## Context + +Meridian's knowledge of language ecosystems — which directory names are +environments, caches, build outputs, or vendored dependencies, and which file +extensions are sources, configs, or compiled artifacts — was spread across +five independently hand-maintained lists in two files: + +1. `HYGIENE_SETTINGS.EXCLUDE_PATTERNS` (constants.ts) — hygiene scan exclusions +2. `HYGIENE_ANALYTICS_EXCLUDE_PATTERNS` (constants.ts) — analytics walker exclusions +3. `HEAVY_ARTIFACT_DIRS` (hygiene/analytics-service.ts) — never-recurse placeholder dirs +4. `COLLECTION_BUCKETS` (hygiene/analytics-utils.ts) — Collections bucketing +5. `ARTIFACT_EXTS` / `ARTIFACT_DIRS` / `SOURCE_EXTS` / `CONFIG_EXTS` (analytics-utils.ts) — categorization + +The lists had already drifted: `.gradle` appeared in 2 and 4 but not 1; +`target` in 4 and 5 but not 1 or 2. The practical consequence was a +**JVM-shaped hole**: pointing the hygiene scan at a Maven repository descended +into `target/` and flooded candidates with `.class` files; `pom.xml` and +`application.properties` categorized as "other"; `.jar`/`.war` were not +artifacts. The tested stacks (Python, JS/TS) were rich; the enterprise Java +footprint was effectively unsupported. + +## Decision + +1. **`src/ecosystems.ts` is the single source of ecosystem semantics.** It + declares `EcosystemProfile` entries (`envDirs`, `cacheDirs`, `buildDirs`, + `vendorDirs`, `artifactExts`, `sourceExts`, `configExts`) per ecosystem + (common, node, python, jvm, go, rust, dotnet, ruby, elixir, native, + terraform, dart, haskell, clojure). The module imports nothing, so every + layer — including `constants.ts` — can consume it without cycles. Adding + ecosystem coverage is one profile entry; consumer lists are never edited. + +2. **All five former lists are derived unions.** Derivation semantics: + - Hygiene scan excludes = base + env ∪ cache ∪ vendor ∪ build (the scan + hunts stray files, not generated-tree contents). + - Analytics excludes = base + env ∪ cache ∪ vendor. Build dirs are + **recursed** so contents surface as prune candidates. + - Heavy/placeholder dirs = env ∪ cache ∪ vendor — definitionally identical + to the analytics exclusion set, so exclusion and placeholder membership + cannot drift apart. + - Collections buckets map 1:1 to the four dir kinds. + - `ARTIFACT_DIRS` (categorization) = env ∪ cache ∪ build; vendor dirs are + never recursed, so their contents never reach `categorize()`. + +3. **Suffix-matched names stay literal.** `*.egg-info` cannot be expressed in + a name-keyed registry; the glob in `constants.ts` and the `endsWith` check + in the analytics walker remain special cases, documented at both sites. + +4. **JVM profile.** `target` (build); `.gradle`, `.kotlin`, `.bloop`, + `.metals` (caches); `.class`, `.jar`, `.war`, `.ear`, `.hprof` (artifacts); + `.java`, `.kt`, `.kts`, `.scala`, `.groovy` (sources); `.xml`, + `.properties`, `.gradle` (configs). Eclipse `.settings/` joins + `WORKSPACE_EXCLUDE_BASE` as IDE metadata (same class as `.idea`/`.vscode`). + +5. **Deliberate omissions.** Bare `bin/` is NOT excluded or bucketed — script + `bin/` dirs with real source are common, and the false-positive cost + outweighs the Eclipse/.NET benefit. `packages/` is likewise omitted + entirely (revised in the production-readiness pass): yarn/pnpm monorepos + keep first-party source there, and silently blinding the hygiene scan to + it costs more than legacy NuGet layouts gain — NuGet shops opt back out + via `.meridianignore`. `obj/` (dotnet) IS excluded (the name is + toolchain-specific enough). `env/` IS excluded — kept for parity with + venv detection; a first-party `env/` dir is rarer than a virtualenv named + `env`, and `.meridianignore` remains the escape hatch. All pinned by test. + +6. **Glob forms are per-consumer.** `dirExcludeGlobs(sets, form)` emits + descendant-only globs (`**//**`) by default — the hygiene scan + matches file paths, and the bare form would wrongly exclude a *file* + named `dist` or `deps`. The analytics walker requests `"both"` because it + pattern-matches directory paths themselves to stop recursion and emit the + placeholder row. Side effect worth naming: pre-registry, the Python heavy + dirs lacked bare forms in the analytics list, so the walker recursed one + readdir level into every venv and emitted no placeholder for it — the + uniform derivation fixed that latent inconsistency. + +## Intended behavior changes + +All strictly toward correctness; guarded by legacy-superset tests in +`tests/ecosystems.test.ts` (every pre-registry pattern must appear in the +derived sets, with documented exceptions): + +- The hygiene scan now excludes `target/`, `.gradle/`, `.yarn/`, `vendor/`, + `deps/`, `_build/`, `packages/`, `.terraform/`, etc. (previously flooded). +- Files under build-output dirs (`dist/`, `target/`, `_build/`, …) categorize + as `artifact`, making aged build products prune candidates — the + generalized form of the Java `target/` fix. +- `_build/` (Elixir) is no longer analytics-excluded: as a build dir it is + now recursed for prune candidates, consistent with `dist/` and `target/`. +- `deps/` reclassifies from buildOutputs to vendoredDeps (Mix deps are + fetched packages); `packages/` leaves the registry entirely (see + decision 5) — it is no longer analytics-excluded, heavy-placeholdered, + or bucketed. +- New cache coverage: `coverage/`, `.nyc_output/`, `.next/`, `.nuxt/`, + `.parcel-cache/`, `.kotlin/`, `.bloop/`, `.metals/` now excluded and + placeholdered everywhere, not just in whichever list happened to have them. + +## Alternatives considered + +**Flat, better-commented union sets (no profiles).** Delivers the same +anti-drift derivation with slightly less structure, but re-creates +drift-within-file (a new ecosystem still edits seven sets) and cannot be +fixture-tested per ecosystem. Rejected. + +**Per-ecosystem enable/disable setting.** No demonstrated need; exclusion +supersets are cheap and harmless on repos that lack the dirs. Rejected as +speculative surface (YAGNI). + +**Registry in `src/domains/hygiene/`.** Rejected: `constants.ts` must consume +it, and constants → domains would invert layering. + +## Consequences + +- **Positive.** Drift is structurally impossible; JVM repos are first-class; + future ecosystems are one-entry additions with a fixture-test pattern to + copy. Exclusion lists, placeholders, buckets, and categorization can no + longer disagree. +- **Cost.** One more top-level module. Derived globs are marginally broader + than the old hand lists (more dirs excluded) — this is the point, but it + does mean scan results change on repos that previously surfaced + generated-tree noise. +- **Cross-references:** ADR 014 (dotdir doctrine — `.meridian` remains in the + walker's skip set), `tests/ecosystems.test.ts` (superset + disjointness + + JVM fixtures). diff --git a/docs/adr/019-pulse-and-retention.md b/docs/adr/019-pulse-and-retention.md new file mode 100644 index 0000000..b0a301f --- /dev/null +++ b/docs/adr/019-pulse-and-retention.md @@ -0,0 +1,109 @@ +# ADR 019 — Pulse History, Retention Engine, and the Storage Surface + +**Date:** 2026-07-03 +**Status:** Accepted +**Extends:** ADR 009 (schema versioning), ADR 011 (additive briefing slices), ADR 014 (dotdir doctrine) + +## Context + +Two gaps, one theme — Meridian observes the workspace over time but kept no +history, and polices the user's hygiene while leaving its own storage +unbounded: + +1. **No longitudinal signal.** Every report was point-in-time. The session + briefing could not answer "is this repo getting healthier or worse since + yesterday?" — the product's stated identity ("general pulse of the project + as it evolves") had no data substrate. +2. **Unbounded self-storage.** The run log + (`.vscode/meridian/run-log.v1.jsonl`) was append-only with no rotation, and + `.meridian/artifacts/` accumulated timestamped exports forever. ADR 014 + explicitly deferred retention; an enterprise-adopted tool with + unbounded-growth files is a liability. + +## Decision + +### 1. Pulse store (`.meridian/pulse/pulse.v1.jsonl`) + +- **Versioned JSONL, ADR 009 discipline**: `PulseSnapshotV1` + (`schemaVersion: 1`), append-rejects unsupported versions, serialized writes + through a queue (`infrastructure/pulse-store.ts`, modeled on `FileRunLog`). +- **Tolerant reader.** Unlike the run log's strict reader, malformed or + future-versioned lines are skipped with a warning, not a hard error — pulse + is a fail-soft peripheral by contract; a torn line must not kill briefings. +- **Location & sharing.** Lives under `.meridian/` (workspace insight, not + host-runtime trace), but **self-ignored** via the artifacts-dir `.gitignore` + pattern: an append-only JSONL shared through git would merge-conflict across + machines and interleave unrelated hosts' sessions. +- **Capture point.** `aggregateSessionBriefing()` reads history, builds the + current snapshot from data already in scope (git core, activity window, + hygiene snapshot, pending risk — zero new analysis), and appends. Appends + are **throttled** (`PULSE.MIN_APPEND_INTERVAL_MS`, 10 min): several + briefings in one sitting are one working session, not several data points. + Optional snapshot fields are absent when unmeasured — never zero-filled. +- **Briefing slice.** Additive, optional `SessionBriefing.pulse` + (ADR 011 rule): deltas vs the previous snapshot (only for fields measured on + both sides), a bounded series (`PULSE.SERIES_LIMIT`) ending at "now", and an + `appended` marker. Rendered as delta cards + inline-SVG trend lines in the + briefing webview; summarized in the deterministic text; passed to the + optional prose layer. Fail-soft flags: "Pulse history unavailable" / + "Pulse history not recorded". + +### 2. Retention engine (`infrastructure/retention.ts`) + +- **Scope is a closed list**: `.meridian/artifacts/` (count + age caps), + the run log (event cap via new `RunLog.compact()`), `.meridian/pulse/` + (self-capping at `PULSE.MAX_SNAPSHOTS` inside the store). Nothing else is + ever touched; artifact deletion joins only self-listed basenames; the + self-ignore `.gitignore` is never a prune target. +- **Settings-backed policy** through the ADR 013 chokepoint: + `retention.artifacts.maxCount` (50), `retention.artifacts.maxAgeDays` (30), + `retention.runLog.maxEvents` (5000); `0` disables a rule. +- **Lazy enforcement, no daemons** (ADR 014 doctrine): once at activation + (fire-and-forget, never blocks) and after each quick-save export. Run-log + compaction executes **inside the write queue** — the only race-safe point — + as a raw-line tail-keep with atomic tmp+rename (`jsonl-tail.ts`), never + parsing, so v1 schema pinning is untouched. +- **Plan/act share one function.** `planArtifactPrune()` is pure; the storage + surface's "would prune" preview and the actual prune both consume it, so + they cannot disagree. + +### 3. Storage surface + +- Per the ADR 006 matrix: a small structured record with glance intent → + **tree, not webview**. A "Meridian Storage" section in the Hygiene view + (rows: Exported Reports, Run Log, Pulse History; footprint + would-prune + preview), fail-soft (omitted on error). All affordances are Meridian-owned + surfaces per ADR 016. +- `hygiene.storageStatus` / `hygiene.pruneStorage` are router-dispatched + domain handlers (Result monad, run-log observability for free). + `meridian.hygiene.pruneStorage` is an ADR 005 specialized registration: + status preview → confirmation modal → prune → toast summary + tree refresh. + +## Alternatives considered + +- **Pulse in the run log.** Rejected: different lifecycle (host-runtime trace + vs workspace insight), different reader tolerance, and it would force a + run-log schema bump ADR 012 explicitly reserved for substantive needs. +- **Committed (shared) pulse history.** Rejected — merge conflicts and + cross-host interleaving; local-first matches the no-exfil posture. +- **Timer-based retention.** Rejected per ADR 014's no-daemon doctrine; write + paths and activation are sufficient enforcement points. +- **Webview storage report.** Rejected — no chart/table density that justifies + a panel (ADR 006 Rule 2). + +## Consequences + +- **Positive.** The briefing gains the longitudinal dimension the product + thesis promised; Meridian-owned storage is bounded by default and visibly + self-policed — "we point our own hygiene engine at ourselves" is now a + demonstrable claim. +- **Cost.** Three new settings; one new dotdir subdir; retention defaults + (50 files / 30 days / 5000 events) silently delete aged exports — mitigated + by documented settings, `0`-disables semantics, and the storage node making + the policy visible. +- **Multi-root caveat.** Pulse store and retention use `workspaceFolders[0]`, + matching the existing single-root assumption (ADR 014). +- **Cross-references:** `src/infrastructure/{pulse-store,retention,jsonl-tail}.ts`, + `src/domains/hygiene/storage-handler.ts`, `SessionBriefing.pulse` + (`src/domains/git/types.ts`), tests + `pulseStore/retention/storageHandler/run-log/sessionAggregator`. diff --git a/package.json b/package.json index 5ba0a1f..0595734 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,11 @@ "title": "Hygiene: Ignore File", "category": "Meridian" }, + { + "command": "meridian.hygiene.pruneStorage", + "title": "Hygiene: Prune Meridian Storage", + "icon": "$(trash)" + }, { "command": "meridian.git.refresh", "title": "Git: Refresh View", @@ -270,6 +275,24 @@ ], "description": "File categories that are automatically flagged as prune candidates when older than minAgeDays." }, + "meridian.retention.artifacts.maxCount": { + "type": "number", + "default": 50, + "minimum": 0, + "description": "Keep at most this many exported reports in .meridian/artifacts/ (oldest pruned first, enforced at activation and after each quick-save). Set to 0 to disable." + }, + "meridian.retention.artifacts.maxAgeDays": { + "type": "number", + "default": 30, + "minimum": 0, + "description": "Prune exported reports in .meridian/artifacts/ older than this many days (enforced at activation and after each quick-save). Set to 0 to disable." + }, + "meridian.retention.runLog.maxEvents": { + "type": "number", + "default": 5000, + "minimum": 0, + "description": "Compact the run log (.vscode/meridian/run-log.v1.jsonl) to its newest N events at activation. Set to 0 to disable." + }, "meridian.sessionBriefing.autoLaunch": { "type": "boolean", "default": false, @@ -365,6 +388,10 @@ "command": "meridian.hygiene.deleteFile", "when": "false" }, + { + "command": "meridian.hygiene.pruneStorage", + "when": "workspaceFolderCount > 0" + }, { "command": "meridian.hygiene.ignoreFile", "when": "false" @@ -420,6 +447,11 @@ "command": "meridian.hygiene.ignoreFile", "when": "view == meridian.hygiene.view && viewItem == file", "group": "hygiene@2" + }, + { + "command": "meridian.hygiene.pruneStorage", + "when": "view == meridian.hygiene.view && viewItem == storageSection", + "group": "inline" } ], "view/item/inline": [ diff --git a/src/constants.ts b/src/constants.ts index dbe3ca2..b04e638 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,9 +2,19 @@ * Centralized, typed constants for the entire application. * No magic strings or numbers; all thresholds, names, and patterns are explicit. * - * Organized by domain for clarity. + * Organized by domain for clarity. Ecosystem-specific directory/extension + * knowledge lives in ecosystems.ts (ADR 018); the exclusion lists below are + * derived from it, never hand-extended. */ +import { + ECOSYSTEM_BUILD_DIRS, + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_VENDOR_DIRS, + dirExcludeGlobs, +} from "./ecosystems"; + // ============================================================================ // Cache Configuration // ============================================================================ @@ -15,6 +25,13 @@ export const CACHE_SETTINGS = { /** Dead code scan cache TTL in milliseconds (5 minutes) */ DEAD_CODE_TTL_MS: 5 * 60 * 1000, + + /** + * Storage-status cache TTL (30s) — the hygiene tree rebuilds on debounced + * file-watcher events; without this, every rebuild re-reads the full run + * log just to line-count it. Prune invalidates explicitly. + */ + STORAGE_STATUS_TTL_MS: 30 * 1000, } as const; // ============================================================================ @@ -27,6 +44,9 @@ export const MERIDIAN_DIR = ".meridian"; /** Generated-report / artifact subdir under the dotdir (self-ignored). */ export const MERIDIAN_ARTIFACTS_DIR = "artifacts"; +/** Pulse-history subdir under the dotdir (self-ignored, ADR 019). */ +export const MERIDIAN_PULSE_DIR = "pulse"; + // ============================================================================ // Workspace Exclusion Base — shared across git and hygiene analytics. // Domain-specific lists extend via spread. @@ -37,8 +57,17 @@ export const WORKSPACE_EXCLUDE_BASE = [ "**/.git/**", "**/.vscode/**", "**/.idea/**", + // Eclipse per-project IDE metadata — same class as .vscode/.idea. + "**/.settings/**", ] as const; +/** + * Python packaging metadata dirs match by SUFFIX (`.egg-info`), which the + * name-keyed ecosystem registry cannot express — kept as a literal glob here + * and as an `endsWith` check in the hygiene analytics walker. + */ +const EGG_INFO_GLOB = "**/*.egg-info/**"; + // ============================================================================ // Hygiene Configuration // ============================================================================ @@ -58,28 +87,20 @@ export const HYGIENE_SETTINGS = { */ MAX_FILE_SIZE_BYTES: 1 * 1024 * 1024, - /** File patterns to exclude from hygiene checks */ + /** + * File patterns to exclude from hygiene checks — every ecosystem env, + * cache, vendor, AND build dir (the scan hunts stray files, not the + * contents of generated trees). Derived from the ecosystem registry. + */ EXCLUDE_PATTERNS: [ ...WORKSPACE_EXCLUDE_BASE, - // Build / output - "**/dist/**", - "**/build/**", - "**/out/**", - "**/bundled/**", - // Python runtime & tooling - "**/.venv/**", - "**/venv/**", - "**/__pycache__/**", - "**/.pytest_cache/**", - "**/.mypy_cache/**", - "**/.ruff_cache/**", - "**/.tox/**", - "**/.eggs/**", - "**/*.egg-info/**", - // JS/TS coverage & caches - "**/coverage/**", - "**/.nyc_output/**", - "**/.cache/**", + ...dirExcludeGlobs([ + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_VENDOR_DIRS, + ECOSYSTEM_BUILD_DIRS, + ]), + EGG_INFO_GLOB, ] as readonly string[], /** Log file patterns to detect */ @@ -91,51 +112,31 @@ export const HYGIENE_SETTINGS = { // ============================================================================ // Hygiene Analytics — lighter exclusion set for the analytics scan. -// Unlike HYGIENE_SETTINGS.EXCLUDE_PATTERNS, we keep dist/, build/, out/, etc. -// so they are surfaced as prune candidates. Heavy dirs (node_modules, venv, -// __pycache__) are excluded from recursion but get a single "exists" placeholder -// so they still show in the report without scanning contents. +// Unlike HYGIENE_SETTINGS.EXCLUDE_PATTERNS, build-output dirs (dist/, build/, +// out/, target/, _build/, …) are NOT excluded here, so their contents are +// recursed and surfaced as prune candidates. Env/cache/vendor dirs are +// excluded from recursion but get a single "exists" placeholder — the +// walker's heavy-dir set derives from the same registry, so exclusion and +// placeholder membership cannot drift apart. // ============================================================================ export const HYGIENE_ANALYTICS_EXCLUDE_PATTERNS = [ ...WORKSPACE_EXCLUDE_BASE, - // Python runtime & tooling - "**/.venv/**", - "**/venv/**", - "**/__pycache__/**", - "**/.pytest_cache/**", - "**/.mypy_cache/**", - "**/.ruff_cache/**", - "**/.tox/**", - "**/.eggs/**", - "**/*.egg-info/**", - // Package managers & build tool caches - "**/.yarn/**", - "**/.pnpm-store/**", - "**/vendor/**", - "**/vendor", - "**/.bundle/**", - "**/.gradle/**", - "**/packages/**", - "**/packages", - "**/.terraform/**", - "**/.terraform", - "**/.dart_tool/**", - "**/.dart_tool", - "**/deps/**", - "**/deps", - "**/_build/**", - "**/_build", - "**/.stack-work/**", - "**/.stack-work", - "**/.cpcache/**", - "**/.cpcache", + // "both" forms: the analytics walker pattern-matches directory paths + // themselves (to stop recursion and emit placeholder rows), which the + // bare "**/" form covers. + ...dirExcludeGlobs([ECOSYSTEM_ENV_DIRS, ECOSYSTEM_CACHE_DIRS, ECOSYSTEM_VENDOR_DIRS], "both"), + EGG_INFO_GLOB, ] as const; // ============================================================================ // Telemetry Event Types // ============================================================================ +// Command lifecycle only — the workflow/agent/cache/user-action kinds were +// pruned with their dead consumers (ADR 012 fallout; security-hardening pass). +// The run-log schema's inert RunEventSource members are a separate, +// deliberate retention. export const TELEMETRY_EVENT_KINDS = { COMMAND_STARTED: "COMMAND_STARTED", COMMAND_COMPLETED: "COMMAND_COMPLETED", @@ -228,6 +229,29 @@ export const SESSION_BRIEFING = { FAILED_RUNS_FLAG_THRESHOLD: 1, } as const; +// ============================================================================ +// Pulse History (ADR 019) +// ============================================================================ + +/** + * Bounds for the longitudinal pulse store (`.meridian/pulse/pulse.v1.jsonl`) + * and the additive `SessionBriefing.pulse` slice derived from it. + */ +export const PULSE = { + /** Hard cap on stored snapshots; the store tail-compacts past this on append. */ + MAX_SNAPSHOTS: 500, + + /** Max history points carried into the briefing's pulse series (wire bound). */ + SERIES_LIMIT: 30, + + /** + * Minimum gap between stored snapshots. Repeated briefings inside the gap + * still render the pulse slice, but do not append — several briefings in + * one sitting are one working session, not several data points. + */ + MIN_APPEND_INTERVAL_MS: 10 * 60 * 1000, +} as const; + // ============================================================================ // Pending-Change Risk // ============================================================================ diff --git a/src/domains/git/service.ts b/src/domains/git/service.ts index 4a2b7aa..0d4e422 100644 --- a/src/domains/git/service.ts +++ b/src/domains/git/service.ts @@ -20,6 +20,7 @@ import { GenerateProseFn, } from "../../types"; import { RunLog } from "../../infrastructure/run-log"; +import { PulseStore } from "../../infrastructure/pulse-store"; import { createStatusHandler, createPullHandler, @@ -54,6 +55,7 @@ export class GitDomainService implements DomainService { private readonly runLog: RunLog | undefined; private readonly getHygieneScan: HygieneScanGetter | undefined; + private readonly pulseStore: PulseStore | undefined; constructor( gitProvider: GitProvider, @@ -61,12 +63,14 @@ export class GitDomainService implements DomainService { workspaceRoot: string = process.cwd(), generateProseFn?: GenerateProseFn, runLog?: RunLog, - getHygieneScan?: HygieneScanGetter + getHygieneScan?: HygieneScanGetter, + pulseStore?: PulseStore ) { this.gitProvider = gitProvider; this.logger = logger; this.runLog = runLog; this.getHygieneScan = getHygieneScan; + this.pulseStore = pulseStore; // Initialize analytics — pass workspace root so git log runs in the correct repo this.analyzer = new GitAnalyzer(workspaceRoot); @@ -85,6 +89,7 @@ export class GitDomainService implements DomainService { runLog: this.runLog, gitAnalyzer: this.analyzer, getHygieneScan: this.getHygieneScan, + pulseStore: this.pulseStore, logger, } satisfies SessionBriefingSources, generateProseFn @@ -145,7 +150,8 @@ export function createGitDomain( workspaceRoot: string = process.cwd(), generateProseFn?: GenerateProseFn, runLog?: RunLog, - getHygieneScan?: HygieneScanGetter + getHygieneScan?: HygieneScanGetter, + pulseStore?: PulseStore ): GitDomainService { - return new GitDomainService(gitProvider, logger, workspaceRoot, generateProseFn, runLog, getHygieneScan); + return new GitDomainService(gitProvider, logger, workspaceRoot, generateProseFn, runLog, getHygieneScan, pulseStore); } diff --git a/src/domains/git/session-aggregator.ts b/src/domains/git/session-aggregator.ts index 7f4bac8..c984007 100644 --- a/src/domains/git/session-aggregator.ts +++ b/src/domains/git/session-aggregator.ts @@ -16,6 +16,7 @@ import { success, } from "../../types"; import { RunLog } from "../../infrastructure/run-log"; +import { PulseSnapshotV1, PulseStore, PULSE_SCHEMA_VERSION } from "../../infrastructure/pulse-store"; import { GitAnalyzer } from "./analytics-service"; import { CoChangePair, FileMetric } from "./analytics-types"; import { normalizeRenamePath } from "./git-path"; @@ -28,8 +29,9 @@ import { PendingChangeFile, PendingChangeCompanions, PendingCompanion, + PulseSlice, } from "./types"; -import { SESSION_BRIEFING, PENDING_RISK, COMPANIONS } from "../../constants"; +import { SESSION_BRIEFING, PENDING_RISK, COMPANIONS, PULSE } from "../../constants"; export type HygieneScanGetter = () => { scan: WorkspaceScan; scannedAt: string } | undefined; @@ -38,6 +40,7 @@ export interface SessionBriefingSources { runLog: RunLog | undefined; gitAnalyzer: GitAnalyzer; getHygieneScan: HygieneScanGetter | undefined; + pulseStore?: PulseStore; logger: Logger; options?: { recentRunLimit?: number; recentCommitLimit?: number }; } @@ -183,10 +186,61 @@ function computePendingChangeCompanions( }; } +/** + * Optional snapshot fields carried into the pulse deltas. A delta exists only + * when the field was measured on BOTH sides (absence means "not measured", + * never zero — matching PulseSnapshotV1). + */ +const PULSE_DELTA_FIELDS = [ + "commitsInWindow", + "filesTouched", + "deadFileCount", + "largeFileCount", + "deadCodeItemCount", +] as const; + +/** + * Build the pulse slice from stored history plus the just-captured current + * snapshot. Pure, exported for tests. `series` includes `current` as its + * final point regardless of whether the throttle let it be stored — the + * chart should always end at "now". + */ +export function buildPulseSlice( + history: ReadonlyArray, + current: PulseSnapshotV1, + appended: boolean +): PulseSlice { + const previous = history.length > 0 ? history[history.length - 1] : undefined; + const slice: PulseSlice = { + series: [...history, current] + .slice(-PULSE.SERIES_LIMIT) + .map((s) => ({ + timestampMs: s.timestampMs, + uncommittedCount: s.uncommittedCount, + ...(s.commitsInWindow !== undefined ? { commitsInWindow: s.commitsInWindow } : {}), + ...(s.deadFileCount !== undefined ? { deadFileCount: s.deadFileCount } : {}), + })), + appended, + }; + if (previous) { + slice.previousAt = new Date(previous.timestampMs).toISOString(); + const deltas: NonNullable = { + uncommittedCount: current.uncommittedCount - previous.uncommittedCount, + }; + for (const field of PULSE_DELTA_FIELDS) { + const curr = current[field]; + const prev = previous[field]; + if (curr !== undefined && prev !== undefined) deltas[field] = curr - prev; + } + slice.deltas = deltas; + } + return slice; +} + export async function aggregateSessionBriefing( sources: SessionBriefingSources ): Promise> { - const { gitProvider, runLog, gitAnalyzer, getHygieneScan, logger, options } = sources; + const { gitProvider, runLog, gitAnalyzer, getHygieneScan, pulseStore, logger, options } = sources; const recentRunLimit = options?.recentRunLimit ?? SESSION_BRIEFING.RECENT_RUN_LIMIT; const recentCommitLimit = options?.recentCommitLimit ?? SESSION_BRIEFING.RECENT_COMMIT_LIMIT; @@ -337,6 +391,52 @@ export async function aggregateSessionBriefing( flags.push("No hygiene scan yet"); } + // ── Pulse history — fail-soft ──────────────────────────────────────────── + let pulse: PulseSlice | undefined; + if (pulseStore) { + const historyResult = await pulseStore.readLatest(PULSE.SERIES_LIMIT); + if (historyResult.kind === "err") { + logger.warn("Session briefing: pulse history read failed", "aggregateSessionBriefing", historyResult.error); + flags.push("Pulse history unavailable"); + } else { + const history = historyResult.value; + const previous = history.length > 0 ? history[history.length - 1] : undefined; + const current: PulseSnapshotV1 = { + schemaVersion: PULSE_SCHEMA_VERSION, + timestampMs: Date.now(), + branch: status.branch, + uncommittedCount: uncommitted.length, + ...(activityWindow + ? { commitsInWindow: activityWindow.commitsInWindow, filesTouched: activityWindow.filesTouched } + : {}), + ...(hygieneSnapshot + ? { + deadFileCount: hygieneSnapshot.deadFileCount, + largeFileCount: hygieneSnapshot.largeFileCount, + logFileCount: hygieneSnapshot.logFileCount, + deadCodeItemCount: hygieneSnapshot.deadCodeItemCount, + } + : {}), + ...(pendingChangeRisk ? { hotspotCount: pendingChangeRisk.hotspotCount } : {}), + }; + + let appended = false; + const throttled = + previous !== undefined && + current.timestampMs - previous.timestampMs < PULSE.MIN_APPEND_INTERVAL_MS; + if (!throttled) { + const appendResult = await pulseStore.append(current); + if (appendResult.kind === "err") { + logger.warn("Session briefing: pulse append failed", "aggregateSessionBriefing", appendResult.error); + flags.push("Pulse history not recorded"); + } else { + appended = true; + } + } + pulse = buildPulseSlice(history, current, appended); + } + } + return success({ generatedAt: new Date().toISOString(), branch: status.branch, @@ -352,5 +452,6 @@ export async function aggregateSessionBriefing( hygieneSnapshot, pendingChangeRisk, pendingChangeCompanions, + pulse, }); } diff --git a/src/domains/git/session-briefing-ui/index.html b/src/domains/git/session-briefing-ui/index.html index a92e027..da0f392 100644 --- a/src/domains/git/session-briefing-ui/index.html +++ b/src/domains/git/session-briefing-ui/index.html @@ -26,6 +26,15 @@

Session Briefing

+
+

Pulse + + +

+
+
+
+

Activity

diff --git a/src/domains/git/session-briefing-ui/script.js b/src/domains/git/session-briefing-ui/script.js index 9c5ed02..31846a1 100644 --- a/src/domains/git/session-briefing-ui/script.js +++ b/src/domains/git/session-briefing-ui/script.js @@ -77,6 +77,7 @@ renderBranchBar(report); renderSummaryCards(report); renderFlags(report.flags); + renderPulse(report.pulse); renderActivity(report.activityWindow); renderHygiene(report.hygieneSnapshot); renderRecentRuns(report.recentRuns); @@ -179,7 +180,7 @@ // trend arrow. No Chart.js dependency (this webview ships none). Returns "" // for degenerate input (missing, <2 points, or all-zero) so the caller can // hide the block, mirroring the topChurnFiles empty-guard. - function sparklineSvg(series) { + function sparklineSvg(series, label) { var data = Array.isArray(series) ? series.map(Number).filter(function (n) { return isFinite(n); }) : []; @@ -187,6 +188,7 @@ var max = Math.max.apply(null, data); var min = Math.min.apply(null, data); if (max <= 0) return ""; + var ariaLabel = label || "Commit frequency trend"; var W = 240, H = 44, padY = H * 0.12; var span = max - min; @@ -200,12 +202,73 @@ var d = "M" + pts.join(" L"); return '' + + '" preserveAspectRatio="none" role="img" aria-label="' + esc(ariaLabel) + '">' + ''; } + // ── Pulse (longitudinal history, ADR 019) ────────────────────────── + + function signed(v) { + return v > 0 ? "+" + v : String(v); + } + + function pulseSeriesOf(series, key) { + var out = []; + for (var i = 0; i < series.length; i++) { + if (typeof series[i][key] === "number") out.push(series[i][key]); + } + return out; + } + + function renderPulse(p) { + var section = document.getElementById("pulseSection"); + if (!p) { + section.style.display = "none"; + return; + } + section.style.display = ""; + + var hint = document.getElementById("pulseHint"); + hint.textContent = p.previousAt + ? "(since " + new Date(p.previousAt).toLocaleString() + ")" + : "(first snapshot — history starts now)"; + + var cards = []; + if (p.deltas) { + var d = p.deltas; + var deltaDefs = [ + ["Commits Δ", d.commitsInWindow], + ["Files Touched Δ", d.filesTouched], + ["Dead Files Δ", d.deadFileCount], + ["Large Files Δ", d.largeFileCount], + ["Dead Code Δ", d.deadCodeItemCount], + ["Uncommitted Δ", d.uncommittedCount], + ]; + for (var i = 0; i < deltaDefs.length; i++) { + if (typeof deltaDefs[i][1] === "number") { + cards.push(metricCard(deltaDefs[i][0], signed(deltaDefs[i][1]))); + } + } + } + document.getElementById("pulseMetrics").innerHTML = cards.join(""); + + var series = p.series || []; + var blocks = []; + var commits = pulseSeriesOf(series, "commitsInWindow"); + var dead = pulseSeriesOf(series, "deadFileCount"); + var commitsSpark = sparklineSvg(commits, "Commits-in-window across briefings"); + var deadSpark = sparklineSvg(dead, "Dead-file count across briefings"); + if (commitsSpark) { + blocks.push('

Activity (' + commits.length + ' snapshots)

' + commitsSpark + '
'); + } + if (deadSpark) { + blocks.push('

Dead Files (' + dead.length + ' snapshots)

' + deadSpark + '
'); + } + document.getElementById("pulseTrendBlock").innerHTML = blocks.join(""); + } + function renderActivity(w) { var section = document.getElementById("activitySection"); if (!w) { diff --git a/src/domains/git/session-briefing-ui/styles.css b/src/domains/git/session-briefing-ui/styles.css index 180124e..a7165e5 100644 --- a/src/domains/git/session-briefing-ui/styles.css +++ b/src/domains/git/session-briefing-ui/styles.css @@ -495,6 +495,18 @@ h3.sub { margin: 0 0 14px; } +/* Pulse trend charts sit side-by-side where width allows, stack otherwise. */ +#pulseTrendBlock { + display: flex; + flex-wrap: wrap; + gap: 0 24px; +} + +.pulse-trend { + flex: 1 1 240px; + min-width: 200px; +} + .risk { display: inline-block; padding: 1px 8px; diff --git a/src/domains/git/session-handler.ts b/src/domains/git/session-handler.ts index cadf130..c03722c 100644 --- a/src/domains/git/session-handler.ts +++ b/src/domains/git/session-handler.ts @@ -72,6 +72,24 @@ function deterministicSummary(agg: SessionBriefing): string { } } + if (agg.pulse?.deltas && agg.pulse.previousAt) { + const d = agg.pulse.deltas; + const parts: string[] = []; + const fmt = (label: string, v: number | undefined): void => { + if (v !== undefined && v !== 0) parts.push(`${label} ${v > 0 ? "+" : ""}${v}`); + }; + fmt("commits-in-window", d.commitsInWindow); + fmt("files-touched", d.filesTouched); + fmt("dead files", d.deadFileCount); + fmt("large files", d.largeFileCount); + fmt("dead-code items", d.deadCodeItemCount); + fmt("uncommitted", d.uncommittedCount); + lines.push( + `Pulse since ${agg.pulse.previousAt}: ` + + (parts.length > 0 ? parts.join(", ") + "." : "no movement.") + ); + } + if (agg.pendingChangeRisk && agg.pendingChangeRisk.totalChanged > 0) { const p = agg.pendingChangeRisk; lines.push( @@ -154,6 +172,7 @@ export function createSessionBriefingHandler( hygieneSnapshot: agg.hygieneSnapshot, pendingChangeRisk: agg.pendingChangeRisk, pendingChangeCompanions: agg.pendingChangeCompanions, + pulse: agg.pulse, }, }); diff --git a/src/domains/git/types.ts b/src/domains/git/types.ts index e322976..70ffb53 100644 --- a/src/domains/git/types.ts +++ b/src/domains/git/types.ts @@ -126,6 +126,37 @@ export interface PendingChangeCompanions { files: PendingCompanion[]; } +/** One point of the pulse series — a stored snapshot reduced to chartable fields. */ +export interface PulsePoint { + timestampMs: number; + uncommittedCount: number; + commitsInWindow?: number; + deadFileCount?: number; +} + +/** + * Longitudinal pulse — how the workspace moved since the previous briefing + * (ADR 019). Additive, optional, fail-soft slice (ADR 011 rule): present iff + * a pulse store was injected and readable. `deltas` fields are current minus + * previous, present only when both sides were measured; `series` is + * oldest→newest and includes the current (possibly not-yet-stored) point; + * `appended` is false when the store's min-interval throttle suppressed the + * write for this briefing. + */ +export interface PulseSlice { + previousAt?: string; + deltas?: { + uncommittedCount: number; + commitsInWindow?: number; + filesTouched?: number; + deadFileCount?: number; + largeFileCount?: number; + deadCodeItemCount?: number; + }; + series: PulsePoint[]; + appended: boolean; +} + export interface SessionBriefing { generatedAt: string; branch: string; @@ -141,6 +172,7 @@ export interface SessionBriefing { hygieneSnapshot?: HygieneSnapshot; pendingChangeRisk?: PendingChangeRisk; pendingChangeCompanions?: PendingChangeCompanions; + pulse?: PulseSlice; } export type SessionBriefingReport = SessionBriefing & { summary: string }; diff --git a/src/domains/hygiene/analytics-service.ts b/src/domains/hygiene/analytics-service.ts index f9c21ec..d175930 100644 --- a/src/domains/hygiene/analytics-service.ts +++ b/src/domains/hygiene/analytics-service.ts @@ -16,6 +16,11 @@ import { } from "./analytics-types"; import { DeadCodeScan } from "../../types"; import { CACHE_SETTINGS, HYGIENE_ANALYTICS_EXCLUDE_PATTERNS } from "../../constants"; +import { + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_VENDOR_DIRS, +} from "../../ecosystems"; import { TtlCache } from "../../infrastructure/cache"; import { pathMatchesAny } from "../../infrastructure/glob-match"; import { ignoreFileMtimeMs, readMeridianIgnorePatterns } from "../../security/ignore-store"; @@ -31,19 +36,16 @@ import { /** * Heavy dirs we never recurse into (would be expensive). We only check existence - * and add a single placeholder entry (one stat) so they still show as hygiene targets. - * Covers: JS/Node, Python, PHP/Ruby, JVM/Gradle, .NET, Terraform, Dart/Flutter, - * Elixir, Haskell, Clojure. + * and add a single placeholder entry (one stat) so they still show as hygiene + * targets. Derived from the ecosystem registry (ADR 018) as env∪cache∪vendor — + * exactly the dirs HYGIENE_ANALYTICS_EXCLUDE_PATTERNS excludes, so exclusion + * and placeholder membership cannot drift. Build-output dirs are absent by + * design: the walker recurses them so contents surface as prune candidates. */ -const HEAVY_ARTIFACT_DIRS = new Set([ - "node_modules", "venv", ".venv", - "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs", - ".yarn", ".pnpm-store", - "vendor", ".bundle", - ".gradle", "packages", - ".terraform", ".dart_tool", - "deps", "_build", - ".stack-work", ".cpcache", +const HEAVY_ARTIFACT_DIRS: ReadonlySet = new Set([ + ...ECOSYSTEM_ENV_DIRS, + ...ECOSYSTEM_CACHE_DIRS, + ...ECOSYSTEM_VENDOR_DIRS, ]); function countLines(filePath: string, ext: string, sizeBytes: number): number { diff --git a/src/domains/hygiene/analytics-utils.ts b/src/domains/hygiene/analytics-utils.ts index f2d46fb..466df65 100644 --- a/src/domains/hygiene/analytics-utils.ts +++ b/src/domains/hygiene/analytics-utils.ts @@ -13,24 +13,38 @@ import { TemporalBucket, TemporalData, } from "./analytics-types"; +import { + ECOSYSTEM_ARTIFACT_EXTS, + ECOSYSTEM_BUILD_DIRS, + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_CONFIG_EXTS, + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_SOURCE_EXTS, + ECOSYSTEM_VENDOR_DIRS, +} from "../../ecosystems"; // ============================================================================ -// Category mapping +// Category mapping — ecosystem-specific sets derive from the registry +// (ADR 018); only the ecosystem-neutral sets are defined here. // ============================================================================ const MARKDOWN_EXTS = new Set([".md", ".mdx"]); const LOG_EXTS = new Set([".log"]); -const CONFIG_EXTS = new Set([".yml", ".yaml", ".json", ".toml", ".ini", ".env"]); +const CONFIG_EXTS = ECOSYSTEM_CONFIG_EXTS; const BACKUP_EXTS = new Set([".bak", ".orig", ".swp"]); const TEMP_EXTS = new Set([".tmp", ".temp"]); -const SOURCE_EXTS = new Set([".ts", ".js", ".py", ".go", ".rs", ".java", ".rb", ".cs", ".tsx", ".jsx", ".sh", ".bash"]); +const SOURCE_EXTS = ECOSYSTEM_SOURCE_EXTS; /** Compiled / generated artifact extensions */ -export const ARTIFACT_EXTS = new Set([".class", ".pyc", ".pyo", ".o", ".obj", ".a", ".so"]); -/** Directory names that indicate build / cache, venvs, or tool output */ -export const ARTIFACT_DIRS = new Set([ - "target", ".next", ".nuxt", ".parcel-cache", - "__pycache__", "venv", ".venv", - ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs", +export const ARTIFACT_EXTS = ECOSYSTEM_ARTIFACT_EXTS; +/** + * Directory names whose contents categorize as "artifact": environments, + * caches, and build outputs. Vendor dirs are deliberately absent — they are + * never recursed, so their contents never reach categorize(). + */ +export const ARTIFACT_DIRS: ReadonlySet = new Set([ + ...ECOSYSTEM_ENV_DIRS, + ...ECOSYSTEM_CACHE_DIRS, + ...ECOSYSTEM_BUILD_DIRS, ]); /** Extensions for which we attempt line counting (text-based only) */ @@ -92,25 +106,17 @@ const COLLECTION_BUCKET_KEYS: readonly CollectionBucket[] = [ ]; /** - * Dir-name → bucket map for the Collections section. Broader than - * HEAVY_ARTIFACT_DIRS in analytics-service.ts: includes build-output dirs - * (dist/build/out/target) that the walker DOES recurse into, so they get - * surfaced as collections via their file paths, not via a placeholder row. + * Dir-name → bucket map for the Collections section, derived directly from + * the ecosystem registry (ADR 018). Broader than HEAVY_ARTIFACT_DIRS in + * analytics-service.ts: includes build-output dirs (dist/build/out/target) + * that the walker DOES recurse into, so they get surfaced as collections via + * their file paths, not via a placeholder row. */ export const COLLECTION_BUCKETS: Record> = { - envs: new Set(["venv", ".venv", "env"]), - caches: new Set([ - "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs", - ".yarn", ".pnpm-store", ".cache", - ".gradle", ".terraform", ".dart_tool", ".cpcache", ".stack-work", ".parcel-cache", - ".next", ".nuxt", - ]), - buildOutputs: new Set([ - "dist", "build", "out", "target", "_build", "deps", "bundled", - ]), - vendoredDeps: new Set([ - "node_modules", "vendor", "packages", ".bundle", - ]), + envs: ECOSYSTEM_ENV_DIRS, + caches: ECOSYSTEM_CACHE_DIRS, + buildOutputs: ECOSYSTEM_BUILD_DIRS, + vendoredDeps: ECOSYSTEM_VENDOR_DIRS, }; /** Resolve a directory name to its Collections bucket, or null if it's not a known heavy-artifact dir. */ diff --git a/src/domains/hygiene/collection-detector.ts b/src/domains/hygiene/collection-detector.ts index 37df5b6..0318c1d 100644 --- a/src/domains/hygiene/collection-detector.ts +++ b/src/domains/hygiene/collection-detector.ts @@ -21,7 +21,7 @@ import { bucketForDirName } from "./analytics-utils"; const MAX_DEPTH = 3; /** Skip these dir names entirely during the walk (not collections, not interesting). */ -const SKIP_DIRS = new Set([".git", ".meridian", ".vscode", ".idea"]); +const SKIP_DIRS = new Set([".git", ".meridian", ".vscode", ".idea", ".settings"]); export function detectCollections(workspaceRoot: string): CollectionsBreakdown { const result: CollectionsBreakdown = { diff --git a/src/domains/hygiene/service.ts b/src/domains/hygiene/service.ts index 271e5ff..ab217e3 100644 --- a/src/domains/hygiene/service.ts +++ b/src/domains/hygiene/service.ts @@ -23,6 +23,8 @@ import { HygieneAnalyzer } from "./analytics-service"; import { DeadCodeAnalyzer } from "./dead-code-analyzer"; import { createShowHygieneAnalyticsHandler } from "./analytics-handler"; import { createImpactAnalysisHandler } from "./impact-analysis-handler"; +import { createStorageHandlers } from "./storage-handler"; +import { RunLog } from "../../infrastructure/run-log"; /** * Hygiene domain commands. @@ -32,6 +34,8 @@ export const HYGIENE_COMMANDS: HygieneCommandName[] = [ "hygiene.cleanup", "hygiene.showAnalytics", "hygiene.impactAnalysis", + "hygiene.storageStatus", + "hygiene.pruneStorage", ]; export class HygieneDomainService implements DomainService { @@ -54,7 +58,8 @@ export class HygieneDomainService implements DomainService { workspaceProvider: WorkspaceProvider, logger: Logger, workspaceRoot?: string, - generateProseFn?: GenerateProseFn + generateProseFn?: GenerateProseFn, + runLog?: RunLog ) { this.logger = logger; this.workspaceRoot = workspaceRoot; @@ -62,6 +67,7 @@ export class HygieneDomainService implements DomainService { this.deadCodeAnalyzer = new DeadCodeAnalyzer(logger); // Initialize handlers + const storage = createStorageHandlers(logger, runLog, workspaceRoot); this.handlers = { "hygiene.scan": createScanHandler(workspaceProvider, logger, this.deadCodeAnalyzer, (scan, scannedAt) => { this.lastScan = { scan, scannedAt }; @@ -69,6 +75,8 @@ export class HygieneDomainService implements DomainService { "hygiene.cleanup": createCleanupHandler(workspaceProvider, logger), "hygiene.showAnalytics": createShowHygieneAnalyticsHandler(this.analyzer, this.deadCodeAnalyzer, logger), "hygiene.impactAnalysis": createImpactAnalysisHandler(logger, generateProseFn), + "hygiene.storageStatus": storage.storageStatus, + "hygiene.pruneStorage": storage.pruneStorage, }; } @@ -140,7 +148,8 @@ export function createHygieneDomain( workspaceProvider: WorkspaceProvider, logger: Logger, workspaceRoot?: string, - generateProseFn?: GenerateProseFn + generateProseFn?: GenerateProseFn, + runLog?: RunLog ): HygieneDomainService { - return new HygieneDomainService(workspaceProvider, logger, workspaceRoot, generateProseFn); + return new HygieneDomainService(workspaceProvider, logger, workspaceRoot, generateProseFn, runLog); } diff --git a/src/domains/hygiene/storage-handler.ts b/src/domains/hygiene/storage-handler.ts new file mode 100644 index 0000000..aa8c6fa --- /dev/null +++ b/src/domains/hygiene/storage-handler.ts @@ -0,0 +1,101 @@ +/** + * Hygiene Storage Handlers — meridian self-policing surface (ADR 019). + * + * hygiene.storageStatus — footprint + would-prune preview for Meridian-owned + * storage (artifacts, run log, pulse). hygiene.pruneStorage — apply the + * retention policy now (artifacts prune + run-log compaction). Thin wrappers + * over infrastructure/retention.ts; the preview and the prune share one + * planner, so they cannot disagree. + * + * Both handlers come from one factory sharing a TtlCache: the hygiene tree + * dispatches storageStatus on every root rebuild (debounced-watcher driven), + * and the status read includes a full run-log line count — the cache bounds + * that to once per CACHE_SETTINGS.STORAGE_STATUS_TTL_MS. A successful prune + * invalidates, so the post-prune refresh never serves the stale preview. + */ + +import { CommandContext, Handler, Logger, Result, failure, success } from "../../types"; +import { + StorageStatus, + computeStorageStatus, + getRetentionPolicy, + pruneArtifacts, + PruneOutcome, +} from "../../infrastructure/retention"; +import { RunLog } from "../../infrastructure/run-log"; +import { INFRASTRUCTURE_ERROR_CODES } from "../../infrastructure/error-codes"; +import { TtlCache } from "../../infrastructure/cache"; +import { CACHE_SETTINGS } from "../../constants"; + +export interface PruneStorageOutcome { + artifacts: PruneOutcome; + runLogDropped: number; +} + +export interface StorageHandlers { + storageStatus: Handler, StorageStatus>; + pruneStorage: Handler, PruneStorageOutcome>; +} + +function resolveRoot(ctx: CommandContext, fallbackRoot?: string): string | undefined { + return ctx.workspaceFolders?.[0] ?? fallbackRoot; +} + +function noWorkspace(context: string): Result { + return failure({ + code: INFRASTRUCTURE_ERROR_CODES.WORKSPACE_NOT_FOUND, + message: "No workspace folder open — Meridian storage lives per-workspace", + details: undefined, + context, + }); +} + +export function createStorageHandlers( + logger: Logger, + runLog?: RunLog, + fallbackRoot?: string +): StorageHandlers { + const statusCache = new TtlCache(CACHE_SETTINGS.STORAGE_STATUS_TTL_MS); + + const storageStatus: StorageHandlers["storageStatus"] = async (ctx) => { + const root = resolveRoot(ctx, fallbackRoot); + if (!root) return noWorkspace("hygiene.storageStatus"); + + const cached = statusCache.get(root); + if (cached) return success(cached); + + const result = await computeStorageStatus(root); + if (result.kind === "ok") statusCache.set(root, result.value); + return result; + }; + + const pruneStorage: StorageHandlers["pruneStorage"] = async (ctx) => { + const root = resolveRoot(ctx, fallbackRoot); + if (!root) return noWorkspace("hygiene.pruneStorage"); + + const policy = getRetentionPolicy(); + const artifactsResult = await pruneArtifacts(root, policy, logger); + if (artifactsResult.kind === "err") return artifactsResult; + + // Run-log compaction is best-effort: a failure downgrades to a warning + // and the artifacts outcome still reports. + let runLogDropped = 0; + if (runLog) { + const compactResult = await runLog.compact(policy.runLogMaxEvents); + if (compactResult.kind === "err") { + logger.warn("Prune storage: run-log compaction failed", "hygiene.pruneStorage", compactResult.error); + } else { + runLogDropped = compactResult.value; + } + } + + statusCache.delete(root); + logger.info( + `Storage pruned: ${artifactsResult.value.deletedCount} artifact(s), ${runLogDropped} run-log event(s)`, + "hygiene.pruneStorage" + ); + return success({ artifacts: artifactsResult.value, runLogDropped }); + }; + + return { storageStatus, pruneStorage }; +} diff --git a/src/ecosystems.ts b/src/ecosystems.ts new file mode 100644 index 0000000..26c91b1 --- /dev/null +++ b/src/ecosystems.ts @@ -0,0 +1,148 @@ +/** + * Ecosystem Registry — single source of truth for language/toolchain + * semantics: which directory names are environments, caches, build outputs, + * or vendored dependencies, and which file extensions are sources, configs, + * or compiled artifacts. + * + * Every exclusion list, collection bucket, and categorization set that used + * to be hand-maintained (and drifted) across constants.ts, + * hygiene/analytics-utils.ts, and hygiene/analytics-service.ts is DERIVED + * from this registry (ADR 018). Adding ecosystem coverage is one profile + * entry here — never an edit to a consumer list. + * + * Bucket semantics: + * - envDirs — local interpreter/runtime environments (never recurse). + * - cacheDirs — regenerable tool caches and coverage output (never recurse). + * - vendorDirs — fetched third-party dependencies (never recurse). + * - buildDirs — compiled/build output. Excluded from the hygiene scan, but + * RECURSED by hygiene analytics so contents surface as prune + * candidates (see constants.ts HYGIENE_ANALYTICS_EXCLUDE_PATTERNS). + * + * Deliberately omitted dir names (ADR 018): bare `bin` (legit script dirs are + * common; Eclipse/.NET bin output is not worth the false-positive rate); + * `packages` (yarn/pnpm monorepos keep first-party source there — silently + * blinding the hygiene scan to it costs more than legacy NuGet layouts gain; + * NuGet shops can opt out via .meridianignore); and `.mvn` / + * `.settings`-style committed project config (IDE metadata lives in + * WORKSPACE_EXCLUDE_BASE instead). + * + * This module imports nothing so any layer — including constants.ts — can + * consume it without cycles. + */ + +export interface EcosystemProfile { + readonly name: string; + readonly envDirs?: readonly string[]; + readonly cacheDirs?: readonly string[]; + readonly buildDirs?: readonly string[]; + readonly vendorDirs?: readonly string[]; + readonly artifactExts?: readonly string[]; + readonly sourceExts?: readonly string[]; + readonly configExts?: readonly string[]; +} + +export const ECOSYSTEM_PROFILES: readonly EcosystemProfile[] = [ + { + // Cross-ecosystem conventions that no single toolchain owns. + name: "common", + cacheDirs: [".cache", "coverage", ".nyc_output"], + buildDirs: ["dist", "build", "out", "bundled"], + sourceExts: [".sh", ".bash"], + configExts: [".json", ".yml", ".yaml", ".env"], + }, + { + name: "node", + cacheDirs: [".yarn", ".pnpm-store", ".next", ".nuxt", ".parcel-cache"], + vendorDirs: ["node_modules"], + sourceExts: [".ts", ".tsx", ".js", ".jsx"], + }, + { + name: "python", + envDirs: ["venv", ".venv", "env"], + cacheDirs: ["__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs"], + artifactExts: [".pyc", ".pyo"], + sourceExts: [".py"], + configExts: [".toml", ".ini"], + }, + { + // Java / Kotlin / Scala / Groovy — Maven, Gradle, sbt toolchains. + name: "jvm", + cacheDirs: [".gradle", ".kotlin", ".bloop", ".metals"], + buildDirs: ["target"], + artifactExts: [".class", ".jar", ".war", ".ear", ".hprof"], + sourceExts: [".java", ".kt", ".kts", ".scala", ".groovy"], + configExts: [".xml", ".properties", ".gradle"], + }, + { + name: "go", + vendorDirs: ["vendor"], + sourceExts: [".go"], + }, + { + // `target` is shared with jvm/Maven — the set union deduplicates. + name: "rust", + buildDirs: ["target"], + sourceExts: [".rs"], + }, + { + name: "dotnet", + buildDirs: ["obj"], + sourceExts: [".cs"], + }, + { + name: "ruby", + vendorDirs: [".bundle"], + sourceExts: [".rb"], + }, + { + name: "elixir", + buildDirs: ["_build"], + vendorDirs: ["deps"], + }, + { + name: "native", + artifactExts: [".o", ".obj", ".a", ".so"], + }, + { name: "terraform", cacheDirs: [".terraform"] }, + { name: "dart", cacheDirs: [".dart_tool"] }, + { name: "haskell", cacheDirs: [".stack-work"] }, + { name: "clojure", cacheDirs: [".cpcache"] }, +]; + +function unionOf(pick: (p: EcosystemProfile) => readonly string[] | undefined): ReadonlySet { + const out = new Set(); + for (const profile of ECOSYSTEM_PROFILES) { + for (const entry of pick(profile) ?? []) out.add(entry); + } + return out; +} + +export const ECOSYSTEM_ENV_DIRS = unionOf((p) => p.envDirs); +export const ECOSYSTEM_CACHE_DIRS = unionOf((p) => p.cacheDirs); +export const ECOSYSTEM_BUILD_DIRS = unionOf((p) => p.buildDirs); +export const ECOSYSTEM_VENDOR_DIRS = unionOf((p) => p.vendorDirs); + +export const ECOSYSTEM_ARTIFACT_EXTS = unionOf((p) => p.artifactExts); +export const ECOSYSTEM_SOURCE_EXTS = unionOf((p) => p.sourceExts); +export const ECOSYSTEM_CONFIG_EXTS = unionOf((p) => p.configExts); + +// Exclusion globs for a group of dir-name sets, sorted for determinism. +// Two forms exist because consumers match different path kinds: +// - "descendants" — only "**//**". For file-path matchers (the hygiene +// scan): excludes contents without accidentally excluding a FILE that +// happens to be named "dist" or "deps". +// - "both" — adds the dir-path form "**/". For the analytics walker, +// which pattern-matches directory paths themselves to stop recursion and +// emit the single placeholder row. +export function dirExcludeGlobs( + sets: ReadonlyArray>, + form: "descendants" | "both" = "descendants" +): readonly string[] { + const names = new Set(); + for (const set of sets) for (const name of set) names.add(name); + return [...names] + .sort() + .flatMap((name) => + form === "both" ? [`**/${name}/**`, `**/${name}`] : [`**/${name}/**`] + ); +} diff --git a/src/infrastructure/error-codes.ts b/src/infrastructure/error-codes.ts index f4cd2da..9e2fdf4 100644 --- a/src/infrastructure/error-codes.ts +++ b/src/infrastructure/error-codes.ts @@ -70,6 +70,10 @@ export const INFRASTRUCTURE_ERROR_CODES = { RUN_LOG_READ_ERROR: "RUN_LOG_READ_ERROR", RUN_LOG_PARSE_ERROR: "RUN_LOG_PARSE_ERROR", RUN_LOG_VERSION_UNSUPPORTED: "RUN_LOG_VERSION_UNSUPPORTED", + PULSE_WRITE_ERROR: "PULSE_WRITE_ERROR", + PULSE_READ_ERROR: "PULSE_READ_ERROR", + PULSE_VERSION_UNSUPPORTED: "PULSE_VERSION_UNSUPPORTED", + RETENTION_ERROR: "RETENTION_ERROR", } as const; // ============================================================================ diff --git a/src/infrastructure/jsonl-tail.ts b/src/infrastructure/jsonl-tail.ts new file mode 100644 index 0000000..ebfd1ab --- /dev/null +++ b/src/infrastructure/jsonl-tail.ts @@ -0,0 +1,39 @@ +/** + * JSONL tail compaction — keep the newest N lines of a line-delimited file, + * atomically (write tmp sibling, rename over). Operates on raw lines and + * never parses JSON, so it is schema-agnostic and safe for version-pinned + * stores (run log ADR 009, pulse store ADR 019): a malformed or + * future-versioned line is preserved verbatim, never interpreted. + * + * Shared by FileRunLog.compact() and FilePulseStore's append-time cap. + */ + +import { promises as fs } from "node:fs"; + +/** + * Rewrite `filePath` to its newest `maxLines` lines. Returns the number of + * lines dropped (0 when under the cap or the file is missing). `maxLines <= 0` + * is a no-op — retention disabled. Not concurrency-safe on its own: callers + * that also append (run log, pulse store) must invoke this inside their + * write queue. + */ +export async function compactJsonlTail(filePath: string, maxLines: number): Promise { + if (maxLines <= 0) return 0; + + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw err; + } + + const lines = raw.split("\n").filter((line) => line.trim().length > 0); + if (lines.length <= maxLines) return 0; + + const kept = lines.slice(-maxLines); + const tmpPath = `${filePath}.tmp`; + await fs.writeFile(tmpPath, kept.join("\n") + "\n", "utf8"); + await fs.rename(tmpPath, filePath); + return lines.length - kept.length; +} diff --git a/src/infrastructure/pulse-store.ts b/src/infrastructure/pulse-store.ts new file mode 100644 index 0000000..ca0d6fa --- /dev/null +++ b/src/infrastructure/pulse-store.ts @@ -0,0 +1,169 @@ +/** + * Pulse store — append-only, versioned JSONL history of per-session workspace + * snapshots, the longitudinal substrate behind the session briefing's pulse + * slice (ADR 019). Modeled on FileRunLog (ADR 009): serialized writes through + * a queue, schema-version guard on both append and read. + * + * Location: `.meridian/pulse/pulse.v1.jsonl` (ADR 014 dotdir). The dir is + * self-ignoring (a `.gitignore` containing `*` is dropped on first write, the + * artifacts-dir pattern) — pulse data is host-local insight; sharing an + * append-only JSONL through git would merge-conflict across machines. + */ + +import { promises as fs } from "node:fs"; +import * as fsSync from "node:fs"; +import path from "node:path"; +import { AppError, Logger, Result, failure, success } from "../types"; +import { MERIDIAN_DIR, MERIDIAN_PULSE_DIR, PULSE } from "../constants"; +import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes"; +import { compactJsonlTail } from "./jsonl-tail"; + +export const PULSE_SCHEMA_VERSION = 1 as const; + +/** + * One stored snapshot. Optional fields mirror the briefing's fail-soft + * peripheral slices: absent when the source was unavailable at capture time, + * never zero-filled (a 0 means "measured as zero", absence means "not + * measured"). + */ +export interface PulseSnapshotV1 { + schemaVersion: typeof PULSE_SCHEMA_VERSION; + timestampMs: number; + branch: string; + uncommittedCount: number; + commitsInWindow?: number; + filesTouched?: number; + deadFileCount?: number; + largeFileCount?: number; + logFileCount?: number; + deadCodeItemCount?: number; + hotspotCount?: number; +} + +export function isSupportedPulseVersion(version: unknown): version is typeof PULSE_SCHEMA_VERSION { + return version === PULSE_SCHEMA_VERSION; +} + +export interface PulseStore { + /** Append one snapshot; tail-compacts past PULSE.MAX_SNAPSHOTS. */ + append(snapshot: PulseSnapshotV1): Promise>; + /** Newest `limit` snapshots, oldest→newest order. */ + readLatest(limit: number): Promise>; +} + +function makeError(code: string, message: string, details: unknown, context: string): AppError { + return { code, message, details, context }; +} + +export class FilePulseStore implements PulseStore { + private readonly dirPath: string; + private readonly filePath: string; + private writeQueue: Promise = Promise.resolve(); + + constructor(workspaceRoot: string, private readonly logger: Logger) { + this.dirPath = path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_PULSE_DIR); + this.filePath = path.join(this.dirPath, `pulse.v${PULSE_SCHEMA_VERSION}.jsonl`); + } + + async append(snapshot: PulseSnapshotV1): Promise> { + if (!isSupportedPulseVersion(snapshot.schemaVersion)) { + return failure( + makeError( + INFRASTRUCTURE_ERROR_CODES.PULSE_VERSION_UNSUPPORTED, + `Pulse store: unsupported schemaVersion ${String(snapshot.schemaVersion)} (append rejected)`, + snapshot, + "FilePulseStore.append" + ) + ); + } + try { + await this.enqueue(async () => { + await fs.mkdir(this.dirPath, { recursive: true }); + this.writeSelfIgnore(); + await fs.appendFile(this.filePath, JSON.stringify(snapshot) + "\n", "utf8"); + await compactJsonlTail(this.filePath, PULSE.MAX_SNAPSHOTS); + }); + return success(void 0); + } catch (err) { + return failure( + makeError( + INFRASTRUCTURE_ERROR_CODES.PULSE_WRITE_ERROR, + `Pulse store: append failed (${this.filePath})`, + err, + "FilePulseStore.append" + ) + ); + } + } + + async readLatest(limit: number): Promise> { + const safeLimit = Math.max(0, Math.trunc(limit)); + if (safeLimit === 0) return success([]); + + let raw: string; + try { + raw = await fs.readFile(this.filePath, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return success([]); + return failure( + makeError( + INFRASTRUCTURE_ERROR_CODES.PULSE_READ_ERROR, + `Pulse store: read failed (${this.filePath})`, + err, + "FilePulseStore.readLatest" + ) + ); + } + + const snapshots: PulseSnapshotV1[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + // Tolerate a torn/corrupt line (e.g. crash mid-append): drop it with a + // warning rather than fail the whole history — pulse is a peripheral, + // fail-soft source by contract (unlike the run log's strict reader). + this.logger.warn("Pulse store: skipping malformed line", "FilePulseStore.readLatest"); + continue; + } + if ( + typeof parsed !== "object" || parsed === null || + !isSupportedPulseVersion((parsed as { schemaVersion?: unknown }).schemaVersion) + ) { + this.logger.warn("Pulse store: skipping unsupported-version line", "FilePulseStore.readLatest"); + continue; + } + snapshots.push(parsed as PulseSnapshotV1); + } + return success(snapshots.slice(-safeLimit)); + } + + /** + * Idempotently drop the self-ignoring `.gitignore` (`*`). Sync + swallowed: + * the write is tiny, one-shot, and non-fatal (matches the artifacts-dir + * writer in webview-provider.ts). + */ + private writeSelfIgnore(): void { + try { + const gitignore = path.join(this.dirPath, ".gitignore"); + if (!fsSync.existsSync(gitignore)) fsSync.writeFileSync(gitignore, "*\n", "utf-8"); + } catch (e) { + this.logger.warn(`Pulse store: self-ignore write failed: ${String(e)}`, "FilePulseStore"); + } + } + + private enqueue(task: () => Promise): Promise { + const run = this.writeQueue.then(task); + this.writeQueue = run.catch(() => { + // Error propagates to append()'s caller; the queue itself must not stall. + }); + return run; + } +} + +export function createPulseStore(workspaceRoot: string, logger: Logger): PulseStore { + return new FilePulseStore(workspaceRoot, logger); +} diff --git a/src/infrastructure/retention.ts b/src/infrastructure/retention.ts new file mode 100644 index 0000000..01fd6ed --- /dev/null +++ b/src/infrastructure/retention.ts @@ -0,0 +1,253 @@ +/** + * Retention engine (ADR 019) — self-policing for Meridian-owned storage. + * + * Scope is exactly three Meridian-owned locations, never anything else: + * 1. `.meridian/artifacts/` — exported reports (count + age caps) + * 2. `.vscode/meridian/run-log.v1.jsonl` — via RunLog.compact (event cap) + * 3. `.meridian/pulse/` — self-capping (pulse-store), status only + * + * Enforcement is lazy (ADR 014 doctrine — no timers, no watchers): once at + * activation (fire-and-forget) and after each artifacts quick-save. The + * planning step is a pure function so the storage surface's "would prune" + * preview and the actual prune can never disagree. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { AppError, Logger, Result, failure, success } from "../types"; +import { MERIDIAN_ARTIFACTS_DIR, MERIDIAN_DIR, MERIDIAN_PULSE_DIR, PULSE } from "../constants"; +import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes"; +import { readSetting } from "./settings"; +import { RunLog, RUN_LOG_RELATIVE_PATH } from "./run-log"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// ============================================================================ +// Policy +// ============================================================================ + +export interface RetentionPolicy { + /** Keep at most this many artifact files; 0 disables the count rule. */ + artifactsMaxCount: number; + /** Prune artifact files older than this many days; 0 disables the age rule. */ + artifactsMaxAgeDays: number; + /** Compact the run log to its newest N events; 0 disables. */ + runLogMaxEvents: number; +} + +/** Resolve the live policy through the ADR 013 settings chokepoint. */ +export function getRetentionPolicy(): RetentionPolicy { + return { + artifactsMaxCount: Math.max(0, readSetting("retention.artifacts.maxCount")), + artifactsMaxAgeDays: Math.max(0, readSetting("retention.artifacts.maxAgeDays")), + runLogMaxEvents: Math.max(0, readSetting("retention.runLog.maxEvents")), + }; +} + +// ============================================================================ +// Planning (pure) +// ============================================================================ + +export interface ArtifactFileInfo { + name: string; + mtimeMs: number; + sizeBytes: number; +} + +/** + * Decide which artifact files the policy removes. Pure. Age rule first, then + * the count cap on survivors (oldest pruned first); newest files always win. + */ +export function planArtifactPrune( + files: ReadonlyArray, + policy: Pick, + nowMs: number +): { keep: ArtifactFileInfo[]; prune: ArtifactFileInfo[] } { + const byNewest = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs); + const prune: ArtifactFileInfo[] = []; + const keep: ArtifactFileInfo[] = []; + + for (const file of byNewest) { + const tooOld = + policy.artifactsMaxAgeDays > 0 && + nowMs - file.mtimeMs > policy.artifactsMaxAgeDays * DAY_MS; + const overCount = policy.artifactsMaxCount > 0 && keep.length >= policy.artifactsMaxCount; + if (tooOld || overCount) { + prune.push(file); + } else { + keep.push(file); + } + } + return { keep, prune }; +} + +// ============================================================================ +// Filesystem inventory +// ============================================================================ + +function artifactsDirPath(workspaceRoot: string): string { + return path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR); +} + +/** + * Inventory the artifacts dir: regular files only, excluding the self-ignore + * `.gitignore` (never a prune target). Missing dir → empty inventory. + */ +export async function listArtifacts(workspaceRoot: string): Promise { + const dir = artifactsDirPath(workspaceRoot); + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } + + const files: ArtifactFileInfo[] = []; + for (const entry of entries) { + if (!entry.isFile() || entry.name === ".gitignore") continue; + try { + const stat = await fs.stat(path.join(dir, entry.name)); + files.push({ name: entry.name, mtimeMs: stat.mtimeMs, sizeBytes: stat.size }); + } catch { + // Raced deletion — skip. + } + } + return files; +} + +// ============================================================================ +// Enforcement +// ============================================================================ + +export interface PruneOutcome { + deletedCount: number; + freedBytes: number; +} + +function makeError(message: string, details: unknown, context: string): AppError { + return { code: INFRASTRUCTURE_ERROR_CODES.RETENTION_ERROR, message, details, context }; +} + +/** + * Apply the artifact policy: plan, then unlink. Only basenames returned by + * listArtifacts are ever joined to the artifacts dir — no external input + * reaches a path. A single failed unlink is logged and skipped, not fatal. + */ +export async function pruneArtifacts( + workspaceRoot: string, + policy: Pick, + logger: Logger +): Promise> { + try { + const files = await listArtifacts(workspaceRoot); + const { prune } = planArtifactPrune(files, policy, Date.now()); + const dir = artifactsDirPath(workspaceRoot); + + let deletedCount = 0; + let freedBytes = 0; + for (const file of prune) { + try { + await fs.unlink(path.join(dir, file.name)); + deletedCount += 1; + freedBytes += file.sizeBytes; + } catch (err) { + logger.warn( + `Retention: failed to prune artifact ${file.name}: ${String(err)}`, + "pruneArtifacts" + ); + } + } + if (deletedCount > 0) { + logger.info( + `Retention: pruned ${deletedCount} artifact(s), freed ${freedBytes} bytes`, + "pruneArtifacts" + ); + } + return success({ deletedCount, freedBytes }); + } catch (err) { + return failure(makeError("Artifact prune failed", err, "pruneArtifacts")); + } +} + +/** + * Activation-time retention pass: artifacts prune + run-log compaction. + * Both legs fail-soft with a warning — retention must never block activation. + */ +export async function runActivationRetention( + workspaceRoot: string, + runLog: RunLog, + logger: Logger +): Promise { + const policy = getRetentionPolicy(); + + const pruneResult = await pruneArtifacts(workspaceRoot, policy, logger); + if (pruneResult.kind === "err") { + logger.warn("Retention: activation artifact prune failed", "runActivationRetention", pruneResult.error); + } + + const compactResult = await runLog.compact(policy.runLogMaxEvents); + if (compactResult.kind === "err") { + logger.warn("Retention: run-log compaction failed", "runActivationRetention", compactResult.error); + } else if (compactResult.value > 0) { + logger.info(`Retention: run log compacted (${compactResult.value} events dropped)`, "runActivationRetention"); + } +} + +// ============================================================================ +// Storage status (the storage surface's data source) +// ============================================================================ + +export interface StorageStatus { + artifacts: { + fileCount: number; + totalBytes: number; + oldestMtimeMs: number | null; + /** Dry-run of the CURRENT policy — shares planArtifactPrune with the real prune. */ + wouldPruneCount: number; + wouldPruneBytes: number; + }; + runLog: { sizeBytes: number; lineCount: number }; + pulse: { sizeBytes: number; snapshotCount: number; maxSnapshots: number }; + policy: RetentionPolicy; +} + +async function fileLineStats(filePath: string): Promise<{ sizeBytes: number; lineCount: number }> { + try { + const raw = await fs.readFile(filePath, "utf8"); + return { + sizeBytes: Buffer.byteLength(raw, "utf8"), + lineCount: raw.split("\n").filter((l) => l.trim().length > 0).length, + }; + } catch { + return { sizeBytes: 0, lineCount: 0 }; + } +} + +export async function computeStorageStatus(workspaceRoot: string): Promise> { + try { + const policy = getRetentionPolicy(); + const files = await listArtifacts(workspaceRoot); + const { prune } = planArtifactPrune(files, policy, Date.now()); + + const runLogStats = await fileLineStats(path.join(workspaceRoot, RUN_LOG_RELATIVE_PATH)); + const pulseStats = await fileLineStats( + path.join(workspaceRoot, MERIDIAN_DIR, MERIDIAN_PULSE_DIR, "pulse.v1.jsonl") + ); + + return success({ + artifacts: { + fileCount: files.length, + totalBytes: files.reduce((sum, f) => sum + f.sizeBytes, 0), + oldestMtimeMs: files.length > 0 ? Math.min(...files.map((f) => f.mtimeMs)) : null, + wouldPruneCount: prune.length, + wouldPruneBytes: prune.reduce((sum, f) => sum + f.sizeBytes, 0), + }, + runLog: runLogStats, + pulse: { ...pulseStats, snapshotCount: pulseStats.lineCount, maxSnapshots: PULSE.MAX_SNAPSHOTS }, + policy, + }); + } catch (err) { + return failure(makeError("Storage status failed", err, "computeStorageStatus")); + } +} diff --git a/src/infrastructure/run-log.ts b/src/infrastructure/run-log.ts index 15ea6cd..20ec1e5 100644 --- a/src/infrastructure/run-log.ts +++ b/src/infrastructure/run-log.ts @@ -16,8 +16,10 @@ import { success, } from "../types"; import { INFRASTRUCTURE_ERROR_CODES } from "./error-codes"; +import { compactJsonlTail } from "./jsonl-tail"; -const RUN_LOG_RELATIVE_PATH = ".vscode/meridian/run-log.v1.jsonl"; +/** Host-runtime run-log path, relative to the workspace root (ADR 009/014). */ +export const RUN_LOG_RELATIVE_PATH = ".vscode/meridian/run-log.v1.jsonl"; export function createRunEventId(): string { return randomUUID(); @@ -32,6 +34,13 @@ export interface RunLog { appendMany(events: RunEventV1[]): Promise>; readByRunId(runId: string): Promise>; readLatest(limit: number): Promise>; + /** + * Retention (ADR 019): keep only the newest `maxEvents` lines, atomically. + * `maxEvents <= 0` disables. Resolves the number of lines dropped. Runs + * inside the write queue so it cannot race an in-flight append. Raw-line + * tail-keep — never parses, so the v1 schema pinning is untouched. + */ + compact(maxEvents: number): Promise>; } function makeError( @@ -100,6 +109,29 @@ export class FileRunLog implements RunLog { return success(all.value.slice(-safeLimit)); } + async compact(maxEvents: number): Promise> { + try { + let dropped = 0; + const task = this.writeQueue.then(async () => { + dropped = await compactJsonlTail(this.filePath, maxEvents); + }); + this.writeQueue = task.catch(() => { + // Error propagates to this caller; the queue itself must not stall. + }); + await task; + return success(dropped); + } catch (err) { + return failure( + makeError( + INFRASTRUCTURE_ERROR_CODES.RUN_LOG_WRITE_ERROR, + `Run log: compaction failed (${this.filePath})`, + err, + "FileRunLog.compact" + ) + ); + } + } + private async enqueueWrite(payload: string): Promise { const writeTask = this.writeQueue.then(async () => { await fs.mkdir(path.dirname(this.filePath), { recursive: true }); diff --git a/src/infrastructure/settings.ts b/src/infrastructure/settings.ts index 91978cf..3d45c47 100644 --- a/src/infrastructure/settings.ts +++ b/src/infrastructure/settings.ts @@ -30,6 +30,9 @@ export const SETTING_DEFAULTS = { "hygiene.prune.maxSizeMB": 1 as number, "hygiene.prune.minLineCount": 0 as number, "hygiene.prune.categories": ["backup", "temp", "log", "artifact"] as readonly string[], + "retention.artifacts.maxCount": 50 as number, + "retention.artifacts.maxAgeDays": 30 as number, + "retention.runLog.maxEvents": 5000 as number, "sessionBriefing.autoLaunch": false as boolean, "startup.enableFileWatchers": true as boolean, } as const; diff --git a/src/infrastructure/webview-provider.ts b/src/infrastructure/webview-provider.ts index 206f39d..392ccb7 100644 --- a/src/infrastructure/webview-provider.ts +++ b/src/infrastructure/webview-provider.ts @@ -12,6 +12,21 @@ import { appendIgnorePattern } from "../security/ignore-store"; import { REPORT_LABELS, reportCsvHeader } from "../report-labels"; import { MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR } from "../constants"; import type { ReportOpenArg } from "../ui/tree-providers/reports-tree-provider"; +import type { Logger } from "../types"; +import { getRetentionPolicy, pruneArtifacts } from "./retention"; + +/** + * Console-backed Logger for the fire-and-forget retention call — this module + * predates Logger injection and reports via the console (see handleError); + * the adapter keeps that convention rather than churning three provider + * constructors for one background call. + */ +const consoleLoggerAdapter: Logger = { + debug: () => { /* retention emits no debug output here */ }, + info: (message) => console.log(`[Meridian] ${message}`), + warn: (message) => console.warn(`[Meridian] ${message}`), + error: (message) => console.error(`[Meridian] ${message}`), +}; /** * Optional hook a provider can opt into for the webview right-click "Ignore" @@ -259,6 +274,12 @@ export abstract class BaseWebviewProvider { this.handleError("Quick-save failed", e); return; } + // Retention (ADR 019): each new export lazily re-enforces the artifacts + // policy. Fire-and-forget — a prune failure never disturbs the save UX. + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (workspaceRoot) { + void pruneArtifacts(workspaceRoot, getRetentionPolicy(), consoleLoggerAdapter); + } const rel = path.join(MERIDIAN_DIR, MERIDIAN_ARTIFACTS_DIR, fileName); vscode.window.showInformationMessage(`Saved to ${rel}`, "Reveal").then((choice) => { if (choice === "Reveal") void vscode.commands.executeCommand("revealInExplorer", target); diff --git a/src/main.ts b/src/main.ts index 29956e3..4ee7c24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,8 @@ import { generateProse } from "./infrastructure/prose-generator"; import { readSetting } from "./infrastructure/settings"; import { getPruneConfig } from "./domains/hygiene/prune-config"; import { createRunLog } from "./infrastructure/run-log"; +import { createPulseStore } from "./infrastructure/pulse-store"; +import { runActivationRetention } from "./infrastructure/retention"; import { migrateLegacyIgnoreFile } from "./infrastructure/dotdir-migration"; import { isSensitiveLoggingEnabled, sanitizeForLogs } from "./security/operation-policy"; @@ -71,6 +73,9 @@ export async function activate(context: vscode.ExtensionContext): Promise const gitProvider = createGitProvider(workspaceRoot); const workspaceProvider = createWorkspaceProvider(workspaceRoot, logger); const runLog = createRunLog(workspaceRoot, logger); + // Pulse history writes into .meridian/ — only when a real workspace folder + // is open, never inside an arbitrary cwd (same guard as the ADR 014 migration). + const pulseStore = workspaceFolder ? createPulseStore(workspaceFolder, logger) : undefined; // ── Router + middleware ───────────────────────────────────────────── // security.logging.sensitive (default "redact"): error text is redacted @@ -84,11 +89,12 @@ export async function activate(context: vscode.ExtensionContext): Promise // ── Domain registration ───────────────────────────────────────────── // Pass the raw (possibly undefined) folder, not the cwd fallback: the // hygiene background scan must never crawl an arbitrary cwd when no - // workspace is open. The service skips its timer when root is undefined. - const hygieneDomain = createHygieneDomain(workspaceProvider, logger, workspaceFolder, generateProse); + // workspace is open. The service skips its timer when root is undefined — + // and the storage handlers then fail with WORKSPACE_NOT_FOUND (ADR 019). + const hygieneDomain = createHygieneDomain(workspaceProvider, logger, workspaceFolder, generateProse, runLog); const gitDomain = createGitDomain( gitProvider, logger, workspaceRoot, generateProse, - runLog, () => hygieneDomain.getLastScan() + runLog, () => hygieneDomain.getLastScan(), pulseStore ); router.registerDomain(gitDomain); router.registerDomain(hygieneDomain); @@ -145,6 +151,12 @@ export async function activate(context: vscode.ExtensionContext): Promise // ── Finalize ──────────────────────────────────────────────────────── statusBar.update(); + // Retention (ADR 019): lazy, fire-and-forget self-policing of Meridian-owned + // storage. Only with a real workspace folder — never prune inside a bare cwd. + if (workspaceFolder) { + void runActivationRetention(workspaceFolder, runLog, logger); + } + // Auto-launch session briefing if configured const autoLaunch = readSetting("sessionBriefing.autoLaunch"); if (autoLaunch) { diff --git a/src/presentation/specialized-commands.ts b/src/presentation/specialized-commands.ts index 3aaf858..d2b5703 100644 --- a/src/presentation/specialized-commands.ts +++ b/src/presentation/specialized-commands.ts @@ -15,6 +15,8 @@ import { HygieneTreeProvider } from "../ui/tree-providers/hygiene-tree-provider" import { copyWithPolicy } from "../security/operation-policy"; import { appendIgnorePattern } from "../security/ignore-store"; import type { ImpactAnalysisResult } from "../domains/hygiene/impact-analysis-handler"; +import type { PruneStorageOutcome } from "../domains/hygiene/storage-handler"; +import type { StorageStatus } from "../infrastructure/retention"; const HR = "─".repeat(UI_SETTINGS.OUTPUT_HR_LENGTH); @@ -75,6 +77,59 @@ export function registerSpecializedCommands( ); hygieneTree.refresh(); }), + + // ── hygiene.pruneStorage (ADR 019) — status → confirm → prune → toast ── + vscode.commands.registerCommand("meridian.hygiene.pruneStorage", async () => { + const freshCtx = getCommandContext(); + + const statusResult = await router.dispatch( + { name: "hygiene.storageStatus", params: {} }, freshCtx + ); + if (statusResult.kind === "err") { + vscode.window.showErrorMessage(`Storage status failed: ${statusResult.error.message}`); + return; + } + const status = statusResult.value as StorageStatus; + const overCap = status.policy.runLogMaxEvents > 0 + ? Math.max(0, status.runLog.lineCount - status.policy.runLogMaxEvents) + : 0; + + if (status.artifacts.wouldPruneCount === 0 && overCap === 0) { + vscode.window.showInformationMessage( + "Meridian storage is within policy — nothing to prune." + ); + return; + } + + const mb = (status.artifacts.wouldPruneBytes / (1024 * 1024)).toFixed(2); + const parts: string[] = []; + if (status.artifacts.wouldPruneCount > 0) { + parts.push(`${status.artifacts.wouldPruneCount} exported report(s) (${mb} MB)`); + } + if (overCap > 0) { + parts.push(`${overCap} run-log event(s)`); + } + const confirm = await vscode.window.showWarningMessage( + `Prune Meridian storage? This removes ${parts.join(" and ")} per the current retention settings.`, + { modal: true }, "Prune" + ); + if (confirm !== "Prune") return; + + const result = await router.dispatch( + { name: "hygiene.pruneStorage", params: {} }, freshCtx + ); + if (result.kind === "ok") { + const outcome = result.value as PruneStorageOutcome; + const freedMb = (outcome.artifacts.freedBytes / (1024 * 1024)).toFixed(2); + vscode.window.showInformationMessage( + `Pruned ${outcome.artifacts.deletedCount} report(s) (${freedMb} MB), ` + + `compacted ${outcome.runLogDropped} run-log event(s).` + ); + hygieneTree.refresh(); + } else { + vscode.window.showErrorMessage(`Prune failed: ${result.error.message}`); + } + }), ); // ── hygiene.impactAnalysis ──────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index b988b35..4fbe543 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,7 +54,13 @@ export type GitCommandName = | "git.commit" | "git.showAnalytics" | "git.sessionBriefing"; -export type HygieneCommandName = "hygiene.scan" | "hygiene.cleanup" | "hygiene.showAnalytics" | "hygiene.impactAnalysis"; +export type HygieneCommandName = + | "hygiene.scan" + | "hygiene.cleanup" + | "hygiene.showAnalytics" + | "hygiene.impactAnalysis" + | "hygiene.storageStatus" + | "hygiene.pruneStorage"; // ============================================================================ // Handler Interface (Aiogram-style Router Pattern) diff --git a/src/ui/tree-providers/hygiene-tree-provider.ts b/src/ui/tree-providers/hygiene-tree-provider.ts index c5ea197..6562bd4 100644 --- a/src/ui/tree-providers/hygiene-tree-provider.ts +++ b/src/ui/tree-providers/hygiene-tree-provider.ts @@ -14,13 +14,15 @@ import * as vscode from "vscode"; import { Command, CommandContext, DeadCodeItem, Logger, Result, WorkspaceScan } from "../../types"; import { ImpactAnalysisResult } from "../../domains/hygiene/impact-analysis-handler"; +import { StorageStatus } from "../../infrastructure/retention"; type Dispatcher = (cmd: Command, ctx: CommandContext) => Promise>; type HygieneItemKind = | "category" | "file" | "markdownFile" | "deadCodeFile" | "deadCodeIssue" | "impactCategory" | "impactTarget" | "impactMetricGroup" | "impactFile" - | "collectionDir" | "report"; + | "collectionDir" | "report" + | "storageSection" | "storageInfo"; class HygieneTreeItem extends vscode.TreeItem { constructor( @@ -226,10 +228,35 @@ export class HygieneTreeProvider implements vscode.TreeDataProvider (bytes / (1024 * 1024)).toFixed(2); + const kb = (bytes: number): string => (bytes / 1024).toFixed(1); + + const rows: HygieneTreeItem[] = []; + + const a = status.artifacts; + const oldest = + a.oldestMtimeMs !== null + ? ` · oldest ${Math.floor((Date.now() - a.oldestMtimeMs) / 86_400_000)}d` + : ""; + const prunable = a.wouldPruneCount > 0 ? ` · ${a.wouldPruneCount} prunable` : ""; + const artifactsRow = new HygieneTreeItem( + "Exported Reports", + "storageInfo", + [], + vscode.TreeItemCollapsibleState.None, + `${a.fileCount} files · ${mb(a.totalBytes)} MB${oldest}${prunable}` + ); + artifactsRow.iconPath = new vscode.ThemeIcon("files"); + artifactsRow.tooltip = + `.meridian/artifacts/ — ${a.fileCount} files, ${mb(a.totalBytes)} MB.` + + (a.wouldPruneCount > 0 + ? ` Current policy would prune ${a.wouldPruneCount} file(s) (${mb(a.wouldPruneBytes)} MB).` + : " Nothing prunable under the current policy."); + rows.push(artifactsRow); + + const overCap = + status.policy.runLogMaxEvents > 0 + ? Math.max(0, status.runLog.lineCount - status.policy.runLogMaxEvents) + : 0; + const runLogRow = new HygieneTreeItem( + "Run Log", + "storageInfo", + [], + vscode.TreeItemCollapsibleState.None, + `${status.runLog.lineCount} events · ${kb(status.runLog.sizeBytes)} KB` + + (overCap > 0 ? ` · ${overCap} over cap` : "") + ); + runLogRow.iconPath = new vscode.ThemeIcon("output"); + runLogRow.tooltip = ".vscode/meridian/run-log.v1.jsonl — compacted per retention settings."; + rows.push(runLogRow); + + const pulseRow = new HygieneTreeItem( + "Pulse History", + "storageInfo", + [], + vscode.TreeItemCollapsibleState.None, + `${status.pulse.snapshotCount}/${status.pulse.maxSnapshots} snapshots · ${kb(status.pulse.sizeBytes)} KB` + ); + pulseRow.iconPath = new vscode.ThemeIcon("pulse"); + pulseRow.tooltip = ".meridian/pulse/ — self-capping; no action needed."; + rows.push(pulseRow); + + const section = new HygieneTreeItem( + "Meridian Storage", + "storageSection", + rows, + vscode.TreeItemCollapsibleState.Collapsed, + a.wouldPruneCount > 0 ? `${a.wouldPruneCount} prunable` : undefined + ); + section.iconPath = new vscode.ThemeIcon("server"); + section.tooltip = "Meridian-owned storage (self-policing, ADR 019)"; + return section; + } + private buildImpactSection(): HygieneTreeItem { const r = this.lastImpactResult!; const m = r.metrics; diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts new file mode 100644 index 0000000..29f4bcb --- /dev/null +++ b/tests/ecosystems.test.ts @@ -0,0 +1,200 @@ +/** + * Ecosystem registry tests (ADR 018) — derivation invariants, legacy-superset + * guards (the derived lists must cover everything the hand-maintained lists + * covered before the registry existed), and JVM coverage fixtures. + */ + +import { describe, it, expect } from "vitest"; +import { + ECOSYSTEM_PROFILES, + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_BUILD_DIRS, + ECOSYSTEM_VENDOR_DIRS, + ECOSYSTEM_ARTIFACT_EXTS, + ECOSYSTEM_SOURCE_EXTS, + ECOSYSTEM_CONFIG_EXTS, + dirExcludeGlobs, +} from "../src/ecosystems"; +import { HYGIENE_SETTINGS, HYGIENE_ANALYTICS_EXCLUDE_PATTERNS } from "../src/constants"; +import { categorize, bucketForDirName, ARTIFACT_DIRS } from "../src/domains/hygiene/analytics-utils"; + +// ============================================================================ +// Legacy lists as they existed before the registry (pre-ADR-018 snapshots). +// If a registry edit ever drops one of these, that is a silent exclusion +// regression — the exact failure mode the registry exists to prevent. +// ============================================================================ + +const LEGACY_SCAN_EXCLUDE_DIRS = [ + "dist", "build", "out", "bundled", + ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", ".tox", ".eggs", + "coverage", ".nyc_output", ".cache", +]; + +const LEGACY_ANALYTICS_EXCLUDE_DIRS = [ + ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", ".tox", ".eggs", + ".yarn", ".pnpm-store", "vendor", ".bundle", ".gradle", + ".terraform", ".dart_tool", "deps", ".stack-work", ".cpcache", + // Documented exceptions (ADR 018): "_build" is a build-output dir — + // analytics now recurses it for prune candidates like dist/ and target/; + // "packages" was dropped from the registry entirely — yarn/pnpm monorepos + // keep first-party source there and excluding it blinded the hygiene scan. +]; + +const LEGACY_HEAVY_DIRS = [ + "node_modules", "venv", ".venv", + "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs", + ".yarn", ".pnpm-store", "vendor", ".bundle", ".gradle", + ".terraform", ".dart_tool", "deps", ".stack-work", ".cpcache", + // "_build" and "packages" moved out deliberately — see above. +]; + +const LEGACY_ARTIFACT_EXTS = [".class", ".pyc", ".pyo", ".o", ".obj", ".a", ".so"]; +const LEGACY_SOURCE_EXTS = [ + ".ts", ".js", ".py", ".go", ".rs", ".java", ".rb", ".cs", ".tsx", ".jsx", ".sh", ".bash", +]; +const LEGACY_CONFIG_EXTS = [".yml", ".yaml", ".json", ".toml", ".ini", ".env"]; + +describe("ecosystem registry derivation", () => { + it("dir buckets are pairwise disjoint (a name resolves to exactly one bucket)", () => { + const buckets = [ + ECOSYSTEM_ENV_DIRS, + ECOSYSTEM_CACHE_DIRS, + ECOSYSTEM_BUILD_DIRS, + ECOSYSTEM_VENDOR_DIRS, + ]; + for (let i = 0; i < buckets.length; i++) { + for (let j = i + 1; j < buckets.length; j++) { + const overlap = [...buckets[i]].filter((n) => buckets[j].has(n)); + expect(overlap).toEqual([]); + } + } + }); + + it("source and config extension sets are disjoint", () => { + const overlap = [...ECOSYSTEM_SOURCE_EXTS].filter((e) => ECOSYSTEM_CONFIG_EXTS.has(e)); + expect(overlap).toEqual([]); + }); + + it("every profile entry is non-empty and extensions carry a leading dot", () => { + for (const profile of ECOSYSTEM_PROFILES) { + for (const dir of [ + ...(profile.envDirs ?? []), ...(profile.cacheDirs ?? []), + ...(profile.buildDirs ?? []), ...(profile.vendorDirs ?? []), + ]) { + expect(dir.length).toBeGreaterThan(0); + expect(dir).not.toContain("/"); + } + for (const ext of [ + ...(profile.artifactExts ?? []), ...(profile.sourceExts ?? []), + ...(profile.configExts ?? []), + ]) { + expect(ext.startsWith(".")).toBe(true); + } + } + }); + + it("dirExcludeGlobs defaults to descendant-only form, deterministically sorted", () => { + const globs = dirExcludeGlobs([new Set(["b", "a"])]); + expect(globs).toEqual(["**/a/**", "**/b/**"]); + }); + + it('dirExcludeGlobs "both" adds the dir-path form (analytics walker contract)', () => { + const globs = dirExcludeGlobs([new Set(["a"])], "both"); + expect(globs).toEqual(["**/a/**", "**/a"]); + }); + + it("scan excludes carry no bare dir-path forms (a FILE named 'dist' is not excluded)", () => { + for (const pattern of HYGIENE_SETTINGS.EXCLUDE_PATTERNS) { + if (pattern.startsWith("**/") && !pattern.includes(".egg-info")) { + expect(pattern.endsWith("/**")).toBe(true); + } + } + }); +}); + +describe("legacy-superset guards (no silent exclusion regressions)", () => { + it("hygiene scan excludes cover every legacy scan-exclude dir", () => { + for (const dir of LEGACY_SCAN_EXCLUDE_DIRS) { + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain(`**/${dir}/**`); + } + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain("**/*.egg-info/**"); + }); + + it("analytics excludes cover every legacy analytics-exclude dir", () => { + for (const dir of LEGACY_ANALYTICS_EXCLUDE_DIRS) { + expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).toContain(`**/${dir}/**`); + } + expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).toContain("**/*.egg-info/**"); + }); + + it("analytics excludes do NOT exclude build-output dirs (recursed for prune candidates)", () => { + for (const dir of ECOSYSTEM_BUILD_DIRS) { + expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).not.toContain(`**/${dir}/**`); + } + }); + + it("heavy/placeholder membership (env∪cache∪vendor) covers every legacy heavy dir", () => { + const heavy = new Set([ + ...ECOSYSTEM_ENV_DIRS, ...ECOSYSTEM_CACHE_DIRS, ...ECOSYSTEM_VENDOR_DIRS, + ]); + for (const dir of LEGACY_HEAVY_DIRS) { + expect(heavy.has(dir), `missing heavy dir: ${dir}`).toBe(true); + } + }); + + it("categorization ext sets cover every legacy ext", () => { + for (const ext of LEGACY_ARTIFACT_EXTS) expect(ECOSYSTEM_ARTIFACT_EXTS.has(ext)).toBe(true); + for (const ext of LEGACY_SOURCE_EXTS) expect(ECOSYSTEM_SOURCE_EXTS.has(ext)).toBe(true); + for (const ext of LEGACY_CONFIG_EXTS) expect(ECOSYSTEM_CONFIG_EXTS.has(ext)).toBe(true); + }); +}); + +describe("JVM coverage (the enterprise-footprint gap ADR 018 closes)", () => { + it("Maven target/ is excluded from the hygiene scan", () => { + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain("**/target/**"); + }); + + it("Gradle and Kotlin caches are excluded and bucketed as caches", () => { + for (const dir of [".gradle", ".kotlin"]) { + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).toContain(`**/${dir}/**`); + expect(bucketForDirName(dir)).toBe("caches"); + } + }); + + it("target/ buckets as a build output and its contents categorize as artifact", () => { + expect(bucketForDirName("target")).toBe("buildOutputs"); + expect(ARTIFACT_DIRS.has("target")).toBe(true); + }); + + it("packaged JVM outputs categorize as artifact", () => { + for (const ext of [".class", ".jar", ".war", ".ear", ".hprof"]) { + expect(categorize(ext, `app${ext}`, `libs/app${ext}`)).toBe("artifact"); + } + }); + + it("JVM-family sources categorize as source", () => { + for (const ext of [".java", ".kt", ".kts", ".scala", ".groovy"]) { + expect(categorize(ext, `Main${ext}`, `src/Main${ext}`)).toBe("source"); + } + }); + + it("pom.xml and application.properties categorize as config, not other", () => { + expect(categorize(".xml", "pom.xml", "pom.xml")).toBe("config"); + expect(categorize(".properties", "application.properties", "src/main/resources/application.properties")).toBe("config"); + expect(categorize(".gradle", "build.gradle", "build.gradle")).toBe("config"); + }); + + it("bare bin/ is deliberately NOT excluded (documented false-positive tradeoff)", () => { + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).not.toContain("**/bin/**"); + expect(bucketForDirName("bin")).toBeNull(); + }); + + it("packages/ is deliberately NOT excluded (yarn/pnpm monorepo first-party source)", () => { + expect(HYGIENE_SETTINGS.EXCLUDE_PATTERNS).not.toContain("**/packages/**"); + expect(HYGIENE_ANALYTICS_EXCLUDE_PATTERNS).not.toContain("**/packages/**"); + expect(bucketForDirName("packages")).toBeNull(); + }); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index f2c0208..725a494 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -165,6 +165,13 @@ export class MockRunLog implements RunLog { const safe = Math.max(0, Math.trunc(limit)); return success(safe === 0 ? [] : this.events.slice(-safe)); } + + async compact(maxEvents: number): Promise> { + if (maxEvents <= 0 || this.events.length <= maxEvents) return success(0); + const dropped = this.events.length - maxEvents; + this.events = this.events.slice(-maxEvents); + return success(dropped); + } } // ============================================================================ diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index cf80262..f29ee36 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -27,6 +27,7 @@ const SPECIALIZED_COMMANDS = new Set([ "meridian.hygiene.deleteFile", "meridian.hygiene.ignoreFile", "meridian.hygiene.impactAnalysis", // ADR 005: active-file fallback + InputBox prompt + "meridian.hygiene.pruneStorage", // ADR 019: status preview + confirmation modal ]); /** View lifecycle commands. No routing, no palette exposure. */ diff --git a/tests/pulseStore.test.ts b/tests/pulseStore.test.ts new file mode 100644 index 0000000..8a96b31 --- /dev/null +++ b/tests/pulseStore.test.ts @@ -0,0 +1,103 @@ +/** + * Pulse store tests (ADR 019) — versioned JSONL append/read, self-ignore, + * cap compaction, and tolerant reads. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { FilePulseStore, PulseSnapshotV1, PULSE_SCHEMA_VERSION } from "../src/infrastructure/pulse-store"; +import { PULSE } from "../src/constants"; +import { MockLogger } from "./fixtures"; + +function snapshot(overrides: Partial = {}): PulseSnapshotV1 { + return { + schemaVersion: PULSE_SCHEMA_VERSION, + timestampMs: Date.now(), + branch: "main", + uncommittedCount: 2, + ...overrides, + }; +} + +describe("FilePulseStore", () => { + let root: string; + let store: FilePulseStore; + let logger: MockLogger; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-pulse-")); + logger = new MockLogger(); + store = new FilePulseStore(root, logger as never); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const pulseFile = () => path.join(root, ".meridian", "pulse", `pulse.v${PULSE_SCHEMA_VERSION}.jsonl`); + + it("append materializes the dir with a self-ignoring .gitignore and persists the snapshot", async () => { + const result = await store.append(snapshot({ deadFileCount: 3 })); + expect(result.kind).toBe("ok"); + + expect(fs.readFileSync(path.join(root, ".meridian", "pulse", ".gitignore"), "utf8")).toBe("*\n"); + const read = await store.readLatest(10); + expect(read.kind).toBe("ok"); + if (read.kind === "ok") { + expect(read.value).toHaveLength(1); + expect(read.value[0].deadFileCount).toBe(3); + } + }); + + it("readLatest returns [] when no history exists (ENOENT is not an error)", async () => { + const read = await store.readLatest(10); + expect(read.kind).toBe("ok"); + if (read.kind === "ok") expect(read.value).toEqual([]); + }); + + it("readLatest returns the newest N in oldest→newest order", async () => { + for (let i = 0; i < 5; i++) { + await store.append(snapshot({ timestampMs: 1000 + i, uncommittedCount: i })); + } + const read = await store.readLatest(3); + expect(read.kind).toBe("ok"); + if (read.kind === "ok") { + expect(read.value.map((s) => s.uncommittedCount)).toEqual([2, 3, 4]); + } + }); + + it("rejects appends with an unsupported schemaVersion", async () => { + const bad = { ...snapshot(), schemaVersion: 99 } as unknown as PulseSnapshotV1; + const result = await store.append(bad); + expect(result.kind).toBe("err"); + if (result.kind === "err") expect(result.error.code).toBe("PULSE_VERSION_UNSUPPORTED"); + }); + + it("skips malformed and unsupported-version lines instead of failing the read", async () => { + await store.append(snapshot({ uncommittedCount: 7 })); + fs.appendFileSync(pulseFile(), "{not json\n" + JSON.stringify({ schemaVersion: 99 }) + "\n"); + await store.append(snapshot({ uncommittedCount: 8 })); + + const read = await store.readLatest(10); + expect(read.kind).toBe("ok"); + if (read.kind === "ok") { + expect(read.value.map((s) => s.uncommittedCount)).toEqual([7, 8]); + } + }); + + it("tail-compacts past PULSE.MAX_SNAPSHOTS on append", async () => { + const lines = Array.from({ length: PULSE.MAX_SNAPSHOTS + 10 }, (_, i) => + JSON.stringify(snapshot({ timestampMs: i, uncommittedCount: i })) + ); + fs.mkdirSync(path.dirname(pulseFile()), { recursive: true }); + fs.writeFileSync(pulseFile(), lines.join("\n") + "\n"); + + await store.append(snapshot({ uncommittedCount: 12345 })); + + const raw = fs.readFileSync(pulseFile(), "utf8").trim().split("\n"); + expect(raw.length).toBe(PULSE.MAX_SNAPSHOTS); + expect(JSON.parse(raw[raw.length - 1]).uncommittedCount).toBe(12345); + }); +}); diff --git a/tests/retention.test.ts b/tests/retention.test.ts new file mode 100644 index 0000000..7b3550e --- /dev/null +++ b/tests/retention.test.ts @@ -0,0 +1,150 @@ +/** + * Retention engine tests (ADR 019) — pure prune planning, artifact pruning + * against a real temp dir, and storage status (including the shared-plan + * guarantee that the preview equals the prune). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// retention → settings → vscode; an empty workspace mock makes readSetting +// fall through to SETTING_DEFAULTS (50 files / 30 days / 5000 events). +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + workspaceFolders: undefined, + }, +})); +import { + ArtifactFileInfo, + computeStorageStatus, + listArtifacts, + planArtifactPrune, + pruneArtifacts, +} from "../src/infrastructure/retention"; +import { MockLogger } from "./fixtures"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = 1_800_000_000_000; + +function file(name: string, ageDays: number, sizeBytes = 100): ArtifactFileInfo { + return { name, mtimeMs: NOW - ageDays * DAY_MS, sizeBytes }; +} + +describe("planArtifactPrune (pure)", () => { + it("keeps everything when both rules are disabled (0)", () => { + const files = [file("a.json", 400), file("b.csv", 1)]; + const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 0, artifactsMaxAgeDays: 0 }, NOW); + expect(keep).toHaveLength(2); + expect(prune).toHaveLength(0); + }); + + it("prunes files older than maxAgeDays regardless of count", () => { + const files = [file("old.json", 31), file("fresh.json", 29)]; + const { prune } = planArtifactPrune(files, { artifactsMaxCount: 0, artifactsMaxAgeDays: 30 }, NOW); + expect(prune.map((f) => f.name)).toEqual(["old.json"]); + }); + + it("count cap keeps the newest N; oldest overflow prunes first", () => { + const files = [file("d1.json", 1), file("d3.json", 3), file("d2.json", 2), file("d4.json", 4)]; + const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 2, artifactsMaxAgeDays: 0 }, NOW); + expect(keep.map((f) => f.name)).toEqual(["d1.json", "d2.json"]); + expect(prune.map((f) => f.name).sort()).toEqual(["d3.json", "d4.json"]); + }); + + it("age and count rules compose (age victims don't consume count slots)", () => { + const files = [file("ancient.json", 90), file("d1.json", 1), file("d2.json", 2)]; + const { keep, prune } = planArtifactPrune(files, { artifactsMaxCount: 2, artifactsMaxAgeDays: 30 }, NOW); + expect(keep.map((f) => f.name)).toEqual(["d1.json", "d2.json"]); + expect(prune.map((f) => f.name)).toEqual(["ancient.json"]); + }); +}); + +describe("pruneArtifacts / listArtifacts / computeStorageStatus (fs)", () => { + let root: string; + let artifactsDir: string; + let logger: MockLogger; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-retention-")); + artifactsDir = path.join(root, ".meridian", "artifacts"); + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.writeFileSync(path.join(artifactsDir, ".gitignore"), "*\n"); + logger = new MockLogger(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeArtifact(name: string, ageDays: number, content = "x".repeat(50)): void { + const p = path.join(artifactsDir, name); + fs.writeFileSync(p, content); + const mtime = new Date(Date.now() - ageDays * DAY_MS); + fs.utimesSync(p, mtime, mtime); + } + + it("listArtifacts excludes the self-ignore .gitignore and returns [] for a missing dir", async () => { + writeArtifact("report.json", 1); + const files = await listArtifacts(root); + expect(files.map((f) => f.name)).toEqual(["report.json"]); + + const empty = await listArtifacts(path.join(root, "nowhere")); + expect(empty).toEqual([]); + }); + + it("prunes by count, keeping the newest and never touching .gitignore", async () => { + writeArtifact("oldest.json", 5); + writeArtifact("middle.json", 3); + writeArtifact("newest.json", 1); + + const result = await pruneArtifacts(root, { artifactsMaxCount: 1, artifactsMaxAgeDays: 0 }, logger as never); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.value.deletedCount).toBe(2); + expect(result.value.freedBytes).toBe(100); + } + expect(fs.readdirSync(artifactsDir).sort()).toEqual([".gitignore", "newest.json"]); + }); + + it("prunes by age", async () => { + writeArtifact("stale.json", 40); + writeArtifact("fresh.json", 1); + + const result = await pruneArtifacts(root, { artifactsMaxCount: 0, artifactsMaxAgeDays: 30 }, logger as never); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") expect(result.value.deletedCount).toBe(1); + expect(fs.existsSync(path.join(artifactsDir, "stale.json"))).toBe(false); + expect(fs.existsSync(path.join(artifactsDir, "fresh.json"))).toBe(true); + }); + + it("storage status reports footprint and a would-prune preview matching the actual prune", async () => { + writeArtifact("a.json", 40); + writeArtifact("b.json", 1); + fs.mkdirSync(path.join(root, ".vscode", "meridian"), { recursive: true }); + fs.writeFileSync(path.join(root, ".vscode", "meridian", "run-log.v1.jsonl"), '{"e":1}\n{"e":2}\n'); + + const status = await computeStorageStatus(root); + expect(status.kind).toBe("ok"); + if (status.kind !== "ok") return; + + expect(status.value.artifacts.fileCount).toBe(2); + expect(status.value.artifacts.totalBytes).toBe(100); + expect(status.value.runLog.lineCount).toBe(2); + // Default policy (30d/50 files): a.json (40d) is the only would-prune victim. + expect(status.value.artifacts.wouldPruneCount).toBe(1); + expect(status.value.artifacts.wouldPruneBytes).toBe(50); + + const pruned = await pruneArtifacts( + root, + { artifactsMaxCount: status.value.policy.artifactsMaxCount, artifactsMaxAgeDays: status.value.policy.artifactsMaxAgeDays }, + logger as never + ); + if (pruned.kind === "ok") { + expect(pruned.value.deletedCount).toBe(status.value.artifacts.wouldPruneCount); + expect(pruned.value.freedBytes).toBe(status.value.artifacts.wouldPruneBytes); + } + }); +}); diff --git a/tests/run-log.test.ts b/tests/run-log.test.ts index 04c86ba..1057ce3 100644 --- a/tests/run-log.test.ts +++ b/tests/run-log.test.ts @@ -121,5 +121,49 @@ describe("run-log", () => { expect(isSupportedRunEventVersion(1)).toBe(true); }); }); + + it("compact keeps only the newest N events, atomically, and reports dropped count", async () => { + await withTempWorkspace(async (workspaceRoot) => { + const log = new FileRunLog(workspaceRoot, new MockLogger()); + for (let i = 0; i < 10; i++) { + await log.append(makeEvent({ runId: `run-${i}` })); + } + + const compacted = await log.compact(4); + expect(compacted.kind).toBe("ok"); + if (compacted.kind === "ok") expect(compacted.value).toBe(6); + + const latest = await log.readLatest(100); + expect(latest.kind).toBe("ok"); + if (latest.kind === "ok") { + expect(latest.value.map((e) => e.runId)).toEqual(["run-6", "run-7", "run-8", "run-9"]); + } + // No tmp residue from the atomic rewrite. + const dir = await fs.readdir(path.join(workspaceRoot, ".vscode", "meridian")); + expect(dir).toEqual(["run-log.v1.jsonl"]); + }); + }); + + it("compact is a no-op when disabled (0) or under the cap, and on a missing file", async () => { + await withTempWorkspace(async (workspaceRoot) => { + const log = new FileRunLog(workspaceRoot, new MockLogger()); + + const missing = await log.compact(5); + expect(missing.kind).toBe("ok"); + if (missing.kind === "ok") expect(missing.value).toBe(0); + + await log.append(makeEvent()); + const disabled = await log.compact(0); + expect(disabled.kind).toBe("ok"); + if (disabled.kind === "ok") expect(disabled.value).toBe(0); + + const underCap = await log.compact(10); + expect(underCap.kind).toBe("ok"); + if (underCap.kind === "ok") expect(underCap.value).toBe(0); + + const latest = await log.readLatest(10); + if (latest.kind === "ok") expect(latest.value).toHaveLength(1); + }); + }); }); diff --git a/tests/sessionAggregator.test.ts b/tests/sessionAggregator.test.ts index 17bbee6..41b570a 100644 --- a/tests/sessionAggregator.test.ts +++ b/tests/sessionAggregator.test.ts @@ -654,6 +654,110 @@ describe('aggregateSessionBriefing', () => { expect(result.value.pendingChangeCompanions).toBeUndefined(); }); + // ── Pulse history (ADR 019) ──────────────────────────────────────────────── + + function makePulseStore(history: import('../src/infrastructure/pulse-store').PulseSnapshotV1[] = []) { + return { + readLatest: vi.fn().mockResolvedValue(success(history)), + append: vi.fn().mockResolvedValue(success(void 0)), + }; + } + + function pastSnapshot(overrides: Partial = {}) { + return { + schemaVersion: 1 as const, + timestampMs: Date.now() - 60 * 60 * 1000, // 1h ago — clear of the throttle + branch: 'main', + uncommittedCount: 5, + commitsInWindow: 40, + deadFileCount: 4, + ...overrides, + }; + } + + it('omits the pulse slice when no pulse store is injected', async () => { + const result = await aggregateSessionBriefing(makeSources(git, logger, runLog)); + if (result.kind !== 'ok') throw new Error('expected ok'); + expect(result.value.pulse).toBeUndefined(); + }); + + it('first briefing: pulse has no deltas, appends, and series holds the current point', async () => { + const store = makePulseStore([]); + const result = await aggregateSessionBriefing( + makeSources(git, logger, runLog, { pulseStore: store as never }) + ); + if (result.kind !== 'ok') throw new Error('expected ok'); + const pulse = result.value.pulse; + expect(pulse).toBeDefined(); + expect(pulse?.deltas).toBeUndefined(); + expect(pulse?.previousAt).toBeUndefined(); + expect(pulse?.appended).toBe(true); + expect(pulse?.series).toHaveLength(1); + expect(store.append).toHaveBeenCalledOnce(); + const stored = store.append.mock.calls[0][0]; + expect(stored.schemaVersion).toBe(1); + expect(stored.branch).toBe('main'); + expect(stored.commitsInWindow).toBe(42); // from makeAnalyzer summary + }); + + it('computes deltas against the previous snapshot and appends past the throttle window', async () => { + git.setAllChanges([createMockChange('a.ts', 'M'), createMockChange('b.ts', 'M')]); + const store = makePulseStore([pastSnapshot()]); + const result = await aggregateSessionBriefing( + makeSources(git, logger, runLog, { pulseStore: store as never }) + ); + if (result.kind !== 'ok') throw new Error('expected ok'); + const pulse = result.value.pulse; + expect(pulse?.previousAt).toBeDefined(); + expect(pulse?.deltas?.uncommittedCount).toBe(2 - 5); + expect(pulse?.deltas?.commitsInWindow).toBe(42 - 40); + // No hygiene scan cached → current side unmeasured → no dead-file delta. + expect(pulse?.deltas?.deadFileCount).toBeUndefined(); + expect(pulse?.appended).toBe(true); + expect(pulse?.series).toHaveLength(2); + }); + + it('throttles the append when the previous snapshot is inside MIN_APPEND_INTERVAL_MS', async () => { + const store = makePulseStore([pastSnapshot({ timestampMs: Date.now() - 1000 })]); + const result = await aggregateSessionBriefing( + makeSources(git, logger, runLog, { pulseStore: store as never }) + ); + if (result.kind !== 'ok') throw new Error('expected ok'); + expect(store.append).not.toHaveBeenCalled(); + expect(result.value.pulse?.appended).toBe(false); + // Slice still renders: deltas + series ending at "now". + expect(result.value.pulse?.deltas).toBeDefined(); + expect(result.value.pulse?.series).toHaveLength(2); + }); + + it('flags and omits pulse when the history read fails (fail-soft)', async () => { + const store = { + readLatest: vi.fn().mockResolvedValue(failure({ code: 'PULSE_READ_ERROR', message: 'x' })), + append: vi.fn(), + }; + const result = await aggregateSessionBriefing( + makeSources(git, logger, runLog, { pulseStore: store as never }) + ); + if (result.kind !== 'ok') throw new Error('expected ok'); + expect(result.value.pulse).toBeUndefined(); + expect(result.value.flags).toContain('Pulse history unavailable'); + expect(store.append).not.toHaveBeenCalled(); + }); + + it('flags but still returns the slice when the append fails (fail-soft)', async () => { + const store = { + readLatest: vi.fn().mockResolvedValue(success([pastSnapshot()])), + append: vi.fn().mockResolvedValue(failure({ code: 'PULSE_WRITE_ERROR', message: 'x' })), + }; + const result = await aggregateSessionBriefing( + makeSources(git, logger, runLog, { pulseStore: store as never }) + ); + if (result.kind !== 'ok') throw new Error('expected ok'); + expect(result.value.flags).toContain('Pulse history not recorded'); + expect(result.value.pulse).toBeDefined(); + expect(result.value.pulse?.appended).toBe(false); + }); + // The session-briefing webview's FLAG_ANCHORS table couples to literal flag // prefixes emitted from this aggregator. The webview JS isn't importable here // (ES5 IIFE served as a static asset), so this test pins the contract from diff --git a/tests/storageHandler.test.ts b/tests/storageHandler.test.ts new file mode 100644 index 0000000..1ed6993 --- /dev/null +++ b/tests/storageHandler.test.ts @@ -0,0 +1,130 @@ +/** + * Hygiene storage handler tests (ADR 019) — hygiene.storageStatus and + * hygiene.pruneStorage over a real temp workspace. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CommandContext } from "../src/types"; +import { MockLogger, MockRunLog } from "./fixtures"; +import type { StorageStatus } from "../src/infrastructure/retention"; +import type { PruneStorageOutcome } from "../src/domains/hygiene/storage-handler"; + +// storage-handler → retention → settings → vscode; empty mock → typed defaults. +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(), + workspaceFolders: undefined, + }, +})); + +import { createStorageHandlers } from "../src/domains/hygiene/storage-handler"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("hygiene storage handlers", () => { + let root: string; + let ctx: CommandContext; + let logger: MockLogger; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-storage-")); + ctx = { extensionPath: "", workspaceFolders: [root] }; + logger = new MockLogger(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeArtifact(name: string, ageDays: number): void { + const dir = path.join(root, ".meridian", "artifacts"); + fs.mkdirSync(dir, { recursive: true }); + const p = path.join(dir, name); + fs.writeFileSync(p, "x".repeat(10)); + const mtime = new Date(Date.now() - ageDays * DAY_MS); + fs.utimesSync(p, mtime, mtime); + } + + it("storageStatus reports an empty workspace cleanly", async () => { + const { storageStatus } = createStorageHandlers(logger as never); + const result = await storageStatus(ctx, {}); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") return; + const status = result.value as StorageStatus; + expect(status.artifacts.fileCount).toBe(0); + expect(status.artifacts.wouldPruneCount).toBe(0); + expect(status.runLog.lineCount).toBe(0); + expect(status.pulse.snapshotCount).toBe(0); + }); + + it("storageStatus fails when no workspace root is resolvable", async () => { + const { storageStatus } = createStorageHandlers(logger as never); + const result = await storageStatus({ extensionPath: "", workspaceFolders: [] }, {}); + expect(result.kind).toBe("err"); + if (result.kind === "err") expect(result.error.code).toBe("WORKSPACE_NOT_FOUND"); + }); + + it("pruneStorage prunes stale artifacts and compacts the run log", async () => { + writeArtifact("stale.json", 40); // default maxAgeDays 30 → prune + writeArtifact("fresh.json", 1); + + const runLog = new MockRunLog(); + // Over the default 5000-event cap. + runLog.setEvents( + Array.from({ length: 5010 }, (_, i) => ({ + schemaVersion: 1 as const, + eventId: `e${i}`, + runId: `r${i}`, + timestampMs: i, + source: "router" as const, + phase: "start" as const, + commandName: "git.status" as const, + })) + ); + + const { pruneStorage } = createStorageHandlers(logger as never, runLog); + const result = await pruneStorage(ctx, {}); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") return; + const outcome = result.value as PruneStorageOutcome; + expect(outcome.artifacts.deletedCount).toBe(1); + expect(outcome.runLogDropped).toBe(10); + expect(fs.existsSync(path.join(root, ".meridian", "artifacts", "fresh.json"))).toBe(true); + expect(fs.existsSync(path.join(root, ".meridian", "artifacts", "stale.json"))).toBe(false); + }); + + it("pruneStorage succeeds without a run log (artifacts-only)", async () => { + writeArtifact("stale.json", 40); + const { pruneStorage } = createStorageHandlers(logger as never); + const result = await pruneStorage(ctx, {}); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") return; + expect((result.value as PruneStorageOutcome).runLogDropped).toBe(0); + }); + + it("storageStatus is TTL-cached per root and invalidated by a successful prune", async () => { + const { storageStatus, pruneStorage } = createStorageHandlers(logger as never); + + const first = await storageStatus(ctx, {}); + expect(first.kind).toBe("ok"); + if (first.kind !== "ok") return; + expect((first.value as StorageStatus).artifacts.fileCount).toBe(0); + + // A new artifact inside the TTL window is invisible — cached value served. + writeArtifact("stale.json", 40); + const second = await storageStatus(ctx, {}); + if (second.kind !== "ok") return; + expect((second.value as StorageStatus).artifacts.fileCount).toBe(0); + + // Prune invalidates: the post-prune status recomputes from disk. + const pruned = await pruneStorage(ctx, {}); + expect(pruned.kind).toBe("ok"); + const third = await storageStatus(ctx, {}); + if (third.kind !== "ok") return; + expect((third.value as StorageStatus).artifacts.fileCount).toBe(0); // stale.json pruned + expect((third.value as StorageStatus).artifacts.wouldPruneCount).toBe(0); + }); +});