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>
157 lines
6.2 KiB
TypeScript
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/']);
|
|
});
|
|
});
|