From 1d37069d257917448940c094dd98391ea2abfaeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serkan=20=C3=96ZAL?= Date: Wed, 3 Jun 2026 16:56:10 +0300 Subject: [PATCH] feat(verification): add path-scoped verification context (.ironbee/VERIFICATION.md) --- CLAUDE.md | 1 + docs/claude-md/configuration.md | 9 + docs/claude-md/project-structure.md | 4 +- docs/claude-md/verification.md | 11 + docs/flow.md | 2 + .../claude/hooks/require-verification.ts | 18 + .../codex/hooks/require-verification.ts | 33 +- src/clients/cursor/hooks/track-action.ts | 33 +- src/hooks/core/session-state.ts | 35 ++ src/hooks/core/verification-context.ts | 383 ++++++++++++++++++ src/lib/config.ts | 93 +++++ src/lib/git.ts | 116 ++++++ src/tui/config/schema.ts | 37 ++ .../reconcile-abandoned-activity.test.ts | 6 +- .../require-verification-recording.test.ts | 2 +- .../verification-context-injection.test.ts | 186 +++++++++ tests/unit/hooks/clear-verdict.test.ts | 4 +- tests/unit/hooks/session-state.test.ts | 2 +- tests/unit/hooks/verification-context.test.ts | 317 +++++++++++++++ .../lib/config-verification-context.test.ts | 108 +++++ tests/unit/lib/git.test.ts | 113 ++++++ 21 files changed, 1496 insertions(+), 17 deletions(-) create mode 100644 src/hooks/core/verification-context.ts create mode 100644 src/lib/git.ts create mode 100644 tests/unit/clients/verification-context-injection.test.ts create mode 100644 tests/unit/hooks/verification-context.test.ts create mode 100644 tests/unit/lib/config-verification-context.test.ts create mode 100644 tests/unit/lib/git.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 54a59f5..7007f02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ npm run clean rm -rf dist - **Paths**: never `split("/")`, `pop()` for basename, `startsWith("/")` for absolute, or hardcode `/` as a separator in regex / string literals. Use `path.basename`, `path.dirname`, `path.join`, `path.isAbsolute`, `path.resolve`. For glob matching against file paths, normalize `\` → `/` first (see `lib/config.ts:matchesAny`). - **POSIX-only assertions** (file mode `0o600`, `chmod`, etc.): guard with `if (process.platform === "win32") return;`. NTFS doesn't honour Unix mode bits. - **Test setup**: when comparing against `tmpdir()`-derived paths, `realpathSync()` first — macOS resolves `/var/folders/...` → `/private/var/folders/...`, Windows resolves 8.3 short names (`RUNNER~1` → `runneradmin`). +- **NEVER assert on a full absolute path that mixes a `tmpdir()` prefix with output from an external process** (`git`, child procs, etc.). On Windows CI, `realpathSync(tmpdir())` is NOT a reliable 8.3 expander — it can keep `RUNNER~1` while the external tool returns the long form `runneradmin`, so `expect(out).toContain(join(repo, "a.ts"))` fails even though the paths refer to the same file (this has now bitten the suite twice). Instead assert on the **separator-normalized relative suffix**: e.g. `const has = (out, rel) => (out ?? []).some(p => p.replace(/\\/g, "/").endsWith("/" + rel)); expect(has(out, "a.ts")).toBe(true);`. Same applies to any path the code builds via `path.join(, …)`. - **Build/scripts**: never use bash-only constructs (`while read`, `${var#prefix}`, `find ... -exec`). Write a Node script under `scripts/` instead — see `scripts/copy-assets.js`. ### Testing diff --git a/docs/claude-md/configuration.md b/docs/claude-md/configuration.md index 50336d4..c3effce 100644 --- a/docs/claude-md/configuration.md +++ b/docs/claude-md/configuration.md @@ -110,6 +110,15 @@ Target selection: | `verification.auto` | `boolean` | true | rerender | Automatic-enforcement sub-toggle (only meaningful when verification.enable !== false). Inverse semantics — opt out with false for "assist mode": the /ironbee-verify command + MCP servers stay installed so the agent can verify manually, but nothing is enforced — no Stop gate, non-blocking (soft) PreToolUse hooks, and the skill/rule are omitted. true → full enforce mode. | | `verification.enable` | `boolean` | true | rerender | Master enforcement switch. Inverse semantics — verification is the core feature, opt out with false. When false, IronBee runs monitoring-only: no enforcement hooks, no skill/rule, no MCP servers; only session/activity/tool_call events flow to the collector. | +### Verification context + +| Key | Type | Default | Artifacts | Description | +| --- | --- | --- | --- | --- | +| `verificationContext.commitDepth` | `number` | 1 | — | Number of recent commits included on the git source (uncommitted ∪ last N commits). 0 = uncommitted only. Ignored when source is "actions". | +| `verificationContext.enable` | `boolean` | true | — | Master switch for path-scoped verification context. When a cycle begins, the first devtools tool call injects the .ironbee/VERIFICATION.md files on/above the changed paths (author-written area guidance) into the agent's context via the host's additionalContext channel. Advisory only (does not gate). Inverse semantics — default-on, opt out with false. Not artifact-affecting (read live). | +| `verificationContext.maxBytes` | `number` | 65536 (64 KB) | — | Aggregate byte cap on the injected context string. When exceeded, the least-specific (root-side) docs are dropped first (leaf-first truncation priority). | +| `verificationContext.source` | `string` | git | — | Changed-path source. "git" (default) = working tree + last N commits, with an actions.jsonl fallback when not a git repo. "actions" = IronBee's own file_change events only (no git subprocess). | + ### Browser cycle | Key | Type | Default | Artifacts | Description | diff --git a/docs/claude-md/project-structure.md b/docs/claude-md/project-structure.md index 19e85f3..a259c5d 100644 --- a/docs/claude-md/project-structure.md +++ b/docs/claude-md/project-structure.md @@ -75,6 +75,7 @@ src/ │ ├── tool-use-stash.ts # generic PreToolUse → PostToolUse stash; tmp-backed so OS reclaims orphans (used by Write to remember existsSync across the call boundary) │ ├── file-diff.ts # diffLineCounts (LCS) + countLines — used by clear-verdict for file_change line stats │ ├── required-tools.ts # alwaysRequired + evidencePaths satisfier — pure logic for the multi-cycle gate +│ ├── verification-context.ts # path-scoped verification guidance: collect changed paths (git + actions fallback) → resolve hierarchical .ironbee/VERIFICATION.md → build injectable string (leaf-first cap); buildVerificationContextOnceForCycle = per-cycle dedup used by require-verification (Claude/Codex) + track-action (Cursor). Spec: docs/verification-context-design.md │ └── actions.ts # session action logger — actions.jsonl read/write ├── clients/ │ ├── base.ts # IClient interface @@ -168,7 +169,8 @@ src/ │ ├── verify-gate.ts # Stop → runVerifyGate + decision:block+reason for continuation prompt + Stop-as-checkpoint session_end synthesis (deterministic id keyed on session_id; backend dedup latest-wins — covers missing SessionEnd hook event) │ └── activity-end.ts # Stop (monitoring mode) → activity_end + Stop-as-checkpoint session_end synthesis ├── lib/ -│ ├── config.ts # Config loader (global + project, glob pattern matching, getActiveCycles, getRequiredToolsConfig, MCP entry builders) +│ ├── config.ts # Config loader (global + project, glob pattern matching, getActiveCycles, getRequiredToolsConfig, MCP entry builders, verificationContext accessors + isIgnoredVerifyPath) +│ ├── git.ts # Minimal git helpers — getChangedPaths(projectDir, commitDepth): absolute changed paths (working tree + last N commits) or null when not a repo; execFileSync, -z NUL output, cross-platform. Consumed by hooks/core/verification-context.ts │ ├── event.ts # Generic Event base + EventType const + hierarchical marker interfaces + SessionStatusEvent (statusline snapshot) — every emitted event extends this │ ├── logger.ts # Log level control + session file logging │ ├── output.ts # Colored console output helpers (picocolors) diff --git a/docs/claude-md/verification.md b/docs/claude-md/verification.md index ee4af66..b96a2f8 100644 --- a/docs/claude-md/verification.md +++ b/docs/claude-md/verification.md @@ -55,6 +55,17 @@ User-defined content keeps each file alive — additional MCP servers, custom ho **Detection of monitoring sessions** (used by `ironbee status` / `ironbee verify`): zero `verification_requested` events. (Not zero `verification_start` — an enforce-mode session where the agent never edited code also has zero of those, but Stop hook still emits `verification_requested(allow)`.) **Assist-mode sessions also have zero `verification_requested`** — assist's Stop hook is `activity-end`, not `verify-gate`, so no gate marker is emitted. From this signal alone, an assist session is indistinguishable from a monitoring one; assist sessions still emit `verification_start` / `verdict_write` when the agent runs a manual `/ironbee-verify` cycle, which is the positive signal that distinguishes them. +## Path-Scoped Verification Context (`.ironbee/VERIFICATION.md`) +**Canonical spec: [`docs/verification-context-design.md`](../verification-context-design.md).** Summary: + +Teams author area-specific verification guidance in `.ironbee/VERIFICATION.md` files placed in any directory. When a verification cycle begins, IronBee injects the guidance relevant to **what changed this cycle** into the agent's context via the host's formal `additionalContext` channel — advisory only, never gates. + +- **Resolution**: for each changed path, walk its dir up to `projectDir` (inclusive), collecting every `.ironbee/VERIFICATION.md`; dedup; merge root→leaf. Missing intermediate levels are skipped. +- **Change source** (`verificationContext.source`): `git` (default — `git status` working tree ∪ last `commitDepth` commits, default 1; `actions.jsonl` fallback when not a git repo) or `actions` (IronBee's own `file_change` events). `ignoredVerifyPatterns` filters the set. +- **Injection point**: the FIRST devtools (`bdt_*`/`ndt_*`/`bedt_*`) tool call of each cycle, deduped per `verificationId` (`state.json:contextInjectedVerificationId`). Claude/Codex → `require-verification` (PreToolUse) `hookSpecificOutput.additionalContext`; Cursor → `track-action` (PostToolUse) `additional_context` (Cursor's preToolUse can't inject on allow). `track-action-monitor` (monitor mode) never injects. +- **Config** (`verificationContext.*`, not artifact-affecting — read live): `enable` (default true, inverse), `source`, `commitDepth`, `maxBytes` (default 64 KB; truncation drops least-specific docs first). +- **Modes**: enforce + assist inject; monitor does not. + ## Verification Flow (verify-gate, multi-cycle) **Canonical spec: [`docs/flow.md`](../flow.md)** — end-to-end runtime flow: hook fire order, per-hook allow/deny branches, multi-cycle gate decision tree, cycle lifecycle, state machine, cleanup paths. The summary below is a quick reference; when in doubt, the spec wins. diff --git a/docs/flow.md b/docs/flow.md index fdbf5a5..9a839ef 100644 --- a/docs/flow.md +++ b/docs/flow.md @@ -129,6 +129,8 @@ Each hook's full job: trigger, reads, writes, allow/deny branches. `mcpServer` is the routed server (`browser-devtools` for `bdt_*`, `node-devtools` for `ndt_*`). Recording enforcement is **browser-only** — `ndt_*` tools bypass the recording check entirely (CLAUDE.md "Recording Enforcement" §2). +**Path-scoped verification context (additionalContext)**: on the ALLOW path, the FIRST devtools call of a verification cycle also injects author-written `.ironbee/VERIFICATION.md` guidance — resolved hierarchically for the changed paths (git working tree + last N commits, actions.jsonl fallback) — via the host's formal `additionalContext` channel (`hookSpecificOutput.additionalContext` on Claude/Codex). Deduped per `verificationId` (`state.json:contextInjectedVerificationId`, stamped even when empty so git isn't re-run per call). Advisory only — never blocks. Disabled by `verificationContext.enable: false`. On **Cursor** the channel moves to `track-action` (PostToolUse `additional_context`) because Cursor's `preToolUse` can't inject on allow. See `docs/verification-context-design.md`. + ### 4.4 `require-verdict` — `PreToolUse` (edit matcher) **Matchers**: Claude `Write|Edit`, Cursor `Write|StrReplace|Delete`. diff --git a/src/clients/claude/hooks/require-verification.ts b/src/clients/claude/hooks/require-verification.ts index 942e9f7..095ee05 100644 --- a/src/clients/claude/hooks/require-verification.ts +++ b/src/clients/claude/hooks/require-verification.ts @@ -28,6 +28,7 @@ import { getActiveVerificationId, getActiveTraceId, getActiveActivityId, isRecor import { resolveProjectName } from "../../../hooks/core/actions"; import { startActivity } from "../../../hooks/core/activity"; import { startVerification, VerificationStartResult } from "../../../hooks/core/verification-lifecycle"; +import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context"; import { loadConfig } from "../../../lib/config"; import { logger, setLogFile } from "../../../lib/logger"; import { extractMcpServerName } from "../util"; @@ -52,6 +53,7 @@ interface ClaudePreToolUseOutput { hookEventName: string; permissionDecision?: string; updatedInput?: Record; + additionalContext?: string; }; } @@ -191,6 +193,22 @@ Then use the verification tools for the active cycle(s) — bdt_* for browser, n }, }; + // Path-scoped verification context: on the first devtools call of this + // cycle, inject the relevant `.ironbee/VERIFICATION.md` guidance via the + // formal `additionalContext` channel (deduped per verificationId). Advisory + // only — never blocks. No-op when disabled / nothing matches / already + // injected for this cycle. + const injectedContext: string = buildVerificationContextOnceForCycle({ + projectDir, + sessionId, + sessionDir, + activeVerificationId: effectiveVerificationId, + config, + }); + if (injectedContext.length > 0 && output.hookSpecificOutput) { + output.hookSpecificOutput.additionalContext = injectedContext; + } + process.stdout.write(JSON.stringify(output)); process.exit(0); } diff --git a/src/clients/codex/hooks/require-verification.ts b/src/clients/codex/hooks/require-verification.ts index 16f57f6..50468e8 100644 --- a/src/clients/codex/hooks/require-verification.ts +++ b/src/clients/codex/hooks/require-verification.ts @@ -27,6 +27,7 @@ import { } from "../../../hooks/core/session-state"; import { resolveProjectName } from "../../../hooks/core/actions"; import { startVerification, VerificationStartResult } from "../../../hooks/core/verification-lifecycle"; +import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context"; import { loadConfig } from "../../../lib/config"; import { logger, setLogFile } from "../../../lib/logger"; import { readStdin } from "../../../lib/stdin"; @@ -159,13 +160,31 @@ Then use the verification tools for the active cycle(s) — mcp__browser-devtool } updatedInput._metadata = metadata; - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "allow", - updatedInput, - }, - })); + // Path-scoped verification context via Codex's `additionalContext` (source- + // verified to be honored on PreToolUse allow). Same per-cycle dedup + core + // as Claude. Advisory only. + const hookSpecificOutput: { + hookEventName: string; + permissionDecision: string; + updatedInput: Record; + additionalContext?: string; + } = { + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput, + }; + const injectedContext: string = buildVerificationContextOnceForCycle({ + projectDir, + sessionId, + sessionDir, + activeVerificationId: effectiveVerificationId, + config, + }); + if (injectedContext.length > 0) { + hookSpecificOutput.additionalContext = injectedContext; + } + + process.stdout.write(JSON.stringify({ hookSpecificOutput })); logger.debug(`require-verification: allowed ${toolName} with _metadata`); process.exit(0); } diff --git a/src/clients/cursor/hooks/track-action.ts b/src/clients/cursor/hooks/track-action.ts index 9b4301b..b176342 100644 --- a/src/clients/cursor/hooks/track-action.ts +++ b/src/clients/cursor/hooks/track-action.ts @@ -40,7 +40,8 @@ import { appendAction, baseFields, ToolCallAction } from "../../../hooks/core/actions"; import { setRecordingActive, getActiveActivityId, getActiveVerificationId, getActiveTraceId } from "../../../hooks/core/session-state"; -import { isJobQueueEnabled } from "../../../lib/config"; +import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context"; +import { isJobQueueEnabled, loadConfig } from "../../../lib/config"; import { logger, setLogFile } from "../../../lib/logger"; import { writeAndExit } from "../../../lib/output"; import { readStdin } from "../../../lib/stdin"; @@ -326,7 +327,35 @@ export async function run(projectDir: string): Promise { } } - writeAndExit(JSON.stringify({}), 0); + // Path-scoped verification context. Cursor's preToolUse can't inject on the + // allow path (agent_message is deny-only), so the injection lives here in + // postToolUse via `additional_context` — fired on the first devtools call of + // the cycle (deduped per verificationId). Non-devtools tools never trigger + // a build. `track-action-monitor` (monitoring mode) is intentionally left + // without this branch — monitor never injects. + const output: { additional_context?: string } = {}; + if (isOurDevToolsTool) { + // Belt-and-suspenders: this is the only loadConfig call on Cursor's + // devtools track-action path, and loadConfig throws on a malformed + // config layer. The injection must never break the hook (the tool_call + // is already persisted above), so swallow everything → no context. + try { + const injectedContext: string = buildVerificationContextOnceForCycle({ + projectDir, + sessionId, + sessionDir, + activeVerificationId: verificationId, + config: loadConfig(projectDir), + }); + if (injectedContext.length > 0) { + output.additional_context = injectedContext; + } + } catch (e: unknown) { + logger.debug(`track-action: verification-context injection skipped: ${e instanceof Error ? e.message : e}`); + } + } + + writeAndExit(JSON.stringify(output), 0); } /** diff --git a/src/hooks/core/session-state.ts b/src/hooks/core/session-state.ts index 7eba504..a2e6fbf 100644 --- a/src/hooks/core/session-state.ts +++ b/src/hooks/core/session-state.ts @@ -66,6 +66,16 @@ export interface SessionState { * upstream statusline to chain (wrapper renders nothing / a default). */ chainedStatusLine: string | null; + /** + * The `verificationId` for which path-scoped verification context has + * already been injected. Per-cycle dedup: the first devtools tool call of a + * cycle injects the `.ironbee/VERIFICATION.md` guidance and stamps the + * active verification id here; subsequent calls in the same cycle skip + * re-injection. Set even when the injected content was empty (so the git + * change-set isn't recomputed on every devtools call). Reset on reconcile; + * a new cycle mints a fresh `verificationId` so injection happens again. + */ + contextInjectedVerificationId: string | null; } const STATE_FILE: string = "state.json"; @@ -85,6 +95,7 @@ const DEFAULT_STATE: SessionState = { usageType: null, usagePlan: null, chainedStatusLine: null, + contextInjectedVerificationId: null, }; /** Generate an OTEL-compatible trace ID (32 hex chars / 16 bytes). */ @@ -117,6 +128,7 @@ export function readState(sessionDir: string): SessionState { usageType: typeof parsed.usageType === "string" && validUsageTypes.includes(parsed.usageType) ? parsed.usageType as SessionUsageType : null, usagePlan: typeof parsed.usagePlan === "string" && parsed.usagePlan.length > 0 ? parsed.usagePlan : null, chainedStatusLine: typeof parsed.chainedStatusLine === "string" && parsed.chainedStatusLine.length > 0 ? parsed.chainedStatusLine : null, + contextInjectedVerificationId: typeof parsed.contextInjectedVerificationId === "string" ? parsed.contextInjectedVerificationId : null, }; } catch (e: unknown) { logger.debug(`failed to read state from ${filePath}: ${e}`); @@ -354,6 +366,25 @@ export function setChainedStatusLine(sessionDir: string, command: string | null writeState(sessionDir, state); } +export function getContextInjectedVerificationId(sessionDir: string): string | undefined { + const state: SessionState = readState(sessionDir); + return state.contextInjectedVerificationId ?? undefined; +} + +/** + * Stamp the verification id for which path-scoped context has been injected. + * Idempotent — same value twice is a no-op write. Pass `null` to clear. + */ +export function setContextInjectedVerificationId(sessionDir: string, verificationId: string | null): void { + const normalized: string | null = typeof verificationId === "string" && verificationId.length > 0 ? verificationId : null; + const state: SessionState = readState(sessionDir); + if (state.contextInjectedVerificationId === normalized) { + return; + } + state.contextInjectedVerificationId = normalized; + writeState(sessionDir, state); +} + export function setActiveActivity(sessionDir: string, activityId: string): void { const state: SessionState = readState(sessionDir); state.activeActivityId = activityId; @@ -400,6 +431,7 @@ export async function closeOpenCycles( logger.debug(`close-open-cycles: ended verification ${state.activeVerificationId} (${reason})`); state.activeVerificationId = null; state.activeTraceId = null; + state.contextInjectedVerificationId = null; dirty = true; } @@ -466,6 +498,7 @@ export async function reconcileSessionState( logger.debug(`reconcile: ended abandoned verification ${state.activeVerificationId}`); state.activeVerificationId = null; state.activeTraceId = null; + state.contextInjectedVerificationId = null; dirty = true; } @@ -563,6 +596,7 @@ export async function reconcileAbandonedActivity( logger.debug(`interrupt-reconcile: ended verification ${state.activeVerificationId}`); state.activeVerificationId = null; state.activeTraceId = null; + state.contextInjectedVerificationId = null; dirty = true; } @@ -653,6 +687,7 @@ export async function reconcileForCompact( logger.debug(`compact-reconcile: ended verification ${state.activeVerificationId}`); state.activeVerificationId = null; state.activeTraceId = null; + state.contextInjectedVerificationId = null; dirty = true; } diff --git a/src/hooks/core/verification-context.ts b/src/hooks/core/verification-context.ts new file mode 100644 index 0000000..f83fa8a --- /dev/null +++ b/src/hooks/core/verification-context.ts @@ -0,0 +1,383 @@ +/** + * IronBee — Path-Scoped Verification Context (core, client-agnostic) + * + * Builds the author-written, change-aware verification guidance that gets + * injected into the agent's context when a verification cycle begins. The + * pipeline: + * + * 1. `collectChangedPaths` — the absolute paths changed this cycle (git + * working tree + last N commits, with an actions.jsonl fallback). + * 2. `resolveContextFiles` — for each changed path, walk its directory up to + * `projectDir` (inclusive), collecting every `.ironbee/VERIFICATION.md`; + * dedup; ordered root→leaf. + * 3. `buildVerificationContext` — render the collected docs into one string, + * capped at `maxBytes` (leaf-first truncation priority). + * + * `buildVerificationContextForSession` ties 1–3 together (and is the single + * point that honors the `verificationContext.enable` master switch). + * `buildVerificationContextOnceForCycle` adds the per-cycle dedup the hook + * adapters use. All functions are pure-ish (fs + git reads only) and never + * throw — failures degrade to "" so a hook is never broken by this feature. + * + * Design: docs/verification-context-design.md + */ + +import { existsSync, readFileSync, realpathSync } from "fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path"; +import { + getVerificationContextCommitDepth, + getVerificationContextEnabled, + getVerificationContextMaxBytes, + getVerificationContextSource, + IronBeeConfig, + isIgnoredVerifyPath, +} from "../../lib/config"; +import { getChangedPaths } from "../../lib/git"; +import { logger } from "../../lib/logger"; +import { getContextInjectedVerificationId, setContextInjectedVerificationId } from "./session-state"; +import { FileChangeAction, getFileChangesSinceLastVerification } from "./actions"; + +/** The conventional per-directory guidance file (not configurable). */ +export const VERIFICATION_DOC_DIR: string = ".ironbee"; +export const VERIFICATION_DOC_FILE: string = "VERIFICATION.md"; + +const HEADER: string = "===== IRONBEE — PROJECT VERIFICATION INSTRUCTIONS (path-scoped) =====\n"; +const INTRO: string = + "The following areas changed this cycle. Follow this guidance IN ADDITION to the standard verification flow.\n"; +const FOOTER: string = "====================================================================\n"; + +/** One resolved guidance doc. `relDir` is `/`-normalized, `""` for the repo root. */ +export interface ResolvedDoc { + absPath: string; + relDir: string; + depth: number; + content: string; +} + +export interface CollectOptions { + source: "git" | "actions"; + commitDepth: number; +} + +function byteLen(s: string): number { + return Buffer.byteLength(s, "utf-8"); +} + +/** realpath a dir, falling back to a plain resolve when it doesn't exist yet. */ +function canonicalDir(dir: string): string { + try { + return realpathSync(dir); + } catch { + return resolve(dir); + } +} + +/** + * Resolve `p` to its physical (symlink-resolved) absolute form by realpath'ing + * the longest existing ancestor and re-appending the non-existing tail. Needed + * because the two change sources disagree on path convention: git returns + * physical paths (repo root via `git rev-parse`), while actions.jsonl carries + * the host-provided logical `file_path`. Without this, a project under a + * symlinked prefix (macOS `/var`→`/private/var`, a symlinked checkout) would + * fail the `isUnderDir(canonProject, …)` subtree filter on the actions path and + * silently drop every changed path. Handles DELETED files (the file itself need + * not exist — only some ancestor dir must). + */ +function canonicalizePath(p: string): string { + const abs: string = resolve(p); + const tail: string[] = []; + let cur: string = abs; + for (;;) { + try { + const real: string = realpathSync(cur); + return tail.length > 0 ? join(real, ...tail) : real; + } catch { + const parent: string = dirname(cur); + if (parent === cur) { + return abs; // no existing ancestor — best effort + } + tail.unshift(basename(cur)); + cur = parent; + } + } +} + +/** True when `p` is a path strictly inside `dir` (not `dir` itself, not above). */ +function isUnderDir(dir: string, p: string): boolean { + const rel: string = relative(dir, p); + return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel); +} + +/** Changed file paths from IronBee's own `file_change` events (fallback / `source: "actions"`). */ +function changedFromActions(actionsFile: string): string[] { + try { + return getFileChangesSinceLastVerification(actionsFile) + .map((fc: FileChangeAction): string => fc.file_path) + .filter((p: string): boolean => typeof p === "string" && p.length > 0); + } catch (e: unknown) { + logger.debug(`verification-context: actions fallback failed: ${e instanceof Error ? e.message : e}`); + return []; + } +} + +/** + * Absolute paths changed this cycle, filtered to the `projectDir` subtree. + * `projectDir` should already be canonical (realpath-resolved). On the `git` + * source, a `null` git result (not a repo / git unavailable) falls back to the + * actions.jsonl source; an empty git result (clean tree) stays empty. + */ +export function collectChangedPaths(projectDir: string, actionsFile: string, opts: CollectOptions): string[] { + let raw: string[]; + if (opts.source === "actions") { + raw = changedFromActions(actionsFile); + } else { + const git: string[] | null = getChangedPaths(projectDir, opts.commitDepth); + raw = git === null ? changedFromActions(actionsFile) : git; + } + + const out: Set = new Set(); + for (const p of raw) { + // Normalize to physical absolute so git (already physical) and actions + // (host-logical) paths both agree with the realpath'd projectDir. + const abs: string = canonicalizePath(isAbsolute(p) ? p : resolve(projectDir, p)); + const rel: string = relative(projectDir, abs); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) { + continue; // outside the projectDir subtree + } + // Skip IronBee's own metadata/runtime (config, the VERIFICATION.md docs + // themselves, sessions/, otel/, logs). These are never "code under + // verification" — and session.log is rewritten on every hook fire, which + // would otherwise keep git perpetually dirty and inject root guidance + // on every cycle regardless of the real change. + if (rel.split(sep).includes(VERIFICATION_DOC_DIR)) { + continue; + } + out.add(abs); + } + return [...out]; +} + +/** + * For each changed (absolute) path, walk its directory up to `projectDir` + * (inclusive) collecting every existing `.ironbee/VERIFICATION.md`. Operates on + * path STRINGS — never stats the changed file itself — so a DELETED file still + * resolves guidance via its present ancestor dirs. Deduped by doc path; ordered + * root→leaf (then by dir for determinism). + */ +export function resolveContextFiles(projectDir: string, changedAbsPaths: string[]): ResolvedDoc[] { + const collected: Map = new Map(); + + for (const fileAbs of changedAbsPaths) { + let cur: string = dirname(fileAbs); + // Guard: only walk within the projectDir subtree (or projectDir itself). + if (cur !== projectDir && !isUnderDir(projectDir, cur)) { + continue; + } + for (;;) { + const candidate: string = join(cur, VERIFICATION_DOC_DIR, VERIFICATION_DOC_FILE); + if (!collected.has(candidate) && existsSync(candidate)) { + try { + const content: string = readFileSync(candidate, "utf-8"); + const relDir: string = relative(projectDir, cur); + const normRel: string = relDir.length === 0 ? "" : relDir.split(sep).join("/"); + const depth: number = normRel.length === 0 ? 0 : normRel.split("/").length; + collected.set(candidate, { absPath: candidate, relDir: normRel, depth, content }); + } catch (e: unknown) { + logger.debug(`verification-context: failed to read ${candidate}: ${e instanceof Error ? e.message : e}`); + } + } + if (cur === projectDir) { + break; + } + const parent: string = dirname(cur); + if (parent === cur) { + break; + } + cur = parent; + } + } + + return [...collected.values()].sort((a: ResolvedDoc, b: ResolvedDoc): number => { + return a.depth - b.depth || a.relDir.localeCompare(b.relDir); + }); +} + +function provenance(doc: ResolvedDoc): { label: string; path: string } { + if (doc.relDir.length === 0) { + return { label: "(repo root)", path: `${VERIFICATION_DOC_DIR}/${VERIFICATION_DOC_FILE}` }; + } + return { label: `${doc.relDir}/`, path: `${doc.relDir}/${VERIFICATION_DOC_DIR}/${VERIFICATION_DOC_FILE}` }; +} + +function renderBlock(doc: ResolvedDoc): string { + const p: { label: string; path: string } = provenance(doc); + return `\n## ${p.label} — ${p.path}\n${doc.content.trimEnd()}\n`; +} + +/** + * Render collected docs into one injectable string, capped at `maxBytes`. + * Presentation order is root→leaf; truncation PRIORITY is leaf-first — when the + * total exceeds the cap, the least-specific (root-side) docs are dropped first, + * since area-specific guidance is more valuable under pressure. Returns "" for + * an empty doc set. + */ +export function buildVerificationContext(docs: ResolvedDoc[], opts: { maxBytes: number }): string { + if (docs.length === 0) { + return ""; + } + const sorted: ResolvedDoc[] = [...docs].sort((a: ResolvedDoc, b: ResolvedDoc): number => { + return a.depth - b.depth || a.relDir.localeCompare(b.relDir); + }); + + const intro: string = HEADER + INTRO; + const budget: number = Math.max(0, opts.maxBytes - byteLen(intro) - byteLen(FOOTER)); + + // Include leaf-first (end of array) until the budget is exhausted. + const includedReversed: ResolvedDoc[] = []; + let used: number = 0; + for (let i: number = sorted.length - 1; i >= 0; i--) { + const block: string = renderBlock(sorted[i]); + const bb: number = byteLen(block); + if (used + bb <= budget) { + includedReversed.push(sorted[i]); + used += bb; + } else if (includedReversed.length === 0) { + // Even the most-specific doc alone exceeds the budget — hard-truncate it. + const truncated: ResolvedDoc = hardTruncate(sorted[i], budget); + includedReversed.push(truncated); + break; + } else { + break; + } + } + const included: ResolvedDoc[] = includedReversed.reverse(); + const dropped: number = sorted.length - included.length; + + const parts: string[] = [intro]; + for (const d of included) { + parts.push(renderBlock(d)); + } + if (dropped > 0) { + parts.push(`\n[${dropped} less-specific VERIFICATION.md file(s) omitted to fit the ${opts.maxBytes}-byte cap]\n`); + } + parts.push(FOOTER); + return parts.join(""); +} + +/** Hard-truncate a single doc's content so its rendered block fits `budget` bytes. */ +function hardTruncate(doc: ResolvedDoc, budget: number): ResolvedDoc { + const note: string = "\n... (truncated)"; + const headerOnly: string = renderBlock({ ...doc, content: "" }); + const room: number = Math.max(0, budget - byteLen(headerOnly) - byteLen(note)); + // BYTE-accurate slice (not char-based) so multi-byte content can't blow past + // the budget. `toString` drops a trailing partial code unit at the boundary, + // which is fine for a truncation note. + const sliced: string = Buffer.from(doc.content, "utf-8").subarray(0, room).toString("utf-8"); + return { ...doc, content: sliced + note }; +} + +/** + * End-to-end build for a session. The SINGLE honoring point for the + * `verificationContext.enable` master switch. Never throws — returns "" on any + * failure or when disabled / nothing to inject. + */ +export function buildVerificationContextForSession( + projectDir: string, + sessionId: string, + config: IronBeeConfig, +): string { + if (!getVerificationContextEnabled(config)) { + return ""; + } + try { + const canonProject: string = canonicalDir(projectDir); + const actionsFile: string = join(canonProject, ".ironbee", "sessions", sessionId, "actions.jsonl"); + const source: "git" | "actions" = getVerificationContextSource(config); + const changed: string[] = collectChangedPaths(canonProject, actionsFile, { + source, + commitDepth: getVerificationContextCommitDepth(config), + }); + const filtered: string[] = changed.filter((p: string): boolean => !isIgnoredVerifyPath(config, p)); + // Observability: the merged set of DIRECTORIES that changed this cycle + // (after the subtree + ignoredVerifyPatterns filters), projectDir-relative + // and deduped — no need to list every file. Capped so a huge commit can't + // flood the log. + const ignoredCount: number = changed.length - filtered.length; + const dirSet: Set = new Set(); + for (const p of filtered) { + const relDir: string = relative(canonProject, dirname(p)).split(sep).join("/"); + dirSet.add(relDir.length === 0 ? "(root)" : relDir); + } + const dirs: string[] = [...dirSet].sort(); + const LOG_CAP: number = 50; + const shown: string[] = dirs.slice(0, LOG_CAP); + const more: string = dirs.length > LOG_CAP ? ` [+${dirs.length - LOG_CAP} more]` : ""; + logger.debug( + `verification-context: source=${source} changed in ${dirs.length} dir(s)` + + (ignoredCount > 0 ? ` (${ignoredCount} ignored)` : "") + + `: ${shown.join(", ")}${more}`, + ); + const docs: ResolvedDoc[] = resolveContextFiles(canonProject, filtered); + // Observability: one line per resolved .ironbee/VERIFICATION.md (root→leaf) + // — byte size + a 100-char preview. + for (const d of docs) { + const label: string = d.relDir.length > 0 + ? `${d.relDir}/${VERIFICATION_DOC_DIR}/${VERIFICATION_DOC_FILE}` + : `${VERIFICATION_DOC_DIR}/${VERIFICATION_DOC_FILE}`; + const collapsed: string = d.content.replace(/\s+/g, " ").trim(); + const preview: string = collapsed.length > 100 ? `${collapsed.slice(0, 100)}...` : collapsed; + logger.debug(`verification-context: • ${label} — ${byteLen(d.content)} bytes :: ${preview}`); + } + return buildVerificationContext(docs, { maxBytes: getVerificationContextMaxBytes(config) }); + } catch (e: unknown) { + logger.debug(`verification-context: build failed: ${e instanceof Error ? e.message : e}`); + return ""; + } +} + +/** + * Per-cycle injection used by the hook adapters: build the context once per + * verification cycle. Returns the context string to inject (possibly "" when + * there's nothing to add), or "" when already injected for this cycle / no + * active cycle / disabled. + * + * The dedup id is stamped even when the resolved content is empty — so the git + * change-set isn't recomputed on every devtools call of a cycle that has no + * matching VERIFICATION.md. + */ +export function buildVerificationContextOnceForCycle(opts: { + projectDir: string; + sessionId: string; + sessionDir: string; + activeVerificationId: string | undefined; + config: IronBeeConfig; +}): string { + const { projectDir, sessionId, sessionDir, activeVerificationId, config } = opts; + if (!activeVerificationId) { + return ""; + } + if (!getVerificationContextEnabled(config)) { + return ""; + } + if (getContextInjectedVerificationId(sessionDir) === activeVerificationId) { + return ""; + } + let ctx: string = ""; + try { + ctx = buildVerificationContextForSession(projectDir, sessionId, config); + } catch (e: unknown) { + logger.debug(`verification-context: once-for-cycle build failed: ${e instanceof Error ? e.message : e}`); + ctx = ""; + } + // Observability: one line per cycle so `session.log` shows whether (and how + // much) path-scoped guidance was injected — the `## ` count is the number of + // resolved .ironbee/VERIFICATION.md areas. + if (ctx.length > 0) { + const areas: number = ctx.split("\n## ").length - 1; + logger.debug(`verification-context: injected ${Buffer.byteLength(ctx, "utf-8")} bytes, ${areas} area(s) for cycle ${activeVerificationId}`); + } else { + logger.debug(`verification-context: no matching .ironbee/VERIFICATION.md for cycle ${activeVerificationId}`); + } + setContextInjectedVerificationId(sessionDir, activeVerificationId); + return ctx; +} diff --git a/src/lib/config.ts b/src/lib/config.ts index b820f7c..11f0fd1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -375,6 +375,30 @@ export interface IronBeeConfig { }; }; + /** + * Path-scoped verification context. When a verification cycle begins, the + * first devtools tool call injects the contents of the `.ironbee/VERIFICATION.md` + * files that sit on (or above) the changed paths — author-written, area-specific + * verification guidance — into the agent's context via the host's formal + * `additionalContext` channel. Advisory only (does not gate). **Inverse + * semantics — default-on; opt out with `enable: false`.** Not artifact-affecting + * (read live at injection time). See `docs/verification-context-design.md`. + */ + verificationContext?: { + /** Master switch (default true — opt out with false). */ + enable?: boolean; + /** + * Changed-path source. `"git"` (default) = working tree + last N commits, + * with an `actions.jsonl` fallback when not a git repo. `"actions"` = + * IronBee's own `file_change` events only (no git subprocess). + */ + source?: "git" | "actions"; + /** Recent commits included on the `git` source. Default 1; `0` = uncommitted only. Ignored for `source: "actions"`. */ + commitDepth?: number; + /** Aggregate byte cap on the injected context (truncated leaf-first past the cap). Default 65536. */ + maxBytes?: number; + }; + /** Allow additional config fields (e.g. `browserDevTools` / `nodeDevTools`). */ [key: string]: unknown; @@ -675,9 +699,25 @@ function mergeConfigLayers(base: IronBeeConfig, override: IronBeeConfig): IronBe merged.backend = mergeCycleConfig(base.backend, override.backend); merged.claude = mergeClaudeConfig(base.claude, override.claude); merged.verification = mergeVerificationConfig(base.verification, override.verification); + merged.verificationContext = mergeVerificationContextConfig(base.verificationContext, override.verificationContext); return merged; } +/** + * Deep merge for the `verificationContext` block so a write to one key in a + * higher-priority layer doesn't wipe a sibling key set in a lower layer (e.g. a + * `--local` write of `commitDepth` must not drop a project-set `source`). + */ +function mergeVerificationContextConfig( + base: IronBeeConfig["verificationContext"], + override: IronBeeConfig["verificationContext"], +): IronBeeConfig["verificationContext"] { + if (base === undefined && override === undefined) { + return undefined; + } + return { ...(base ?? {}), ...(override ?? {}) }; +} + /** * Deep merge for the `verification` block (`enable` + `auto`) so a write to one * key in a higher-priority layer doesn't wipe the sibling key set in a lower @@ -1585,6 +1625,59 @@ export function getAutoVerifyEnabled(config: IronBeeConfig): boolean { return (section as { auto?: unknown }).auto !== false; } +/** Default aggregate byte cap for injected verification context. */ +export const DEFAULT_VERIFICATION_CONTEXT_MAX_BYTES: number = 65536; +/** Default number of recent commits included on the `git` change source. */ +export const DEFAULT_VERIFICATION_CONTEXT_COMMIT_DEPTH: number = 1; + +/** + * Whether path-scoped verification-context injection is enabled. + * **Inverse semantics (default-on)** — section absent / empty / `enable: true` + * → true; only `enable: false` → false. Mirrors `getVerificationEnabled`. + */ +export function getVerificationContextEnabled(config: IronBeeConfig): boolean { + const section: unknown = config.verificationContext; + if (section === undefined || section === null || typeof section !== "object" || Array.isArray(section)) { + return true; + } + return (section as { enable?: unknown }).enable !== false; +} + +/** Changed-path source for verification context. Default `"git"`. */ +export function getVerificationContextSource(config: IronBeeConfig): "git" | "actions" { + return config.verificationContext?.source === "actions" ? "actions" : "git"; +} + +/** Recent-commit depth for the `git` source. Default 1; clamped to `>= 0`. */ +export function getVerificationContextCommitDepth(config: IronBeeConfig): number { + const v: unknown = config.verificationContext?.commitDepth; + if (typeof v === "number" && Number.isFinite(v) && v >= 0) { + return Math.floor(v); + } + return DEFAULT_VERIFICATION_CONTEXT_COMMIT_DEPTH; +} + +/** Aggregate byte cap for injected context. Default 65536; clamped to `> 0`. */ +export function getVerificationContextMaxBytes(config: IronBeeConfig): number { + const v: unknown = config.verificationContext?.maxBytes; + if (typeof v === "number" && Number.isFinite(v) && v > 0) { + return Math.floor(v); + } + return DEFAULT_VERIFICATION_CONTEXT_MAX_BYTES; +} + +/** + * Whether `filePath` matches the cross-cycle `ignoredVerifyPatterns` ignore + * list. Exposed so the verification-context collector can drop ignored paths + * (docs, generated files, …) without re-implementing glob matching. Globs are + * anchored with `(^|/)…$`, so an absolute path matches the same patterns a + * repo-relative one would. + */ +export function isIgnoredVerifyPath(config: IronBeeConfig, filePath: string): boolean { + const ignored: string[] = config.ignoredVerifyPatterns ?? []; + return ignored.length > 0 && matchesAny(filePath, ignored); +} + /** * Resolved verification mode — the tri-state every client's `install()` and the * docs branch on. Collapses the `verification.enable` × `verification.auto` diff --git a/src/lib/git.ts b/src/lib/git.ts new file mode 100644 index 0000000..e137b51 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,116 @@ +/** + * IronBee — minimal git helpers + * + * Used by the path-scoped verification-context feature to discover which files + * changed (working tree + recent commits) so the right `.ironbee/VERIFICATION.md` + * guidance can be resolved hierarchically. Intentionally tiny and dependency-free. + * + * Cross-platform rules (per CLAUDE.md): `execFileSync` with an args array (no + * shell, no pipelines), `-z` NUL-separated output (immune to filename quoting / + * spaces), and `-c core.quotePath=false` for good measure. Every git failure is + * swallowed — callers treat `null` as "git unavailable, fall back". + */ + +import { execFileSync } from "child_process"; +import { isAbsolute, join } from "path"; +import { logger } from "./logger"; + +/** Hard cap on how long any single git invocation may run (ms). */ +const GIT_TIMEOUT_MS: number = 5000; + +/** + * Run one git command in `cwd`. Returns stdout (utf-8) on success, or `null` + * on ANY failure (not a repo, missing binary, non-zero exit, timeout). Never + * throws. + */ +function runGit(cwd: string, args: string[]): string | null { + try { + const out: string = execFileSync("git", ["-c", "core.quotePath=false", ...args], { + cwd, + encoding: "utf-8", + timeout: GIT_TIMEOUT_MS, + stdio: ["ignore", "pipe", "ignore"], + maxBuffer: 16 * 1024 * 1024, + }); + return out; + } catch (e: unknown) { + logger.debug(`git ${args.join(" ")} failed: ${e instanceof Error ? e.message : e}`); + return null; + } +} + +/** Split a `-z` (NUL-separated) git output into non-empty entries. */ +function splitNul(out: string | null): string[] { + if (out === null) { + return []; + } + return out.split("\0").filter((s: string): boolean => s.length > 0); +} + +/** + * Discover the absolute paths of files changed in the working tree plus the + * last `commitDepth` commits. + * + * Returns: + * - `string[]` of ABSOLUTE paths (joined onto the repo root, which may differ + * from `projectDir` in a monorepo / subdir install — callers filter to the + * `projectDir` subtree themselves). + * - `[]` when git ran fine but nothing changed. + * - `null` when git is unavailable (not a repo, missing binary, hard error) — + * the signal for the caller to fall back to `actions.jsonl`. + * + * `commitDepth <= 0` includes only uncommitted changes. A failure to read the + * committed slice (initial commit, shallow clone, `N > history`) degrades to + * uncommitted-only rather than dropping the whole git path. + */ +export function getChangedPaths(projectDir: string, commitDepth: number): string[] | null { + const topLevel: string | null = runGit(projectDir, ["rev-parse", "--show-toplevel"]); + if (topLevel === null) { + return null; + } + const repoRoot: string = topLevel.trim(); + if (repoRoot.length === 0) { + return null; + } + + const rel: Set = new Set(); + + // Uncommitted: unstaged + staged + untracked. Untracked is read via + // `ls-files --others --exclude-standard` (not `status --porcelain`) so an + // untracked directory expands to its individual files rather than collapsing + // to a single `dir/` entry — dir-ancestor resolution needs real file paths. + for (const p of splitNul(runGit(projectDir, ["diff", "--name-only", "-z"]))) { + rel.add(p); + } + for (const p of splitNul(runGit(projectDir, ["diff", "--name-only", "-z", "--cached"]))) { + rel.add(p); + } + for (const p of splitNul(runGit(projectDir, ["ls-files", "--others", "--exclude-standard", "-z"]))) { + rel.add(p); + } + + // Committed slice: last N commits. Best-effort — a failure here leaves the + // uncommitted set intact. + if (commitDepth > 0) { + const ranged: string | null = runGit(projectDir, ["diff", "--name-only", "-z", `HEAD~${commitDepth}`, "HEAD"]); + if (ranged !== null) { + for (const p of splitNul(ranged)) { + rel.add(p); + } + } else { + // Fewer than N commits (fresh repo / shallow clone): fall back to the + // single latest commit. + for (const p of splitNul(runGit(projectDir, ["show", "--name-only", "--format=", "-z", "HEAD"]))) { + rel.add(p); + } + } + } + + const abs: string[] = []; + for (const p of rel) { + // git paths are repo-root-relative and use `/`; isAbsolute guards the + // (shouldn't-happen) case of an already-absolute entry. + abs.push(isAbsolute(p) ? p : join(repoRoot, p)); + } + return abs; +} diff --git a/src/tui/config/schema.ts b/src/tui/config/schema.ts index 70f7443..3e58d65 100644 --- a/src/tui/config/schema.ts +++ b/src/tui/config/schema.ts @@ -68,6 +68,7 @@ export const ARTIFACT_AFFECTING_TOP_KEYS: ReadonlySet = new Set( export const GROUP_ORDER: readonly string[] = [ "general", "verification", + "verificationContext", "browser", "node", "backend", @@ -90,6 +91,7 @@ export const GROUP_ORDER: readonly string[] = [ export const GROUP_TITLES: Record = { general: "General", verification: "Verification", + verificationContext: "Verification context", browser: "Browser cycle", node: "Node cycle", backend: "Backend cycle", @@ -178,6 +180,41 @@ export const CONFIG_SCHEMA: ConfigSchemaEntry[] = [ artifactAffecting: true, }, + // ── verification context (path-scoped guidance) ──────────────────────── + { + path: "verificationContext.enable", + type: "boolean", + editor: "toggle", + description: "Master switch for path-scoped verification context. When a cycle begins, the first devtools tool call injects the .ironbee/VERIFICATION.md files on/above the changed paths (author-written area guidance) into the agent's context via the host's additionalContext channel. Advisory only (does not gate). Inverse semantics — default-on, opt out with false. Not artifact-affecting (read live).", + def: "true", + artifactAffecting: false, + }, + { + path: "verificationContext.source", + type: "string", + editor: "enum", + enumValues: ["git", "actions"], + description: "Changed-path source. \"git\" (default) = working tree + last N commits, with an actions.jsonl fallback when not a git repo. \"actions\" = IronBee's own file_change events only (no git subprocess).", + def: "git", + artifactAffecting: false, + }, + { + path: "verificationContext.commitDepth", + type: "number", + editor: "number", + description: "Number of recent commits included on the git source (uncommitted ∪ last N commits). 0 = uncommitted only. Ignored when source is \"actions\".", + def: "1", + artifactAffecting: false, + }, + { + path: "verificationContext.maxBytes", + type: "number", + editor: "number", + description: "Aggregate byte cap on the injected context string. When exceeded, the least-specific (root-side) docs are dropped first (leaf-first truncation priority).", + def: "65536 (64 KB)", + artifactAffecting: false, + }, + // ── browser cycle ────────────────────────────────────────────────────── { path: "browser.enable", diff --git a/tests/unit/clients/codex/reconcile-abandoned-activity.test.ts b/tests/unit/clients/codex/reconcile-abandoned-activity.test.ts index 381bfb1..5800cad 100644 --- a/tests/unit/clients/codex/reconcile-abandoned-activity.test.ts +++ b/tests/unit/clients/codex/reconcile-abandoned-activity.test.ts @@ -53,7 +53,7 @@ describe("reconcileAbandonedActivity", () => { userEmail: null, usageType: null, usagePlan: null, - chainedStatusLine: null, + chainedStatusLine: null, contextInjectedVerificationId: null, }); await reconcileAbandonedActivity(SESSION_DIR, ACTIONS_FILE, appendFn); expect(readActions()).toHaveLength(0); @@ -74,7 +74,7 @@ describe("reconcileAbandonedActivity", () => { userEmail: null, usageType: null, usagePlan: null, - chainedStatusLine: null, + chainedStatusLine: null, contextInjectedVerificationId: null, }); await reconcileAbandonedActivity(SESSION_DIR, ACTIONS_FILE, appendFn); const actions = readActions(); @@ -98,7 +98,7 @@ describe("reconcileAbandonedActivity", () => { userEmail: null, usageType: null, usagePlan: null, - chainedStatusLine: null, + chainedStatusLine: null, contextInjectedVerificationId: null, }); await reconcileAbandonedActivity(SESSION_DIR, ACTIONS_FILE, appendFn); const actions = readActions(); diff --git a/tests/unit/clients/codex/require-verification-recording.test.ts b/tests/unit/clients/codex/require-verification-recording.test.ts index 466dbf3..9951e98 100644 --- a/tests/unit/clients/codex/require-verification-recording.test.ts +++ b/tests/unit/clients/codex/require-verification-recording.test.ts @@ -68,7 +68,7 @@ function writeState(state: Record): void { userEmail: null, usageType: null, usagePlan: null, - chainedStatusLine: null, + chainedStatusLine: null, contextInjectedVerificationId: null, ...state, })); } diff --git a/tests/unit/clients/verification-context-injection.test.ts b/tests/unit/clients/verification-context-injection.test.ts new file mode 100644 index 0000000..0e837ab --- /dev/null +++ b/tests/unit/clients/verification-context-injection.test.ts @@ -0,0 +1,186 @@ +/** + * Adapter wiring: path-scoped verification context is injected via each host's + * formal context-injection channel on the FIRST devtools call of a cycle, and + * deduped on subsequent calls. + * - Claude / Codex: PreToolUse require-verification → hookSpecificOutput.additionalContext + * - Cursor: PostToolUse track-action → additional_context + * + * Hermetic: source forced to "actions" via a project config + an actions.jsonl + * carrying an allow marker and a file_change under payment/. homedir mocked so + * the developer's real global config can't leak in. + */ + +import { mkdirSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +let mockStdinValue: string = "{}"; +jest.mock("../../../src/lib/stdin", () => ({ + readStdin: (): string => mockStdinValue, +})); + +let mockHomedir: string = ""; +jest.mock("os", () => { + const actual: typeof import("os") = jest.requireActual("os"); + return { ...actual, homedir: (): string => mockHomedir || actual.homedir() }; +}); + +const mockExit: jest.SpyInstance = jest.spyOn(process, "exit").mockImplementation(((code?: string | number | null): never => { + throw new Error(`process.exit(${code})`); +}) as (code?: string | number | null) => never); +let stdoutBuffer: string = ""; +jest.spyOn(process.stdout, "write").mockImplementation((chunk: unknown, ...args: unknown[]): boolean => { + stdoutBuffer += typeof chunk === "string" ? chunk : String(chunk); + const cb: unknown = args[args.length - 1]; + if (typeof cb === "function") { + (cb as () => void)(); + } + return true; +}); +jest.spyOn(process.stderr, "write").mockImplementation((): boolean => true); + +import { setActiveVerification, setActiveActivity } from "../../../src/hooks/core/session-state"; +import { run as runClaudeRequireVerification } from "../../../src/clients/claude/hooks/require-verification"; +import { run as runCodexRequireVerification } from "../../../src/clients/codex/hooks/require-verification"; +import { run as runCursorTrackAction } from "../../../src/clients/cursor/hooks/track-action"; + +let testDir: string; +let sessionDir: string; +const SESSION_ID: string = "test-sid"; + +function seedProject(): void { + // project config → source: actions (hermetic, no git) + mkdirSync(join(testDir, ".ironbee"), { recursive: true }); + writeFileSync(join(testDir, ".ironbee", "config.json"), JSON.stringify({ verificationContext: { source: "actions" } })); + // payment area guidance + mkdirSync(join(testDir, "payment", ".ironbee"), { recursive: true }); + writeFileSync(join(testDir, "payment", ".ironbee", "VERIFICATION.md"), "PAYMENT-VERIFY-STEPS"); + // actions.jsonl: allow marker + a file_change under payment/ + mkdirSync(sessionDir, { recursive: true }); + const charge: string = join(testDir, "payment", "charge.ts"); + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n" + + JSON.stringify({ type: "file_change", file_path: charge, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }) + "\n"); +} + +beforeEach((): void => { + const base: string = join(tmpdir(), `ironbee-vctx-inj-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(base, { recursive: true }); + testDir = realpathSync(base); + sessionDir = join(testDir, ".ironbee", "sessions", SESSION_ID); + mockHomedir = join(testDir, "home"); + mkdirSync(join(mockHomedir, ".ironbee"), { recursive: true }); + mockStdinValue = "{}"; + stdoutBuffer = ""; + mockExit.mockClear(); + seedProject(); + setActiveVerification(sessionDir, "v1", "trace-1"); + setActiveActivity(sessionDir, "a1"); +}); + +afterEach((): void => { + rmSync(testDir, { recursive: true, force: true }); +}); + +function claudeStdin(): string { + return JSON.stringify({ + session_id: SESSION_ID, + tool_name: "mcp__browser-devtools__bdt_navigation_go-to", + tool_input: { url: "http://localhost:3000" }, + }); +} + +describe("Claude require-verification — additionalContext injection", (): void => { + it("injects path-scoped guidance on the first devtools call", async (): Promise => { + mockStdinValue = claudeStdin(); + await runClaudeRequireVerification(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + const hso: Record = out.hookSpecificOutput as Record; + expect(hso.additionalContext).toBeDefined(); + expect(hso.additionalContext as string).toContain("PAYMENT-VERIFY-STEPS"); + expect(hso.additionalContext as string).toContain("PROJECT VERIFICATION INSTRUCTIONS"); + }); + + it("does NOT re-inject on the second devtools call of the same cycle", async (): Promise => { + mockStdinValue = claudeStdin(); + await runClaudeRequireVerification(testDir).catch((): void => {}); + stdoutBuffer = ""; + await runClaudeRequireVerification(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + const hso: Record = out.hookSpecificOutput as Record; + expect(hso.additionalContext).toBeUndefined(); + }); + + it("does not inject when verificationContext.enable is false", async (): Promise => { + writeFileSync(join(testDir, ".ironbee", "config.json"), JSON.stringify({ verificationContext: { source: "actions", enable: false } })); + mockStdinValue = claudeStdin(); + await runClaudeRequireVerification(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + const hso: Record = out.hookSpecificOutput as Record; + expect(hso.additionalContext).toBeUndefined(); + // still allows the tool + injects _metadata + expect(hso.permissionDecision).toBe("allow"); + }); +}); + +describe("Codex require-verification — additionalContext injection", (): void => { + it("injects path-scoped guidance alongside permissionDecision:allow", async (): Promise => { + mockStdinValue = JSON.stringify({ + session_id: SESSION_ID, + tool_name: "mcp__browser-devtools__bdt_navigation_go-to", + tool_input: {}, + }); + await runCodexRequireVerification(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + const hso: Record = out.hookSpecificOutput as Record; + expect(hso.permissionDecision).toBe("allow"); + expect(hso.additionalContext as string).toContain("PAYMENT-VERIFY-STEPS"); + }); + + it("dedups on the second call", async (): Promise => { + const stdin: string = JSON.stringify({ session_id: SESSION_ID, tool_name: "mcp__browser-devtools__bdt_navigation_go-to", tool_input: {} }); + mockStdinValue = stdin; + await runCodexRequireVerification(testDir).catch((): void => {}); + stdoutBuffer = ""; + await runCodexRequireVerification(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + const hso: Record = out.hookSpecificOutput as Record; + expect(hso.additionalContext).toBeUndefined(); + }); +}); + +describe("Cursor track-action — additional_context injection (postToolUse)", (): void => { + it("injects path-scoped guidance on the first devtools call", async (): Promise => { + mockStdinValue = JSON.stringify({ + conversation_id: SESSION_ID, + tool_name: "MCP:bdt_navigation_go-to", + tool_input: { url: "http://localhost:3000" }, + tool_output: "ok", + }); + await runCursorTrackAction(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + expect(out.additional_context as string).toContain("PAYMENT-VERIFY-STEPS"); + }); + + it("does NOT inject for a non-devtools tool", async (): Promise => { + mockStdinValue = JSON.stringify({ + conversation_id: SESSION_ID, + tool_name: "Read", + tool_input: { path: "x.ts" }, + tool_output: "data", + }); + await runCursorTrackAction(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + expect(out.additional_context).toBeUndefined(); + }); + + it("dedups on the second devtools call", async (): Promise => { + const stdin: string = JSON.stringify({ conversation_id: SESSION_ID, tool_name: "MCP:bdt_navigation_go-to", tool_input: {}, tool_output: "ok" }); + mockStdinValue = stdin; + await runCursorTrackAction(testDir).catch((): void => {}); + stdoutBuffer = ""; + await runCursorTrackAction(testDir).catch((): void => {}); + const out: Record = JSON.parse(stdoutBuffer) as Record; + expect(out.additional_context).toBeUndefined(); + }); +}); diff --git a/tests/unit/hooks/clear-verdict.test.ts b/tests/unit/hooks/clear-verdict.test.ts index 3508124..6e11837 100644 --- a/tests/unit/hooks/clear-verdict.test.ts +++ b/tests/unit/hooks/clear-verdict.test.ts @@ -20,7 +20,7 @@ afterEach((): void => { describe("runClearVerdict", (): void => { it("deletes verdict file and resets retries but preserves lastVerdictStatus", (): void => { writeFileSync(verdictFile, JSON.stringify({ status: "pass" })); - writeState(testDir, { retries: 2, activeVerificationId: null, activeTraceId: null, lastVerdictStatus: "pass", activeFixId: null, activeActivityId: null, phase: null, recordingRequired: false, recordingActive: false, active: false, userEmail: null, usageType: null, usagePlan: null, chainedStatusLine: null }); + writeState(testDir, { retries: 2, activeVerificationId: null, activeTraceId: null, lastVerdictStatus: "pass", activeFixId: null, activeActivityId: null, phase: null, recordingRequired: false, recordingActive: false, active: false, userEmail: null, usageType: null, usagePlan: null, chainedStatusLine: null, contextInjectedVerificationId: null }); runClearVerdict({ verdictFile, sessionDir: testDir }); @@ -46,7 +46,7 @@ describe("runClearVerdict", (): void => { }); it("resets retries even if verdict file does not exist but preserves lastVerdictStatus", (): void => { - writeState(testDir, { retries: 3, activeVerificationId: null, activeTraceId: null, lastVerdictStatus: "fail", activeFixId: null, activeActivityId: null, phase: null, recordingRequired: false, recordingActive: false, active: false, userEmail: null, usageType: null, usagePlan: null, chainedStatusLine: null }); + writeState(testDir, { retries: 3, activeVerificationId: null, activeTraceId: null, lastVerdictStatus: "fail", activeFixId: null, activeActivityId: null, phase: null, recordingRequired: false, recordingActive: false, active: false, userEmail: null, usageType: null, usagePlan: null, chainedStatusLine: null, contextInjectedVerificationId: null }); runClearVerdict({ verdictFile, sessionDir: testDir }); diff --git a/tests/unit/hooks/session-state.test.ts b/tests/unit/hooks/session-state.test.ts index 8a0c281..3004ddb 100644 --- a/tests/unit/hooks/session-state.test.ts +++ b/tests/unit/hooks/session-state.test.ts @@ -284,7 +284,7 @@ describe("phase", (): void => { userEmail: null, usageType: null, usagePlan: null, - chainedStatusLine: null, + chainedStatusLine: null, contextInjectedVerificationId: null, }); const state: SessionState = readState(sessionDir); diff --git a/tests/unit/hooks/verification-context.test.ts b/tests/unit/hooks/verification-context.test.ts new file mode 100644 index 0000000..0dab5f8 --- /dev/null +++ b/tests/unit/hooks/verification-context.test.ts @@ -0,0 +1,317 @@ +/** + * Path-scoped verification context — core module tests. + * + * Covers resolveContextFiles (hierarchical walk + dedup + ordering), + * buildVerificationContext (rendering + leaf-first truncation), collectChangedPaths + * (actions source + subtree filter), buildVerificationContextForSession + * (enable gate + end-to-end), and buildVerificationContextOnceForCycle (per-cycle dedup). + */ + +import { mkdirSync, rmSync, writeFileSync, realpathSync, symlinkSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + buildVerificationContext, + buildVerificationContextForSession, + buildVerificationContextOnceForCycle, + collectChangedPaths, + resolveContextFiles, + ResolvedDoc, +} from "../../../src/hooks/core/verification-context"; +import { IronBeeConfig } from "../../../src/lib/config"; +import { getContextInjectedVerificationId } from "../../../src/hooks/core/session-state"; + +let projectDir: string; + +function writeDoc(relDir: string, content: string): void { + const dir: string = relDir.length === 0 ? projectDir : join(projectDir, relDir); + const ironbeeDir: string = join(dir, ".ironbee"); + mkdirSync(ironbeeDir, { recursive: true }); + writeFileSync(join(ironbeeDir, "VERIFICATION.md"), content); +} + +beforeEach((): void => { + const base: string = join(tmpdir(), `ironbee-vctx-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(base, { recursive: true }); + projectDir = realpathSync(base); +}); + +afterEach((): void => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +describe("resolveContextFiles — hierarchical walk", (): void => { + it("collects every .ironbee/VERIFICATION.md from file dir up to projectDir (inclusive)", (): void => { + writeDoc("", "root guidance"); + writeDoc("payment", "payment guidance"); + writeDoc(join("payment", "stripe"), "stripe guidance"); + const changed: string[] = [join(projectDir, "payment", "stripe", "charge.ts")]; + + const docs: ResolvedDoc[] = resolveContextFiles(projectDir, changed); + + expect(docs.map((d: ResolvedDoc): string => d.relDir)).toEqual(["", "payment", "payment/stripe"]); + expect(docs.map((d: ResolvedDoc): number => d.depth)).toEqual([0, 1, 2]); + expect(docs[0].content).toBe("root guidance"); + expect(docs[2].content).toBe("stripe guidance"); + }); + + it("skips intermediate levels that have no doc", (): void => { + writeDoc("", "root"); + writeDoc(join("payment", "stripe"), "stripe"); + // payment/ has NO doc + const changed: string[] = [join(projectDir, "payment", "stripe", "charge.ts")]; + + const docs: ResolvedDoc[] = resolveContextFiles(projectDir, changed); + expect(docs.map((d: ResolvedDoc): string => d.relDir)).toEqual(["", "payment/stripe"]); + }); + + it("dedups a shared parent doc when many files under it change", (): void => { + writeDoc("payment", "payment"); + const changed: string[] = [ + join(projectDir, "payment", "a.ts"), + join(projectDir, "payment", "b.ts"), + join(projectDir, "payment", "sub", "c.ts"), + ]; + const docs: ResolvedDoc[] = resolveContextFiles(projectDir, changed); + expect(docs).toHaveLength(1); + expect(docs[0].relDir).toBe("payment"); + }); + + it("returns [] when no docs exist on any walked path", (): void => { + const changed: string[] = [join(projectDir, "src", "x.ts")]; + expect(resolveContextFiles(projectDir, changed)).toEqual([]); + }); + + it("resolves guidance for a DELETED file (uses path string, not existsSync)", (): void => { + writeDoc("payment", "payment"); + // payment/old.ts does not exist on disk + const changed: string[] = [join(projectDir, "payment", "old.ts")]; + const docs: ResolvedDoc[] = resolveContextFiles(projectDir, changed); + expect(docs).toHaveLength(1); + expect(docs[0].relDir).toBe("payment"); + }); +}); + +describe("buildVerificationContext — rendering + truncation", (): void => { + function docOf(relDir: string, depth: number, content: string): ResolvedDoc { + return { absPath: join(projectDir, relDir, ".ironbee", "VERIFICATION.md"), relDir, depth, content }; + } + + it("returns '' for empty docs", (): void => { + expect(buildVerificationContext([], { maxBytes: 65536 })).toBe(""); + }); + + it("renders root→leaf with provenance headers", (): void => { + const out: string = buildVerificationContext( + [docOf("", 0, "ROOT"), docOf("payment", 1, "PAY")], + { maxBytes: 65536 }, + ); + expect(out).toContain("PROJECT VERIFICATION INSTRUCTIONS"); + expect(out).toContain("## (repo root) — .ironbee/VERIFICATION.md"); + expect(out).toContain("ROOT"); + expect(out).toContain("## payment/ — payment/.ironbee/VERIFICATION.md"); + expect(out).toContain("PAY"); + // root appears before leaf + expect(out.indexOf("ROOT")).toBeLessThan(out.indexOf("PAY")); + }); + + it("drops least-specific (root) docs first under the byte cap (leaf-first priority)", (): void => { + const root: ResolvedDoc = docOf("", 0, "R".repeat(500)); + const leaf: ResolvedDoc = docOf("payment", 1, "L".repeat(120)); + // cap big enough for frame + the whole leaf, but not for both docs + const out: string = buildVerificationContext([root, leaf], { maxBytes: 600 }); + expect(out).toContain("L".repeat(120)); // leaf kept + expect(out).not.toContain("R".repeat(500)); // root dropped + expect(out).toContain("omitted to fit"); + }); + + it("hard-truncates a single oversized doc", (): void => { + const leaf: ResolvedDoc = docOf("payment", 1, "L".repeat(5000)); + const out: string = buildVerificationContext([leaf], { maxBytes: 400 }); + expect(out).toContain("... (truncated)"); + expect(Buffer.byteLength(out, "utf-8")).toBeLessThanOrEqual(450); // ~cap (small slack) + }); + + it("hard-truncates multi-byte content without exceeding the cap (byte-accurate)", (): void => { + // 5000 emoji (4 bytes each) — a char-based slice would blow past the cap. + const leaf: ResolvedDoc = docOf("payment", 1, "😀".repeat(5000)); + const out: string = buildVerificationContext([leaf], { maxBytes: 600 }); + expect(out).toContain("... (truncated)"); + expect(Buffer.byteLength(out, "utf-8")).toBeLessThanOrEqual(700); // cap + frame slack, NOT 20000 + }); +}); + +describe("collectChangedPaths — actions source + subtree filter", (): void => { + const SESSION_ID: string = "sid-1"; + + function writeActions(lines: object[]): string { + const sessionDir: string = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + const actionsFile: string = join(sessionDir, "actions.jsonl"); + writeFileSync(actionsFile, lines.map((l: object): string => JSON.stringify(l)).join("\n") + "\n"); + return actionsFile; + } + + it("reads file_change paths since the last allow verification_requested", (): void => { + const abs: string = join(projectDir, "payment", "charge.ts"); + const actionsFile: string = writeActions([ + { type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: abs, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }, + ]); + const out: string[] = collectChangedPaths(projectDir, actionsFile, { source: "actions", commitDepth: 1 }); + expect(out).toEqual([abs]); + }); + + it("excludes IronBee's own .ironbee/ runtime paths from the changed set", (): void => { + const code: string = join(projectDir, "payment", "charge.ts"); + const internal1: string = join(projectDir, ".ironbee", "config.json"); + const internal2: string = join(projectDir, ".ironbee", "sessions", SESSION_ID, "session.log"); + const internal3: string = join(projectDir, "payment", ".ironbee", "VERIFICATION.md"); + const actionsFile: string = writeActions([ + { type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: code, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: internal1, operation: "update", timestamp: 3, id: "f2", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: internal2, operation: "update", timestamp: 4, id: "f3", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: internal3, operation: "update", timestamp: 5, id: "f4", session_id: SESSION_ID, project_name: "p" }, + ]); + const out: string[] = collectChangedPaths(projectDir, actionsFile, { source: "actions", commitDepth: 1 }); + expect(out).toEqual([code]); // all .ironbee/* paths dropped + }); + + it("filters out paths outside the projectDir subtree", (): void => { + const inside: string = join(projectDir, "payment", "a.ts"); + const outside: string = join(tmpdir(), "elsewhere", "b.ts"); + const actionsFile: string = writeActions([ + { type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: inside, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }, + { type: "file_change", file_path: outside, operation: "update", timestamp: 3, id: "f2", session_id: SESSION_ID, project_name: "p" }, + ]); + const out: string[] = collectChangedPaths(projectDir, actionsFile, { source: "actions", commitDepth: 1 }); + expect(out).toEqual([inside]); + }); +}); + +describe("buildVerificationContextForSession — enable gate + end-to-end (actions source)", (): void => { + const SESSION_ID: string = "sid-2"; + + function setup(): void { + writeDoc("payment", "PAYMENT VERIFY STEPS"); + const sessionDir: string = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + const abs: string = join(projectDir, "payment", "charge.ts"); + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n" + + JSON.stringify({ type: "file_change", file_path: abs, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }) + "\n"); + } + + it("injects the payment doc end-to-end", (): void => { + setup(); + const config: IronBeeConfig = { verificationContext: { source: "actions" } }; + const out: string = buildVerificationContextForSession(projectDir, SESSION_ID, config); + expect(out).toContain("PAYMENT VERIFY STEPS"); + expect(out).toContain("payment/.ironbee/VERIFICATION.md"); + }); + + it("returns '' when verificationContext.enable is false (single honoring point)", (): void => { + setup(); + const config: IronBeeConfig = { verificationContext: { enable: false, source: "actions" } }; + expect(buildVerificationContextForSession(projectDir, SESSION_ID, config)).toBe(""); + }); + + it("resolves through a symlinked project path with a logical (non-realpath) file_path", (): void => { + // Reproduces the actions-fallback symlink mismatch: projectDir given to + // the hook is a symlink, file_path in actions.jsonl is logical (under the + // symlink), but canonicalDir realpaths projectDir. canonicalizePath must + // bring the logical file_path to the same physical base. + writeDoc("payment", "PAYMENT VERIFY STEPS"); + const sessionDir: string = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + + const linkBase: string = join(tmpdir(), `ironbee-vctx-link-${Date.now()}-${Math.random().toString(36).slice(2)}`); + let link: string; + try { + symlinkSync(projectDir, linkBase, "dir"); + link = linkBase; + } catch { + return; // symlink not permitted (e.g. Windows without privilege) — skip + } + try { + const logicalCharge: string = join(link, "payment", "charge.ts"); // logical path under the symlink + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n" + + JSON.stringify({ type: "file_change", file_path: logicalCharge, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }) + "\n"); + const config: IronBeeConfig = { verificationContext: { source: "actions" } }; + // pass the SYMLINK path as projectDir (as a host env var might) + const out: string = buildVerificationContextForSession(link, SESSION_ID, config); + expect(out).toContain("PAYMENT VERIFY STEPS"); + } finally { + rmSync(linkBase, { recursive: true, force: true }); + } + }); + + it("respects ignoredVerifyPatterns (no guidance for an ignored path)", (): void => { + writeDoc("docs", "DOCS GUIDANCE"); + const sessionDir: string = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + const abs: string = join(projectDir, "docs", "readme.md"); + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n" + + JSON.stringify({ type: "file_change", file_path: abs, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }) + "\n"); + const config: IronBeeConfig = { verificationContext: { source: "actions" }, ignoredVerifyPatterns: ["docs/**"] }; + expect(buildVerificationContextForSession(projectDir, SESSION_ID, config)).toBe(""); + }); +}); + +describe("buildVerificationContextOnceForCycle — per-cycle dedup", (): void => { + const SESSION_ID: string = "sid-3"; + let sessionDir: string; + + function setup(): void { + writeDoc("payment", "PAY STEPS"); + sessionDir = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + const abs: string = join(projectDir, "payment", "charge.ts"); + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n" + + JSON.stringify({ type: "file_change", file_path: abs, operation: "update", timestamp: 2, id: "f1", session_id: SESSION_ID, project_name: "p" }) + "\n"); + } + + const config: IronBeeConfig = { verificationContext: { source: "actions" } }; + + it("injects on first call, skips on second (same verificationId)", (): void => { + setup(); + const first: string = buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: "v1", config }); + expect(first).toContain("PAY STEPS"); + expect(getContextInjectedVerificationId(sessionDir)).toBe("v1"); + + const second: string = buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: "v1", config }); + expect(second).toBe(""); + }); + + it("re-injects for a new verificationId (new cycle)", (): void => { + setup(); + buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: "v1", config }); + const next: string = buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: "v2", config }); + expect(next).toContain("PAY STEPS"); + expect(getContextInjectedVerificationId(sessionDir)).toBe("v2"); + }); + + it("stamps dedup id even when nothing matches (avoids recomputing per call)", (): void => { + // no docs anywhere + sessionDir = join(projectDir, ".ironbee", "sessions", SESSION_ID); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, "actions.jsonl"), + JSON.stringify({ type: "verification_requested", action: "allow", timestamp: 1, id: "m1", session_id: SESSION_ID, project_name: "p" }) + "\n"); + const out: string = buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: "v9", config }); + expect(out).toBe(""); + expect(getContextInjectedVerificationId(sessionDir)).toBe("v9"); + }); + + it("no-op without an active verificationId", (): void => { + setup(); + const out: string = buildVerificationContextOnceForCycle({ projectDir, sessionId: SESSION_ID, sessionDir, activeVerificationId: undefined, config }); + expect(out).toBe(""); + expect(getContextInjectedVerificationId(sessionDir)).toBeUndefined(); + }); +}); diff --git a/tests/unit/lib/config-verification-context.test.ts b/tests/unit/lib/config-verification-context.test.ts new file mode 100644 index 0000000..80351df --- /dev/null +++ b/tests/unit/lib/config-verification-context.test.ts @@ -0,0 +1,108 @@ +/** + * verificationContext config: accessors (defaults/clamping), ignore-matcher, + * and cross-layer deep-merge (the load-bearing §10.14 fix — a higher-layer write + * of one key must not drop sibling keys from lower layers). + */ + +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// eslint-disable-next-line no-var +var homedirOverride: string | undefined; +jest.mock("os", (): typeof import("os") => { + const actual: typeof import("os") = jest.requireActual("os"); + return { ...actual, homedir: (): string => homedirOverride ?? actual.homedir() }; +}); + +import { + getVerificationContextEnabled, + getVerificationContextSource, + getVerificationContextCommitDepth, + getVerificationContextMaxBytes, + isIgnoredVerifyPath, + loadConfig, + IronBeeConfig, +} from "../../../src/lib/config"; + +describe("verificationContext accessors", (): void => { + it("enable defaults to true (inverse semantics) and only false disables", (): void => { + expect(getVerificationContextEnabled({})).toBe(true); + expect(getVerificationContextEnabled({ verificationContext: {} })).toBe(true); + expect(getVerificationContextEnabled({ verificationContext: { enable: true } })).toBe(true); + expect(getVerificationContextEnabled({ verificationContext: { enable: false } })).toBe(false); + }); + + it("source defaults to git; only 'actions' overrides", (): void => { + expect(getVerificationContextSource({})).toBe("git"); + expect(getVerificationContextSource({ verificationContext: { source: "actions" } })).toBe("actions"); + // unknown value falls back to git + expect(getVerificationContextSource({ verificationContext: { source: "nope" as "git" } })).toBe("git"); + }); + + it("commitDepth defaults to 1, clamps to >= 0, floors", (): void => { + expect(getVerificationContextCommitDepth({})).toBe(1); + expect(getVerificationContextCommitDepth({ verificationContext: { commitDepth: 0 } })).toBe(0); + expect(getVerificationContextCommitDepth({ verificationContext: { commitDepth: 3 } })).toBe(3); + expect(getVerificationContextCommitDepth({ verificationContext: { commitDepth: 2.9 } })).toBe(2); + expect(getVerificationContextCommitDepth({ verificationContext: { commitDepth: -5 } })).toBe(1); + }); + + it("maxBytes defaults to 65536, clamps to > 0", (): void => { + expect(getVerificationContextMaxBytes({})).toBe(65536); + expect(getVerificationContextMaxBytes({ verificationContext: { maxBytes: 1024 } })).toBe(1024); + expect(getVerificationContextMaxBytes({ verificationContext: { maxBytes: 0 } })).toBe(65536); + expect(getVerificationContextMaxBytes({ verificationContext: { maxBytes: -1 } })).toBe(65536); + }); +}); + +describe("isIgnoredVerifyPath", (): void => { + const cfg: IronBeeConfig = { ignoredVerifyPatterns: ["docs/**", "*.test.ts"] }; + it("matches ignore globs on absolute paths (anchored (^|/)…$)", (): void => { + expect(isIgnoredVerifyPath(cfg, "/Users/x/proj/docs/readme.md")).toBe(true); + expect(isIgnoredVerifyPath(cfg, "/Users/x/proj/src/foo.test.ts")).toBe(true); + expect(isIgnoredVerifyPath(cfg, "/Users/x/proj/src/foo.ts")).toBe(false); + }); + it("returns false when there are no ignore patterns", (): void => { + expect(isIgnoredVerifyPath({}, "/any/path.ts")).toBe(false); + }); +}); + +describe("verificationContext cross-layer deep-merge", (): void => { + let home: string; + let projectDir: string; + + beforeEach((): void => { + const base: string = join(tmpdir(), `ironbee-vctx-merge-${Date.now()}-${Math.random().toString(36).slice(2)}`); + home = join(base, "home"); + projectDir = join(base, "proj"); + mkdirSync(join(home, ".ironbee"), { recursive: true }); + mkdirSync(join(projectDir, ".ironbee"), { recursive: true }); + homedirOverride = home; + }); + + afterEach((): void => { + homedirOverride = undefined; + rmSync(join(projectDir, ".."), { recursive: true, force: true }); + }); + + it("merges sibling keys across global / project / local without dropping any", (): void => { + writeFileSync(join(home, ".ironbee", "config.json"), JSON.stringify({ verificationContext: { source: "actions" } })); + writeFileSync(join(projectDir, ".ironbee", "config.json"), JSON.stringify({ verificationContext: { commitDepth: 3 } })); + writeFileSync(join(projectDir, ".ironbee", "config.local.json"), JSON.stringify({ verificationContext: { maxBytes: 100 } })); + + const merged: IronBeeConfig = loadConfig(projectDir); + expect(merged.verificationContext).toEqual({ source: "actions", commitDepth: 3, maxBytes: 100 }); + // and the accessors reflect the merged shape + expect(getVerificationContextSource(merged)).toBe("actions"); + expect(getVerificationContextCommitDepth(merged)).toBe(3); + expect(getVerificationContextMaxBytes(merged)).toBe(100); + }); + + it("higher layer wins on the same key", (): void => { + writeFileSync(join(home, ".ironbee", "config.json"), JSON.stringify({ verificationContext: { commitDepth: 1 } })); + writeFileSync(join(projectDir, ".ironbee", "config.local.json"), JSON.stringify({ verificationContext: { commitDepth: 9 } })); + const merged: IronBeeConfig = loadConfig(projectDir); + expect(getVerificationContextCommitDepth(merged)).toBe(9); + }); +}); diff --git a/tests/unit/lib/git.test.ts b/tests/unit/lib/git.test.ts new file mode 100644 index 0000000..da865f6 --- /dev/null +++ b/tests/unit/lib/git.test.ts @@ -0,0 +1,113 @@ +/** + * lib/git.ts — getChangedPaths tests against a real throwaway git repo. + * + * Skips gracefully if `git` isn't on PATH (CI always has it; some sandboxes + * may not). Repos are created under tmpdir and torn down per-test. + */ + +import { execFileSync } from "child_process"; +import { mkdirSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { getChangedPaths } from "../../../src/lib/git"; + +let gitAvailable: boolean = true; +try { + execFileSync("git", ["--version"], { stdio: "ignore" }); +} catch { + gitAvailable = false; +} + +const d: jest.Describe = gitAvailable ? describe : describe.skip; + +let repo: string; + +/** + * Assert a returned absolute path refers to `rel` (a `/`-joined repo-relative + * path) without comparing the absolute prefix. On Windows CI the tmpdir prefix + * can differ between Node's realpathSync (`RUNNER~1`) and git's output + * (`runneradmin`), so we match the separator-normalized suffix only. + * See CLAUDE.md "Cross-platform" — never assert full tmpdir+external-tool paths. + */ +function includesRel(out: string[] | null, rel: string): boolean { + return (out ?? []).some((p: string): boolean => p.replace(/\\/g, "/").endsWith(`/${rel}`)); +} + +function git(args: string[]): void { + execFileSync("git", args, { cwd: repo, stdio: "ignore" }); +} + +function commitAll(message: string): void { + git(["add", "-A"]); + git(["-c", "user.email=t@t.t", "-c", "user.name=t", "commit", "-m", message]); +} + +beforeEach((): void => { + const base: string = join(tmpdir(), `ironbee-git-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(base, { recursive: true }); + repo = realpathSync(base); +}); + +afterEach((): void => { + rmSync(repo, { recursive: true, force: true }); +}); + +d("getChangedPaths", (): void => { + it("returns null when the dir is not a git repo", (): void => { + expect(getChangedPaths(repo, 1)).toBeNull(); + }); + + it("includes uncommitted modifications (absolute paths)", (): void => { + git(["init"]); + writeFileSync(join(repo, "a.ts"), "v1"); + commitAll("init"); + writeFileSync(join(repo, "a.ts"), "v2-modified"); + + const out: string[] | null = getChangedPaths(repo, 1); + expect(out).not.toBeNull(); + expect(includesRel(out, "a.ts")).toBe(true); + }); + + it("includes untracked files (each file, not the collapsed dir)", (): void => { + git(["init"]); + writeFileSync(join(repo, "seed.ts"), "x"); + commitAll("init"); + mkdirSync(join(repo, "newdir"), { recursive: true }); + writeFileSync(join(repo, "newdir", "fresh.ts"), "new"); + + const out: string[] | null = getChangedPaths(repo, 1); + expect(includesRel(out, "newdir/fresh.ts")).toBe(true); + }); + + it("includes the last commit's files with commitDepth >= 1", (): void => { + git(["init"]); + writeFileSync(join(repo, "first.ts"), "1"); + commitAll("c1"); + writeFileSync(join(repo, "second.ts"), "2"); + commitAll("c2"); // working tree clean after this + + const out: string[] | null = getChangedPaths(repo, 1); + expect(includesRel(out, "second.ts")).toBe(true); + expect(includesRel(out, "first.ts")).toBe(false); // only the last commit + }); + + it("excludes committed-only changes with commitDepth 0 (uncommitted only)", (): void => { + git(["init"]); + writeFileSync(join(repo, "first.ts"), "1"); + commitAll("c1"); + writeFileSync(join(repo, "second.ts"), "2"); + commitAll("c2"); + + const out: string[] | null = getChangedPaths(repo, 0); + expect(out).toEqual([]); // clean tree, no commit slice + }); + + it("returns [] for a clean repo (distinct from null)", (): void => { + git(["init"]); + writeFileSync(join(repo, "x.ts"), "x"); + commitAll("c1"); + const out: string[] | null = getChangedPaths(repo, 0); + expect(out).toEqual([]); + }); +});