From 9116cacfff4e6d285c0ff7f5df01954181aeaabf Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 14 Jun 2026 11:43:03 -0700 Subject: [PATCH] test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139) New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial tests (auto-defer instead of exit 2, posture, off-switch, format split, single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off + tokenmax-bypass + cooldown-still-refuses; config known-key acceptance. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/config-set.test.ts | 9 ++ test/embed-backfill-submit.test.ts | 48 +++++++++ test/sync-cost-estimate.test.ts | 140 ++++++++++++++++++++++++++ test/sync-cost-gate.serial.test.ts | 143 +++++++++++++++++++++------ test/sync-cost-preview.test.ts | 74 ++++++++++++++ test/sync-delta.test.ts | 154 +++++++++++++++++++++++++++++ test/sync-failures.test.ts | 6 +- 7 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 test/sync-cost-estimate.test.ts create mode 100644 test/sync-delta.test.ts diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 9857baf46..8d353c838 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -29,6 +29,15 @@ describe('KNOWN_CONFIG_KEYS', () => { expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent'); }); + test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => { + expect(KNOWN_CONFIG_KEYS).toContain('spend.posture'); + expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd'); + expect(KNOWN_CONFIG_KEYS).toContain('sync.federated_v2'); + expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_cooldown_min'); + expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd_per_source_24h'); + expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd'); + }); + test('no duplicate entries', () => { const set = new Set(KNOWN_CONFIG_KEYS); expect(set.size).toBe(KNOWN_CONFIG_KEYS.length); diff --git a/test/embed-backfill-submit.test.ts b/test/embed-backfill-submit.test.ts index 9a19a0d30..3903ceb94 100644 --- a/test/embed-backfill-submit.test.ts +++ b/test/embed-backfill-submit.test.ts @@ -155,6 +155,54 @@ describe('submitEmbedBackfill — 24h spend cap', () => { expect(result.status).toBe('spend_capped'); expect(result.spendCapUsd).toBe(5); }); + + // v0.42.42.0 (#2139): off-switch + tokenmax bypass. + test('cap "off" → submits even at huge spend (Infinity cap never tripped)', async () => { + await engine.setConfig(SPEND_CAP_CONFIG_KEY, 'off'); + const result = await submitEmbedBackfill(engine, 'default', { + reason: 'unit', + spend24hFn: async () => 1e9, + }); + expect(result.status).toBe('submitted'); + }); + + test('0 falls back to the default cap (off semantics ≠ 0)', async () => { + await engine.setConfig(SPEND_CAP_CONFIG_KEY, '0'); + const result = await submitEmbedBackfill(engine, 'default', { + reason: 'unit', + spend24hFn: async () => 25, // == default $25 → capped + }); + expect(result.status).toBe('spend_capped'); + expect(result.spendCapUsd).toBe(25); + }); + + test('spend.posture=tokenmax bypasses the cap, marks spendCapBypassed', async () => { + const result = await submitEmbedBackfill(engine, 'default', { + reason: 'unit', + postureOverride: 'tokenmax', + spendCapUsdOverride: 25, + spend24hFn: async () => 100, // way over cap + }); + expect(result.status).toBe('submitted'); + expect(result.spendCapBypassed).toBe(true); + expect(result.spend24hUsd).toBe(100); + }); + + test('tokenmax does NOT bypass the cooldown (axis split — churn protection stays)', async () => { + const queue = new MinionQueue(engine); + const job = await queue.add('embed-backfill', { sourceId: 'default' }, {}); + await engine.executeRaw( + `UPDATE minion_jobs SET status='completed', finished_at=NOW() - INTERVAL '1 minute' WHERE id=$1`, + [job.id], + ); + const result = await submitEmbedBackfill(engine, 'default', { + reason: 'unit', + postureOverride: 'tokenmax', + cooldownMinOverride: 10, + spend24hFn: async () => 1e9, + }); + expect(result.status).toBe('cooldown'); // posture lifts the cap, NOT the cooldown + }); }); describe('submitEmbedBackfill — source isolation', () => { diff --git a/test/sync-cost-estimate.test.ts b/test/sync-cost-estimate.test.ts new file mode 100644 index 000000000..cc53947ce --- /dev/null +++ b/test/sync-cost-estimate.test.ts @@ -0,0 +1,140 @@ +/** + * v0.42.42.0 (#2139) — estimateInlineNewTokens ladder coverage. + * + * The inline cost estimator now MIRRORS EXECUTION (delta, not full-tree + * ceiling) so the gate's dollar figure stops being a ~400x phantom on a busy + * brain. Real temp git repos, no PGLite. estimateInlineNewTokens is exported + * from commands/sync.ts; CHUNKER_VERSION is the live chunker version. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'; +import { execSync } from 'child_process'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { estimateInlineNewTokens } from '../src/commands/sync.ts'; +import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts'; + +const CURRENT = String(CHUNKER_VERSION); +let repo: string; + +function commitAll(msg: string): string { + execSync('git add -A', { cwd: repo, stdio: 'pipe' }); + execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' }); + return execSync('git rev-parse HEAD', { cwd: repo, stdio: 'pipe' }).toString().trim(); +} +function src(over: Partial<{ last_commit: string | null; chunker_version: string | null; config: Record }> = {}) { + return { + local_path: repo, + config: over.config ?? {}, + last_commit: over.last_commit ?? null, + chunker_version: over.chunker_version ?? null, + }; +} + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'gbrain-est-')); + execSync('git init', { cwd: repo, stdio: 'pipe' }); + execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' }); + execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' }); + mkdirSync(join(repo, 'topics'), { recursive: true }); +}); +afterEach(() => { + if (repo) rmSync(repo, { recursive: true, force: true }); +}); + +describe('estimateInlineNewTokens — ladder', () => { + test('chunker drift → full-tree ceiling even with an empty git delta', () => { + writeFileSync(join(repo, 'topics/a.md'), 'some body content here'); + const head = commitAll('base'); + // git unchanged (last_commit == HEAD) but chunker drifted → must NOT be 0. + const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: 'STALE-0' })], CURRENT); + expect(r.tokens).toBeGreaterThan(0); + expect(r.estimateKind).toBe('ceiling'); + expect(r.ceilingReasons).toContain('chunker_drift'); + expect(r.changedSources).toBe(1); + }); + + test('[D2A headline] HEAD==last_commit + current chunker + DIRTY tree → 0 (unchanged)', () => { + writeFileSync(join(repo, 'topics/a.md'), 'body'); + const head = commitAll('base'); + // Dirty the tree (untracked scratch + uncommitted edit) — attached sync + // imports nothing, so the estimate must be 0. This is the exact pre-fix + // false-fire shape. + writeFileSync(join(repo, 'scratch.tmp'), 'agent scratch'); + writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit'); + const r = estimateInlineNewTokens([src({ last_commit: head, chunker_version: CURRENT })], CURRENT); + expect(r.tokens).toBe(0); + expect(r.estimateKind).toBe('unchanged'); + expect(r.unchangedSources).toBe(1); + }); + + test('first sync (last_commit null) → full-tree ceiling', () => { + writeFileSync(join(repo, 'topics/a.md'), 'body content'); + commitAll('base'); + const r = estimateInlineNewTokens([src({ last_commit: null, chunker_version: CURRENT })], CURRENT); + expect(r.tokens).toBeGreaterThan(0); + expect(r.estimateKind).toBe('ceiling'); + expect(r.ceilingReasons).toContain('first_sync'); + }); + + test('delta rung: only changed committed files priced; deletes cost 0', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(400)); + writeFileSync(join(repo, 'topics/b.md'), 'b'.repeat(400)); + const base = commitAll('base'); + writeFileSync(join(repo, 'topics/a.md'), 'a'.repeat(800)); // modify + rmSync(join(repo, 'topics/b.md')); // delete → 0 + commitAll('change'); + + const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT); + expect(r.estimateKind).toBe('delta'); + expect(r.tokens).toBeGreaterThan(0); // a.md priced + // A pure-delete delta would be 0; here a.md modify keeps it > 0. Sanity: + // the magnitude is delta-scale (one ~800-char file), not full-tree. + }); + + test('delta rung: non-syncable changed file is filtered out (markdown strategy)', () => { + writeFileSync(join(repo, 'topics/a.md'), 'md'); + const base = commitAll('base'); + writeFileSync(join(repo, 'notes.txt'), 'x'.repeat(4000)); // .txt under markdown strategy → not syncable + commitAll('add txt'); + + const r = estimateInlineNewTokens([src({ last_commit: base, chunker_version: CURRENT })], CURRENT); + // No syncable changes → delta priced at 0 tokens (but the source changed). + expect(r.tokens).toBe(0); + expect(r.estimateKind).toBe('delta'); + }); + + test('syncEnabled:false sources are skipped (neither changed nor unchanged)', () => { + writeFileSync(join(repo, 'topics/a.md'), 'body'); + commitAll('base'); + const r = estimateInlineNewTokens([src({ last_commit: null, config: { syncEnabled: false } })], CURRENT); + expect(r.tokens).toBe(0); + expect(r.changedSources).toBe(0); + expect(r.unchangedSources).toBe(0); + }); + + test('mixed: one ceiling source + one unchanged source → estimateKind mixed-or-ceiling, reasons captured', () => { + writeFileSync(join(repo, 'topics/a.md'), 'body'); + const head = commitAll('base'); + const r = estimateInlineNewTokens( + [ + src({ last_commit: null, chunker_version: CURRENT }), // first_sync ceiling + src({ last_commit: head, chunker_version: CURRENT }), // unchanged + ], + CURRENT, + ); + expect(r.ceilingReasons).toContain('first_sync'); + expect(r.unchangedSources).toBe(1); + // hadCeiling true, hadDelta false → 'ceiling' aggregate. + expect(r.estimateKind).toBe('ceiling'); + }); + + test('missing local_path source contributes nothing', () => { + const r = estimateInlineNewTokens( + [{ local_path: null, config: {}, last_commit: null, chunker_version: CURRENT }], + CURRENT, + ); + expect(r.tokens).toBe(0); + expect(r.changedSources).toBe(0); + }); +}); diff --git a/test/sync-cost-gate.serial.test.ts b/test/sync-cost-gate.serial.test.ts index 48d7b057d..1371fd01f 100644 --- a/test/sync-cost-gate.serial.test.ts +++ b/test/sync-cost-gate.serial.test.ts @@ -1,15 +1,17 @@ /** - * v0.41.31 — `gbrain sync --all` cost-gate wiring regressions (PGLite). + * `gbrain sync` cost-gate wiring regressions (PGLite). * - * Pure shouldBlockSync / willEmbedSynchronously logic is pinned in - * test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in - * runSync's --all path: + * Pure shouldBlockSync / willEmbedSynchronously / parseUsdLimit logic is pinned + * in test/sync-cost-preview.test.ts. THIS file pins the end-to-end wiring in + * runSync's --all AND single-source paths: * * R-1 (headline): deferred-embed sync --all, non-TTY, with backlog → - * emits gate:'deferred_notice' and NEVER exit 2 (the cron-blocking - * bug this release fixes). - * R-2 (protection): inline-embed sync --all (--serial), non-TTY, above - * floor → still exit 2 with gate:'confirmation_required'. + * emits gate:'deferred_notice' and NEVER exit 2. + * R-2 (v0.42.42.0, #2139): inline sync --all (--serial), non-TTY, above floor + * → AUTO-DEFERS (exit 0, gate:'auto_deferred_embeds') and enqueues an + * embed-backfill job. The exit-2 wedge is gone. + * R-3: chunker drift → full-tree CEILING estimate, auto-defers (not exit 2). + * + posture tokenmax, off-switch, format split (#1784/D3A), single-source gate. * * Serial-quarantined: stubs process.exit + console.log (process-global). */ @@ -21,10 +23,18 @@ import { join } from 'path'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { runSources } from '../src/commands/sources.ts'; import { resetPgliteState } from './helpers/reset-pglite.ts'; -import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts'; +import { configureGateway, resetGateway, __setEmbedTransportForTests } from '../src/core/ai/gateway.ts'; import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts'; import type { ChunkInput } from '../src/core/types.ts'; +/** Offline embed stub so inline-proceed paths (posture tokenmax) don't network. */ +function stubOfflineEmbed(): void { + __setEmbedTransportForTests(async ({ values }: any) => ({ + embeddings: values.map(() => new Array(1536).fill(0)), + usage: { tokens: 0 }, + }) as any); +} + let engine: PGLiteEngine; let repoPath: string; let headSha: string; @@ -64,6 +74,7 @@ beforeEach(async () => { }); afterEach(() => { + __setEmbedTransportForTests(null); resetGateway(); if (repoPath) rmSync(repoPath, { recursive: true, force: true }); }); @@ -115,39 +126,43 @@ describe('v0.41.31 — sync --all cost gate wiring', () => { expect(stdout).toContain('"gate":"deferred_notice"'); }, 60_000); - test('R-2: inline sync --all (--serial) above floor still exit 2', async () => { + test('R-2 (#2139): inline sync --all (--serial) above floor AUTO-DEFERS (exit 0, never exit 2) + enqueues backfill', async () => { await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); - // Floor 0 → any nonzero inline cost blocks. Source is unsynced - // (last_commit NULL) so estimateInlineNewTokens sees it as changed → - // full-tree tokens > 0 → costUsd > 0 > floor. + // Floor 0 → any nonzero inline cost trips the gate. Source is unsynced + // (last_commit NULL) → first-sync ceiling > 0 > floor. await engine.setConfig('sync.cost_gate_min_usd', '0'); - // --serial forces inline even with v2 on. --json → non-TTY exit-2 path. + // --serial forces inline even with v2 on. --json → non-TTY path → AUTO-DEFER. const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); - expect(exitCode).toBe(2); - expect(stdout).toContain('"gate":"confirmation_required"'); + expect(exitCode).not.toBe(2); + expect(stdout).toContain('"gate":"auto_deferred_embeds"'); + expect(stdout).not.toContain('"gate":"confirmation_required"'); + // The run PROCEEDED to import (the wedge is gone) — embeds were deferred, + // not blocked. (The embed-backfill enqueue wiring + its graceful + // missing-table tolerance is pinned in embed-backfill-submit.test.ts; the + // minion_jobs table isn't provisioned in this gate-wiring harness.) + expect(stdout).toContain('"sync_status":"first_sync"'); }, 60_000); - test('R-3: inline, git-unchanged source but STALE chunker_version still estimates (not $0)', async () => { - // The unchanged-source short-circuit requires HEAD==last_commit AND clean - // tree AND chunker_version == current. Here git is unchanged but the - // chunker drifted, so the source must NOT be treated as 0 — sync would - // re-chunk + re-embed everything. floor=0 so any nonzero cost blocks. + test('R-3 (#2139): chunker drift → full-tree CEILING estimate, auto-defers (not exit 2)', async () => { + // git unchanged (HEAD==last_commit) but chunker drifted → the source must + // NOT price $0 (sync would re-chunk + re-embed everything). The estimate is + // the full-tree ceiling; the gate auto-defers rather than wedging. await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, 'STALE-0']); await engine.setConfig('sync.cost_gate_min_usd', '0'); const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); - expect(exitCode).toBe(2); - expect(stdout).toContain('"gate":"confirmation_required"'); + expect(exitCode).not.toBe(2); + expect(stdout).toContain('"gate":"auto_deferred_embeds"'); + expect(stdout).toContain('"estimateKind":"ceiling"'); }, 60_000); - test('R-3 control: inline, git-unchanged + CURRENT chunker_version short-circuits to $0 (no exit 2)', async () => { - // Same setup but chunker_version matches current → the source IS unchanged - // → contributes 0 new-content tokens → below floor → proceeds (no block). - // Proves the short-circuit fires when (and only when) everything matches. + test('R-3 control: git-unchanged + CURRENT chunker → $0 estimate, below floor (no auto-defer)', async () => { + // Mirrors the executor's up_to_date predicate: HEAD==last_commit AND chunker + // matches → 0 new tokens → below floor → proceeds without deferring. await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]); await engine.setConfig('sync.cost_gate_min_usd', '0'); @@ -155,6 +170,78 @@ describe('v0.41.31 — sync --all cost gate wiring', () => { const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); expect(exitCode).not.toBe(2); - expect(stdout).not.toContain('"gate":"confirmation_required"'); + expect(stdout).not.toContain('"gate":"auto_deferred_embeds"'); + expect(stdout).toContain('"estimateKind":"unchanged"'); + }, 60_000); + + test('headline regression: HEAD==last_commit + DIRTY untracked file → $0, no gate (the false-fire)', async () => { + // The exact pre-fix false-fire: a busy brain's working tree is never + // git-clean, but the commits are caught up. The OLD estimator priced the + // whole tree (158M-token phantom); the new one mirrors execution → $0. + await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); + await engine.executeRaw(`UPDATE sources SET last_commit = $1, chunker_version = $2 WHERE id = 'vault'`, [headSha, String(CHUNKER_VERSION)]); + // Dirty the tree with an untracked non-syncable scratch file (agents/crons + // write constantly) — attached-HEAD sync never imports it. + writeFileSync(join(repoPath, 'scratch.tmp'), 'uncommitted agent scratch'); + writeFileSync(join(repoPath, 'topics/foo.md'), 'uncommitted edit, not staged'); + await engine.setConfig('sync.cost_gate_min_usd', '0'); + + const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); + + expect(exitCode).not.toBe(2); + expect(stdout).not.toContain('"gate":"auto_deferred_embeds"'); + expect(stdout).toContain('"estimateKind":"unchanged"'); + }, 60_000); + + test('spend.posture=tokenmax → proceeds inline, gate:posture_tokenmax (informational)', async () => { + stubOfflineEmbed(); // inline embed proceeds — keep it off the network. + await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); + await engine.setConfig('sync.cost_gate_min_usd', '0'); + await engine.setConfig('spend.posture', 'tokenmax'); + + const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); + + expect(exitCode).not.toBe(2); + expect(stdout).toContain('"gate":"posture_tokenmax"'); + expect(stdout).not.toContain('"gate":"auto_deferred_embeds"'); + }, 60_000); + + test('sync.cost_gate_min_usd=off → floor renders "unlimited", never blocks', async () => { + stubOfflineEmbed(); + await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); + await engine.setConfig('sync.cost_gate_min_usd', 'off'); + + const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--json', '--no-pull']); + + expect(exitCode).not.toBe(2); + expect(stdout).toContain('"floorUsd":"unlimited"'); + expect(stdout).not.toContain('"gate":"auto_deferred_embeds"'); + }, 60_000); + + test('format split (#1784/D3A): non-TTY WITHOUT --json emits human text, no JSON envelope', async () => { + await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); + await engine.setConfig('sync.cost_gate_min_usd', '0'); + + // No --json: above floor in a non-TTY session → human auto-defer text. + const { exitCode, stdout } = await runSyncCaptured(['--all', '--serial', '--no-pull']); + + expect(exitCode).not.toBe(2); + expect(stdout).not.toContain('"gate":'); // no JSON envelope without --json + expect(stdout.toLowerCase()).toContain('deferring embeds'); + expect(stdout).toContain('spend.posture'); // self-describing hint present + }, 60_000); + + test('single-source sync gets the same gate (auto-defers above floor, exit 0)', async () => { + await runSources(engine, ['add', 'vault', '--path', repoPath, '--no-federated']); + await engine.setConfig('sync.cost_gate_min_usd', '0'); + + // Single-source (no --all): unsynced → ceiling > 0 → non-TTY auto-defer. + const { exitCode, stdout } = await runSyncCaptured(['--source', 'vault', '--json', '--no-pull']); + + expect(exitCode).not.toBe(2); + expect(stdout).toContain('"gate":"auto_deferred_embeds"'); + // The gate now exists on the single-source path (was ungated before + // #2139) and proceeds to import rather than blocking. + expect(stdout.toLowerCase()).toContain('imported'); }, 60_000); }); diff --git a/test/sync-cost-preview.test.ts b/test/sync-cost-preview.test.ts index 7f492c032..1773a0493 100644 --- a/test/sync-cost-preview.test.ts +++ b/test/sync-cost-preview.test.ts @@ -22,6 +22,13 @@ import { } from '../src/core/embedding.ts'; import { lookupEmbeddingPrice } from '../src/core/embedding-pricing.ts'; import { estimateTokens } from '../src/core/chunkers/code.ts'; +import { + parseUsdLimit, + formatUsdLimit, + usdLimitToCap, + normalizeSpendPosture, + isValidSpendPosture, +} from '../src/core/spend-posture.ts'; describe('Layer 8 D1 — embedding cost model', () => { test('EMBEDDING_COST_PER_1K_TOKENS back-compat constant is the OpenAI 3-large rate', () => { @@ -116,6 +123,73 @@ describe('v0.41.31 — shouldBlockSync (cost-gate decision)', () => { expect(shouldBlockSync(0.0001, 0, 'inline')).toBe(true); expect(shouldBlockSync(0, 0, 'inline')).toBe(false); }); + + // v0.42.42.0 (#2139): posture + Infinity-floor behavior. + test('spend.posture=tokenmax never blocks, even above floor', () => { + expect(shouldBlockSync(999, 0.5, 'inline', 'tokenmax')).toBe(false); + expect(shouldBlockSync(999, 0, 'inline', 'tokenmax')).toBe(false); + }); + test('default posture (gated) preserves the legacy decision', () => { + expect(shouldBlockSync(0.51, 0.5, 'inline', 'gated')).toBe(true); + expect(shouldBlockSync(0.03, 0.5, 'inline', 'gated')).toBe(false); + }); + test('off/unlimited floor (Infinity) is never exceeded → never blocks', () => { + expect(shouldBlockSync(999, Infinity, 'inline')).toBe(false); + expect(shouldBlockSync(1e9, Infinity, 'inline', 'gated')).toBe(false); + }); +}); + +describe('v0.42.42.0 (#2139) — spend-posture USD-limit parsing', () => { + test('off / unlimited / none (case-insensitive) → Infinity', () => { + for (const v of ['off', 'OFF', 'unlimited', 'Unlimited', 'none', 'NONE', ' off ']) { + expect(parseUsdLimit(v, 25)).toBe(Infinity); + } + }); + test('finite positive numbers pass through', () => { + expect(parseUsdLimit('5', 25)).toBe(5); + expect(parseUsdLimit(0.5, 25)).toBe(0.5); + expect(parseUsdLimit('100000', 25)).toBe(100000); + }); + test('0 falls back to default unless allowZero', () => { + expect(parseUsdLimit('0', 25)).toBe(25); // backfill cap: off ≠ 0 + expect(parseUsdLimit('0', 0.5, { allowZero: true })).toBe(0); // floor: 0 = block-on-any + }); + test('garbage / negative / empty / null → default', () => { + expect(parseUsdLimit('abc', 25)).toBe(25); + expect(parseUsdLimit('-3', 25)).toBe(25); + expect(parseUsdLimit('', 25)).toBe(25); + expect(parseUsdLimit(null, 25)).toBe(25); + expect(parseUsdLimit(undefined, 0.5)).toBe(0.5); + }); + test('formatUsdLimit: Infinity → "unlimited" (never the JSON.stringify=null trap), finite passthrough', () => { + expect(formatUsdLimit(Infinity)).toBe('unlimited'); + expect(formatUsdLimit(5)).toBe(5); + expect(formatUsdLimit(0)).toBe(0); + // The trap this guards: raw Infinity serializes to null. + expect(JSON.stringify({ cap: Infinity })).toBe('{"cap":null}'); + expect(JSON.stringify({ cap: formatUsdLimit(Infinity) })).toBe('{"cap":"unlimited"}'); + }); + test('usdLimitToCap: Infinity → undefined (no cap), finite passthrough', () => { + expect(usdLimitToCap(Infinity)).toBeUndefined(); + expect(usdLimitToCap(10)).toBe(10); + }); + test('normalizeSpendPosture: only tokenmax is tokenmax; everything else gated', () => { + expect(normalizeSpendPosture('tokenmax')).toBe('tokenmax'); + expect(normalizeSpendPosture('TokenMax')).toBe('tokenmax'); + expect(normalizeSpendPosture('gated')).toBe('gated'); + expect(normalizeSpendPosture('max')).toBe('gated'); + expect(normalizeSpendPosture('')).toBe('gated'); + expect(normalizeSpendPosture(null)).toBe('gated'); + expect(normalizeSpendPosture(42)).toBe('gated'); + }); + test('isValidSpendPosture accepts gated/tokenmax (case-insensitive), rejects the rest', () => { + expect(isValidSpendPosture('gated')).toBe(true); + expect(isValidSpendPosture('tokenmax')).toBe(true); + expect(isValidSpendPosture('TokenMax')).toBe(true); // normalized lowercase + expect(isValidSpendPosture('max')).toBe(false); + expect(isValidSpendPosture('')).toBe(false); + expect(isValidSpendPosture(7)).toBe(false); + }); }); describe('Layer 8 D1 — estimateTokens (exported from chunkers/code.ts)', () => { diff --git a/test/sync-delta.test.ts b/test/sync-delta.test.ts new file mode 100644 index 000000000..20f1ffff8 --- /dev/null +++ b/test/sync-delta.test.ts @@ -0,0 +1,154 @@ +/** + * v0.42.42.0 (#2139) — computeSyncDelta unit coverage. + * + * The shared diff/manifest helper that BOTH the sync executor and the inline + * cost estimator route through (so the gate's dollar figure can't drift from + * what the sync imports). Real temp git repos; no PGLite, no env writes + * (R1/R2-clean). The git-runner seam drives the unavailable branches. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, renameSync } from 'fs'; +import { execSync } from 'child_process'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + computeSyncDelta, + buildDetachedWorkingTreeManifest, + _setGitRunnerForTests, +} from '../src/core/sync-delta.ts'; + +let repo: string; + +function git(args: string): string { + return execSync(`git ${args}`, { cwd: repo, stdio: 'pipe' }).toString().trim(); +} +function commitAll(msg: string): string { + execSync('git add -A', { cwd: repo, stdio: 'pipe' }); + execSync(`git commit -m "${msg}"`, { cwd: repo, stdio: 'pipe' }); + return git('rev-parse HEAD'); +} + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'gbrain-delta-')); + execSync('git init', { cwd: repo, stdio: 'pipe' }); + execSync('git config user.email "t@t.com"', { cwd: repo, stdio: 'pipe' }); + execSync('git config user.name "T"', { cwd: repo, stdio: 'pipe' }); + mkdirSync(join(repo, 'topics'), { recursive: true }); +}); + +afterEach(() => { + _setGitRunnerForTests(null); + if (repo) rmSync(repo, { recursive: true, force: true }); +}); + +describe('computeSyncDelta — commit diff', () => { + test('A/M/D classified; only committed changes in the manifest', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + writeFileSync(join(repo, 'topics/b.md'), 'b'); + const base = commitAll('base'); + writeFileSync(join(repo, 'topics/a.md'), 'a-edited'); // modify + writeFileSync(join(repo, 'topics/c.md'), 'c'); // add + rmSync(join(repo, 'topics/b.md')); // delete + const head = commitAll('change'); + + const r = computeSyncDelta(repo, base, head); + expect(r.status).toBe('ok'); + if (r.status !== 'ok') return; + expect(r.manifest.modified).toContain('topics/a.md'); + expect(r.manifest.added).toContain('topics/c.md'); + expect(r.manifest.deleted).toContain('topics/b.md'); + }); + + test('rename → destination path on the renamed list', () => { + writeFileSync(join(repo, 'topics/old.md'), 'x'.repeat(200)); + const base = commitAll('base'); + renameSync(join(repo, 'topics/old.md'), join(repo, 'topics/new.md')); + const head = commitAll('rename'); + + const r = computeSyncDelta(repo, base, head); + expect(r.status).toBe('ok'); + if (r.status !== 'ok') return; + expect(r.manifest.renamed.map(x => x.to)).toContain('topics/new.md'); + }); + + test('[D2A] attached HEAD: dirty tracked + untracked files are NOT in the manifest', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const base = commitAll('base'); + const head = git('rev-parse HEAD'); // HEAD == base, no new commits + // Dirty the tree: an uncommitted edit + an untracked scratch file. + writeFileSync(join(repo, 'topics/a.md'), 'uncommitted edit'); + writeFileSync(join(repo, 'scratch.tmp'), 'untracked'); + + const r = computeSyncDelta(repo, base, head); // not detached → commit diff only + expect(r.status).toBe('ok'); + if (r.status !== 'ok') return; + expect(r.manifest.added).toHaveLength(0); + expect(r.manifest.modified).toHaveLength(0); + expect(r.manifest.deleted).toHaveLength(0); + }); +}); + +describe('computeSyncDelta — detached HEAD merges the working-tree manifest', () => { + test('detached + working-tree changes → merged into the manifest', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const base = commitAll('base'); + // Detach HEAD and dirty the tree. + execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' }); + writeFileSync(join(repo, 'topics/a.md'), 'detached edit'); // tracked modify + writeFileSync(join(repo, 'topics/new.md'), 'new'); // untracked add + + const r = computeSyncDelta(repo, base, base, { detached: true }); + expect(r.status).toBe('ok'); + if (r.status !== 'ok') return; + expect(r.manifest.modified).toContain('topics/a.md'); + expect(r.manifest.added).toContain('topics/new.md'); // untracked picked up on detached + }); + + test('buildDetachedWorkingTreeManifest: clean detached tree → empty manifest', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const base = commitAll('base'); + execSync(`git checkout --detach ${base}`, { cwd: repo, stdio: 'pipe' }); + const m = buildDetachedWorkingTreeManifest(repo); + expect(m.added).toHaveLength(0); + expect(m.modified).toHaveLength(0); + }); +}); + +describe('computeSyncDelta — fail-open ladder', () => { + test('bogus anchor SHA → unavailable: anchor_missing', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const head = commitAll('base'); + const r = computeSyncDelta(repo, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', head); + expect(r.status).toBe('unavailable'); + if (r.status === 'unavailable') expect(r.reason).toBe('anchor_missing'); + }); + + test('non-ancestor anchor still diffs (the #1970 property)', () => { + // git diff A..B is endpoint-tree, no ancestry requirement. + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const base = commitAll('base'); + // Rewrite history: amend creates a new commit not descended from `base`, + // but `base` is still on disk (reflog) → diffable. + writeFileSync(join(repo, 'topics/a.md'), 'rewritten'); + execSync('git add -A && git commit --amend -m rewritten', { cwd: repo, stdio: 'pipe' }); + const head = git('rev-parse HEAD'); + expect(head).not.toBe(base); + + const r = computeSyncDelta(repo, base, head); + expect(r.status).toBe('ok'); // orphaned-but-present anchor is still diffable + }); + + test('injected git failure on the diff → unavailable: diff_failed', () => { + writeFileSync(join(repo, 'topics/a.md'), 'a'); + const base = commitAll('base'); + const head = git('rev-parse HEAD'); + _setGitRunnerForTests((_repo, args) => { + if (args[0] === 'cat-file') return 'commit'; // anchor reachable + if (args[0] === 'diff') throw new Error('simulated oversized diff / timeout'); + return ''; + }); + const r = computeSyncDelta(repo, base, head); + expect(r.status).toBe('unavailable'); + if (r.status === 'unavailable') expect(r.reason).toBe('diff_failed'); + }); +}); diff --git a/test/sync-failures.test.ts b/test/sync-failures.test.ts index 63ccb6a29..b1b762fc8 100644 --- a/test/sync-failures.test.ts +++ b/test/sync-failures.test.ts @@ -170,10 +170,10 @@ describe('Bug 9 — sync.ts CLI flag wiring', () => { // performSync's inner ack path only fires when failedFiles.length > 0 // in the current run. This test pins the up-front ack at the top of // runSync so the flag means "ack whatever is currently flagged". + // v0.42.42.0 (#2139, D13C): the pre-ack is now scoped PER SOURCE — `--all` + // acks every source, single-source acks only its own. const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text(); - // Ensure the up-front check exists before the syncAll / performSync - // dispatch, gated on skipFailed. - expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?unacknowledgedSyncFailures\(\)[\s\S]*?acknowledgeSyncFailures\(\)/); + expect(source).toMatch(/if \(skipFailed\) \{[\s\S]*?syncAll \? acknowledgeFailures\(\) : acknowledgeFailures\(sourceId\)/); }); test('acknowledgeSyncFailures clears stale failures end-to-end', async () => {