Files
gbrain/test/sync-walk-dispatch.test.ts
triton6564685 dfdf97748e feat(v0.18.2.fork.1): PW 1 part 2 — allowlist + sync-walk-dispatch + v26 migration
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>
2026-05-07 21:18:37 +08:00

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);
});
});