Three fork-local patches that ship atomically (one image rebuild) per memory-dashboard PW 1 part 2 design plan (D3 + D4 + D9 + D13). Patch #2 (Gap 4): native .gbrain-allowlist enforcement. - src/core/allowlist-resolver.ts (NEW) — gitignore-style globs (*, **, ?), rsync-style negations, lenient when file absent, strict when present, 60s in-process cache. - Wired at 3 call sites: sync.ts (rename + adds/mods loops), import.ts (processFile), operations.ts (MCP put_page consults source.local_path). - test/allowlist-resolver.test.ts (19 tests) covering glob semantics, walk-up discovery, comments / blank lines, negation last-match-wins, EC-2 malformed glob, real-world memory-dashboard pattern set. Patch #3 (Gap 7 D13): sync walk per-file slug-aware dispatch. Pre-fix `gbrain sync --repo <path>` (no --source) silently mis-dispatched every page to source 'default' because resolveSourceId skips priority 5 (manifest slug-prefix) when slug is undefined — comment in source- resolver.ts:117-125 spells this out. - sync.ts runSync: detect '.gbrain-source' content == 'MANIFEST' literal (case-sensitive) → set manifestMode=true, sourceId=undefined. - sync.ts performSync rename + adds/mods loops: when manifestMode, derive per-file slug, call resolveBySlugPrefix → fall back to 'default-ambiguous' tombstone on no-match. - import.ts runImport processFile: same per-file dispatch when manifestMode. - test/sync-walk-dispatch.test.ts (5 tests — CR-7 MANDATORY) including cross-prefix collision + slug-no-match tombstone + allowlist interaction. Migration v26 (Gap 0 D4 + D9): source taxonomy rewrite. Idempotent SQL via composite UNIQUE protection. Renames gstack-brain (overly-broad slug-prefix [projects/, builder-journey]) → gstack-meta (narrow [retros/, analytics/]) via create+migrate-pages+drop pattern. Installs longer per-project rules so projects/triton6564685-stock- dashboard/... routes to stock-dashboard rather than gstack-meta catch-all. Creates default-ambiguous tombstone for slug-no-match writes. - src/core/migrate.ts MIGRATIONS array entry v26. - test/migration-v26.test.ts (13 tests — CR-6) including idempotency. CR-7 is the load-bearing regression test per IRON RULE: without patch #3 + the corresponding test, manifest mode silently mis-dispatches, breaking the entire sync.sh sentinel-value architecture. Verified at fork build time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
7.0 KiB
TypeScript
172 lines
7.0 KiB
TypeScript
/**
|
|
* v0.18.2.fork.1 — CR-7 (MANDATORY) — Patch #3 sync-walk-dispatch.
|
|
*
|
|
* Without this patch `gbrain sync --repo X` (no `--source` flag) silently
|
|
* mis-dispatches every file to source `default` (or undefined → legacy global
|
|
* config path), because resolveSourceId skips priority 5 (manifest slug-prefix)
|
|
* when slug is undefined. Recon-verified against the fork's source-resolver.ts
|
|
* line 117-125 comment.
|
|
*
|
|
* Patch #3 makes sync.ts performSync + import.ts runImport thread per-file
|
|
* slug to `resolveBySlugPrefix(engine, slug)` so manifest priority 5 fires
|
|
* once per file. Slug no-match falls back to `default-ambiguous` (tombstone).
|
|
*
|
|
* Coverage:
|
|
* - manifestMode=true: per-file dispatch via slug-prefix → correct source
|
|
* - cross-prefix collision: longest-prefix wins (TEN-6)
|
|
* - slug no-match → default-ambiguous tombstone
|
|
* - manifestMode=false: preserves explicit sourceId for all files (no
|
|
* regression on the existing repo-wide attribution path)
|
|
* - Allowlist gate (Patch #2) interacts cleanly: filtered files don't
|
|
* land at all, regardless of dispatch
|
|
*
|
|
* Failure mode this guards: HTTP 200 from sync, gbrain logs imported
|
|
* successfully, but pages all land in `default`. User won't notice until
|
|
* Stage 2 graph displays wrong groupings.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runImport } from '../src/commands/import.ts';
|
|
import { __invalidateSlugPrefixCache } from '../src/core/source-resolver.ts';
|
|
import { __invalidateAllowlistCache } from '../src/core/allowlist-resolver.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
const cleanupDirs: string[] = [];
|
|
|
|
function mkRepoFixture(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-cr7-'));
|
|
cleanupDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function writeMd(repoRoot: string, relPath: string, body = 'content'): void {
|
|
const full = join(repoRoot, relPath);
|
|
mkdirSync(join(full, '..'), { recursive: true });
|
|
writeFileSync(
|
|
full,
|
|
`---\ntitle: ${relPath}\ntype: note\n---\n${body}\n`,
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ type: 'pglite' } as never);
|
|
await engine.initSchema();
|
|
|
|
// Mirror the v26 post-migration source taxonomy. v26 already ran via
|
|
// initSchema (it's in MIGRATIONS), creating gstack-meta + default-ambiguous.
|
|
// Add per-project sources so manifest priority 5 has rules to match.
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, config) VALUES
|
|
('memory-dashboard', 'memory-dashboard',
|
|
'{"federated": true, "slug_prefix_rules": ["memory-dashboard/", "projects/triton6564685-memory-dashboard/"]}'::jsonb),
|
|
('stock-dashboard', 'stock-dashboard',
|
|
'{"federated": true, "slug_prefix_rules": ["stock-dashboard/", "projects/triton6564685-stock-dashboard/"]}'::jsonb)
|
|
ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config`,
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
for (const d of cleanupDirs) {
|
|
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
__invalidateSlugPrefixCache();
|
|
__invalidateAllowlistCache();
|
|
});
|
|
|
|
describe('CR-7 — manifestMode=true dispatches per-file via slug-prefix', () => {
|
|
test('three files in three different prefixes → three correct sources', async () => {
|
|
const repo = mkRepoFixture();
|
|
writeMd(repo, 'retros/2026-05-07-cr7-a.md');
|
|
writeMd(repo, 'projects/triton6564685-stock-dashboard/checkpoints/cr7-a.md');
|
|
writeMd(repo, 'projects/triton6564685-memory-dashboard/notes/cr7-a.md');
|
|
|
|
await runImport(engine, [repo, '--no-embed'], { manifestMode: true });
|
|
|
|
const rows = await engine.executeRaw<{ slug: string; source_id: string }>(
|
|
`SELECT slug, source_id FROM pages WHERE slug LIKE '%cr7-a%' ORDER BY slug`,
|
|
);
|
|
const map = Object.fromEntries(rows.map((r) => [r.slug, r.source_id]));
|
|
|
|
expect(map['retros/2026-05-07-cr7-a']).toBe('gstack-meta');
|
|
expect(map['projects/triton6564685-stock-dashboard/checkpoints/cr7-a']).toBe('stock-dashboard');
|
|
expect(map['projects/triton6564685-memory-dashboard/notes/cr7-a']).toBe('memory-dashboard');
|
|
});
|
|
|
|
test('slug-no-match → default-ambiguous (tombstone fallback)', async () => {
|
|
const repo = mkRepoFixture();
|
|
writeMd(repo, 'unknown-prefix/cr7-b.md');
|
|
|
|
await runImport(engine, [repo, '--no-embed'], { manifestMode: true });
|
|
|
|
const rows = await engine.executeRaw<{ source_id: string }>(
|
|
`SELECT source_id FROM pages WHERE slug = 'unknown-prefix/cr7-b'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].source_id).toBe('default-ambiguous');
|
|
});
|
|
|
|
test('cross-prefix collision: longest-prefix wins (TEN-6)', async () => {
|
|
const repo = mkRepoFixture();
|
|
// `projects/triton6564685-memory-dashboard/` (39 chars) wins over substring `retros/`
|
|
writeMd(repo, 'projects/triton6564685-memory-dashboard/retros/cr7-c.md');
|
|
|
|
await runImport(engine, [repo, '--no-embed'], { manifestMode: true });
|
|
|
|
const rows = await engine.executeRaw<{ source_id: string }>(
|
|
`SELECT source_id FROM pages WHERE slug = 'projects/triton6564685-memory-dashboard/retros/cr7-c'`,
|
|
);
|
|
expect(rows[0].source_id).toBe('memory-dashboard');
|
|
});
|
|
});
|
|
|
|
describe('CR-7 — manifestMode=false preserves explicit sourceId (no regression)', () => {
|
|
test('all files land in opts.sourceId regardless of slug', async () => {
|
|
const repo = mkRepoFixture();
|
|
writeMd(repo, 'retros/2026-05-07-cr7-d.md');
|
|
writeMd(repo, 'projects/triton6564685-stock-dashboard/checkpoints/cr7-d.md');
|
|
|
|
// No manifestMode flag → legacy attribution: explicit sourceId wins.
|
|
await runImport(engine, [repo, '--no-embed'], { sourceId: 'memory-dashboard' });
|
|
|
|
const rows = await engine.executeRaw<{ slug: string; source_id: string }>(
|
|
`SELECT slug, source_id FROM pages WHERE slug LIKE '%cr7-d%' ORDER BY slug`,
|
|
);
|
|
expect(rows.length).toBe(2);
|
|
for (const r of rows) {
|
|
expect(r.source_id).toBe('memory-dashboard');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('CR-7 — allowlist (Patch #2) interaction', () => {
|
|
test('non-allowlisted files dont land regardless of manifest dispatch', async () => {
|
|
const repo = mkRepoFixture();
|
|
// Strict allowlist: only retros/* allowed.
|
|
writeFileSync(join(repo, '.gbrain-allowlist'), 'retros/*.md\n');
|
|
writeMd(repo, 'retros/2026-05-07-cr7-e.md');
|
|
writeMd(repo, 'projects/triton6564685-stock-dashboard/notes/cr7-e.md');
|
|
|
|
await runImport(engine, [repo, '--no-embed'], { manifestMode: true });
|
|
|
|
const allowed = await engine.executeRaw<{ source_id: string }>(
|
|
`SELECT source_id FROM pages WHERE slug = 'retros/2026-05-07-cr7-e'`,
|
|
);
|
|
expect(allowed.length).toBe(1);
|
|
expect(allowed[0].source_id).toBe('gstack-meta');
|
|
|
|
const blocked = await engine.executeRaw<{ slug: string }>(
|
|
`SELECT slug FROM pages WHERE slug = 'projects/triton6564685-stock-dashboard/notes/cr7-e'`,
|
|
);
|
|
expect(blocked.length).toBe(0);
|
|
});
|
|
});
|