diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7ec429b..7439421 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -53,7 +53,7 @@ Trace the blast radius of a file or function change by analyzing imports, call s Open a dashboard displaying Hygiene analytics: prune candidates over time, file-type breakdown, disk impact (total and per-category), and age/size distributions. Configurable thresholds via workspace settings. ### File actions (sidebar / explorer context) -- **Delete File** — remove a file flagged by the last scan (confirmation required). +- **Delete File** — remove a file flagged by the last scan (confirmation required; the file is moved to the OS trash where supported, so a mis-click stays recoverable). - **Ignore File** — append the file's pattern to `.meridian/.meridianignore`. ### **hygiene.pruneStorage** (Meridian Storage) @@ -64,7 +64,7 @@ Meridian polices its own storage ([ADR 019](./adr/019-pulse-and-retention.md)). ## Sidebar Views & UI ### **Panel Reports View** (`meridian.reports.view`) -First view in the Meridian activity-bar container — the anchored, first-class entry point for the three webview reports, in order: **Session Briefing**, **Git Analytics**, **Hygiene Analytics**. Report rows carry no glyphs by design. Single-click (or the hover **View** action, `$(open-preview)`) reveals a live panel without recomputing, or runs the report once if no panel is open. The hover **Refresh** action (`$(refresh)`) always recomputes. Each row shows a coarse freshness description ("just now", "5m ago", …) read from its `.meridian/latest/` snapshot mtime, redrawn on every snapshot write; the view-title **Reveal Latest Snapshots** action (`Meridian: Reveal Latest Snapshots`, also in the palette) reveals `latest/` in the Explorer ([ADR 020](./adr/020-latest-snapshot-contract.md)). The former in-tree "View Git Report" / "View Hygiene Report" nodes and the Git/Hygiene view-title report icons were removed — report entry now lives exclusively here. +First view in the Meridian activity-bar container — the anchored, first-class entry point for the three webview reports, in order: **Session Briefing**, **Git Analytics**, **Hygiene Analytics**. Report rows carry no glyphs by design. Single-click (or the hover **View** action, `$(open-preview)`) reveals a live panel without recomputing, or runs the report once if no panel is open. The hover **Refresh** action (`$(refresh)`) always recomputes. Each row shows a coarse freshness description ("just now", "5m ago", …) read from its `.meridian/latest/` snapshot mtime, redrawn on every snapshot write; the view-title **Reveal Latest Snapshots** action (`Meridian: Reveal Latest Snapshots`, also in the palette) reveals `latest/` in the Explorer, and `Meridian: Refresh Latest Snapshots` recomputes all three reports and rewrites the snapshots without opening any panel — useful before pointing a coding agent at them ([ADR 020](./adr/020-latest-snapshot-contract.md)). The former in-tree "View Git Report" / "View Hygiene Report" nodes and the Git/Hygiene view-title report icons were removed — report entry now lives exclusively here. ### **Git View** (`meridian.git.view`) Browse current branch, dirty state, change groups (staged/unstaged/untracked, expandable to files), and recent commits. The Git Analytics report is reached via the Panel Reports view. @@ -95,9 +95,9 @@ All features respect workspace settings under the `meridian.*` namespace, includ Per-workspace Meridian state lives under `.meridian/` at the workspace root (see [ADR 014](./adr/014-dotdir-doctrine.md)): - `.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/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 }`. Misconfigurations (unknown keys, wrong value types, malformed JSON) are still ignored at read time but reported once per file change in the `Meridian` output channel, so a typo never fails silently. - `.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)). -- `.meridian/latest/` — local, self-gitignored, agent-readable snapshot of the three reports (`session-briefing.v1.json`, `git-analytics.v1.json`, `hygiene-analytics.v1.json`), each a `{ schemaVersion, kind, generatedAt, report }` envelope overwritten on every report render ("latest = last rendered"); no history, no runtime integration — coding agents read the files directly ([ADR 020](./adr/020-latest-snapshot-contract.md)). +- `.meridian/latest/` — local, self-gitignored, agent-readable snapshot of the three reports (`session-briefing.v1.json`, `git-analytics.v1.json`, `hygiene-analytics.v1.json`), each a `{ schemaVersion, kind, generatedAt, repo?, report }` envelope overwritten on every report render and by `Meridian: Refresh Latest Snapshots` ("latest = last computed"); the optional `repo` field fingerprints repository state (branch, HEAD sha, dirty counts) at write time so agents can detect staleness against `git rev-parse HEAD`; no history, no runtime integration — coding agents read the files directly ([ADR 020](./adr/020-latest-snapshot-contract.md)). - `.meridian/AGENTS.md` — generated once on first snapshot write, documenting the `.meridian/latest/` contract and a paste-ready agent-rules snippet; never overwritten once present, safe to edit ([ADR 020](./adr/020-latest-snapshot-contract.md)). --- diff --git a/docs/adr/020-latest-snapshot-contract.md b/docs/adr/020-latest-snapshot-contract.md index c52069c..9349da7 100644 --- a/docs/adr/020-latest-snapshot-contract.md +++ b/docs/adr/020-latest-snapshot-contract.md @@ -176,6 +176,39 @@ new subsystem. "updated Nm ago" row descriptions (redrawn via `onLatestSnapshotWrite`) and never parses the JSON, so it cannot couple to the envelope shape. +## Addendum (2026-07-12) — repo fingerprint and headless refresh + +Two additive extensions, both inside the v1 envelope contract (adding a +field is non-breaking; only removal or re-meaning forces `v2`): + +1. **Envelope `repo` staleness fingerprint.** Each write stamps an optional + `repo: { branch, head, isDirty, staged, unstaged, untracked }` captured at + write time, resolved through an injectable provider registered at + activation from the GitProvider (this module still never imports `vscode` + or shells git). `generatedAt` only says *when* a snapshot was written; + `repo.head` lets an agent compare against `git rev-parse HEAD` and *know* + whether it predates recent commits. All-or-nothing and fail-soft: any + failed lookup omits the field entirely (absent = "not measured", never a + partial or fabricated fingerprint), and a provider failure can never lose + a snapshot. + +2. **Second sanctioned write path: `meridian.latest.refresh`.** The original + decision's weakness was that snapshots only refreshed when a *human + rendered a webview* — an agent could only ever read state as fresh as the + last click. `Meridian: Refresh Latest Snapshots` + (`src/presentation/latest-refresh.ts`) dispatches the three report + commands through the router and writes each result through the same + `writeLatestSnapshot()` chokepoint, opening no panel. Point 3's semantics + widen from "latest = last rendered" to **"latest = last computed"** + (render or explicit refresh); the handlers return the exact object a + webview would receive, so the two write paths cannot diverge. This does + *not* reopen the ADR 012 runtime-surface question: it is a user-triggered + command (palette / keybinding / task), pull-only — no daemon, no watcher, + no LM tool. + +`.meridian/AGENTS.md` documents both (new workspaces only — the file is +never overwritten once present, by design). + ## Cross-references - `src/infrastructure/latest-snapshot.ts` — `writeLatestSnapshot()`, diff --git a/package.json b/package.json index d207033..c6f7f0c 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,11 @@ "title": "Reveal Latest Snapshots", "icon": "$(folder-opened)", "category": "Meridian" + }, + { + "command": "meridian.latest.refresh", + "title": "Refresh Latest Snapshots", + "category": "Meridian" } ], "configuration": { @@ -413,6 +418,10 @@ { "command": "meridian.latest.reveal", "when": "workspaceFolderCount > 0" + }, + { + "command": "meridian.latest.refresh", + "when": "workspaceFolderCount > 0" } ], "view/title": [ diff --git a/src/infrastructure/git-provider.ts b/src/infrastructure/git-provider.ts index e0ffa5b..de33221 100644 --- a/src/infrastructure/git-provider.ts +++ b/src/infrastructure/git-provider.ts @@ -330,6 +330,10 @@ class RealGitProvider implements GitProvider { return result; } + async getHeadCommit(): Promise> { + return git(["rev-parse", "HEAD"], this.workspaceRoot); + } + async getRecentCommits(count: number): Promise> { const logResult = await git( ["log", `-${count}`, "--pretty=format:%h|%s|%an", "--numstat"], diff --git a/src/infrastructure/latest-snapshot.ts b/src/infrastructure/latest-snapshot.ts index fbc9d27..0aba902 100644 --- a/src/infrastructure/latest-snapshot.ts +++ b/src/infrastructure/latest-snapshot.ts @@ -28,10 +28,27 @@ export type LatestSnapshotKind = keyof typeof LATEST_SNAPSHOT_FILES; export const LATEST_SNAPSHOT_SCHEMA_VERSION = 1; +/** + * Repository state captured at write time — the envelope's staleness + * fingerprint (ADR 020 addendum). An agent compares `head` against + * `git rev-parse HEAD` to detect a snapshot that predates recent commits. + * Declared explicitly (not derived from GitStatus) so the wire shape cannot + * silently widen if the internal type gains fields. + */ +export interface LatestSnapshotRepoContext { + branch: string; + head: string; + isDirty: boolean; + staged: number; + unstaged: number; + untracked: number; +} + interface LatestSnapshotEnvelope { schemaVersion: typeof LATEST_SNAPSHOT_SCHEMA_VERSION; kind: LatestSnapshotKind; generatedAt: string; + repo?: LatestSnapshotRepoContext; report: unknown; } @@ -52,16 +69,21 @@ off disk. No runtime integration or tool call is required. Each file is a JSON envelope: \`\`\`json -{ "schemaVersion": 1, "kind": "sessionBriefing", "generatedAt": "", "report": { ... } } +{ "schemaVersion": 1, "kind": "sessionBriefing", "generatedAt": "", "repo": { "branch": "main", "head": "", "isDirty": false, "staged": 0, "unstaged": 0, "untracked": 0 }, "report": { ... } } \`\`\` ## Semantics -- **Latest = last rendered.** Each file is overwritten in place whenever the - corresponding report webview renders (initial open, refresh, or filter). - There is no history — read history via \`.meridian/pulse/\` instead. +- **Latest = last computed.** Each file is overwritten in place whenever the + corresponding report is computed — a webview render (initial open, refresh, + or filter) or the \`Meridian: Refresh Latest Snapshots\` command. There is + no history — read history via \`.meridian/pulse/\` instead. - **Freshness.** The envelope's \`generatedAt\` is the write time; \`report.generatedAt\` (when present) is when the report was computed. +- **Staleness.** When present, \`repo\` captures repository state at write + time. Compare \`repo.head\` against \`git rev-parse HEAD\` to detect a + snapshot that predates recent commits; \`repo\` may be absent (not + measured), never fabricated. - **Absent optional fields mean "not measured," never zero.** Do not treat a missing field as a zero value. - **Open sets.** Flag \`id\`s and \`severity\` values may gain members within @@ -123,6 +145,23 @@ async function writeFileIfMissing(filePath: string, content: string, logger: Log */ let writeQueue: Promise = Promise.resolve(); +export type LatestSnapshotRepoContextProvider = () => Promise; + +let repoContextProvider: LatestSnapshotRepoContextProvider | null = null; + +/** + * Register the resolver for the envelope's `repo` staleness fingerprint — + * wired once at activation from the GitProvider (this module stays + * `vscode`-free and never shells git itself). `null` (or a provider that + * resolves null, e.g. a non-git workspace) simply omits the field: absent + * means "not measured", per the envelope's fail-soft field semantics. + */ +export function setLatestSnapshotRepoContextProvider( + provider: LatestSnapshotRepoContextProvider | null +): void { + repoContextProvider = provider; +} + export type LatestSnapshotWriteListener = (kind: LatestSnapshotKind) => void; const writeListeners = new Set(); @@ -170,10 +209,22 @@ export function writeLatestSnapshot( await writeFileIfMissing(path.join(latestDir, ".gitignore"), "*\n", logger); await writeFileIfMissing(path.join(workspaceRoot, MERIDIAN_DIR, AGENTS_MD_FILENAME), AGENTS_MD_CONTENT, logger); + // Fingerprint failure is isolated like the side files: a broken provider + // degrades to an envelope without `repo`, never a lost snapshot. + let repo: LatestSnapshotRepoContext | null = null; + if (repoContextProvider) { + try { + repo = await repoContextProvider(); + } catch (e) { + logger.warn(`Latest-snapshot repo context failed for ${kind}: ${String(e)}`, "writeLatestSnapshot"); + } + } + const envelope: LatestSnapshotEnvelope = { schemaVersion: LATEST_SNAPSHOT_SCHEMA_VERSION, kind, generatedAt: new Date().toISOString(), + ...(repo ? { repo } : {}), report, }; diff --git a/src/infrastructure/settings.ts b/src/infrastructure/settings.ts index 3d45c47..21982db 100644 --- a/src/infrastructure/settings.ts +++ b/src/infrastructure/settings.ts @@ -40,6 +40,40 @@ export const SETTING_DEFAULTS = { export type SettingKey = keyof typeof SETTING_DEFAULTS; export type SettingValue = typeof SETTING_DEFAULTS[K]; +/** + * What a (re)parse of `.meridian/settings.json` found wrong. All three are + * silently *ignored* at read time (fall-through semantics are the contract); + * the diagnostics exist so a typo'd key or mis-typed value is reported once + * instead of failing silently forever. + */ +export interface WorkspaceSettingsDiagnostics { + /** File-level failure: unreadable, malformed JSON, or not a JSON object. */ + parseError?: string; + /** Keys with no SETTING_DEFAULTS counterpart (typo'd or retired). */ + unknownKeys: string[]; + /** Known keys whose value shape mismatches the typed default. */ + mismatchedKeys: string[]; +} + +export type WorkspaceSettingsDiagnosticsListener = ( + diagnostics: WorkspaceSettingsDiagnostics, + file: string +) => void; + +let diagnosticsListener: WorkspaceSettingsDiagnosticsListener | null = null; + +/** + * Register the sink for settings-file diagnostics (main.ts wires the Meridian + * output channel). Invoked at most once per file change — parsing is keyed to + * the file's mtime, so a hot readSetting() path never re-emits. Listener + * errors are swallowed; reporting must never break a settings read. + */ +export function setWorkspaceSettingsDiagnosticsListener( + listener: WorkspaceSettingsDiagnosticsListener | null +): void { + diagnosticsListener = listener; +} + let workspaceSettingsCache: | { mtime: number; root: string; values: Record } | null = null; @@ -54,32 +88,77 @@ function vscodeWorkspaceRoot(): string | undefined { } } +/** Key-level audit of a parsed overrides object (file-level errors are handled by the caller). */ +function diagnoseOverrides(values: Record): WorkspaceSettingsDiagnostics { + const unknownKeys: string[] = []; + const mismatchedKeys: string[] = []; + for (const [key, value] of Object.entries(values)) { + if (!(key in SETTING_DEFAULTS)) { + unknownKeys.push(key); + } else if (value !== null && !overrideMatchesDefault(value, key as SettingKey)) { + // JSON null is the documented "not supplied" idiom — never flagged. + mismatchedKeys.push(key); + } + } + return { unknownKeys, mismatchedKeys }; +} + +function emitDiagnostics(diagnostics: WorkspaceSettingsDiagnostics, file: string): void { + if (!diagnostics.parseError && diagnostics.unknownKeys.length === 0 && diagnostics.mismatchedKeys.length === 0) { + return; + } + try { + diagnosticsListener?.(diagnostics, file); + } catch { + // A broken listener must never break a settings read. + } +} + function readWorkspaceSettings(): Record { const root = vscodeWorkspaceRoot(); if (!root) return {}; const file = path.join(root, MERIDIAN_DIR, "settings.json"); + + // An absent file is the normal case: no overrides, no diagnostic. + let stat: fs.Stats; try { - const stat = fs.statSync(file); - if ( - workspaceSettingsCache && - workspaceSettingsCache.root === root && - workspaceSettingsCache.mtime === stat.mtimeMs - ) { - return workspaceSettingsCache.values; - } - const raw = fs.readFileSync(file, "utf-8"); - const parsed: unknown = JSON.parse(raw); - const values: Record = - typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - workspaceSettingsCache = { mtime: stat.mtimeMs, root, values }; - return values; + stat = fs.statSync(file); } catch { - // Missing file, malformed JSON, ENOENT — all collapse to "no overrides". workspaceSettingsCache = null; return {}; } + + if ( + workspaceSettingsCache && + workspaceSettingsCache.root === root && + workspaceSettingsCache.mtime === stat.mtimeMs + ) { + return workspaceSettingsCache.values; + } + + // Cache miss = the file changed (or first read): parse once, audit once. + // A failed parse is cached against the same mtime so a malformed file is + // neither re-parsed nor re-reported on every readSetting() call. + let values: Record = {}; + let diagnostics: WorkspaceSettingsDiagnostics; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8")); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + values = parsed as Record; + diagnostics = diagnoseOverrides(values); + } else { + diagnostics = { parseError: "not a JSON object of key/value pairs", unknownKeys: [], mismatchedKeys: [] }; + } + } catch (e) { + diagnostics = { + parseError: e instanceof Error ? e.message : String(e), + unknownKeys: [], + mismatchedKeys: [], + }; + } + workspaceSettingsCache = { mtime: stat.mtimeMs, root, values }; + emitDiagnostics(diagnostics, file); + return values; } // Shape check against the typed default. Rejects mismatched workspace-file diff --git a/src/infrastructure/workspace-provider.ts b/src/infrastructure/workspace-provider.ts index fdf3d84..04a9d41 100644 --- a/src/infrastructure/workspace-provider.ts +++ b/src/infrastructure/workspace-provider.ts @@ -16,6 +16,14 @@ import { } from "../types"; import { resolveWorkspacePath } from "../security/path-guard"; +/** + * Recoverable-delete capability, injected from the host (main.ts passes + * vscode.workspace.fs.delete with useTrash) so this module stays vscode-free. + * Must reject when the file cannot be trashed; the provider then falls back + * to a permanent unlink. + */ +export type MoveToTrashFn = (absolutePath: string) => Promise; + function fsError(code: string, op: string, filePath: string, err: unknown): AppError { return { code, @@ -92,7 +100,11 @@ function compilePattern(pattern: string): (name: string) => boolean { } class RealWorkspaceProvider implements WorkspaceProvider { - constructor(private readonly workspaceRoot: string, private readonly logger?: Logger) {} + constructor( + private readonly workspaceRoot: string, + private readonly logger?: Logger, + private readonly moveToTrash?: MoveToTrashFn + ) {} async findFiles(pattern: string): Promise> { try { @@ -165,6 +177,20 @@ class RealWorkspaceProvider implements WorkspaceProvider { ) ); } + // Trash-first: a mis-confirmed delete stays recoverable from the OS + // trash. Fall back to a permanent unlink where trash is unavailable + // (e.g. some remote filesystems) so deletion keeps working there. + if (this.moveToTrash) { + try { + await this.moveToTrash(target); + return success(undefined); + } catch (trashErr) { + this.logger?.warn( + `Trash unavailable for '${target}' (${trashErr instanceof Error ? trashErr.message : String(trashErr)}); deleting permanently`, + "WorkspaceProvider" + ); + } + } await fs.unlink(target); return success(undefined); } catch (err) { @@ -175,7 +201,12 @@ class RealWorkspaceProvider implements WorkspaceProvider { /** * Factory: creates a real WorkspaceProvider for the given workspace root. + * When `moveToTrash` is supplied, deleteFile is trash-first (recoverable). */ -export function createWorkspaceProvider(workspaceRoot: string, logger?: Logger): WorkspaceProvider { - return new RealWorkspaceProvider(workspaceRoot, logger); +export function createWorkspaceProvider( + workspaceRoot: string, + logger?: Logger, + moveToTrash?: MoveToTrashFn +): WorkspaceProvider { + return new RealWorkspaceProvider(workspaceRoot, logger, moveToTrash); } diff --git a/src/main.ts b/src/main.ts index 4ee7c24..a060702 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,10 +20,11 @@ import { ConsoleTelemetrySink, } from "./infrastructure/telemetry"; import { generateProse } from "./infrastructure/prose-generator"; -import { readSetting } from "./infrastructure/settings"; +import { readSetting, setWorkspaceSettingsDiagnosticsListener } from "./infrastructure/settings"; import { getPruneConfig } from "./domains/hygiene/prune-config"; import { createRunLog } from "./infrastructure/run-log"; import { createPulseStore } from "./infrastructure/pulse-store"; +import { setLatestSnapshotRepoContextProvider } from "./infrastructure/latest-snapshot"; import { runActivationRetention } from "./infrastructure/retention"; import { migrateLegacyIgnoreFile } from "./infrastructure/dotdir-migration"; import { isSensitiveLoggingEnabled, sanitizeForLogs } from "./security/operation-policy"; @@ -34,6 +35,7 @@ import { setupStatusBar } from "./presentation/status-bar"; import { setupFileWatchers } from "./presentation/file-watchers"; import { setupTreeProviders } from "./presentation/tree-setup"; import { registerSpecializedCommands } from "./presentation/specialized-commands"; +import { registerLatestRefreshCommand } from "./presentation/latest-refresh"; import { createWebviewPanels } from "./presentation/webview-setup"; // ============================================================================ @@ -61,6 +63,20 @@ export async function activate(context: vscode.ExtensionContext): Promise const outputChannel = vscode.window.createOutputChannel("Meridian"); context.subscriptions.push(outputChannel); + // Surface .meridian/settings.json misconfigurations — a typo'd key or + // mis-typed value falls through silently by contract (ADR 014), so report + // it once per file change in the output channel instead of never. + setWorkspaceSettingsDiagnosticsListener((diagnostics, file) => { + const parts: string[] = []; + if (diagnostics.parseError) parts.push(`parse error: ${diagnostics.parseError}`); + if (diagnostics.unknownKeys.length > 0) parts.push(`unknown key(s): ${diagnostics.unknownKeys.join(", ")}`); + if (diagnostics.mismatchedKeys.length > 0) parts.push(`wrong value type, ignored: ${diagnostics.mismatchedKeys.join(", ")}`); + const message = `${file} — ${parts.join("; ")}`; + logger.warn(message, "settings"); + outputChannel.appendLine(`[${new Date().toISOString()}] ${message}`); + }); + context.subscriptions.push({ dispose: () => setWorkspaceSettingsDiagnosticsListener(null) }); + const telemetry = new TelemetryTracker(new ConsoleTelemetrySink(false)); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const workspaceRoot = workspaceFolder ?? process.cwd(); @@ -71,7 +87,26 @@ export async function activate(context: vscode.ExtensionContext): Promise migrateLegacyIgnoreFile(workspaceFolder, logger); const gitProvider = createGitProvider(workspaceRoot); - const workspaceProvider = createWorkspaceProvider(workspaceRoot, logger); + // Trash-first deletion: hygiene's user-facing Delete File stays recoverable + // from the OS trash; the provider falls back to a permanent unlink where + // the host filesystem has no trash. + const workspaceProvider = createWorkspaceProvider(workspaceRoot, logger, (absolutePath) => + Promise.resolve( + vscode.workspace.fs.delete(vscode.Uri.file(absolutePath), { recursive: false, useTrash: true }) + ) + ); + + // ADR 020 addendum: every latest-snapshot write stamps a `repo` staleness + // fingerprint resolved through the GitProvider (the only place that shells + // git). All-or-nothing: any failed lookup (e.g. non-git workspace) omits + // the field rather than writing a partial fingerprint. + setLatestSnapshotRepoContextProvider(async () => { + const [status, head] = await Promise.all([gitProvider.status(), gitProvider.getHeadCommit()]); + if (status.kind !== "ok" || head.kind !== "ok") return null; + const { branch, isDirty, staged, unstaged, untracked } = status.value; + return { branch, head: head.value, isDirty, staged, unstaged, untracked }; + }); + context.subscriptions.push({ dispose: () => setLatestSnapshotRepoContextProvider(null) }); 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). @@ -131,6 +166,8 @@ export async function activate(context: vscode.ExtensionContext): Promise registerSpecializedCommands(context, router, outputChannel, ctxFn, trees.hygieneTree); + registerLatestRefreshCommand(context, router, ctxFn, getPruneConfig, logger); + const statusBar = setupStatusBar(context, gitProvider, () => { trees.gitTree.refresh(); trees.hygieneTree.refresh(); diff --git a/src/presentation/latest-refresh.ts b/src/presentation/latest-refresh.ts new file mode 100644 index 0000000..317a9bb --- /dev/null +++ b/src/presentation/latest-refresh.ts @@ -0,0 +1,94 @@ +/** + * Headless snapshot refresh (ADR 020 addendum) — recompute all three reports + * through the router and rewrite `.meridian/latest/` without opening any + * webview panel. Gives agents (and keybindings/tasks) fresh snapshots on + * demand instead of waiting for a human to render a report; the Reports tree + * freshness rows update automatically via onLatestSnapshotWrite. + */ + +import * as vscode from "vscode"; +import { CommandRouter } from "../router"; +import { Command, CommandContext, Logger, Result } from "../types"; +import { PruneConfig } from "../domains/hygiene/analytics-types"; +import { REPORT_LABELS } from "../report-labels"; +import { writeLatestSnapshot, LatestSnapshotKind } from "../infrastructure/latest-snapshot"; + +export interface LatestRefreshOutcome { + written: LatestSnapshotKind[]; + failures: Array<{ kind: LatestSnapshotKind; message: string }>; +} + +type Dispatch = (cmd: Command, ctx: CommandContext) => Promise>; + +/** + * Dispatch each report command and snapshot every success — one report's + * failure never blocks the others. Sequential, analytics first: the session + * briefing reads the same analyzer at the same default period (3mo), so it + * reuses the cache the first dispatch just warmed. Awaits the queued writes, + * so "written" means the files are on disk when this resolves. + */ +export async function refreshLatestSnapshots( + workspaceRoot: string, + dispatch: Dispatch, + ctx: CommandContext, + pruneConfig: PruneConfig, + logger: Logger +): Promise { + const jobs: ReadonlyArray<{ kind: LatestSnapshotKind; command: Command }> = [ + { kind: "gitAnalytics", command: { name: "git.showAnalytics", params: {} } }, + { kind: "hygieneAnalytics", command: { name: "hygiene.showAnalytics", params: pruneConfig } }, + { kind: "sessionBriefing", command: { name: "git.sessionBriefing", params: {} } }, + ]; + + const outcome: LatestRefreshOutcome = { written: [], failures: [] }; + for (const { kind, command } of jobs) { + const result = await dispatch(command, ctx); + if (result.kind === "ok") { + await writeLatestSnapshot(workspaceRoot, kind, result.value, logger); + outcome.written.push(kind); + } else { + outcome.failures.push({ kind, message: result.error.message }); + } + } + return outcome; +} + +export function registerLatestRefreshCommand( + context: vscode.ExtensionContext, + router: CommandRouter, + getCommandContext: () => CommandContext, + readPruneConfig: () => PruneConfig, + logger: Logger +): void { + context.subscriptions.push( + vscode.commands.registerCommand("meridian.latest.refresh", async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) { + void vscode.window.showInformationMessage( + "Open a workspace folder to refresh Meridian snapshots." + ); + return; + } + + const outcome = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Refreshing Meridian snapshots…" }, + () => refreshLatestSnapshots( + root, (cmd, ctx) => router.dispatch(cmd, ctx), getCommandContext(), readPruneConfig(), logger + ) + ); + + if (outcome.failures.length === 0) { + void vscode.window.showInformationMessage( + `Meridian snapshots refreshed (${outcome.written.length}/${outcome.written.length}).` + ); + } else { + const detail = outcome.failures + .map((f) => `${REPORT_LABELS[f.kind]}: ${f.message}`) + .join("; "); + void vscode.window.showWarningMessage( + `Refreshed ${outcome.written.length}/${outcome.written.length + outcome.failures.length} Meridian snapshots — ${detail}` + ); + } + }) + ); +} diff --git a/src/presentation/specialized-commands.ts b/src/presentation/specialized-commands.ts index d2b5703..0ce9c81 100644 --- a/src/presentation/specialized-commands.ts +++ b/src/presentation/specialized-commands.ts @@ -41,8 +41,9 @@ export function registerSpecializedCommands( if (!filePath) return; const filename = nodePath.basename(filePath); const confirm = await vscode.window.showWarningMessage( - `Delete "${filename}"? This cannot be undone.`, - { modal: true }, "Delete" + `Delete "${filename}"?`, + { modal: true, detail: "The file is moved to the OS trash where supported." }, + "Delete" ); if (confirm !== "Delete") return; const freshCtx = getCommandContext(); diff --git a/src/types.ts b/src/types.ts index 4fbe543..cd4d1f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,7 @@ export interface GitProvider { fetch(remote?: string): Promise>; // Fetch from remote without pulling (network-policy gated) getRemoteUrl(remote?: string): Promise>; // Get remote URL for generating diff links getCurrentBranch(): Promise>; // Get current branch name + getHeadCommit(): Promise>; // Full HEAD sha — staleness fingerprint for latest snapshots (ADR 020) getRecentCommits(count: number): Promise>; getUntrackedFiles(): Promise>; } diff --git a/tests/fixtures.ts b/tests/fixtures.ts index f2bde64..a9fb60e 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -69,6 +69,7 @@ export class MockGitProvider implements GitProvider { private allChanges: GitFileChange[] = []; private currentBranch = 'main'; + private headCommit = '0123456789abcdef0123456789abcdef01234567'; private remoteUrl = 'https://github.com/test/repo.git'; // Override default values for tests @@ -84,6 +85,10 @@ export class MockGitProvider implements GitProvider { this.currentBranch = branch; } + setHeadCommit(sha: string): void { + this.headCommit = sha; + } + setRemoteUrl(url: string): void { this.remoteUrl = url; } @@ -121,6 +126,10 @@ export class MockGitProvider implements GitProvider { return success(this.currentBranch); } + async getHeadCommit(): Promise> { + return success(this.headCommit); + } + async getRecentCommits(_count: number): Promise> { return success([]); } diff --git a/tests/latestRefresh.test.ts b/tests/latestRefresh.test.ts new file mode 100644 index 0000000..60500ef --- /dev/null +++ b/tests/latestRefresh.test.ts @@ -0,0 +1,101 @@ +/** + * Headless snapshot refresh tests (ADR 020 addendum) — the core + * refreshLatestSnapshots orchestration: dispatch order, snapshot writes per + * successful report, and per-report failure isolation. The vscode command + * wrapper is thin wiring and is exercised only for module resolution. + */ + +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"; + +vi.mock("vscode", () => ({})); + +import { refreshLatestSnapshots } from "../src/presentation/latest-refresh"; +import { LATEST_SNAPSHOT_FILES, MERIDIAN_DIR, MERIDIAN_LATEST_DIR } from "../src/constants"; +import { Command, CommandContext, Result, success, failure } from "../src/types"; +import { PruneConfig } from "../src/domains/hygiene/analytics-types"; +import { MockLogger, createMockContext } from "./fixtures"; + +const PRUNE_CONFIG: PruneConfig = { + minAgeDays: 30, + maxSizeMB: 1, + minLineCount: 0, + categories: ["backup", "temp", "log", "artifact"], +}; + +describe("refreshLatestSnapshots", () => { + let root: string; + let ctx: CommandContext; + let logger: MockLogger; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-refresh-")); + ctx = createMockContext(); + logger = new MockLogger(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function readSnapshot(file: string): Record { + return JSON.parse( + fs.readFileSync(path.join(root, MERIDIAN_DIR, MERIDIAN_LATEST_DIR, file), "utf-8") + ); + } + + it("dispatches all three report commands and writes all three snapshots", async () => { + const dispatched: Command[] = []; + const dispatch = async (cmd: Command): Promise> => { + dispatched.push(cmd); + return success({ from: cmd.name }); + }; + + const outcome = await refreshLatestSnapshots(root, dispatch, ctx, PRUNE_CONFIG, logger); + + expect(outcome.written).toEqual(["gitAnalytics", "hygieneAnalytics", "sessionBriefing"]); + expect(outcome.failures).toEqual([]); + + // Analytics dispatched before the briefing so the briefing reuses the + // just-warmed analyzer cache; hygiene receives the injected prune config. + expect(dispatched.map((c) => c.name)).toEqual([ + "git.showAnalytics", "hygiene.showAnalytics", "git.sessionBriefing", + ]); + expect(dispatched[1]?.params).toEqual(PRUNE_CONFIG); + + expect(readSnapshot(LATEST_SNAPSHOT_FILES.gitAnalytics).report).toEqual({ from: "git.showAnalytics" }); + expect(readSnapshot(LATEST_SNAPSHOT_FILES.hygieneAnalytics).report).toEqual({ from: "hygiene.showAnalytics" }); + expect(readSnapshot(LATEST_SNAPSHOT_FILES.sessionBriefing).report).toEqual({ from: "git.sessionBriefing" }); + }); + + it("one report's failure never blocks the others", async () => { + const dispatch = async (cmd: Command): Promise> => + cmd.name === "hygiene.showAnalytics" + ? failure({ code: "SCAN_FAILED", message: "no scan yet" }) + : success({ from: cmd.name }); + + const outcome = await refreshLatestSnapshots(root, dispatch, ctx, PRUNE_CONFIG, logger); + + expect(outcome.written).toEqual(["gitAnalytics", "sessionBriefing"]); + expect(outcome.failures).toEqual([{ kind: "hygieneAnalytics", message: "no scan yet" }]); + + const latestDir = path.join(root, MERIDIAN_DIR, MERIDIAN_LATEST_DIR); + expect(fs.existsSync(path.join(latestDir, LATEST_SNAPSHOT_FILES.gitAnalytics))).toBe(true); + expect(fs.existsSync(path.join(latestDir, LATEST_SNAPSHOT_FILES.hygieneAnalytics))).toBe(false); + expect(fs.existsSync(path.join(latestDir, LATEST_SNAPSHOT_FILES.sessionBriefing))).toBe(true); + }); + + it("snapshots are on disk when the promise resolves (writes are awaited, not queued-and-forgotten)", async () => { + const dispatch = async (cmd: Command): Promise> => success({ from: cmd.name }); + + await refreshLatestSnapshots(root, dispatch, ctx, PRUNE_CONFIG, logger); + + const entries = fs.readdirSync(path.join(root, MERIDIAN_DIR, MERIDIAN_LATEST_DIR)); + expect(entries.filter((e) => e.endsWith(".tmp"))).toHaveLength(0); + expect(entries).toEqual( + expect.arrayContaining(Object.values(LATEST_SNAPSHOT_FILES)) + ); + }); +}); diff --git a/tests/latestSnapshot.test.ts b/tests/latestSnapshot.test.ts index 7c1d09c..494bd15 100644 --- a/tests/latestSnapshot.test.ts +++ b/tests/latestSnapshot.test.ts @@ -11,8 +11,10 @@ import * as path from "node:path"; import { writeLatestSnapshot, + setLatestSnapshotRepoContextProvider, LATEST_SNAPSHOT_SCHEMA_VERSION, LatestSnapshotKind, + LatestSnapshotRepoContext, } from "../src/infrastructure/latest-snapshot"; import { LATEST_SNAPSHOT_FILES, MERIDIAN_DIR, MERIDIAN_LATEST_DIR } from "../src/constants"; import { MockLogger } from "./fixtures"; @@ -29,6 +31,7 @@ describe("writeLatestSnapshot", () => { }); afterEach(() => { + setLatestSnapshotRepoContextProvider(null); fs.rmSync(root, { recursive: true, force: true }); }); @@ -66,6 +69,45 @@ describe("writeLatestSnapshot", () => { expect(typeof parsed.generatedAt).toBe("string"); expect(new Date(parsed.generatedAt as string).toISOString()).toBe(parsed.generatedAt); expect(parsed.report).toEqual({ summary: { totalCommits: 3 } }); + // No provider registered → the repo fingerprint is absent, never fabricated. + expect(parsed.repo).toBeUndefined(); + }); + + describe("repo staleness fingerprint (ADR 020 addendum)", () => { + const fingerprint: LatestSnapshotRepoContext = { + branch: "main", + head: "0123456789abcdef0123456789abcdef01234567", + isDirty: true, + staged: 1, + unstaged: 2, + untracked: 3, + }; + + it("stamps the envelope with the provider's repo context", async () => { + setLatestSnapshotRepoContextProvider(async () => fingerprint); + await writeLatestSnapshot(root, "sessionBriefing", { branch: "main" }, logger); + + expect(readSnapshot("sessionBriefing").repo).toEqual(fingerprint); + }); + + it("omits repo when the provider resolves null (e.g. non-git workspace)", async () => { + setLatestSnapshotRepoContextProvider(async () => null); + await writeLatestSnapshot(root, "gitAnalytics", {}, logger); + + expect(readSnapshot("gitAnalytics").repo).toBeUndefined(); + }); + + it("a throwing provider degrades to no fingerprint — the snapshot still lands, with a warn", async () => { + setLatestSnapshotRepoContextProvider(async () => { + throw new Error("git exploded"); + }); + await writeLatestSnapshot(root, "hygieneAnalytics", { files: [] }, logger); + + const parsed = readSnapshot("hygieneAnalytics"); + expect(parsed.repo).toBeUndefined(); + expect(parsed.report).toEqual({ files: [] }); + expect(logger.getByLevel("warn").length).toBeGreaterThan(0); + }); }); it("serializes Date fields in the report to ISO strings", async () => { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 7128808..749af69 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -32,12 +32,14 @@ const SPECIALIZED_COMMANDS = new Set([ ]); /** - * Palette-visible utility commands registered in tree-setup.ts. Distinct from - * INFRASTRUCTURE_COMMANDS (which are enforced out of the palette) and from - * SPECIALIZED_COMMANDS (whose home is specialized-commands.ts). + * Palette-visible utility commands registered in the presentation layer + * (tree-setup.ts, latest-refresh.ts). Distinct from INFRASTRUCTURE_COMMANDS + * (which are enforced out of the palette) and from SPECIALIZED_COMMANDS + * (whose home is specialized-commands.ts). */ const PRESENTATION_UTILITY_COMMANDS = new Set([ - "meridian.latest.reveal", // reveal-or-explain .meridian/latest/ (ADR 020) + "meridian.latest.reveal", // reveal-or-explain .meridian/latest/ (ADR 020) + "meridian.latest.refresh", // headless recompute of .meridian/latest/ (ADR 020 addendum) ]); /** View lifecycle commands. No routing, no palette exposure. */ diff --git a/tests/settings.test.ts b/tests/settings.test.ts index bbd58e5..6020f02 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,9 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; -import { readSetting, SETTING_DEFAULTS } from "../src/infrastructure/settings"; +import { + readSetting, + setWorkspaceSettingsDiagnosticsListener, + SETTING_DEFAULTS, + WorkspaceSettingsDiagnostics, +} from "../src/infrastructure/settings"; vi.mock("vscode", () => ({ workspace: { @@ -203,6 +208,119 @@ describe("readSetting()", () => { expect(readSetting("security.gitNetwork.allowedHosts")).toEqual([]); }); + describe("misconfiguration diagnostics", () => { + let emitted: Array<{ diagnostics: WorkspaceSettingsDiagnostics; file: string }>; + + beforeEach(() => { + emitted = []; + setWorkspaceSettingsDiagnosticsListener((diagnostics, file) => { + emitted.push({ diagnostics, file }); + }); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue( + mockCfg((_key, fallback) => fallback) + ); + }); + + afterEach(() => { + setWorkspaceSettingsDiagnosticsListener(null); + }); + + function writeSettings(root: string, content: string): void { + fs.mkdirSync(path.join(root, ".meridian"), { recursive: true }); + fs.writeFileSync(path.join(root, ".meridian", "settings.json"), content); + } + + it("reports unknown keys once per file change, not once per read", () => { + const root = makeWorkspace(); + writeSettings(root, JSON.stringify({ "hygine.prune.minAgeDays": 7 })); + setWorkspaceRoot(root); + + readSetting("hygiene.prune.minAgeDays"); + readSetting("hygiene.prune.maxSizeMB"); + + expect(emitted).toHaveLength(1); + expect(emitted[0].diagnostics.unknownKeys).toEqual(["hygine.prune.minAgeDays"]); + expect(emitted[0].diagnostics.mismatchedKeys).toEqual([]); + expect(emitted[0].file).toBe(path.join(root, ".meridian", "settings.json")); + }); + + it("reports type-mismatched values for known keys", () => { + const root = makeWorkspace(); + writeSettings(root, JSON.stringify({ "startup.enableFileWatchers": "true" })); + setWorkspaceRoot(root); + + expect(readSetting("startup.enableFileWatchers")).toBe(true); + expect(emitted).toHaveLength(1); + expect(emitted[0].diagnostics.mismatchedKeys).toEqual(["startup.enableFileWatchers"]); + expect(emitted[0].diagnostics.unknownKeys).toEqual([]); + }); + + it("reports a parse error for malformed JSON — and only once despite repeated reads", () => { + const root = makeWorkspace(); + writeSettings(root, "{ not valid json"); + setWorkspaceRoot(root); + + readSetting("hygiene.prune.minAgeDays"); + readSetting("hygiene.prune.minAgeDays"); + + expect(emitted).toHaveLength(1); + expect(emitted[0].diagnostics.parseError).toBeTruthy(); + }); + + it("reports non-object JSON as a parse error", () => { + const root = makeWorkspace(); + writeSettings(root, JSON.stringify(["nope"])); + setWorkspaceRoot(root); + + readSetting("startup.enableFileWatchers"); + expect(emitted).toHaveLength(1); + expect(emitted[0].diagnostics.parseError).toContain("JSON object"); + }); + + it("stays silent for a valid file, a missing file, and the JSON-null idiom", () => { + const validRoot = makeWorkspace(); + writeSettings(validRoot, JSON.stringify({ "hygiene.prune.minAgeDays": 7, "model.default": null })); + setWorkspaceRoot(validRoot); + expect(readSetting("hygiene.prune.minAgeDays")).toBe(7); + + const emptyRoot = makeWorkspace(); + setWorkspaceRoot(emptyRoot); + readSetting("hygiene.prune.minAgeDays"); + + expect(emitted).toEqual([]); + }); + + it("re-reports when the file changes", () => { + const root = makeWorkspace(); + const file = path.join(root, ".meridian", "settings.json"); + writeSettings(root, JSON.stringify({ "first.unknown": 1 })); + setWorkspaceRoot(root); + readSetting("hygiene.prune.minAgeDays"); + + fs.writeFileSync(file, JSON.stringify({ "second.unknown": 2 })); + // Force an mtime the cache cannot mistake for the first write. + const later = new Date(Date.now() + 5_000); + fs.utimesSync(file, later, later); + readSetting("hygiene.prune.minAgeDays"); + + expect(emitted.map((e) => e.diagnostics.unknownKeys)).toEqual([ + ["first.unknown"], + ["second.unknown"], + ]); + }); + + it("a throwing listener never breaks the settings read", () => { + setWorkspaceSettingsDiagnosticsListener(() => { + throw new Error("listener bug"); + }); + const root = makeWorkspace(); + writeSettings(root, JSON.stringify({ "typo.key": 1 })); + setWorkspaceRoot(root); + + expect(readSetting("hygiene.prune.minAgeDays")).toBe(30); + }); + }); + it("accepts a well-typed string[] overlay", () => { const root = makeWorkspace(); fs.mkdirSync(path.join(root, ".meridian")); diff --git a/tests/workspaceProviderTrash.test.ts b/tests/workspaceProviderTrash.test.ts new file mode 100644 index 0000000..3bd6bbd --- /dev/null +++ b/tests/workspaceProviderTrash.test.ts @@ -0,0 +1,100 @@ +/** + * Trash-first deleteFile — the injected MoveToTrashFn takes precedence over + * a permanent unlink, path-guard still runs first, and trash failure falls + * back to unlink (deletion keeps working on hosts without a trash). + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createWorkspaceProvider } from "../src/infrastructure/workspace-provider"; +import { MockLogger } from "./fixtures"; + +describe("WorkspaceProvider trash-first deleteFile", () => { + let root: string; + let logger: MockLogger; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-ws-trash-")); + logger = new MockLogger(); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function makeFile(name: string): string { + const abs = path.join(root, name); + fs.writeFileSync(abs, "x"); + return abs; + } + + it("routes deletion through the trash fn with the guarded absolute path", async () => { + const abs = makeFile("doomed.txt"); + const moveToTrash = vi.fn(async (p: string) => { + fs.unlinkSync(p); // the host trash removes the file from its location + }); + const provider = createWorkspaceProvider(root, logger, moveToTrash); + + const result = await provider.deleteFile("doomed.txt"); + + expect(result.kind).toBe("ok"); + expect(moveToTrash).toHaveBeenCalledExactlyOnceWith(abs); + expect(fs.existsSync(abs)).toBe(false); + }); + + it("never unlinks itself when the trash fn succeeds", async () => { + const abs = makeFile("kept-by-mock.txt"); + // A trash fn that records but doesn't remove: if the provider also + // unlinked, the file would be gone — its survival proves trash-only. + const moveToTrash = vi.fn(async () => {}); + const provider = createWorkspaceProvider(root, logger, moveToTrash); + + const result = await provider.deleteFile("kept-by-mock.txt"); + + expect(result.kind).toBe("ok"); + expect(fs.existsSync(abs)).toBe(true); + }); + + it("falls back to permanent unlink (with a warn) when trash is unavailable", async () => { + const abs = makeFile("no-trash.txt"); + const moveToTrash = vi.fn(async () => { + throw new Error("trash not supported on this filesystem"); + }); + const provider = createWorkspaceProvider(root, logger, moveToTrash); + + const result = await provider.deleteFile("no-trash.txt"); + + expect(result.kind).toBe("ok"); + expect(fs.existsSync(abs)).toBe(false); + expect(logger.getByLevel("warn")).toHaveLength(1); + }); + + it("path-guard rejects an outside target before the trash fn ever runs", async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "meridian-ws-outside-")); + const outside = path.join(outsideDir, "secret.txt"); + fs.writeFileSync(outside, "secret"); + const moveToTrash = vi.fn(async () => {}); + const provider = createWorkspaceProvider(root, logger, moveToTrash); + + const result = await provider.deleteFile(outside); + + expect(result.kind).toBe("err"); + expect(moveToTrash).not.toHaveBeenCalled(); + expect(fs.existsSync(outside)).toBe(true); + fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + it("still refuses to delete a directory, trash fn or not", async () => { + fs.mkdirSync(path.join(root, "subdir")); + const moveToTrash = vi.fn(async () => {}); + const provider = createWorkspaceProvider(root, logger, moveToTrash); + + const result = await provider.deleteFile("subdir"); + + expect(result.kind).toBe("err"); + expect(moveToTrash).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, "subdir"))).toBe(true); + }); +});