mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 07:10:12 +02:00
fix(brain-cache): loadMeta tolerates malformed _meta.json without crashing (#1879)
loadMeta returned the parsed JSON verbatim. A valid JSON file that lacked the last_refresh map made three consumers (isStale, cmdInvalidate, refreshEntity) throw a TypeError dereferencing meta.last_refresh — the sibling last_attempt was already guarded, last_refresh wasn't. Fix in loadMeta: - Shape-guard: JSON.parse can return null/array/string/number; non-object → fresh meta. - Normalize ONLY the dereferenced maps (last_refresh, last_attempt). - Deliberately do NOT default schema_version/endpoint_hash. Leaving them absent makes schemaVersionMismatch()/endpointSwitched() force a rebuild (missing identity = mismatch = safe); defaulting them would suppress cache invalidation and trust a stale file of unknown provenance. Tests: missing last_refresh no longer throws; null/array/primitive treated as cold; missing schema_version forces rebuild instead of a trusted warm hit. Reported by @jbetala7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-1
@@ -83,7 +83,23 @@ function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string |
|
||||
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
||||
// #1879: a valid JSON file can still be the wrong shape. JSON.parse can return
|
||||
// null/array/string/number, and a partial object can omit last_refresh — three
|
||||
// consumers (isStale, cmdInvalidate, refreshEntity) dereference meta.last_refresh
|
||||
// unguarded and crash with a TypeError.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
||||
}
|
||||
const meta = parsed as CacheMeta;
|
||||
// Normalize ONLY the dereferenced maps. Do NOT default schema_version /
|
||||
// endpoint_hash — leaving them absent makes schemaVersionMismatch() /
|
||||
// endpointSwitched() correctly force a rebuild (missing identity = mismatch =
|
||||
// safe). Defaulting them to current values would suppress invalidation and
|
||||
// trust a stale file of unknown provenance.
|
||||
meta.last_refresh = meta.last_refresh ?? {};
|
||||
meta.last_attempt = meta.last_attempt ?? {};
|
||||
return meta;
|
||||
} catch {
|
||||
// Corrupt _meta — start fresh (entries will refresh on next access).
|
||||
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
|
||||
|
||||
@@ -86,6 +86,41 @@ describe('brain-cache meta lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user