Files
gstack/test/brain-cache-roundtrip.test.ts
T
Garry TanandClaude Fable 5 acc354fcfa fix: read project-scoped MCP registrations in gbrain detection (#2499)
Claude Code registers MCP servers at two scopes in ~/.claude.json: user
scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers
— what `claude mcp add` WITHOUT --scope user writes). Every gbrain
detection site read only user scope, so a correctly configured
project-scoped brain was invisible: brain-aware blocks suppressed,
remote-mode artifacts sync never recognised, and detectEndpointHash fell
through to the 'local' literal — two different project-scoped brains
hashed identically, so switching between them never invalidated the
cache, the exact scenario the function's docstring says it exists to
catch. Nothing errored; the features just quietly were not there.

Two sites fixed:

- scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared
  detection block (rendered into every tier-2+ SKILL.md) now resolves the
  gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the
  nearest-ancestor project entry for $PWD that actually carries a gbrain
  server (longest matching key with a path-boundary check: /a/repo never
  matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its
  parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from
  the resolved entry, so claude.json is parsed once per skill start. All
  SKILL.md files regenerated in this commit; the ship golden fixtures and
  three carve-guard skeleton caps (plan-eng-review, plan-devex-review,
  office-hours; ~1.5KB rendered growth per skill) are refreshed with
  measured values.
- bin/gstack-brain-cache detectEndpointHash: same resolution order in TS
  (user scope, else nearest-ancestor project entry by cwd, both path
  separators for Windows keys).

Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the
regenerated block (static markers + a FUNCTIONAL run of the exact
rendered lines against a fixture ~/.claude.json with only a
project-scoped registration, plus an outside-cwd discriminator);
detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover
project-scope resolve, path-boundary, nearest-ancestor distinct hashes,
and user-scope precedence.

Root-cause analysis by @samporter-31 in #2499.

Fixes #2499

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00

258 lines
11 KiB
TypeScript

/**
* brain-cache roundtrip integration tests (T2a / T19).
*
* Exercises the non-MCP-dependent parts of the cache layer:
* - Path resolution per scope (cross-project vs per-project)
* - Atomic _meta.json write/read
* - TTL staleness detection
* - Invalidate clears last_refresh
* - Schema-version mismatch triggers rebuild attempt (D4 A4)
* - Endpoint switch triggers rebuild attempt
*
* The brain-reachable refresh path (MCP fetch + compress) is tested
* separately in brain-cache-stale-but-usable.test.ts using a mocked
* spawnGbrain. T2a focuses on the cache-state machine.
*
* Uses tmp GSTACK_HOME per-test to avoid polluting the real ~/.gstack/.
* Gate-tier, free, ~50ms.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, existsSync, writeFileSync, readFileSync, rmSync, mkdirSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let TMP_HOME: string;
const ORIGINAL_HOME = process.env.GSTACK_HOME;
beforeEach(() => {
TMP_HOME = mkdtempSync(join(tmpdir(), 'gstack-cache-test-'));
process.env.GSTACK_HOME = TMP_HOME;
// Reload the cache module fresh per test so it picks up the new HOME.
delete require.cache[require.resolve('../bin/gstack-brain-cache')];
});
afterEach(() => {
if (ORIGINAL_HOME) process.env.GSTACK_HOME = ORIGINAL_HOME;
else delete process.env.GSTACK_HOME;
try { rmSync(TMP_HOME, { recursive: true, force: true }); } catch { /* best effort */ }
});
async function importCache(): Promise<typeof import('../bin/gstack-brain-cache')> {
return (await import('../bin/gstack-brain-cache')) as typeof import('../bin/gstack-brain-cache');
}
describe('brain-cache paths', () => {
test('cross-project entity (user-profile) lives in ~/.gstack/brain-cache/', async () => {
const mod = await importCache();
const path = mod.entityPath('user-profile', null);
expect(path).toBe(join(TMP_HOME, 'brain-cache', 'user-profile.md'));
});
test('per-project entity (product) lives in ~/.gstack/projects/<slug>/brain-cache/', async () => {
const mod = await importCache();
const path = mod.entityPath('product', 'helsinki');
expect(path).toBe(join(TMP_HOME, 'projects', 'helsinki', 'brain-cache', 'product.md'));
});
test('throws on unknown entity', async () => {
const mod = await importCache();
expect(() => mod.entityPath('not-an-entity', null)).toThrow();
});
test('per-project entity without slug throws', async () => {
const mod = await importCache();
expect(() => mod.entityPath('product', null)).toThrow();
});
});
describe('brain-cache meta lifecycle', () => {
test('cmdMeta on empty cache returns valid fresh meta', async () => {
const mod = await importCache();
const meta = mod.cmdMeta('helsinki');
expect(meta.schema_version).toMatch(/^\d+\.\d+\.\d+$/);
expect(meta.endpoint_hash).toMatch(/^[a-f0-9]{1,8}$|^local$/);
expect(meta.last_refresh).toEqual({});
});
test('cmdInvalidate writes meta even if no prior refresh', async () => {
const mod = await importCache();
mod.cmdInvalidate('product', 'helsinki');
const meta = mod.cmdMeta('helsinki');
// last_refresh remains empty (we just delete an absent key — that's a no-op
// but the meta file is now written to disk).
expect(meta.last_refresh.product).toBeUndefined();
expect(existsSync(join(TMP_HOME, 'projects', 'helsinki', 'brain-cache', '_meta.json'))).toBe(true);
});
});
describe('brain-cache malformed _meta.json (#1879)', () => {
function seedMeta(content: string): void {
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, '_meta.json'), content);
}
test('cmdInvalidate does not throw when last_refresh is missing', async () => {
const mod = await importCache();
// Valid JSON object, but no last_refresh map — the original crash.
seedMeta(JSON.stringify({ schema_version: '0.0.1', endpoint_hash: 'x' }));
expect(() => mod.cmdInvalidate('product', 'helsinki')).not.toThrow();
});
test('cmdGet does not throw on null / array / primitive _meta.json', async () => {
const mod = await importCache();
for (const bad of ['null', '[]', '"a string"', '42']) {
seedMeta(bad);
expect(() => mod.cmdGet('product', 'helsinki')).not.toThrow();
}
});
test('missing schema_version is treated as a mismatch (forces rebuild, not trust)', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, 'product.md'), '# stale-no-schema\n');
// No schema_version field — must NOT be trusted as a warm hit.
seedMeta(JSON.stringify({ endpoint_hash: mod.detectEndpointHash(), last_refresh: { product: Date.now() } }));
const result = mod.cmdGet('product', 'helsinki');
// Brain unreachable in test → rebuild path runs; must not be a trusted warm hit.
expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state);
});
});
describe('brain-cache endpoint detection', () => {
test('detectEndpointHash returns "local" when no ~/.claude.json gbrain MCP', async () => {
// We don't write ~/.claude.json in the temp env, so this falls through to local.
const mod = await importCache();
// The user's real ~/.claude.json may have an MCP server; in that case the hash
// will be a real sha8. Either way, it's a stable string.
const hash = mod.detectEndpointHash();
expect(typeof hash).toBe('string');
expect(hash.length).toBeGreaterThan(0);
});
// #2499: project-scoped registrations (.projects["/path"].mcpServers.gbrain)
// were never read — two different project-scoped brains both hashed to
// 'local', so switching between them never invalidated the cache.
test('detectEndpointHash resolves a project-scoped gbrain URL for a cwd inside the project (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { type: 'http', url: 'https://a.example/mcp' } } },
},
}));
const inside = mod.detectEndpointHash(cj, '/w/repo/src/deep');
expect(inside).not.toBe('local');
expect(inside).toHaveLength(8);
// Path-boundary check: /w/repo2 is NOT inside /w/repo.
expect(mod.detectEndpointHash(cj, '/w/repo2')).toBe('local');
});
test('detectEndpointHash prefers the nearest-ancestor project entry (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://outer.example/mcp' } } },
'/w/repo/nested': { mcpServers: { gbrain: { url: 'https://inner.example/mcp' } } },
},
}));
const inner = mod.detectEndpointHash(cj, '/w/repo/nested/sub');
const outer = mod.detectEndpointHash(cj, '/w/repo/other');
expect(inner).not.toBe(outer); // two brains → two hashes (the docstring scenario)
expect(inner).not.toBe('local');
expect(outer).not.toBe('local');
});
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
mcpServers: { gbrain: { url: 'https://user.example/mcp' } },
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
// Same file minus the user-scope entry → different hash proves user scope won.
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
});
});
describe('brain-cache schema mismatch behavior', () => {
test('schema-version mismatch in meta triggers full-rebuild attempt on next get', async () => {
const mod = await importCache();
// Pre-seed meta with a different schema version, and a cache file that's
// recent enough to be "warm" by TTL but stale by schema version.
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, 'product.md'), '# stale-from-old-schema\n');
writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({
schema_version: '0.0.1',
endpoint_hash: mod.detectEndpointHash(),
last_refresh: { product: Date.now() },
last_attempt: {},
}));
const result = mod.cmdGet('product', 'helsinki');
// Brain is unreachable in this test (no gbrain mock), so refresh fails and
// the file gets deleted by the rebuild step. State should be 'missing' or
// 'stale-fallback' depending on whether the rebuild left a file behind.
expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state);
}, 30000);
// ^ 30s: the schema-mismatch rebuild refreshes EVERY per-project entity,
// each spawning the real gbrain CLI (no mock here). With an unreachable
// brain each spawn runs to its own timeout, and under machine load the
// stack exceeds bun's 5s default — observed at 5.2-5.4s on a loaded box,
// identically on pre-fix binaries (load flake, not a code regression).
});
describe('brain-cache state machine', () => {
test('warm: pre-seeded fresh cache returns warm without touching brain', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
const productContent = '# Product: helsinki\n\nA test product.\n';
writeFileSync(join(cacheDir, 'product.md'), productContent);
writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({
schema_version: '1.0.0', // matches GSTACK_SCHEMA_PACK_VERSION
endpoint_hash: mod.detectEndpointHash(),
last_refresh: { product: Date.now() }, // fresh
last_attempt: {},
}));
const result = mod.cmdGet('product', 'helsinki');
expect(result.state).toBe('warm');
expect(readFileSync(result.path, 'utf-8')).toBe(productContent);
});
test('missing: no cache + no brain returns missing state', async () => {
const mod = await importCache();
const result = mod.cmdGet('brand', 'helsinki');
expect(result.state).toBe('missing');
});
test('stale-fallback: stale cache with unreachable brain returns stale-fallback', async () => {
const mod = await importCache();
const cacheDir = join(TMP_HOME, 'projects', 'helsinki', 'brain-cache');
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, 'product.md'), '# stale\n');
// Set last_refresh way in the past (> 1d TTL for product)
writeFileSync(join(cacheDir, '_meta.json'), JSON.stringify({
schema_version: '1.0.0',
endpoint_hash: mod.detectEndpointHash(),
last_refresh: { product: 0 }, // epoch start = very stale
last_attempt: {},
}));
const result = mod.cmdGet('product', 'helsinki');
// Brain unreachable → cold refresh fails → stale-but-usable fallback
expect(result.state).toBe('stale-fallback');
});
});