/** Verify and acknowledge persistence type changes from current-tree schema history. */ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { basename, join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { JSON_SCHEMA, load } from 'js-yaml' import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts' import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts' import { extractPersistenceSchema } from './persistence-schema.ts' import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts' import { renderPersistencePair } from './persistence-artifacts.ts' import type { PersistenceArtifact } from './persistence-artifacts.ts' const HISTORY_DIRECTORY = 'docs/persistence-changes' const CURRENT_SCHEMA = 'docs/persistence-schema.json' const ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u const DIGEST_PATTERN = /^[a-f0-9]{64}$/u const EXPLANATION_PLACEHOLDER = 'TODO: explain this change.' const EVIDENCE_PLACEHOLDER = 'TODO: record validation evidence.' /** The author's acknowledgement of one mechanically classified transition. */ export type PersistenceDecision = 'same-version' | 'version-bump' /** One root's successor; null after values preserve a deletion in its history. */ export interface PersistenceChange { readonly root: string readonly previous: string | null readonly after: string | null readonly decision: PersistenceDecision } /** A document's machine record, independent of its translated prose. */ export interface PersistenceChangeRecord { readonly schemaVersion: 1 readonly id: string readonly baseline: boolean readonly changes: readonly PersistenceChange[] } /** A parsed acknowledgement and its self-contained after schemas. */ export interface PersistenceHistoryEntry { readonly record: PersistenceChangeRecord readonly snapshot: PersistenceSchemaInventory } /** One detected type change, with a path that reviewers can locate. */ export interface PersistenceTypeChange { readonly kind: PersistenceTypeChangeKind readonly path: string readonly description: string readonly requiresVersionBump: boolean } const CHANGE_DESCRIPTIONS = { 'root-added': 'root added', 'root-removed': 'root removed', 'root-classification-changed': 'root classification changed', 'type-changed': 'type changed', 'property-removed': 'property removed', 'property-made-optional': 'property made optional', 'property-made-required': 'property made required', 'optional-property-added': 'optional property added', 'required-property-added': 'required property added', 'index-signature-changed': 'index signature changed', 'tuple-length-changed': 'tuple length changed', 'tuple-element-cardinality-changed': 'tuple element cardinality changed', 'union-variants-changed': 'union variants changed', } as const /** Stable structural classification independent of diagnostic prose. */ export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS /** Author-supplied paragraphs used to complete one language of a change record. */ export interface PersistenceChangeProse { readonly summary: string readonly compatibility: string readonly verification: string } /** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */ export interface PersistenceChangeProsePair { readonly en: PersistenceChangeProse readonly zh: PersistenceChangeProse } interface ReportedChange extends PersistenceTypeChange { readonly root: string } interface RootTransition { readonly root: string readonly kind: PersistenceRoot['kind'] readonly before: string | null readonly after: string | null } class PersistenceChangeFailure extends Error { constructor( message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [], readonly roots: readonly RootTransition[] = [], ) { super(message) } } interface CommandResult { readonly schemaVersion: 1 readonly ok: boolean readonly operation: 'check' | 'baseline' | 'record' | 'update' readonly message: string readonly changes: readonly ReportedChange[] readonly roots: readonly RootTransition[] readonly files: readonly string[] readonly recordId?: string readonly code?: string } interface Tip { readonly id: string readonly root: PersistenceRoot | null } /** Verified per-root history tips; historical schemas need not match the current tree. */ export interface PersistenceHistory { readonly entries: readonly PersistenceHistoryEntry[] readonly tips: ReadonlyMap } function record(value: unknown, label: string): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`) return value as Record } function keys(value: Record, expected: readonly string[], label: string, optional: readonly string[] = []): void { const missing = expected.find(key => !Object.hasOwn(value, key)) const unexpected = Object.keys(value).find(key => !expected.includes(key) && !optional.includes(key)) if (missing !== undefined || unexpected !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${unexpected}` : `missing field ${missing}`}`) } function array(value: unknown, label: string): unknown[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`) return value } function textValue(value: unknown, label: string): string { if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be a non-empty string`) return value } function bool(value: unknown, label: string): boolean { if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`) return value } function identifier(value: unknown, label: string): string { const id = textValue(value, label) if (!ID_PATTERN.test(id)) throw new Error(`${label} must be YYYY-MM-DD-slug`) return id } function digest(value: unknown, label: string): string { const result = textValue(value, label) if (!DIGEST_PATTERN.test(result)) throw new Error(`${label} must be a SHA-256 digest`) return result } function reference(value: unknown, count: number, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`) return value as number } function parseSchema(value: unknown, label: string): CanonicalSchema { const input = record(value, label) keys(input, ['root', 'nodes'], label) if (input.root !== 0) throw new Error(`${label}.root must be zero`) const nodes = array(input.nodes, `${label}.nodes`) if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`) const ref = (value: unknown): number => reference(value, nodes.length, label) for (const [index, raw] of nodes.entries()) { const node = record(raw, `${label}.nodes[${index}]`) switch (node.kind) { case 'primitive': keys(node, ['kind', 'type'], label) if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`) break case 'literal': keys(node, ['kind', 'value'], label) if (!['string', 'boolean', 'number'].includes(typeof node.value) || typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`) break case 'opaque': keys(node, ['kind', 'reason'], label) if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`) break case 'array': keys(node, ['kind', 'element'], label) ref(node.element) break case 'tuple': keys(node, ['kind', 'elements'], label) for (const rawElement of array(node.elements, label)) { const element = record(rawElement, label) keys(element, ['type', 'optional', 'rest'], label) ref(element.type) bool(element.optional, label) bool(element.rest, label) } break case 'object': { keys(node, ['kind', 'properties', 'indices'], label) const names = new Set() for (const rawProperty of array(node.properties, label)) { const property = record(rawProperty, label) keys(property, ['name', 'type', 'optional'], label) if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`) const name = property.name if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`) names.add(name) ref(property.type) bool(property.optional, label) } for (const rawIndex of array(node.indices, label)) { const entry = record(rawIndex, label) keys(entry, ['key', 'value'], label) ref(entry.key) ref(entry.value) } break } case 'union': keys(node, ['kind', 'types'], label) if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`) for (const item of node.types as unknown[]) ref(item) break default: throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`) } } const schema = input as unknown as CanonicalSchema const canonical = canonicalizeSchema(schema.nodes, schema.root) if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`) return schema } /** Parse a persisted schema inventory, rejecting malformed graphs and digest drift. * @param value - JSON read from a current or historical schema file. * @returns the validated inventory. */ export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory { const input = record(value, 'schema inventory') keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory') if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version') const names = new Set() for (const rawRoot of array(input.roots, 'schema roots')) { const root = record(rawRoot, 'schema root') keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface']) const key = textValue(root.key, 'root key') if (names.has(key)) throw new Error(`duplicate schema root ${key}`) names.add(key) if (root.kind === 'event') { if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`) bool(root.surface, 'event surface membership') } else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key)) && (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`) if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`) const schema = parseSchema(root.schema, key) if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true) if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`) } for (const rawType of array(input.types, 'schema types')) { const type = record(rawType, 'schema type') keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type') const schema = parseSchema(type.schema, 'shared schema') if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch') for (const name of array(type.names, 'type names')) textValue(name, 'type name') for (const source of array(type.sources, 'type sources')) textValue(source, 'type source') } return input as unknown as PersistenceSchemaInventory } function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean): void { const pending = [0] const visited = new Set() while (pending.length > 0) { const index = pending.pop() as number if (visited.has(index)) continue visited.add(index) const node = schema.nodes[index] as SchemaNode if (node.kind === 'union') { pending.push(...node.types); continue } if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`) const tag = node.properties.find(property => property.name === 'type') const value = tag === undefined ? undefined : schema.nodes[tag.type] if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`) const operation = node.properties.find(property => property.name === 'surfaceOp') if (surface ? operation?.optional !== false : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`) } } function subDigest(schema: CanonicalSchema, node: number): string { return schemaDigest(canonicalizeSchema(schema.nodes, node)) } function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined { const owners = new Map() function assign(previous: number, visited: Set): boolean { for (const next of candidates[previous] ?? []) { if (visited.has(next)) continue visited.add(next) const owner = owners.get(next) if (owner === undefined || assign(owner, visited)) { owners.set(next, previous) return true } } return false } for (let previous = 0; previous < candidates.length; previous += 1) { if (!assign(previous, new Set())) return undefined } const matches: number[] = [] for (const [next, previous] of owners) matches[previous] = next return matches } /** Classify structural differences; only optional payload properties and ordinary event additions are additive. * @param before - predecessor root, or absence for an addition. * @param after - successor root, or absence for deletion. * @returns concrete changes and their format-bump requirement. */ export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] { if (before === null) { return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added', requiresVersionBump: after.kind !== 'event' || after.surface !== false }] } if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }] const key = after.key const oldRoot = before const newRoot = after const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange => ({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump }) const changes: PersistenceTypeChange[] = [] if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed')) if (before.digest === after.digest) return changes const fingerprints = [new Map(), new Map()] as const const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => { let result = fingerprints[side].get(index) if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) } return result } type Scope = 'event' | 'body' | 'strict' function compare( oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet, ): PersistenceTypeChange[] { if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return [] const pair = `${oldIndex}:${newIndex}:${scope}` if (ancestors.has(pair)) return [] // Recursive pairs are assumptions for this candidate only. A failed sibling // or unmatched union arm cannot leave a cached success for another candidate. const active = new Set(ancestors).add(pair) const differences: PersistenceTypeChange[] = [] const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => { differences.push(describe(path, kind, requiresVersionBump)) } const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => { differences.push(...compare(oldType, newType, child, childScope, active)) } const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode const newNode = newRoot.schema.nodes[newIndex] as SchemaNode if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')] if (oldNode.kind === 'object' && newNode.kind === 'object') { const oldProps = new Map(oldNode.properties.map(property => [property.name, property])) const newProps = new Map(newNode.properties.map(property => [property.name, property])) for (const [name, property] of oldProps) { const next = newProps.get(name) const child = `${path}.${name}` if (next === undefined) { add(child, 'property-removed'); continue } if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional) descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict') } for (const [name, property] of newProps) { if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional) } const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry])) const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry])) if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed') for (const [index, entry] of oldIndices) { const next = newIndices.get(index) if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict') } return differences } if (oldNode.kind === 'array' && newNode.kind === 'array') { return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active) } if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') { if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')] for (const [index, element] of oldNode.elements.entries()) { const next = newNode.elements[index] as SchemaTupleElement if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed') descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict') } return differences } if (oldNode.kind === 'union' && newNode.kind === 'union') { if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')] const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active))) const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) => candidate.every(change => !change.requiresVersionBump) ? [index] : []))) if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? []) const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index])) const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index])) const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index) const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index) if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active) return [describe(path, 'union-variants-changed')] } return [describe(path, 'type-changed')] } changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set())) if (changes.length === 0) changes.push(describe(key, 'type-changed')) return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()] } function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord { const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source) if (frontmatter === null || record(load(frontmatter[1] as string, { schema: JSON_SCHEMA }), filename).kind !== 'persistence-change') throw new Error(`${filename}: kind must be persistence-change`) const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)] const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source) if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`) const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename) keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename) if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`) const id = identifier(input.id, filename) if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`) bool(input.baseline, filename) const roots = new Set() for (const value of array(input.changes, `${filename} changes`)) { const change = record(value, filename) keys(change, ['root', 'previous', 'after', 'decision'], filename) const root = textValue(change.root, 'changed root') if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`) roots.add(root) if (change.previous !== null) identifier(change.previous, 'previous record') if (change.after !== null) digest(change.after, 'after digest') if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`) } if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`) if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`) return input as unknown as PersistenceChangeRecord } function headerVersion(root: PersistenceRoot | null): number | undefined { if (root === null) return undefined const node = root.schema.nodes[0] if (node?.kind !== 'object') return undefined const property = node.properties.find(item => item.name === 'version') if (property === undefined) return undefined const version = root.schema.nodes[property.type] return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined } /** Check every historical transition and return each root's unique current tip. * @param entries - parsed documents and their self-contained schema snapshots. * @returns validated history and tips, without consulting Git or current source. */ export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory { if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline') const records = new Map() for (const entry of entries) { if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`) records.set(entry.record.id, entry) const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort() if (JSON.stringify(expected) !== JSON.stringify(entry.snapshot.roots.map(root => root.key).sort())) throw new Error(`${entry.record.id}: snapshot roots do not match acknowledged after schemas`) for (const change of entry.record.changes) { const root = entry.snapshot.roots.find(root => root.key === change.root) if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`) if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`) } } const states = new Map() const successors = new Map() const nodes = new Map() const tips = new Map() const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root]) for (const entry of entries) for (const change of entry.record.changes) { const parentKey = nodeKey(change.previous, change.root) if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`) successors.set(parentKey, entry.record.id) nodes.set(nodeKey(entry.record.id, change.root), { entry, change }) } function visit(id: string, root: string): PersistenceRoot | null { const key = nodeKey(id, root) const found = nodes.get(key) if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`) if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`) const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null if (states.get(key) === 'visited') return after states.set(key, 'visiting') const before = found.change.previous === null ? null : visit(found.change.previous, root) if (!found.entry.record.baseline) { const differences = classifyPersistenceChange(before, after) if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`) if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') { throw new PersistenceChangeFailure( `${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`, 'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)], ) } if (found.change.decision === 'version-bump') { const header = found.entry.record.changes.find(change => change.root === 'SessionHeader') const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader') const from = headerVersion(oldHeader) const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null) if (from === undefined || to === undefined || to <= from) { throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`, 'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)]) } } } states.set(key, 'visited') if (!successors.has(key)) tips.set(root, { id, root: after }) return after } for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root) const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader') || !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope') || !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) { throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots') } return { entries, tips } } /** Read and validate all current-tree persistence acknowledgement files. * @param root - checkout or isolated fixture root. * @returns checked history without comparing its tips to current source. */ export function loadPersistenceHistory(root: string): PersistenceHistory { return validatePersistenceHistory(readPersistenceEntries(root)) } function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] { const directory = join(root, HISTORY_DIRECTORY) if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization') const files = readdirSync(directory).sort() const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md') const snapshots = new Set(files.filter(file => file.endsWith('.schema.json'))) const entries = documents.map((filename) => { const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n') const allowIncomplete = filename === `${allowIncompleteId}.md` const change = parseDocument(source, filename, allowIncomplete) const snapshotName = `${change.id}.schema.json` if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`) const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8'))) const translatedName = `${change.id}.zh.md` if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`) const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n') const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1] const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)] if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`) if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`) return { record: change, snapshot } }) if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`) return entries } function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] { const roots = new Map(current.roots.map(root => [root.key, root])) const differences: string[] = [] for (const key of new Set([...history.tips.keys(), ...roots.keys()])) { if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key) } return differences.sort() } /** Verify current generated output and acknowledgement tips together. * @param root - checkout or isolated fixture root. * @param current - freshly extracted current-source inventory. * @returns verified history. */ export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory { const history = loadPersistenceHistory(root) const differences = reportedDifferences(history, current) const transitions = rootTransitions(history, current) const committedPath = join(root, CURRENT_SCHEMA) if (!existsSync(committedPath)) { throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions) } const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8'))) if (JSON.stringify(committed) !== JSON.stringify(current)) { throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions) } if (differences.length !== 0) { const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`) throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions) } return history } function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition { const root = (after ?? before) as PersistenceRoot return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null } } function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] { const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current) return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null)) } function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] { return currentDifferences(history, current).flatMap(root => classifyPersistenceChange( history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null, ).map(change => ({ root, ...change }))) } function machineBlock(change: PersistenceChangeRecord): string { return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:', ...change.changes.flatMap(item => [` - root: ${JSON.stringify(item.root)}`, ` previous: ${item.previous === null ? 'null' : JSON.stringify(item.previous)}`, ` after: ${item.after === null ? 'null' : JSON.stringify(item.after)}`, ` decision: ${item.decision}`]), '```'].join('\n') } function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string { const summary = chinese ? '概述' : 'Summary' const compatibility = chinese ? '兼容性' : 'Compatibility' const verification = chinese ? '验证' : 'Verification' return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '', `# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '', `## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '', `- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '', '', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '', '', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '', '', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n') } /** Parse explicit authored prose without supplying compatibility or validation claims. * @param value - decoded JSON supplied through --prose. * @returns complete English and Chinese section text. */ export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair { const pair = record(value, 'persistence prose') keys(pair, ['en', 'zh'], 'persistence prose') for (const locale of ['en', 'zh']) { const sections = record(pair[locale], `persistence prose ${locale}`) keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`) for (const [name, value] of Object.entries(sections)) { const text = textValue(value, `${locale}.${name}`) if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) { throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`) } } } return pair as unknown as PersistenceChangeProsePair } function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string { source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change)) if (prose === undefined) return source const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification'] for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) { const lines = source.split('\n') const heading = `## ${headings[index]}` const start = lines.indexOf(heading) if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`) let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line)) if (end < 0) end = lines.length let anchor = end while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1 if (/^<\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1 lines.splice(start + 1, end - start - 1, '', text.trim(), '') source = lines.join('\n') } return source } function commandOperation(args: readonly string[]): CommandResult['operation'] { if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline' if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record' if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update' return 'check' } function executeCommand( args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory, artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[], ): CommandResult { const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: { check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' }, decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' }, } }) if (values.root !== undefined) root = resolve(values.root) const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined] if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID') if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update') const operation = commandOperation(args) if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update') const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8'))) const current = parsePersistenceSnapshot(extract(root)) if (operation === 'check') { const history = verifyPersistenceChanges(root, current) return { schemaVersion: 1, ok: true, operation, message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] } } const baseline = operation === 'baseline' const update = operation === 'update' const id = identifier(values.baseline ?? values.record ?? values.update, 'record id') const directory = join(root, HISTORY_DIRECTORY) if (baseline && existsSync(directory) && readdirSync(directory).some(file => file.endsWith('.schema.json') || ID_PATTERN.test(file.replace(/\.md$/u, '')))) throw new Error('persistence baseline already exists; baseline creation cannot reset history') if (values.decision !== undefined && values.decision !== 'same-version' && values.decision !== 'version-bump') { throw new Error('--decision must be same-version or version-bump') } const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined) const existing = update ? entries.find(entry => entry.record.id === id) : undefined if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`) if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline') if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) { throw new Error(`${id}: cannot update an acknowledgement with successors`) } const prior = entries.filter(entry => entry !== existing) const history = baseline ? undefined : validatePersistenceHistory(prior) const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current) if (changed.length === 0) throw new Error('no persistence type changes to acknowledge') const differences = history === undefined ? [] : reportedDifferences(history, current) const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version') const roots = current.roots.filter(root => changed.includes(root.key)) const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({ root: key, previous: history?.tips.get(key)?.id ?? null, after: roots.find(root => root.key === key)?.digest ?? null, decision, })) } const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] } validatePersistenceHistory([...prior, { record: change, snapshot }]) const document = (chinese: boolean): string => { const supplied = chinese ? prose?.zh : prose?.en return existing === undefined ? scaffold(change, chinese, supplied) : updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied) } const english = document(false) const chinese = document(true) parseDocument(english, `${id}.md`, prose === undefined && existing === undefined) parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined) const recordFiles = [ ...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese), { path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' }, ] if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`) const outputs = [...artifacts(root, current), ...recordFiles] for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true }) for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' }) const completion = baseline ? 'Complete both record documents and refresh their translation pairing.' : `Complete the compatibility and verification prose with --update ${id} --prose FILE.` const message = existing === undefined && prose === undefined ? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}` : `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.` return { schemaVersion: 1, ok: true, operation, recordId: id, message, changes: differences, roots: rootTransitions(history, current), files: outputs.map(file => file.path) } } /** Execute tree-only verification or author an explicit persistence acknowledgement. * @param args - check, baseline, record, or update arguments; --json selects structured output. * @param root - checkout root; defaults to this script's repository. * @param extract - current-source extraction function; fixtures supply their own source reader. * @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact. * @returns text or one JSON result; JSON failures retain ok:false for the process entry point. */ export function runPersistenceChanges( args: readonly string[], root: string = resolve(import.meta.dirname, '..'), extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema, artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts, ): string { try { const result = executeCommand(args, root, extract, artifacts) return args.includes('--json') ? JSON.stringify(result) : result.message } catch (error: unknown) { if (!args.includes('--json')) throw error const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args), message: error instanceof Error ? error.message : String(error), code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed', changes: error instanceof PersistenceChangeFailure ? error.changes : [], roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] } return JSON.stringify(result) } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { try { const output = runPersistenceChanges(process.argv.slice(2)) console.log(output) if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1 } catch (error: unknown) { console.error(error instanceof Error ? error.message : String(error)) process.exitCode = 1 } }