mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(reindex): add-only tag reconciliation + DB-only re-chunk preserves frontmatter (#1621) reindex --markdown and re-import no longer wipe DB-side enrichment tags. Tag reconciliation is now ADD-ONLY (import-file.ts): re-import adds current frontmatter tags and never deletes, so auto/dream/signal-detector tags survive. The reindex DB-only fallback reconstructs full markdown via serializeMarkdown so re-chunking a page with no on-disk source preserves frontmatter/title/timeline. * fix(migrations): v0.13.1 grandfather chunked, source-safe, soft-delete-filtered (#1581) phaseCGrandfather rewritten from a per-page getPage+putPage loop (which hung 70+ min on an 82K-page PGLite brain) to a chunked bulk SQL pass keyed on pages.id (NOT slug — slug isn't globally unique), filtering deleted_at IS NULL, with a batched rollback log carrying source identity. * fix(migrations): run schema phases in-process to fix Windows getaddrinfo ENOTFOUND (#1605) The 9 'gbrain init --migrate-only' execSync spawns died on Windows+bun+Supabase (child DNS resolution). runMigrateOnlyCore (extracted from initMigrateOnly) runs the schema bring-up in-process for all engines, unblocking schema_version advancement. Includes async-call-site audit, a wall-clock guard, and a runGbrainSubprocess stderr-capture wrapper for the remaining backfill spawns. * fix(sync): ReDoS hardening + diagnostics for schema-pack regexes (#1569) Input-length cap in runRegexBounded + route the unbounded link-inference path through it (closes the only no-timeout ReDoS hole); star-height lint rule warns on nested-quantifier patterns; --no-schema-pack sync escape hatch; GBRAIN_SYNC_TRACE per-file begin heartbeat; PGLite serve/sync concurrency doc. Defensive hardening + diagnostics — the deterministic ~3100-file wedge root cause remains open (no repro). * docs(todos): file v0.41.37.0 fix-wave follow-ups (#1621/#1605/#1569) * chore: bump version and changelog (v0.41.37.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync README + CLAUDE.md for v0.41.37.0 critical fix wave Add reindex add-only tag reconciliation (#1621), v0.13.1 grandfather + Windows in-process migration (#1581/#1605), and schema-pack ReDoS hardening + sync --no-schema-pack / GBRAIN_SYNC_TRACE triage (#1569) to CLAUDE.md key-files annotations and README Troubleshooting. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): bump llms-full.txt budget 700KB→750KB (CLAUDE.md crossed 700KB after master merge) The build-llms size-budget test failed: llms-full.txt is 703,244 bytes after the v0.41.37.0 key-files annotations merged on top of master's v0.41.34/35/36 CLAUDE.md additions. Matches the v0.41.9.0 precedent (600→700); the single-fetch bundle still fits comfortably in modern long-context models. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
// v0.40.6.0 — lint-rules.ts unit tests. 36 cases (11 rules covering each
|
|
// of clean / single-violation / multi-violation paths plus the audit-aware
|
|
// rule's empty-DB and audit-best-effort paths).
|
|
|
|
import { describe, expect, it } from 'bun:test';
|
|
import type { SchemaPackManifest } from '../src/core/schema-pack/manifest-v1.ts';
|
|
import {
|
|
aliasShadowsType,
|
|
aliasDeclaredByTwoTypes,
|
|
aliasReferencesUndeclaredType,
|
|
enrichableTypesUndeclared,
|
|
linkTypesUndeclared,
|
|
frontmatterLinksUndeclared,
|
|
expertRoutingWithoutPrefix,
|
|
prefixCollision,
|
|
prefixStrictSubsetOverlap,
|
|
runAllLintRules,
|
|
runFilePlaneLintRules,
|
|
FILE_PLANE_LINT_RULES,
|
|
ALL_LINT_RULES,
|
|
} from '../src/core/schema-pack/lint-rules.ts';
|
|
|
|
function mk(opts: Partial<SchemaPackManifest>): SchemaPackManifest {
|
|
const baseTypes = opts.page_types ?? [];
|
|
return {
|
|
api_version: 'gbrain-schema-pack-v1',
|
|
name: opts.name ?? 'p',
|
|
version: '1.0.0',
|
|
description: '',
|
|
gbrain_min_version: '0.38.0',
|
|
extends: null,
|
|
borrow_from: [],
|
|
page_types: baseTypes,
|
|
link_types: opts.link_types ?? [],
|
|
frontmatter_links: opts.frontmatter_links ?? [],
|
|
takes_kinds: ['fact', 'take', 'bet', 'hunch'],
|
|
enrichable_types: opts.enrichable_types ?? [],
|
|
filing_rules: [],
|
|
} as SchemaPackManifest;
|
|
}
|
|
|
|
const baseType = (over: { name: string; aliases?: string[]; extractable?: boolean; expert?: boolean; prefixes?: string[] }) => ({
|
|
name: over.name,
|
|
primitive: 'entity' as const,
|
|
path_prefixes: over.prefixes ?? [],
|
|
aliases: over.aliases ?? [],
|
|
extractable: over.extractable ?? false,
|
|
expert_routing: over.expert ?? false,
|
|
});
|
|
|
|
describe('aliasShadowsType', () => {
|
|
it('clean: no aliases shadow type names', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'person', aliases: ['alias-only'] })] });
|
|
expect(await aliasShadowsType(m)).toEqual([]);
|
|
});
|
|
|
|
it('single: alias matches another declared type', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'person' }),
|
|
baseType({ name: 'researcher', aliases: ['person'] }),
|
|
] });
|
|
const issues = await aliasShadowsType(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.rule).toBe('alias_shadows_type');
|
|
expect(issues[0]!.severity).toBe('error');
|
|
});
|
|
|
|
it('skips when alias matches self (self-alias is degenerate but not shadow)', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'person', aliases: ['person'] })] });
|
|
expect(await aliasShadowsType(m)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('aliasDeclaredByTwoTypes', () => {
|
|
it('clean: each alias declared by at most one type', async () => {
|
|
expect(await aliasDeclaredByTwoTypes(mk({ page_types: [baseType({ name: 'p', aliases: ['x'] })] }))).toEqual([]);
|
|
});
|
|
|
|
it('flags alias claimed by two types', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', aliases: ['shared'] }),
|
|
baseType({ name: 'b', aliases: ['shared'] }),
|
|
] });
|
|
const issues = await aliasDeclaredByTwoTypes(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.severity).toBe('error');
|
|
expect(issues[0]!.message).toContain('shared');
|
|
expect(issues[0]!.message).toContain('a');
|
|
expect(issues[0]!.message).toContain('b');
|
|
});
|
|
|
|
it('flags multiple distinct duplicate-alias collisions', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', aliases: ['x', 'y'] }),
|
|
baseType({ name: 'b', aliases: ['x', 'y'] }),
|
|
] });
|
|
const issues = await aliasDeclaredByTwoTypes(m);
|
|
expect(issues.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('aliasReferencesUndeclaredType', () => {
|
|
it('clean: aliases all match declared types', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'person' }),
|
|
baseType({ name: 'researcher', aliases: ['person'] }),
|
|
] });
|
|
expect(await aliasReferencesUndeclaredType(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags alias pointing at undeclared type', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', aliases: ['ghost'] })] });
|
|
const issues = await aliasReferencesUndeclaredType(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.severity).toBe('warning');
|
|
expect(issues[0]!.message).toContain('ghost');
|
|
});
|
|
|
|
it('flags multiple undeclared references separately', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', aliases: ['g1', 'g2'] })] });
|
|
expect((await aliasReferencesUndeclaredType(m)).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('enrichableTypesUndeclared', () => {
|
|
it('clean: all enrichable_types are declared page_types', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'person' })],
|
|
enrichable_types: [{ type: 'person', rubric: 'r' }],
|
|
});
|
|
expect(await enrichableTypesUndeclared(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags enrichable that names a ghost type', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'person' })],
|
|
enrichable_types: [{ type: 'ghost', rubric: 'r' }],
|
|
});
|
|
const issues = await enrichableTypesUndeclared(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.severity).toBe('error');
|
|
});
|
|
|
|
it('aggregates multiple ghost references', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'person' })],
|
|
enrichable_types: [
|
|
{ type: 'ghost1', rubric: 'r' },
|
|
{ type: 'ghost2', rubric: 'r' },
|
|
],
|
|
});
|
|
expect((await enrichableTypesUndeclared(m)).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('linkTypesUndeclared', () => {
|
|
it('clean: inference targets resolve', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'person' }), baseType({ name: 'company' })],
|
|
link_types: [{ name: 'works_at', inference: { page_type: 'person', target_type: 'company' } }],
|
|
});
|
|
expect(await linkTypesUndeclared(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags inference.page_type referencing ghost', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'company' })],
|
|
link_types: [{ name: 'works_at', inference: { page_type: 'ghost', target_type: 'company' } }],
|
|
});
|
|
expect((await linkTypesUndeclared(m)).length).toBe(1);
|
|
});
|
|
|
|
it('flags both page_type AND target_type independently', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'person' })],
|
|
link_types: [{ name: 'l', inference: { page_type: 'g1', target_type: 'g2' } }],
|
|
});
|
|
expect((await linkTypesUndeclared(m)).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('frontmatterLinksUndeclared', () => {
|
|
it('clean: page_type + link_type both resolve', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'meeting' })],
|
|
link_types: [{ name: 'attended' }],
|
|
frontmatter_links: [{ page_type: 'meeting', fields: ['attendees'], link_type: 'attended' }],
|
|
});
|
|
expect(await frontmatterLinksUndeclared(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags unknown page_type', async () => {
|
|
const m = mk({
|
|
page_types: [],
|
|
link_types: [{ name: 'attended' }],
|
|
frontmatter_links: [{ page_type: 'ghost', fields: ['x'], link_type: 'attended' }],
|
|
});
|
|
const issues = await frontmatterLinksUndeclared(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.rule).toBe('frontmatter_links_undeclared_page_type');
|
|
});
|
|
|
|
it('flags unknown link_type', async () => {
|
|
const m = mk({
|
|
page_types: [baseType({ name: 'meeting' })],
|
|
link_types: [],
|
|
frontmatter_links: [{ page_type: 'meeting', fields: ['x'], link_type: 'ghost' }],
|
|
});
|
|
const issues = await frontmatterLinksUndeclared(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.rule).toBe('frontmatter_links_undeclared_link_type');
|
|
});
|
|
});
|
|
|
|
describe('expertRoutingWithoutPrefix', () => {
|
|
it('clean: expert types have prefixes', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', expert: true, prefixes: ['people/'] })] });
|
|
expect(await expertRoutingWithoutPrefix(m)).toEqual([]);
|
|
});
|
|
|
|
it('warns: expert-routed type lacks prefix', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', expert: true })] });
|
|
const issues = await expertRoutingWithoutPrefix(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.severity).toBe('warning');
|
|
});
|
|
|
|
it('skips non-expert types without prefix (legitimate concept-only types)', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'concept' })] });
|
|
expect(await expertRoutingWithoutPrefix(m)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('prefixCollision', () => {
|
|
it('clean: each prefix declared by only one type', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['a/'] }),
|
|
baseType({ name: 'b', prefixes: ['b/'] }),
|
|
] });
|
|
expect(await prefixCollision(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags two types declaring same prefix', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['shared/'] }),
|
|
baseType({ name: 'b', prefixes: ['shared/'] }),
|
|
] });
|
|
const issues = await prefixCollision(m);
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0]!.severity).toBe('error');
|
|
});
|
|
|
|
it('aggregates multiple prefix collisions', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['x/', 'y/'] }),
|
|
baseType({ name: 'b', prefixes: ['x/', 'y/'] }),
|
|
] });
|
|
expect((await prefixCollision(m)).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('prefixStrictSubsetOverlap', () => {
|
|
it('clean: prefixes are unrelated', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['people/'] }),
|
|
baseType({ name: 'b', prefixes: ['companies/'] }),
|
|
] });
|
|
expect(await prefixStrictSubsetOverlap(m)).toEqual([]);
|
|
});
|
|
|
|
it('flags one type prefix that is a strict subset of another', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'researcher', prefixes: ['people/researchers/'] }),
|
|
baseType({ name: 'person', prefixes: ['people/'] }),
|
|
] });
|
|
const issues = await prefixStrictSubsetOverlap(m);
|
|
// strict-subset detection fires for the researcher prefix.
|
|
expect(issues.length).toBeGreaterThanOrEqual(1);
|
|
expect(issues[0]!.severity).toBe('warning');
|
|
});
|
|
|
|
it('does not flag identical prefixes (that is prefixCollision territory)', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['x/'] }),
|
|
baseType({ name: 'b', prefixes: ['x/'] }),
|
|
] });
|
|
// prefixStrictSubsetOverlap only fires on STRICT subsets; identical is collision's job.
|
|
expect(await prefixStrictSubsetOverlap(m)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('runFilePlaneLintRules — composition', () => {
|
|
it('returns ok:true for a clean manifest', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'person', prefixes: ['people/'] })] });
|
|
const report = await runFilePlaneLintRules(m);
|
|
expect(report.ok).toBe(true);
|
|
expect(report.errors).toEqual([]);
|
|
});
|
|
|
|
it('returns ok:false when any error fires', async () => {
|
|
const m = mk({ page_types: [
|
|
baseType({ name: 'a', prefixes: ['x/'] }),
|
|
baseType({ name: 'b', prefixes: ['x/'] }),
|
|
] });
|
|
const report = await runFilePlaneLintRules(m);
|
|
expect(report.ok).toBe(false);
|
|
expect(report.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('separates warnings from errors', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', expert: true })] });
|
|
const report = await runFilePlaneLintRules(m);
|
|
expect(report.ok).toBe(true);
|
|
expect(report.errors).toEqual([]);
|
|
expect(report.warnings.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('skips DB-aware rules (file-plane only)', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', extractable: true, prefixes: ['ghost/'] })] });
|
|
const report = await runFilePlaneLintRules(m);
|
|
// extractable_empty_corpus needs an engine; this should NOT fire here.
|
|
expect(report.warnings.find((w) => w.rule === 'extractable_empty_corpus')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('runAllLintRules — composition', () => {
|
|
it('without engine, behaves like runFilePlaneLintRules', async () => {
|
|
const m = mk({ page_types: [baseType({ name: 'r', extractable: true })] });
|
|
const report = await runAllLintRules(m);
|
|
expect(report.warnings.find((w) => w.rule === 'extractable_empty_corpus')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('rule registry shape', () => {
|
|
it('ALL_LINT_RULES contains 12 rules', () => {
|
|
// v0.41.37.0 #1569 added link_regex_catastrophic_backtrack (file-plane).
|
|
expect(ALL_LINT_RULES.length).toBe(12);
|
|
});
|
|
|
|
it('FILE_PLANE_LINT_RULES excludes the 2 DB-aware rules', () => {
|
|
expect(FILE_PLANE_LINT_RULES.length).toBe(10);
|
|
expect(FILE_PLANE_LINT_RULES.every((r) => !r.planeAware)).toBe(true);
|
|
});
|
|
|
|
it('all rule names are unique', () => {
|
|
const names = ALL_LINT_RULES.map((r) => r.name);
|
|
expect(new Set(names).size).toBe(names.length);
|
|
});
|
|
});
|