Files
gbrain/test/sources-update-slug-prefix.test.ts
triton6564685 5df6031adc fix(v0.18.2.fork.1): allow underscore in slug-prefix rules
Phase 6 deploy uncovered: chezmoi-managed prefixes like `dot_claude/`
are legitimate slugs (chezmoi convention maps `~/.claude/` → `dot_claude/`
in source tree). The original validator rejected underscores, which
blocked the Phase 4 source taxonomy mid-way:

  $ gbrain sources update claude-config --slug-prefix 'dot_claude/,claude'
  Invalid slug-prefix rule "dot_claude/". Must be lowercase a-z, 0-9,
  '-', '/', optionally ending in '*'. Reject: underscores, ...

Underscore is now first-class. Updated regex + comment + test (flipped
the "reject underscore" case to "accept underscore" with chezmoi
example).

Discovered during Phase 6 deploy: blocked at E3 step 4 of 5
(`gbrain sources update claude-config --slug-prefix 'dot_claude/,claude'`).
First 3 commands had succeeded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:09:20 +08:00

157 lines
6.2 KiB
TypeScript

/**
* v0.18.2.fork.1 — `gbrain sources add --slug-prefix` + new `update` subcommand.
*
* Verifies:
* - runAdd accepts --slug-prefix '<rule>,<rule>' and writes config.slug_prefix_rules
* - runUpdate replaces config.slug_prefix_rules in-place
* - runUpdate '' clears the rules
* - runUpdate on missing source errors out (exit 3)
* - prefix validator rejects: underscores, uppercase, mid-string '*',
* multi-level glob '**', empty, whitespace, oversize
* - validator accepts: literal prefix, trailing '*', '/'-separated paths
* - runUpdate preserves other config keys (federated stays put)
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runSources } from '../src/commands/sources.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ type: 'pglite' } as never);
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
// Reset fixture sources between tests so write-then-update doesn't leak across cases.
await engine.executeRaw(
`DELETE FROM sources WHERE id LIKE 'test-%'`,
);
});
async function readConfig(id: string): Promise<Record<string, unknown>> {
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
`SELECT config FROM sources WHERE id = $1`,
[id],
);
if (rows.length === 0) return {};
const cfg = rows[0].config;
return typeof cfg === 'string' ? JSON.parse(cfg) : cfg;
}
describe('runAdd --slug-prefix', () => {
test('accepts comma-separated rules and persists to config.slug_prefix_rules', async () => {
await runSources(engine, ['add', 'test-md', '--slug-prefix', 'memory-dashboard/,builder-journey']);
const cfg = await readConfig('test-md');
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/', 'builder-journey']);
});
test('accepts trailing-glob form', async () => {
await runSources(engine, ['add', 'test-glob', '--slug-prefix', 'wedding-planning/*']);
const cfg = await readConfig('test-glob');
expect(cfg.slug_prefix_rules).toEqual(['wedding-planning/*']);
});
test('add without --slug-prefix leaves config without the key', async () => {
await runSources(engine, ['add', 'test-bare']);
const cfg = await readConfig('test-bare');
expect(cfg.slug_prefix_rules).toBeUndefined();
});
});
describe('runUpdate --slug-prefix', () => {
test('replaces rules in-place on existing source', async () => {
await runSources(engine, ['add', 'test-update', '--slug-prefix', 'old-prefix/']);
await runSources(engine, ['update', 'test-update', '--slug-prefix', 'new-a/,new-b/']);
const cfg = await readConfig('test-update');
expect(cfg.slug_prefix_rules).toEqual(['new-a/', 'new-b/']);
});
test("update --slug-prefix '' clears rules entirely", async () => {
await runSources(engine, ['add', 'test-clear', '--slug-prefix', 'foo/']);
await runSources(engine, ['update', 'test-clear', '--slug-prefix', '']);
const cfg = await readConfig('test-clear');
expect(cfg.slug_prefix_rules).toBeUndefined();
});
test('preserves other config keys (federated)', async () => {
await runSources(engine, ['add', 'test-fed', '--federated', '--slug-prefix', 'a/']);
await runSources(engine, ['update', 'test-fed', '--slug-prefix', 'b/']);
const cfg = await readConfig('test-fed');
expect(cfg.federated).toBe(true);
expect(cfg.slug_prefix_rules).toEqual(['b/']);
});
});
describe('Prefix grammar validator (Issue #6 — fail-fast at write time)', () => {
// Validator runs both at runAdd write time and runUpdate write time.
// We test via runAdd since it's the canonical surface; same code path.
const expectReject = async (rule: string, hint: string) => {
let threw = false;
let msg = '';
try {
await runSources(engine, ['add', `test-reject-${Math.random().toString(36).slice(2, 8)}`, '--slug-prefix', rule]);
} catch (e) {
threw = true;
msg = e instanceof Error ? e.message : String(e);
}
expect(threw).toBe(true);
if (hint) expect(msg).toContain(hint);
};
test('accept underscore (chezmoi-style prefixes like dot_claude/)', async () => {
await runSources(engine, ['add', 'test-accept-under', '--slug-prefix', 'dot_claude/,foo_bar/']);
const cfg = await readConfig('test-accept-under');
expect(cfg.slug_prefix_rules).toEqual(['dot_claude/', 'foo_bar/']);
});
test('reject uppercase', async () => {
await expectReject('MemoryDashboard/', 'Invalid slug-prefix');
});
test('reject mid-string glob', async () => {
await expectReject('foo*bar/', "'*' may only appear as the final character");
});
test('reject multi-level glob', async () => {
await expectReject('a/**', "Multi-level glob");
});
test('reject empty after split', async () => {
let threw = false;
try {
await runSources(engine, ['add', 'test-reject-empty', '--slug-prefix', ',,,']);
} catch (e) {
threw = true;
const msg = e instanceof Error ? e.message : String(e);
expect(msg).toContain('--slug-prefix value is empty');
}
expect(threw).toBe(true);
});
test('reject whitespace inside rule', async () => {
await expectReject('foo bar/', 'Invalid slug-prefix');
});
test('reject oversized rule', async () => {
await expectReject('a'.repeat(65) + '/', 'too long');
});
test('accept literal prefix', async () => {
await runSources(engine, ['add', 'test-accept-lit', '--slug-prefix', 'memory-dashboard/']);
const cfg = await readConfig('test-accept-lit');
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/']);
});
test('accept trailing star', async () => {
await runSources(engine, ['add', 'test-accept-star', '--slug-prefix', 'projects/*']);
const cfg = await readConfig('test-accept-star');
expect(cfg.slug_prefix_rules).toEqual(['projects/*']);
});
test('accept hyphen-segments and slashes', async () => {
await runSources(engine, ['add', 'test-accept-segments', '--slug-prefix', 'design/memory-dashboard/internal/']);
const cfg = await readConfig('test-accept-segments');
expect(cfg.slug_prefix_rules).toEqual(['design/memory-dashboard/internal/']);
});
});