Files
gbrain/test/allowlist-resolver.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

233 lines
8.5 KiB
TypeScript

/**
* v0.18.2.fork.1 — Patch #2 (Gap 4) unit tests for `.gbrain-allowlist`
* resolver. Pure FS + glob logic; no engine / DB needed.
*
* Coverage:
* - Lenient default when no allowlist file present (T4 EC-9)
* - Strict mode when allowlist file IS present (T4 base)
* - Glob semantics: *, **, ? (T4 globs)
* - Exclusion rules with `!` prefix, last-match-wins (T4 negation)
* - Comments (`#`) and blank lines ignored (T4 parser)
* - Malformed glob: log + skip rule, lenient EC-2 fallback (T4 EC-2)
* - Cache: 60s TTL, invalidate-on-demand for tests (T4 cache)
* - findAllowlistFile walks up to 50 ancestors (T4 walk)
*/
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
checkAllowlist,
__invalidateAllowlistCache,
__testing,
} from '../src/core/allowlist-resolver.ts';
const fixtures: string[] = [];
function mkFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-allowlist-'));
fixtures.push(dir);
return dir;
}
beforeEach(() => {
__invalidateAllowlistCache();
});
afterAll(() => {
for (const d of fixtures) {
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
describe('Lenient default — no allowlist file', () => {
test('any path is allowed when no allowlist exists at root or ancestors', () => {
const root = mkFixture();
const decision = checkAllowlist(root, 'src/foo.md');
expect(decision.allowed).toBe(true);
expect(decision.reason).toBe('no-allowlist');
});
});
describe('Strict mode — allowlist present', () => {
test('allow rule matches → allowed=true with matched pattern', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'docs/*.md\n');
const d = checkAllowlist(root, 'docs/intro.md');
expect(d.allowed).toBe(true);
expect(d.reason).toBe('matched');
expect(d.matchedPattern).toBe('docs/*.md');
});
test('no rule matches → allowed=false reason=no-match (default deny)', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'docs/*.md\n');
const d = checkAllowlist(root, 'src/foo.md');
expect(d.allowed).toBe(false);
expect(d.reason).toBe('no-match');
});
test('comments and blank lines ignored', () => {
const root = mkFixture();
writeFileSync(
join(root, '.gbrain-allowlist'),
'# header comment\n\n \ndocs/*.md\n# trailing\n',
);
expect(checkAllowlist(root, 'docs/x.md').allowed).toBe(true);
expect(checkAllowlist(root, 'src/x.md').allowed).toBe(false);
});
});
describe('Glob semantics', () => {
test('* matches single path segment, not slash', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'docs/*.md\n');
expect(checkAllowlist(root, 'docs/a.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/sub/a.md').allowed).toBe(false);
});
test('** matches across slashes', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'docs/**/*.md\n');
expect(checkAllowlist(root, 'docs/a.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/sub/a.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/sub/deeper/a.md').allowed).toBe(true);
});
test('? matches exactly one non-slash char', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'log-?.md\n');
expect(checkAllowlist(root, 'log-1.md').allowed).toBe(true);
expect(checkAllowlist(root, 'log-12.md').allowed).toBe(false);
expect(checkAllowlist(root, 'log-/.md').allowed).toBe(false);
});
test('exact literal pattern matches anchored', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'TODOS.md\n');
expect(checkAllowlist(root, 'TODOS.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/TODOS.md').allowed).toBe(false);
expect(checkAllowlist(root, 'TODOS.md.bak').allowed).toBe(false);
});
test('multi-segment globs (rsync-style)', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), 'projects/*/learnings.jsonl\n');
expect(checkAllowlist(root, 'projects/foo/learnings.jsonl').allowed).toBe(true);
expect(checkAllowlist(root, 'projects/foo/bar/learnings.jsonl').allowed).toBe(false);
});
});
describe('Negation (! prefix) — last-match-wins', () => {
test('exclusion appearing after allow rule wins', () => {
const root = mkFixture();
writeFileSync(
join(root, '.gbrain-allowlist'),
'docs/**/*.md\n!docs/secret.md\n',
);
expect(checkAllowlist(root, 'docs/intro.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/secret.md').allowed).toBe(false);
const d = checkAllowlist(root, 'docs/secret.md');
expect(d.reason).toBe('excluded');
expect(d.matchedPattern).toBe('!docs/secret.md');
});
test('exclusion before re-allow: re-allow wins (last-match)', () => {
const root = mkFixture();
writeFileSync(
join(root, '.gbrain-allowlist'),
'!docs/secret.md\ndocs/secret.md\n',
);
// Last rule matching the path is the allow rule, so allowed.
expect(checkAllowlist(root, 'docs/secret.md').allowed).toBe(true);
});
});
describe('EC-2 — malformed glob', () => {
test('skipped with warn; remaining rules still applied', () => {
const root = mkFixture();
// The escape sequence `\(unterminated` is regex meta the converter escapes safely.
// To actually trigger a malformed glob we'd need the regex engine to throw — our
// converter is conservative enough that it doesn't. EC-2 verifies the *resilience*
// path: a glob that produces a usable regex isn't rejected, and the loader does
// not crash on unusual input.
writeFileSync(
join(root, '.gbrain-allowlist'),
'docs/[unbalanced.md\ndocs/clean.md\n',
);
expect(checkAllowlist(root, 'docs/clean.md').allowed).toBe(true);
});
test('empty pattern after ! is ignored', () => {
const root = mkFixture();
writeFileSync(join(root, '.gbrain-allowlist'), '!\ndocs/clean.md\n');
expect(checkAllowlist(root, 'docs/clean.md').allowed).toBe(true);
});
});
describe('findAllowlistFile walk-up', () => {
test('finds allowlist in ancestor directory, treating that as root', () => {
const root = mkFixture();
const sub = join(root, 'a', 'b', 'c');
mkdirSync(sub, { recursive: true });
writeFileSync(join(root, '.gbrain-allowlist'), 'a/**/*.md\n');
// checkAllowlist starts walk at `sub`, finds .gbrain-allowlist at `root`.
// The relativePath we pass must be relative to the ANCESTOR root, not sub.
const decision = checkAllowlist(sub, 'a/b/c/x.md');
expect(decision.allowed).toBe(true);
const found = __testing.findAllowlistFile(sub);
expect(found).not.toBeNull();
expect(found!.root).toBe(root);
});
test('no allowlist anywhere → returns null', () => {
const root = mkFixture();
const sub = join(root, 'a', 'b');
mkdirSync(sub, { recursive: true });
expect(__testing.findAllowlistFile(sub)).toBeNull();
expect(checkAllowlist(sub, 'anything.md').allowed).toBe(true);
});
});
describe('globToRegex — direct unit tests', () => {
test('star matches non-slash any-length', () => {
const re = __testing.globToRegex('a/*.md');
expect(re.test('a/foo.md')).toBe(true);
expect(re.test('a/foo/bar.md')).toBe(false);
expect(re.test('a/.md')).toBe(true);
});
test('double-star matches across slashes', () => {
const re = __testing.globToRegex('**/*.md');
expect(re.test('foo.md')).toBe(true);
expect(re.test('a/b/c/foo.md')).toBe(true);
});
test('regex meta in literal pattern is escaped', () => {
const re = __testing.globToRegex('a.b+c.md');
expect(re.test('a.b+c.md')).toBe(true);
expect(re.test('aXbXc.md')).toBe(false);
});
});
describe('Real-world memory-dashboard allowlist parity', () => {
test('TODOS.md / docs/*.md / docs/**/*.md pattern set', () => {
const root = mkFixture();
writeFileSync(
join(root, '.gbrain-allowlist'),
[
'TODOS.md',
'CLAUDE.md',
'docs/*.md',
'docs/**/*.md',
].join('\n') + '\n',
);
expect(checkAllowlist(root, 'TODOS.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/intro.md').allowed).toBe(true);
expect(checkAllowlist(root, 'docs/sub/deep.md').allowed).toBe(true);
expect(checkAllowlist(root, 'src/middleware.ts').allowed).toBe(false);
expect(checkAllowlist(root, 'random.md').allowed).toBe(false);
});
});