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>
233 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|