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>
This commit is contained in:
2026-05-06 23:09:20 +08:00
parent bb125e2baa
commit 5df6031adc
2 changed files with 18 additions and 10 deletions

View File

@@ -47,17 +47,23 @@ function validateSourceId(id: string): void {
// - Single-level glob: 'wedding-planning/*' (cosmetic; same as literal) // - Single-level glob: 'wedding-planning/*' (cosmetic; same as literal)
// //
// Rules: // Rules:
// - Allowed chars: lowercase a-z, 0-9, '-' (hyphen), '/' (slash) // - Allowed chars: lowercase a-z, 0-9, '-' (hyphen), '_' (underscore), '/' (slash)
// - Optional single trailing '*' (no other position) // - Optional single trailing '*' (no other position)
// - Length 1..64 (excluding the trailing '*') // - Length 1..64 (excluding the trailing '*')
// - Reject underscores ('_'), uppercase, whitespace, mid-string '*', // - Reject uppercase, whitespace, mid-string '*', multi-level globs
// multi-level globs ('**'), any other punctuation // ('**'), any other punctuation
// //
// Why fail-fast at write time: a typo'd rule (`memory_dashboard/`) writes // Why fail-fast at write time: a typo'd rule writes successfully into
// successfully into config_jsonb but never matches anything at routing // config_jsonb but never matches anything at routing time — the put_page
// time — the put_page silently falls to brain-default. Catching at // silently falls to brain-default. Catching at CLI-write moment surfaces
// CLI-write moment surfaces "this is a typo" before bad data lands. // the typo before bad data lands.
const SLUG_PREFIX_RULE_RE = /^[a-z0-9](?:[a-z0-9-/]{0,62}[a-z0-9-/])?\*?$/; //
// Underscore note (v0.18.2.fork.1 Phase 6 fix): chezmoi-managed prefixes
// like `dot_claude/` are legitimate slugs — chezmoi conventionally maps
// `~/.claude/` → `dot_claude/` in source. Earlier draft of this validator
// rejected underscores, which broke Phase 4 source taxonomy. Underscore
// is now first-class.
const SLUG_PREFIX_RULE_RE = /^[a-z0-9_](?:[a-z0-9_\-/]{0,62}[a-z0-9_\-/])?\*?$/;
function validateSlugPrefix(rule: string): void { function validateSlugPrefix(rule: string): void {
if (!rule || rule.length === 0) { if (!rule || rule.length === 0) {
throw new Error('Empty slug-prefix rule. Each --slug-prefix entry must be a non-empty string.'); throw new Error('Empty slug-prefix rule. Each --slug-prefix entry must be a non-empty string.');

View File

@@ -106,8 +106,10 @@ describe('Prefix grammar validator (Issue #6 — fail-fast at write time)', () =
if (hint) expect(msg).toContain(hint); if (hint) expect(msg).toContain(hint);
}; };
test('reject underscore', async () => { test('accept underscore (chezmoi-style prefixes like dot_claude/)', async () => {
await expectReject('memory_dashboard/', 'Invalid slug-prefix'); 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 () => { test('reject uppercase', async () => {
await expectReject('MemoryDashboard/', 'Invalid slug-prefix'); await expectReject('MemoryDashboard/', 'Invalid slug-prefix');