v0.40.6.0 Phase 2 — mutate.ts withMutation skeleton + 11 primitives

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-23 10:17:48 -07:00
co-authored by Claude Opus 4.7
parent e8ea179256
commit 21beda7fd7
4 changed files with 1212 additions and 1 deletions
+38
View File
@@ -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';
+644
View File
@@ -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<string, unknown>;
constructor(
code: SchemaPackMutationError['code'],
message: string,
details?: Record<string, unknown>,
) {
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} <new-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 <source> ${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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>).length === 0) {
parts.push(`${pad}${k}: {}`);
} else {
parts.push(`${pad}${k}:`);
parts.push(emitYamlMapping(v as Record<string, unknown>, 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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<Omit<PackPageType, 'name'>>;
}
export async function updateTypeOnPack(packName: string, opts: UpdateTypeOpts, mutateOpts: MutateOpts = {}): Promise<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
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<MutateResult> {
return updateTypeOnPack(packName, { name: typeName, patch: { extractable: value } }, { ...mutateOpts });
}
export async function setExpertRoutingOnType(packName: string, typeName: string, value: boolean, mutateOpts: MutateOpts = {}): Promise<MutateResult> {
return updateTypeOnPack(packName, { name: typeName, patch: { expert_routing: value } }, { ...mutateOpts });
}
+2 -1
View File
@@ -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', () => {
+528
View File
@@ -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<SchemaPackManifest>): 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' });
});
});
});