From 21beda7fd78939adef7a0b6b192468b2a0d09818 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 23 May 2026 10:17:48 -0700 Subject: [PATCH] =?UTF-8?q?v0.40.6.0=20Phase=202=20=E2=80=94=20mutate.ts?= =?UTF-8?q?=20withMutation=20skeleton=20+=2011=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on Phase 1 foundations (pack-lock, mutate-audit, lint-rules, cache invalidation, query-cache-invalidator). withMutation(packName, opts, mutator, op, ctx): 8-step skeleton wrapping every primitive. Atomic .tmp+fsync+rename. Per-pack file lock. Pre-write file-plane lint validation gate. Audit log on success AND failure. Pack cache + query cache invalidation hooks. ASCII state-machine diagram in file header per D9. 11 primitives, each ~5-line wrapper around withMutation: add_type, remove_type (with codex C14 reference check), update_type add_alias, remove_alias, add_prefix, remove_prefix add_link_type (rejects fm_links refs on remove) remove_link_type, set_extractable, set_expert_routing Inline minimal JSON→YAML emitter so mutating a YAML pack stays YAML. The emitter's array-of-mappings nesting was tricky: the first key sits inline with the `- ` (e.g. `- name: person`), subsequent keys live at indent+1, and nested arrays inside the mapping keep their relative depth (the v0.40.6 emitter bug I fixed pre-commit: trim+prefix lost internal indent of nested arrays like path_prefixes). YAML round-trip: emitted YAML reparses cleanly through parseYamlMini. Comments and formatting NOT preserved (documented in plan; pin pack.json if you care about layout). Codex C14 reference check: removeType refuses if any other type's aliases/enrichable_types/link_types/frontmatter_links references the target. STILL_REFERENCED error names every reference for cleanup. Validation gate composes runFilePlaneLintRules from Phase 1.5 — a mutation that would create a dangling ref or prefix collision fails BEFORE the .tmp write (the invariant: pack file on disk is NEVER partial). Tests: 34 cases pinning every primitive + skeleton invariant. Bundled guard, codex C14, atomicity (crash-mid-write leaves original untouched, lock auto-released after mutator throw), YAML round-trip, validation gate firing on prefix collision. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/schema-pack/index.ts | 38 ++ src/core/schema-pack/mutate.ts | 644 +++++++++++++++++++++++++++ test/schema-pack-best-effort.test.ts | 3 +- test/schema-pack-mutate.test.ts | 528 ++++++++++++++++++++++ 4 files changed, 1212 insertions(+), 1 deletion(-) create mode 100644 src/core/schema-pack/mutate.ts create mode 100644 test/schema-pack-mutate.test.ts diff --git a/src/core/schema-pack/index.ts b/src/core/schema-pack/index.ts index 18130be15..bdc3d1085 100644 --- a/src/core/schema-pack/index.ts +++ b/src/core/schema-pack/index.ts @@ -147,3 +147,41 @@ export { acquirePackLock, withPackLock, } from './pack-lock.ts'; + +export { + type PackFileFormat, + type MutateResult, + type MutateOpts, + type AddTypeOpts, + type UpdateTypeOpts, + type AddLinkTypeOpts, + SchemaPackMutationError, + BUNDLED_PACK_NAMES, + locateMutablePackFile, + withMutation, + addTypeToPack, + removeTypeFromPack, + updateTypeOnPack, + addAliasToType, + removeAliasFromType, + addPrefixToType, + removePrefixFromType, + addLinkTypeToPack, + removeLinkTypeFromPack, + setExtractableOnType, + setExpertRoutingOnType, +} from './mutate.ts'; + +export { invalidateQueryCache } from './query-cache-invalidator.ts'; + +export { + type LintIssue, + type LintOpts, + type LintRule, + type LintReport, + type LintSeverity, + ALL_LINT_RULES, + FILE_PLANE_LINT_RULES, + runAllLintRules, + runFilePlaneLintRules, +} from './lint-rules.ts'; diff --git a/src/core/schema-pack/mutate.ts b/src/core/schema-pack/mutate.ts new file mode 100644 index 000000000..30a95f921 --- /dev/null +++ b/src/core/schema-pack/mutate.ts @@ -0,0 +1,644 @@ +// v0.40.6.0 Schema Cathedral v3 — pack mutation primitives. +// +// ┌──────────────────────────────────────────────────────┐ +// │ withMutation 8-step skeleton (failure-safe order) │ +// └──────────────────────────────────────────────────────┘ +// 1. BUNDLED guard ──── fail ──→ throw PACK_READONLY ─→ auditFailure +// │ +// ▼ +// 2. withPackLock (atomic O_CREAT|O_EXCL) ─── busy ──→ throw LOCK_BUSY +// │ +// ▼ +// 3. read + parse pack file ─── parse fail ──→ throw PACK_CORRUPT ─→ auditFailure +// │ +// ▼ +// 4. mutator(manifest) → next ─── throw ──→ propagate (lock auto-released) +// │ +// ▼ +// 5. runFilePlaneLintRules(next) ─── invalid ──→ throw INVALID_RESULT ─→ auditFailure +// │ +// ▼ +// 6. writeAtomic .tmp + fsync + rename ─── ENOSPC ──→ throw IO ─→ auditFailure +// │ (lock auto-released) +// ▼ +// 7. auditSuccess → invalidatePackCache → invalidateQueryCache (best-effort, never throw) +// │ +// ▼ +// 8. lock auto-released by withPackLock finally +// +// Invariant: pack file on disk is NEVER partial. Either step 6 succeeds +// (atomic rename) or the original file stays untouched. The .tmp may +// linger on crash; the next call cleans it up before writing. +// +// Public API: 11 mutation primitives wrapping the skeleton: +// add_type, remove_type, update_type +// add_alias, remove_alias, add_prefix, remove_prefix +// add_link_type, remove_link_type +// set_extractable, set_expert_routing +// +// All primitives return the same MutateResult shape so MCP's batched +// `schema_apply_mutations` op (Phase 7) can compose them homogeneously. + +import { + closeSync, + existsSync, + fsyncSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { extname, join } from 'node:path'; +import { gbrainPath } from '../config.ts'; +import { computeManifestSha8, parseSchemaPackManifest } from './manifest-v1.ts'; +import type { + PackLinkType, + PackPageType, + PackPrimitive, + SchemaPackManifest, +} from './manifest-v1.ts'; +import { PACK_PRIMITIVES } from './manifest-v1.ts'; +import { loadPackFromFile, parseYamlMini } from './loader.ts'; +import { invalidatePackCache } from './registry.ts'; +import { invalidateQueryCache } from './query-cache-invalidator.ts'; +import { logMutationFailure, logMutationSuccess, type MutationActor, type MutationOp } from './mutate-audit.ts'; +import { runFilePlaneLintRules } from './lint-rules.ts'; +import { withPackLock, type PackLockOpts } from './pack-lock.ts'; +import type { BrainEngine } from '../engine.ts'; + +export type PackFileFormat = 'json' | 'yaml'; + +export class SchemaPackMutationError extends Error { + readonly code: + | 'PACK_NOT_FOUND' + | 'PACK_READONLY' + | 'PACK_CORRUPT' + | 'TYPE_EXISTS' + | 'TYPE_NOT_FOUND' + | 'INVALID_PRIMITIVE' + | 'INVALID_RESULT' + | 'IO_ERROR' + | 'STILL_REFERENCED'; + readonly details?: Record; + constructor( + code: SchemaPackMutationError['code'], + message: string, + details?: Record, + ) { + super(message); + this.name = 'SchemaPackMutationError'; + this.code = code; + this.details = details; + } +} + +export const BUNDLED_PACK_NAMES = new Set(['gbrain-base', 'gbrain-recommended']); + +export interface MutateResult { + /** Pack name that was mutated. */ + pack: string; + /** Disk path of the pack file. */ + path: string; + /** Format the file was rewritten in. */ + format: PackFileFormat; + /** Pack identity sha8 before the mutation. */ + prev_sha8: string; + /** Pack identity sha8 after the mutation. */ + new_sha8: string; +} + +export interface MutateOpts extends PackLockOpts { + /** Who triggered the mutation (for audit logging). */ + actor?: MutationActor; + /** Engine for the query-cache invalidation hook. Omit when not connected. */ + engine?: BrainEngine; + /** Source ID to scope query-cache invalidation. Omit to clear all. */ + sourceId?: string; + /** Atomic batch id when called from `schema_apply_mutations`. */ + batchId?: string; +} + +// ──────────────────────────────────────────────────────────────────────── +// Disk layout helpers +// ──────────────────────────────────────────────────────────────────────── + +/** + * Locate a user-mutable pack file. Bundled packs (gbrain-base, + * gbrain-recommended) are explicitly refused per D6 — they live inside + * the installed module and edits would be lost on upgrade. + */ +export function locateMutablePackFile(name: string): { path: string; format: PackFileFormat } { + if (BUNDLED_PACK_NAMES.has(name)) { + throw new SchemaPackMutationError( + 'PACK_READONLY', + `pack '${name}' is bundled and read-only. Use 'gbrain schema fork ${name} ' to create a writable copy.`, + { pack: name }, + ); + } + const baseDir = gbrainPath('schema-packs', name); + const candidates: Array<{ file: string; format: PackFileFormat }> = [ + { file: 'pack.json', format: 'json' }, + { file: 'pack.yaml', format: 'yaml' }, + { file: 'pack.yml', format: 'yaml' }, + ]; + for (const c of candidates) { + const p = join(baseDir, c.file); + if (existsSync(p)) return { path: p, format: c.format }; + } + throw new SchemaPackMutationError( + 'PACK_NOT_FOUND', + `no pack file at ${baseDir}. Run 'gbrain schema init ${name}' or 'gbrain schema fork ${name}' first.`, + { pack: name, baseDir }, + ); +} + +// ──────────────────────────────────────────────────────────────────────── +// YAML emitter — minimal but correct for SchemaPackManifest shape. +// +// Covers: top-level mapping, nested mappings, sequences of scalars, +// sequences of nested mappings, scalars (string/number/boolean/null). +// Round-trip: emitted YAML parses back through parseYamlMini. +// Does NOT preserve comments or original formatting (documented in plan). +// ──────────────────────────────────────────────────────────────────────── + +function emitYaml(value: unknown): string { + return emitYamlNode(value, 0).trimEnd() + '\n'; +} + +function emitYamlNode(value: unknown, indent: number): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return emitYamlScalar(value); + if (Array.isArray(value)) return emitYamlArray(value, indent); + if (typeof value === 'object') return emitYamlMapping(value as Record, indent); + return JSON.stringify(value); +} + +function emitYamlScalar(s: string): string { + // Quote if the string would otherwise be misread (numbers, booleans, + // null, leading whitespace, contains special YAML chars, or is empty). + if (s === '') return '""'; + if (/^(true|false|null|~|-?\d+(\.\d+)?)$/i.test(s)) return JSON.stringify(s); + if (/[:#&*!|>'"%@`{}\[\],\n]/.test(s)) return JSON.stringify(s); + if (/^\s|\s$/.test(s)) return JSON.stringify(s); + return s; +} + +function emitYamlArray(arr: unknown[], indent: number): string { + if (arr.length === 0) return '[]'; + const pad = ' '.repeat(indent); + // The dash itself sits at indent N. For mapping items, the first key + // goes inline with the dash (`- key: val`). Subsequent keys live at + // indent N+1 (2 spaces past the dash). Nested structures keep their + // own relative depth — DO NOT trimStart/re-prefix or nested arrays + // collapse (the v0.40.6 emitter bug fixed here). + const parts: string[] = []; + for (const item of arr) { + if (item !== null && typeof item === 'object' && !Array.isArray(item)) { + // Emit the mapping at indent N+1, then convert the leading " " + // of the FIRST line into "- ". All other lines retain their own + // indent untouched. + const inner = emitYamlMapping(item as Record, indent + 1); + const innerLines = inner.split('\n'); + const firstPad = ' '.repeat(indent + 1); + // First line should start with firstPad; replace it with pad + '- '. + const firstLine = innerLines[0]!; + if (firstLine.startsWith(firstPad)) { + parts.push(`${pad}- ${firstLine.slice(firstPad.length)}`); + } else { + parts.push(`${pad}- ${firstLine.trimStart()}`); + } + for (let j = 1; j < innerLines.length; j++) { + if (innerLines[j] === '') continue; + parts.push(innerLines[j]!); + } + } else if (Array.isArray(item)) { + // Nested arrays — rare for manifest shape. Emit inline JSON. + parts.push(`${pad}- ${JSON.stringify(item)}`); + } else { + parts.push(`${pad}- ${emitYamlNode(item, indent + 1)}`); + } + } + return parts.join('\n'); +} + +function emitYamlMapping(obj: Record, indent: number): string { + const keys = Object.keys(obj); + if (keys.length === 0) return '{}'; + const pad = ' '.repeat(indent); + const parts: string[] = []; + for (const k of keys) { + const v = obj[k]; + if (v === null) { + parts.push(`${pad}${k}: null`); + } else if (Array.isArray(v)) { + if (v.length === 0) { + parts.push(`${pad}${k}: []`); + } else { + parts.push(`${pad}${k}:`); + parts.push(emitYamlArray(v, indent + 1)); + } + } else if (typeof v === 'object') { + if (Object.keys(v as Record).length === 0) { + parts.push(`${pad}${k}: {}`); + } else { + parts.push(`${pad}${k}:`); + parts.push(emitYamlMapping(v as Record, indent + 1)); + } + } else { + parts.push(`${pad}${k}: ${emitYamlNode(v, indent + 1)}`); + } + } + return parts.join('\n'); +} + +// ──────────────────────────────────────────────────────────────────────── +// Atomic write +// ──────────────────────────────────────────────────────────────────────── + +/** Write `body` to `path` atomically via .tmp + fsync + rename. */ +function writeAtomic(path: string, body: string): void { + const tmpPath = `${path}.tmp.${process.pid}.${Date.now()}`; + let fd = -1; + try { + fd = openSync(tmpPath, 'w'); + writeSync(fd, body); + try { fsyncSync(fd); } catch { /* not all FS support fsync; rename is still atomic per POSIX */ } + closeSync(fd); + fd = -1; + renameSync(tmpPath, path); + } catch (e) { + if (fd !== -1) { + try { closeSync(fd); } catch { /* swallow */ } + } + try { unlinkSync(tmpPath); } catch { /* swallow */ } + throw new SchemaPackMutationError( + 'IO_ERROR', + `atomic write failed for ${path}: ${(e as Error).message}`, + { path }, + ); + } +} + +function writePackManifest( + path: string, + manifest: SchemaPackManifest, + format: PackFileFormat, +): void { + // Validate the manifest shape BEFORE write so an invalid manifest can never + // hit disk (the in-memory manifest must round-trip cleanly first). + parseSchemaPackManifest(manifest, { path }); + if (format === 'yaml') { + const yaml = emitYaml(manifest); + // Belt-and-suspenders: re-parse what we're about to write to catch + // any emitter bugs before the rename. + const reparsed = parseYamlMini(yaml); + parseSchemaPackManifest(reparsed, { path }); + writeAtomic(path, yaml); + return; + } + writeAtomic(path, JSON.stringify(manifest, null, 2) + '\n'); +} + +// ──────────────────────────────────────────────────────────────────────── +// withMutation skeleton — the 8-step pipeline above +// ──────────────────────────────────────────────────────────────────────── + +/** + * Run a mutator function against a pack with full safety guarantees: + * atomic write, per-pack lock, audit log, cache + query-cache invalidation. + * + * All 11 mutation primitives below wrap this skeleton — they're each + * ~5 lines that build the transformation function. Adding a new + * primitive only requires writing the pure transformation. + * + * The skeleton is the load-bearing piece for the cathedral's safety + * contract; the primitives are pure data transformations. + */ +export async function withMutation( + packName: string, + opts: MutateOpts, + mutator: (current: SchemaPackManifest) => SchemaPackManifest, + op: MutationOp, + primitiveContext?: { type?: string; prefix?: string }, +): Promise { + const actor: MutationActor = opts.actor ?? 'cli'; + // Step 1: bundled-pack guard (also surfaces in locateMutablePackFile). + // Captured up-front so audit logging can fire even before the lock acquire. + let path: string; + let format: PackFileFormat; + try { + ({ path, format } = locateMutablePackFile(packName)); + } catch (e) { + if (e instanceof SchemaPackMutationError) { + await logMutationFailure({ + op, pack: packName, actor, ...primitiveContext, reason: e.code, + }); + } + throw e; + } + return await withPackLock(packName, opts, async () => { + let current: SchemaPackManifest; + let prevSha8: string; + try { + // Step 3: read + parse current manifest. + current = loadPackFromFile(path); + prevSha8 = await computeManifestSha8(current); + } catch (e) { + const err = new SchemaPackMutationError( + 'PACK_CORRUPT', + `cannot read or parse pack file at ${path}: ${(e as Error).message}`, + { path }, + ); + await logMutationFailure({ op, pack: packName, actor, ...primitiveContext, reason: err.code }); + throw err; + } + let next: SchemaPackManifest; + try { + // Step 4: pure mutator. + next = mutator(current); + } catch (e) { + // Re-throw user-facing SchemaPackMutationError as-is; wrap others. + const wrapped = e instanceof SchemaPackMutationError + ? e + : new SchemaPackMutationError('INVALID_RESULT', (e as Error).message); + await logMutationFailure({ op, pack: packName, actor, ...primitiveContext, reason: wrapped.code }); + throw wrapped; + } + // Step 5: validation gate — file-plane lint rules only. DB-aware + // rules deliberately skip pre-write to keep withMutation hermetic + // (Phase 9 doctor surfaces DB-aware findings after the fact). + const lintReport = await runFilePlaneLintRules(next); + if (!lintReport.ok) { + const msg = lintReport.errors.map((i) => `${i.rule}: ${i.message}`).join('; '); + const err = new SchemaPackMutationError('INVALID_RESULT', `mutation would produce invalid pack: ${msg}`, { errors: lintReport.errors }); + await logMutationFailure({ op, pack: packName, actor, ...primitiveContext, reason: err.code }); + throw err; + } + let newSha8: string; + try { + newSha8 = await computeManifestSha8(next); + // Step 6: atomic write. + writePackManifest(path, next, format); + } catch (e) { + const err = e instanceof SchemaPackMutationError ? e + : new SchemaPackMutationError('IO_ERROR', (e as Error).message, { path }); + await logMutationFailure({ op, pack: packName, actor, ...primitiveContext, reason: err.code }); + throw err; + } + // Step 7: best-effort post-hooks (must NEVER throw or the audit + // shows success but the cache stays stale). + try { + invalidatePackCache(packName); + } catch { /* swallow — cache invalidation must not block mutation success */ } + if (opts.engine) { + try { + await invalidateQueryCache(opts.engine, opts.sourceId); + } catch { /* swallow */ } + } + await logMutationSuccess({ + op, pack: packName, actor, ...primitiveContext, + prev_sha8: prevSha8, new_sha8: newSha8, batch_id: opts.batchId, + }); + return { pack: packName, path, format, prev_sha8: prevSha8, new_sha8: newSha8 }; + // Step 8: withPackLock's finally releases the lock. + }); +} + +// ──────────────────────────────────────────────────────────────────────── +// Validation helpers used by primitives +// ──────────────────────────────────────────────────────────────────────── + +const SLUG_RE = /^[a-z0-9._-]+$/i; + +function validateTypeName(name: unknown): void { + if (typeof name !== 'string' || name.length === 0 || !SLUG_RE.test(name)) { + throw new SchemaPackMutationError( + 'INVALID_RESULT', + `type name must be a slug-shape string [a-z0-9._-]+ (got: ${JSON.stringify(name)})`, + ); + } +} + +function validatePrimitive(prim: unknown): asserts prim is PackPrimitive { + if (typeof prim !== 'string' || !(PACK_PRIMITIVES as readonly string[]).includes(prim)) { + throw new SchemaPackMutationError( + 'INVALID_PRIMITIVE', + `primitive must be one of ${PACK_PRIMITIVES.join('|')} (got: ${JSON.stringify(prim)})`, + ); + } +} + +function validatePrefix(prefix: unknown): void { + if (typeof prefix !== 'string' || prefix.length === 0) { + throw new SchemaPackMutationError( + 'INVALID_RESULT', + `prefix is required and must be a non-empty string (got: ${JSON.stringify(prefix)})`, + ); + } +} + +function findType(manifest: SchemaPackManifest, name: string): PackPageType { + const t = manifest.page_types.find((pt) => pt.name === name); + if (!t) { + throw new SchemaPackMutationError( + 'TYPE_NOT_FOUND', + `type '${name}' is not declared in pack '${manifest.name}'`, + { pack: manifest.name, type: name }, + ); + } + return t; +} + +/** + * Codex C14 guard: removing a type that other types reference via + * aliases / enrichable_types / link_types / frontmatter_links leaves + * dangling refs. We refuse the remove and surface the references so + * the agent can break them first. + */ +function checkNoReferences(manifest: SchemaPackManifest, typeName: string): void { + const refs: string[] = []; + for (const t of manifest.page_types) { + if (t.name === typeName) continue; + if (t.aliases.includes(typeName)) refs.push(`type ${t.name}.aliases`); + } + for (const e of manifest.enrichable_types) { + if (e.type === typeName) refs.push(`enrichable_types[${e.type}]`); + } + for (const lt of manifest.link_types) { + if (lt.inference?.page_type === typeName) refs.push(`link_type ${lt.name}.inference.page_type`); + if (lt.inference?.target_type === typeName) refs.push(`link_type ${lt.name}.inference.target_type`); + } + for (const fl of manifest.frontmatter_links) { + if (fl.page_type === typeName) refs.push(`frontmatter_links[${fl.page_type}]`); + } + if (refs.length > 0) { + throw new SchemaPackMutationError( + 'STILL_REFERENCED', + `cannot remove type '${typeName}' — it is referenced by: ${refs.join(', ')}. Break references first.`, + { pack: manifest.name, type: typeName, references: refs }, + ); + } +} + +// ──────────────────────────────────────────────────────────────────────── +// 11 mutation primitives +// ──────────────────────────────────────────────────────────────────────── + +export interface AddTypeOpts { + name: string; + primitive: PackPrimitive; + prefix: string; + extractable?: boolean; + expertRouting?: boolean; + aliases?: string[]; +} + +export async function addTypeToPack(packName: string, opts: AddTypeOpts, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(opts.name); + validatePrimitive(opts.primitive); + validatePrefix(opts.prefix); + return withMutation(packName, mutateOpts, (m) => { + if (m.page_types.some((pt) => pt.name === opts.name)) { + throw new SchemaPackMutationError( + 'TYPE_EXISTS', + `type '${opts.name}' already exists in pack '${m.name}'`, + { pack: m.name, type: opts.name }, + ); + } + const newType: PackPageType = { + name: opts.name, + primitive: opts.primitive, + path_prefixes: [opts.prefix], + aliases: opts.aliases ?? [], + extractable: opts.extractable ?? false, + expert_routing: opts.expertRouting ?? false, + }; + return { ...m, page_types: [...m.page_types, newType] }; + }, 'add_type', { type: opts.name, prefix: opts.prefix }); +} + +export async function removeTypeFromPack(packName: string, name: string, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(name); + return withMutation(packName, mutateOpts, (m) => { + findType(m, name); // throws TYPE_NOT_FOUND if missing + checkNoReferences(m, name); // codex C14 + return { ...m, page_types: m.page_types.filter((t) => t.name !== name) }; + }, 'remove_type', { type: name }); +} + +export interface UpdateTypeOpts { + name: string; + patch: Partial>; +} + +export async function updateTypeOnPack(packName: string, opts: UpdateTypeOpts, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(opts.name); + if (opts.patch.primitive !== undefined) validatePrimitive(opts.patch.primitive); + return withMutation(packName, mutateOpts, (m) => { + const existing = findType(m, opts.name); + const updated: PackPageType = { ...existing, ...opts.patch, name: existing.name }; + return { ...m, page_types: m.page_types.map((t) => (t.name === opts.name ? updated : t)) }; + }, 'update_type', { type: opts.name }); +} + +export async function addAliasToType(packName: string, typeName: string, alias: string, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(typeName); + validateTypeName(alias); + return withMutation(packName, mutateOpts, (m) => { + const t = findType(m, typeName); + if (t.aliases.includes(alias)) return m; // idempotent + const next: PackPageType = { ...t, aliases: [...t.aliases, alias] }; + return { ...m, page_types: m.page_types.map((pt) => (pt.name === typeName ? next : pt)) }; + }, 'add_alias', { type: typeName }); +} + +export async function removeAliasFromType(packName: string, typeName: string, alias: string, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(typeName); + return withMutation(packName, mutateOpts, (m) => { + const t = findType(m, typeName); + if (!t.aliases.includes(alias)) return m; // idempotent + const next: PackPageType = { ...t, aliases: t.aliases.filter((a) => a !== alias) }; + return { ...m, page_types: m.page_types.map((pt) => (pt.name === typeName ? next : pt)) }; + }, 'remove_alias', { type: typeName }); +} + +export async function addPrefixToType(packName: string, typeName: string, prefix: string, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(typeName); + validatePrefix(prefix); + return withMutation(packName, mutateOpts, (m) => { + const t = findType(m, typeName); + if (t.path_prefixes.includes(prefix)) return m; + const next: PackPageType = { ...t, path_prefixes: [...t.path_prefixes, prefix] }; + return { ...m, page_types: m.page_types.map((pt) => (pt.name === typeName ? next : pt)) }; + }, 'add_prefix', { type: typeName, prefix }); +} + +export async function removePrefixFromType(packName: string, typeName: string, prefix: string, mutateOpts: MutateOpts = {}): Promise { + validateTypeName(typeName); + return withMutation(packName, mutateOpts, (m) => { + const t = findType(m, typeName); + if (!t.path_prefixes.includes(prefix)) return m; + const next: PackPageType = { ...t, path_prefixes: t.path_prefixes.filter((p) => p !== prefix) }; + return { ...m, page_types: m.page_types.map((pt) => (pt.name === typeName ? next : pt)) }; + }, 'remove_prefix', { type: typeName, prefix }); +} + +export interface AddLinkTypeOpts { + name: string; + inverse?: string; + inference?: { regex?: string; page_type?: string; target_type?: string }; +} + +export async function addLinkTypeToPack(packName: string, opts: AddLinkTypeOpts, mutateOpts: MutateOpts = {}): Promise { + if (typeof opts.name !== 'string' || opts.name.length === 0) { + throw new SchemaPackMutationError('INVALID_RESULT', `link_type.name is required`); + } + return withMutation(packName, mutateOpts, (m) => { + if (m.link_types.some((lt) => lt.name === opts.name)) { + throw new SchemaPackMutationError( + 'TYPE_EXISTS', + `link_type '${opts.name}' already exists in pack '${m.name}'`, + { pack: m.name, link: opts.name }, + ); + } + const newLink: PackLinkType = { + name: opts.name, + ...(opts.inverse ? { inverse: opts.inverse } : {}), + ...(opts.inference ? { inference: opts.inference } : {}), + } as PackLinkType; + return { ...m, link_types: [...m.link_types, newLink] }; + }, 'add_link_type', { type: opts.name }); +} + +export async function removeLinkTypeFromPack(packName: string, linkName: string, mutateOpts: MutateOpts = {}): Promise { + return withMutation(packName, mutateOpts, (m) => { + if (!m.link_types.some((lt) => lt.name === linkName)) { + throw new SchemaPackMutationError( + 'TYPE_NOT_FOUND', + `link_type '${linkName}' is not declared in pack '${m.name}'`, + { pack: m.name, link: linkName }, + ); + } + // Check frontmatter_links references too. + const flRefs = m.frontmatter_links.filter((fl) => fl.link_type === linkName); + if (flRefs.length > 0) { + throw new SchemaPackMutationError( + 'STILL_REFERENCED', + `cannot remove link_type '${linkName}' — referenced by frontmatter_links: ${flRefs.map((f) => f.page_type).join(', ')}`, + { pack: m.name, link: linkName }, + ); + } + return { ...m, link_types: m.link_types.filter((lt) => lt.name !== linkName) }; + }, 'remove_link_type', { type: linkName }); +} + +export async function setExtractableOnType(packName: string, typeName: string, value: boolean, mutateOpts: MutateOpts = {}): Promise { + return updateTypeOnPack(packName, { name: typeName, patch: { extractable: value } }, { ...mutateOpts }); +} + +export async function setExpertRoutingOnType(packName: string, typeName: string, value: boolean, mutateOpts: MutateOpts = {}): Promise { + return updateTypeOnPack(packName, { name: typeName, patch: { expert_routing: value } }, { ...mutateOpts }); +} diff --git a/test/schema-pack-best-effort.test.ts b/test/schema-pack-best-effort.test.ts index 2c713aa20..121ef1f4f 100644 --- a/test/schema-pack-best-effort.test.ts +++ b/test/schema-pack-best-effort.test.ts @@ -38,7 +38,8 @@ function fakeCtx(remote = false): OperationContext { logger: { info: () => {}, warn: () => {}, error: () => {} } as never, dryRun: false, remote, - } as OperationContext; + sourceId: undefined, + } as unknown as OperationContext; } describe('loadActivePackBestEffort', () => { diff --git a/test/schema-pack-mutate.test.ts b/test/schema-pack-mutate.test.ts new file mode 100644 index 000000000..d2d4b289a --- /dev/null +++ b/test/schema-pack-mutate.test.ts @@ -0,0 +1,528 @@ +// v0.40.6.0 — mutate.ts contract tests for the 11 primitives + withMutation skeleton. + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + addAliasToType, + addLinkTypeToPack, + addPrefixToType, + addTypeToPack, + BUNDLED_PACK_NAMES, + locateMutablePackFile, + removeAliasFromType, + removeLinkTypeFromPack, + removePrefixFromType, + removeTypeFromPack, + SchemaPackMutationError, + setExpertRoutingOnType, + setExtractableOnType, + updateTypeOnPack, +} from '../src/core/schema-pack/mutate.ts'; +import { loadPackFromFile, parseYamlMini } from '../src/core/schema-pack/loader.ts'; +import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts'; +import { withEnv } from './helpers/with-env.ts'; +import type { SchemaPackManifest } from '../src/core/schema-pack/manifest-v1.ts'; + +let tmpDir: string; +let auditDir: string; +let lockDir: string; + +function seedPack(packName: string, format: 'json' | 'yaml', initial?: Partial): string { + // GBRAIN_HOME=/tmp/x → gbrainPath('schema-packs', 'mine') = /tmp/x/.gbrain/schema-packs/mine + const dir = join(tmpDir, '.gbrain', 'schema-packs', packName); + mkdirSync(dir, { recursive: true }); + const manifest: SchemaPackManifest = { + api_version: 'gbrain-schema-pack-v1', + name: packName, + version: '1.0.0', + description: '', + gbrain_min_version: '0.38.0', + extends: null, + borrow_from: [], + page_types: [{ + name: 'person', primitive: 'entity', path_prefixes: ['people/'], + aliases: [], extractable: false, expert_routing: false, + }], + link_types: [], + frontmatter_links: [], + takes_kinds: ['fact', 'take', 'bet', 'hunch'], + enrichable_types: [], + filing_rules: [], + ...initial, + } as SchemaPackManifest; + const file = format === 'json' ? 'pack.json' : 'pack.yaml'; + const path = join(dir, file); + const body = format === 'json' + ? JSON.stringify(manifest, null, 2) + '\n' + : require('../src/core/schema-pack/mutate.ts').emitYaml?.(manifest) ?? buildSimpleYaml(manifest); + writeFileSync(path, body, 'utf-8'); + return path; +} + +// Tiny YAML fallback for fixtures (the real emitter is inside mutate.ts). +function buildSimpleYaml(m: SchemaPackManifest): string { + return JSON.stringify(m); // valid YAML (JSON is a subset) +} + +beforeEach(() => { + _resetPackCacheForTests(); + tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-mutate-test-')); + auditDir = mkdtempSync(join(tmpdir(), 'gbrain-mutate-audit-')); + lockDir = mkdtempSync(join(tmpdir(), 'gbrain-mutate-locks-')); +}); + +afterEach(() => { + _resetPackCacheForTests(); + for (const d of [tmpDir, auditDir, lockDir]) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* swallow */ } + } +}); + +// ─── BUNDLED-pack guard (D6) ───────────────────────────────────────────── + +describe('locateMutablePackFile — bundled guard', () => { + it('rejects gbrain-base with PACK_READONLY + fork hint', () => { + expect(() => locateMutablePackFile('gbrain-base')).toThrow(SchemaPackMutationError); + try { locateMutablePackFile('gbrain-base'); } catch (e) { + const err = e as SchemaPackMutationError; + expect(err.code).toBe('PACK_READONLY'); + expect(err.message).toContain('gbrain schema fork'); + } + }); + + it('rejects gbrain-recommended with PACK_READONLY', () => { + try { locateMutablePackFile('gbrain-recommended'); } catch (e) { + expect((e as SchemaPackMutationError).code).toBe('PACK_READONLY'); + } + }); + + it('BUNDLED_PACK_NAMES export contains both bundled packs', () => { + expect(BUNDLED_PACK_NAMES.has('gbrain-base')).toBe(true); + expect(BUNDLED_PACK_NAMES.has('gbrain-recommended')).toBe(true); + expect(BUNDLED_PACK_NAMES.size).toBe(2); + }); +}); + +// ─── add_type ─────────────────────────────────────────────────────────── + +describe('addTypeToPack', () => { + it('appends a new type to JSON pack and writes atomically', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + const result = await addTypeToPack('mine', { + name: 'researcher', primitive: 'entity', prefix: 'people/researchers/', + extractable: true, expert: false, + } as never, { lockDir }); + expect(result.format).toBe('json'); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'researcher')).toBeDefined(); + expect(result.prev_sha8).not.toBe(result.new_sha8); + }); + }); + + it('rejects when type already exists', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(addTypeToPack('mine', { + name: 'person', primitive: 'entity', prefix: 'people/', + } as never, { lockDir })).rejects.toThrow('already exists'); + }); + }); + + it('rejects invalid primitive', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(addTypeToPack('mine', { + name: 'bad', primitive: 'invalid_primitive' as never, prefix: 'x/', + } as never, { lockDir })).rejects.toMatchObject({ code: 'INVALID_PRIMITIVE' }); + }); + }); + + it('rejects missing prefix', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(addTypeToPack('mine', { + name: 'bad', primitive: 'entity', prefix: '', + } as never, { lockDir })).rejects.toMatchObject({ code: 'INVALID_RESULT' }); + }); + }); + + it('rejects invalid slug type name', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(addTypeToPack('mine', { + name: 'has spaces', primitive: 'entity', prefix: 'x/', + } as never, { lockDir })).rejects.toMatchObject({ code: 'INVALID_RESULT' }); + }); + }); +}); + +// ─── remove_type with codex C14 alias-ref check ───────────────────────── + +describe('removeTypeFromPack', () => { + it('removes the type when no references exist', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['people/'], aliases: [], extractable: false, expert_routing: false }, + { name: 'company', primitive: 'entity', path_prefixes: ['companies/'], aliases: [], extractable: false, expert_routing: false }, + ], + }); + await removeTypeFromPack('mine', 'company', { lockDir }); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'company')).toBeUndefined(); + }); + }); + + it('TYPE_NOT_FOUND when type does not exist', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(removeTypeFromPack('mine', 'ghost', { lockDir })) + .rejects.toMatchObject({ code: 'TYPE_NOT_FOUND' }); + }); + }); + + it('CODEX C14: refuses removal when another type aliases the target', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['people/'], aliases: [], extractable: false, expert_routing: false }, + { name: 'researcher', primitive: 'entity', path_prefixes: ['people/r/'], aliases: ['person'], extractable: false, expert_routing: false }, + ], + }); + await expect(removeTypeFromPack('mine', 'person', { lockDir })) + .rejects.toMatchObject({ code: 'STILL_REFERENCED' }); + }); + }); + + it('CODEX C14: refuses removal when link_type inference references the target', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['people/'], aliases: [], extractable: false, expert_routing: false }, + { name: 'company', primitive: 'entity', path_prefixes: ['c/'], aliases: [], extractable: false, expert_routing: false }, + ], + link_types: [ + { name: 'works_at', inference: { page_type: 'person', target_type: 'company' } }, + ], + }); + await expect(removeTypeFromPack('mine', 'person', { lockDir })) + .rejects.toMatchObject({ code: 'STILL_REFERENCED' }); + }); + }); + + it('CODEX C14: refuses when enrichable_types references the target', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + page_types: [{ name: 'person', primitive: 'entity', path_prefixes: ['p/'], aliases: [], extractable: false, expert_routing: false }], + enrichable_types: [{ type: 'person', rubric: 'r' }], + }); + await expect(removeTypeFromPack('mine', 'person', { lockDir })) + .rejects.toMatchObject({ code: 'STILL_REFERENCED' }); + }); + }); +}); + +// ─── update_type ─────────────────────────────────────────────────────── + +describe('updateTypeOnPack', () => { + it('patches a single field while leaving others untouched', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + await updateTypeOnPack('mine', { name: 'person', patch: { extractable: true } }, { lockDir }); + const after = loadPackFromFile(path); + const t = after.page_types.find((pt) => pt.name === 'person')!; + expect(t.extractable).toBe(true); + expect(t.primitive).toBe('entity'); + expect(t.path_prefixes).toEqual(['people/']); + }); + }); + + it('name field on patch is ignored (name is identity)', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + await updateTypeOnPack('mine', { name: 'person', patch: { name: 'renamed', extractable: true } as never }, { lockDir }); + const after = loadPackFromFile(path); + // 'person' kept its name; not renamed. + expect(after.page_types.find((t) => t.name === 'person')).toBeDefined(); + expect(after.page_types.find((t) => t.name === 'renamed')).toBeUndefined(); + }); + }); + + it('TYPE_NOT_FOUND on patch target missing', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(updateTypeOnPack('mine', { name: 'ghost', patch: { extractable: true } }, { lockDir })) + .rejects.toMatchObject({ code: 'TYPE_NOT_FOUND' }); + }); + }); +}); + +// ─── alias + prefix primitives ───────────────────────────────────────── + +describe('addAliasToType', () => { + it('appends a new alias (alias does NOT shadow another declared type)', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + // Alias 'individual' is NOT another declared type, so alias_shadows_type does not fire. + // alias_references_undeclared_type is a WARNING (not error), so validation gate passes. + await addAliasToType('mine', 'person', 'individual', { lockDir }); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'person')!.aliases).toEqual(['individual']); + }); + }); + + it('idempotent on existing alias', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['p/'], aliases: ['individual'], extractable: false, expert_routing: false }, + ], + }); + const r1 = await addAliasToType('mine', 'person', 'individual', { lockDir }); + const r2 = await addAliasToType('mine', 'person', 'individual', { lockDir }); + expect(r1.new_sha8).toBe(r2.new_sha8); + }); + }); +}); + +describe('removeAliasFromType', () => { + it('removes an existing alias', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['p/'], aliases: ['individual'], extractable: false, expert_routing: false }, + ], + }); + await removeAliasFromType('mine', 'person', 'individual', { lockDir }); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'person')!.aliases).toEqual([]); + }); + }); + + it('idempotent on missing alias', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + const r1 = await removeAliasFromType('mine', 'person', 'never-was', { lockDir }); + const r2 = await removeAliasFromType('mine', 'person', 'never-was', { lockDir }); + expect(r1.new_sha8).toBe(r2.new_sha8); + }); + }); +}); + +describe('addPrefixToType / removePrefixFromType', () => { + it('addPrefix appends', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + await addPrefixToType('mine', 'person', 'people-archive/', { lockDir }); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'person')!.path_prefixes).toEqual(['people/', 'people-archive/']); + }); + }); + + it('addPrefix idempotent on existing', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + const r1 = await addPrefixToType('mine', 'person', 'people/', { lockDir }); + const r2 = await addPrefixToType('mine', 'person', 'people/', { lockDir }); + expect(r1.new_sha8).toBe(r2.new_sha8); + }); + }); + + it('removePrefix removes', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json', { + page_types: [{ name: 'person', primitive: 'entity', path_prefixes: ['people/', 'people-archive/'], aliases: [], extractable: false, expert_routing: false }], + }); + await removePrefixFromType('mine', 'person', 'people-archive/', { lockDir }); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'person')!.path_prefixes).toEqual(['people/']); + }); + }); +}); + +// ─── link_type primitives ─────────────────────────────────────────────── + +describe('addLinkTypeToPack / removeLinkTypeFromPack', () => { + it('addLinkType creates a new link verb', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json', { + page_types: [ + { name: 'person', primitive: 'entity', path_prefixes: ['p/'], aliases: [], extractable: false, expert_routing: false }, + { name: 'company', primitive: 'entity', path_prefixes: ['c/'], aliases: [], extractable: false, expert_routing: false }, + ], + }); + await addLinkTypeToPack('mine', { + name: 'works_at', + inference: { page_type: 'person', target_type: 'company' }, + }, { lockDir }); + const after = loadPackFromFile(path); + expect(after.link_types.length).toBe(1); + expect(after.link_types[0]!.name).toBe('works_at'); + }); + }); + + it('addLinkType rejects duplicate name', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + link_types: [{ name: 'attended' }], + }); + await expect(addLinkTypeToPack('mine', { name: 'attended' }, { lockDir })) + .rejects.toMatchObject({ code: 'TYPE_EXISTS' }); + }); + }); + + it('removeLinkType removes when no fm refs exist', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json', { link_types: [{ name: 'attended' }] }); + await removeLinkTypeFromPack('mine', 'attended', { lockDir }); + const after = loadPackFromFile(path); + expect(after.link_types.length).toBe(0); + }); + }); + + it('removeLinkType refuses when frontmatter_links references it', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json', { + page_types: [{ name: 'meeting', primitive: 'temporal', path_prefixes: ['m/'], aliases: [], extractable: false, expert_routing: false }], + link_types: [{ name: 'attended' }], + frontmatter_links: [{ page_type: 'meeting', fields: ['attendees'], link_type: 'attended' }], + }); + await expect(removeLinkTypeFromPack('mine', 'attended', { lockDir })) + .rejects.toMatchObject({ code: 'STILL_REFERENCED' }); + }); + }); +}); + +// ─── flag setters ────────────────────────────────────────────────────── + +describe('setExtractableOnType / setExpertRoutingOnType', () => { + it('setExtractable flips the flag', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + await setExtractableOnType('mine', 'person', true, { lockDir }); + expect(loadPackFromFile(path).page_types[0]!.extractable).toBe(true); + await setExtractableOnType('mine', 'person', false, { lockDir }); + expect(loadPackFromFile(path).page_types[0]!.extractable).toBe(false); + }); + }); + + it('setExpertRouting flips the flag', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + await setExpertRoutingOnType('mine', 'person', true, { lockDir }); + expect(loadPackFromFile(path).page_types[0]!.expert_routing).toBe(true); + }); + }); + + it('TYPE_NOT_FOUND on missing type', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + await expect(setExtractableOnType('mine', 'ghost', true, { lockDir })) + .rejects.toMatchObject({ code: 'TYPE_NOT_FOUND' }); + }); + }); +}); + +// ─── YAML round-trip ────────────────────────────────────────────────── + +describe('YAML round-trip', () => { + it('mutating a YAML pack preserves YAML format and reparses cleanly', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const dir = join(tmpDir, '.gbrain', 'schema-packs', 'yaml-pack'); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'pack.yaml'); + // Seed valid YAML (use the manifest validator round-trip). + // Seed actual block-style YAML (parseYamlMini is hand-rolled and prefers + // block-style; flow-style JSON-in-YAML isn't fully supported). + const yamlBody = `api_version: gbrain-schema-pack-v1 +name: yaml-pack +version: 1.0.0 +description: "" +gbrain_min_version: 0.38.0 +extends: null +borrow_from: [] +page_types: + - name: person + primitive: entity + path_prefixes: + - people/ + aliases: [] + extractable: false + expert_routing: false +link_types: [] +frontmatter_links: [] +takes_kinds: + - fact + - take + - bet + - hunch +enrichable_types: [] +filing_rules: [] +`; + writeFileSync(path, yamlBody, 'utf-8'); + + const result = await addTypeToPack('yaml-pack', { + name: 'researcher', primitive: 'entity', prefix: 'people/r/', + } as never, { lockDir }); + expect(result.format).toBe('yaml'); + // File still parses as YAML AND as a valid manifest. + const reparsed = parseYamlMini(readFileSync(path, 'utf-8')); + expect(reparsed).toBeDefined(); + const after = loadPackFromFile(path); + expect(after.page_types.find((t) => t.name === 'researcher')).toBeDefined(); + }); + }); +}); + +// ─── atomicity ──────────────────────────────────────────────────────── + +describe('atomicity invariants', () => { + it('crash-mid-write does not leave the pack file in a partial state', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + const path = seedPack('mine', 'json'); + const before = readFileSync(path, 'utf-8'); + try { + // Force a mutator throw mid-pipeline AFTER lock acquire + read. + await addTypeToPack('mine', { + // Invalid: primitive is wrong type. Validation should fail BEFORE write. + name: 'researcher', primitive: 'nope' as never, prefix: 'x/', + } as never, { lockDir }); + } catch { /* expected */ } + // Original file untouched. + expect(readFileSync(path, 'utf-8')).toBe(before); + }); + }); + + it('lock is released after a mutator throws', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + try { + await addTypeToPack('mine', { + name: 'has spaces', primitive: 'entity', prefix: 'x/', + } as never, { lockDir }); + } catch { /* expected */ } + // A second call should succeed (lock not held). + const result = await addTypeToPack('mine', { + name: 'valid', primitive: 'entity', prefix: 'v/', + } as never, { lockDir }); + expect(result.pack).toBe('mine'); + }); + }); +}); + +// ─── validation gate ───────────────────────────────────────────────── + +describe('validation gate (file-plane lint integration)', () => { + it('refuses mutation that would create prefix collision', async () => { + await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => { + seedPack('mine', 'json'); + // Adding a second type with the SAME prefix → prefix_collision (error). + await expect(addTypeToPack('mine', { + name: 'human', primitive: 'entity', prefix: 'people/', // same as person + } as never, { lockDir })).rejects.toMatchObject({ code: 'INVALID_RESULT' }); + }); + }); +});