mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.7
parent
e8ea179256
commit
21beda7fd7
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user