import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { FULLTEXT_INDEX_ADMIN, isPublicOnlyScope } from "../index.js"; /** * Doctrine test — pins the universal-coverage invariant for the * fulltext index so future schema edits cannot silently re-narrow. * * Doctrine: search is "find any node in my graph that mentions this * term" — there is no allowlist of search-eligible labels. A narrow * index (covering only `KnowledgeDocument | Section | Chunk`) silently * returns zero hits for Person/Organization/Task/Conversation/etc. * queries regardless of term. * * This test reads `platform/neo4j/schema.cypher` (the schema source of truth) * and `platform/ui/app/lib/graph-labels.ts` (the canvas label registry) at * runtime, parses both, and asserts: * * 1. The fulltext index name in `schema.cypher` matches the * `FULLTEXT_INDEX_NAME` constant in the lib. Drift between the two * breaks BM25 silently — the lib queries an index name that doesn't * exist, hits the graceful-fallback `[]` branch, and search returns * no BM25 hits. * 2. The label union in the index ⊇ union of: * - `Object.keys(GRAPH_LABEL_COLOURS)` (canvas-registered labels) * - every label declared via `CREATE CONSTRAINT|INDEX FOR (n:Label)` * in `schema.cypher` (write-targeted labels) * minus a small explicit excluded set (`Trashed`, `GraphPreference`) * that carry no operator-meaningful textual content. * 3. The property union in the index ⊇ the canonical text-property list * (the properties that writers across the codebase assign to nodes). * New writers extending the canon must add their property here. * * Failing assertions point the next person at exactly the schema drift to * close — not "search is mysteriously broken." */ const __dirname = dirname(fileURLToPath(import.meta.url)); const SCHEMA_PATH = resolve(__dirname, "../../../../neo4j/schema.cypher"); const LABELS_PATH = resolve(__dirname, "../../../graph-style/src/index.ts"); /** * Labels declared in `schema.cypher` or `GRAPH_LABEL_COLOURS` that legitimately * carry no searchable textual content. The doctrine test allows these to be * absent from the fulltext index union without failing. * * - `Trashed` — soft-delete flag label; nodes carrying it are * already excluded from every search via * `notTrashed()` in the lib. * - `GraphPreference` — per-(accountId, userId) UI metadata for /graph; * carries no textual content, never operator-visible * as a search hit. * - `Email` — legacy label retired by Task 321; email threads * now land on `:KnowledgeDocument {source:'email'}`. * The colour entry survives for legacy nodes; no * new :Email writes occur. * - `Session`, `AnonVisitor`, `PageView`, `Click`, `ScrollMilestone` * — visitor analytics nodes (Task 357). Identity * or event roll-ups; no operator-meaningful free * text. The writer (`visitor-event.ts`) sets * only ids, timestamps, and url fields. * - `Recommendation` — visitor-graph label declared in schema but no * writer in the platform; no documented text. * - `Component` — admin-resume rehydration only (legacy pre-066 * admin surface); body is a JSON-encoded * component payload, not free-text. * - `CloudflareTunnel`, `CloudflareHostname` * — legacy graph projections of cloudflared state. * Task 288 retired the writer; nodes carry * connector/hostname identifiers only. */ const EXCLUDED_FROM_INDEX = new Set([ "Trashed", "GraphPreference", "Email", "Session", "AnonVisitor", "PageView", "Click", "ScrollMilestone", "Recommendation", "Component", "CloudflareTunnel", "CloudflareHostname", ]); /** * Canonical text-property list — every property the platform's writers * assign that the operator might search for. Extending the canon in a new * writer means extending this list AND the schema's `ON EACH [...]` clause. * Discovered via codebase sweep across `memory-write`, * `memory-archive-write`, `memory-ingest`, `email-store`, `neo4j-store`, * `workflow-create`, `tool-call-writer`. */ const CANONICAL_TEXT_PROPERTIES = [ // Generic "name", "title", "summary", "body", "content", "text", "description", "headline", "abstract", "note", "label", "value", "message", "preview", "tagline", // Person "firstName", "lastName", "givenName", "familyName", "email", // Person denormalised current-job title, written by // `memory-archive-write` linkedin-connections handler from // `[:WORKS_FOR].title` so BM25 reaches it. Distinct from generic // `title` (above) which means something different per label. "currentTitle", // Email "subject", "bodyPreview", "fromName", "fromAddress", "screeningReason", // EmailAccount "agentAddress", // Credential "authority", // AccessGrant "contactValue", // ToolCall "toolName", // Agent — public-agent projection. `displayName` mirrors // config.json; `slug` is the directory-name identifier; `role` is the // discriminator carried on the four owned :KnowledgeDocument projections // ('identity' | 'soul' | 'knowledge' | 'knowledge-summary'). Adding any // new agent-side text property to the projector requires extending both // the schema's ON EACH list and this canon, otherwise BM25 silently // misses operator queries that match the new field. "displayName", "slug", "role", ]; interface IndexDeclaration { name: string; labels: Set; properties: Set; } /** * Extract the `CREATE FULLTEXT INDEX entity_search ... ;` block from * `schema.cypher` and parse its label and property sets. * * Uses a paren-balanced scan from the matched `CREATE FULLTEXT INDEX ` * to the next semicolon — survives whitespace/comment/line-break formatting * drift that a single monolithic regex would not. Failing here means the * declaration's shape diverged from `CREATE FULLTEXT INDEX IF NOT EXISTS * FOR (n:A|B|...) ON EACH [n.x, n.y, ...];` in a way the parser does not * recognise — extend this function before the test. */ function parseFulltextIndex(rawCypher: string, indexName: string): IndexDeclaration { // Strip line comments first so a commented-out historical declaration // does not fool the extractor into parsing the comment instead of the // live declaration. const cypher = stripLineComments(rawCypher); const declStart = cypher.search( new RegExp(`CREATE\\s+FULLTEXT\\s+INDEX\\s+${indexName}\\b`, "m"), ); if (declStart < 0) { throw new Error( `parseFulltextIndex: could not find 'CREATE FULLTEXT INDEX ${indexName}' in schema.cypher. ` + `Drift between schema and FULLTEXT_INDEX_NAME constant in graph-search/src/index.ts.`, ); } const declEnd = cypher.indexOf(";", declStart); if (declEnd < 0) { throw new Error( `parseFulltextIndex: no terminating ';' after 'CREATE FULLTEXT INDEX ${indexName}' — schema.cypher is malformed.`, ); } const block = cypher.slice(declStart, declEnd); const forMatch = block.match(/FOR\s+\(n:([^\)]+)\)/); if (!forMatch) { throw new Error( `parseFulltextIndex: could not extract FOR (n:...) clause from index ${indexName}. Block: ${block.slice(0, 200)}...`, ); } const labels = new Set( forMatch[1] .split("|") .map((s) => s.trim()) .filter((s) => s.length > 0), ); const onEachMatch = block.match(/ON\s+EACH\s+\[([^\]]+)\]/); if (!onEachMatch) { throw new Error( `parseFulltextIndex: could not extract ON EACH [...] clause from index ${indexName}. Block: ${block.slice(0, 200)}...`, ); } const properties = new Set( onEachMatch[1] .split(",") .map((s) => s.trim().replace(/^n\./, "")) .filter((s) => s.length > 0), ); return { name: indexName, labels, properties }; } /** * Strip Cypher `// line comments` before regex-parsing so an example or * migration note inside a comment block — e.g. `// previously: CREATE * INDEX FOR (g:Goal) ON (g.embedding)` — does not inject a phantom label * into the schema-declared set. Block comments `/* ... *​/` are not * stripped because schema.cypher does not use them today. */ function stripLineComments(cypher: string): string { return cypher.replace(/^\s*\/\/.*$/gm, ""); } /** * Extract every label declared in `schema.cypher` via `FOR (alias:Label)`. * Each `CREATE CONSTRAINT|INDEX|VECTOR INDEX|FULLTEXT INDEX` clause that * targets a label produces one entry. The fulltext index itself uses * `FOR (n:A|B|...)` — we extract the union of all alternatives. */ function parseSchemaDeclaredLabels(cypher: string): Set { const labels = new Set(); const stripped = stripLineComments(cypher); const matches = stripped.matchAll(/FOR\s+\(\w+:([^\)]+)\)/g); for (const m of matches) { for (const label of m[1].split("|")) { const trimmed = label.trim(); // Filter out anything that doesn't look like a Neo4j label (defensive // against future schema patterns that might inject non-label tokens). if (/^[A-Z][A-Za-z0-9]*$/.test(trimmed)) { labels.add(trimmed); } } } return labels; } /** * Extract `Object.keys(GRAPH_LABEL_COLOURS)` from `graph-labels.ts` by * matching the export's object-literal keys. Tolerates trailing commas, * inline comments between keys, and leading whitespace. */ function parseGraphLabelColours(source: string): Set { const exportMatch = source.match( /export\s+const\s+GRAPH_LABEL_COLOURS\s*:\s*[^=]+=\s*\{([\s\S]+?)\n\}/m, ); if (!exportMatch) { throw new Error( "parseGraphLabelColours: could not find 'export const GRAPH_LABEL_COLOURS' object literal in graph-labels.ts.", ); } const labels = new Set(); // Match keys that are bare identifiers (camelCase or PascalCase). Skip // commented-out lines and string-quoted keys (none today, but defensive). for (const m of exportMatch[1].matchAll(/^\s*([A-Z][A-Za-z0-9]*)\s*:/gm)) { labels.add(m[1]); } return labels; } describe("entity_search_admin fulltext index — universal-coverage doctrine", () => { const schemaCypher = readFileSync(SCHEMA_PATH, "utf-8"); const labelsSource = readFileSync(LABELS_PATH, "utf-8"); const adminDecl = parseFulltextIndex(schemaCypher, FULLTEXT_INDEX_ADMIN); const schemaDeclaredLabels = parseSchemaDeclaredLabels(schemaCypher); const colourLabels = parseGraphLabelColours(labelsSource); const requiredLabels = new Set(); for (const l of colourLabels) if (!EXCLUDED_FROM_INDEX.has(l)) requiredLabels.add(l); for (const l of schemaDeclaredLabels) if (!EXCLUDED_FROM_INDEX.has(l)) requiredLabels.add(l); // The single `entity_search` index that the Task 416 split replaces must // be explicitly dropped, not left behind on existing deployments — // otherwise it keeps populating from writes and consumes disk for no // reader. it("schema drops the single `entity_search` index that the split replaces", () => { // Neo4j's `DROP INDEX IF EXISTS` is universal across index types // (fulltext, vector, btree); there is no `DROP FULLTEXT INDEX` variant. expect(schemaCypher).toMatch(/DROP\s+INDEX\s+entity_search\s+IF\s+EXISTS\b/); }); // The retired public twin keeps a permanent DROP (no CREATE) so existing // deployments stop populating the dead index. Pin it: silently dropping the // DROP along with the CREATE would strand the index on every live install. it("schema permanently drops the retired `entity_search_public` index", () => { expect(schemaCypher).toMatch(/DROP\s+INDEX\s+entity_search_public\s+IF\s+EXISTS\b/); expect(schemaCypher).not.toMatch(/CREATE\s+FULLTEXT\s+INDEX\s+entity_search_public\b/); }); // The public twin and its read path were retired (Task 654) once public // agents became toolless; `entity_search_admin` is the only fulltext index. describe(`admin index (${adminDecl.name})`, () => { it("name matches the lib constant", () => { expect(adminDecl.name).toBe(FULLTEXT_INDEX_ADMIN); }); // Task 421 closed the pre-existing schema drift: the index union now // covers every operator-meaningful label registered in // GRAPH_LABEL_COLOURS and every schema-declared label, with the // exclusion list above enumerating the labels that legitimately // carry no searchable text. it("labels ⊇ Object.keys(GRAPH_LABEL_COLOURS) − {excluded} (Task 421)", () => { const missing = [...colourLabels] .filter((l) => !EXCLUDED_FROM_INDEX.has(l)) .filter((l) => !adminDecl.labels.has(l)); expect(missing).toEqual([]); }); it("labels ⊇ every schema-declared label − {excluded} (Task 421)", () => { const missing = [...schemaDeclaredLabels] .filter((l) => !EXCLUDED_FROM_INDEX.has(l)) .filter((l) => !adminDecl.labels.has(l)); expect(missing).toEqual([]); }); it("properties ⊇ canonical text-property list", () => { const missing = CANONICAL_TEXT_PROPERTIES.filter( (p) => !adminDecl.properties.has(p), ); expect(missing).toEqual([]); }); it("carries compiledTruth and not compiledTruthPublic (Task 416 split)", () => { expect(adminDecl.properties.has("compiledTruth")).toBe(true); expect(adminDecl.properties.has("compiledTruthPublic")).toBe(false); }); it("sanity floor on parsed size", () => { expect(adminDecl.labels.size).toBeGreaterThanOrEqual(30); expect(adminDecl.properties.size).toBeGreaterThanOrEqual(20); }); }); it("required-label set is non-trivial — sanity check that the parsers ran", () => { expect(requiredLabels.size).toBeGreaterThanOrEqual(30); }); }); // `isPublicOnlyScope` survives the public-index retirement as the gate for the // `projectPublicProperties` swap in the memory MCP's memory-search.ts. That swap // is currently unreachable — every live read passes `allowedScopes: undefined`, // so the predicate returns false — but it stays for when a public read surface // is restored. The BM25 index picker that also used it is gone. describe("public-only scope predicate", () => { it("is true for the exact public-only scope", () => { expect(isPublicOnlyScope(["public"])).toBe(true); }); it("is false for every other shape", () => { for (const shape of [undefined, [], ["shared"], ["public", "shared"], ["admin"]] as const) { expect(isPublicOnlyScope(shape)).toBe(false); } }); });