persistence-changes.ts 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. /** Verify and acknowledge persistence type changes from current-tree schema history. */
  2. import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
  3. import { basename, join, resolve } from 'node:path'
  4. import { parseArgs } from 'node:util'
  5. import { JSON_SCHEMA, load } from 'js-yaml'
  6. import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
  7. import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
  8. import { extractPersistenceSchema } from './persistence-schema.ts'
  9. import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
  10. import { renderPersistencePair } from './persistence-artifacts.ts'
  11. import type { PersistenceArtifact } from './persistence-artifacts.ts'
  12. const HISTORY_DIRECTORY = 'docs/persistence-changes'
  13. const CURRENT_SCHEMA = 'docs/persistence-schema.json'
  14. const ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u
  15. const DIGEST_PATTERN = /^[a-f0-9]{64}$/u
  16. const EXPLANATION_PLACEHOLDER = 'TODO: explain this change.'
  17. const EVIDENCE_PLACEHOLDER = 'TODO: record validation evidence.'
  18. /** The author's acknowledgement of one mechanically classified transition. */
  19. export type PersistenceDecision = 'same-version' | 'version-bump'
  20. /** One root's successor; null after values preserve a deletion in its history. */
  21. export interface PersistenceChange {
  22. readonly root: string
  23. readonly previous: string | null
  24. readonly after: string | null
  25. readonly decision: PersistenceDecision
  26. }
  27. /** A document's machine record, independent of its translated prose. */
  28. export interface PersistenceChangeRecord {
  29. readonly schemaVersion: 1
  30. readonly id: string
  31. readonly baseline: boolean
  32. readonly changes: readonly PersistenceChange[]
  33. }
  34. /** A parsed acknowledgement and its self-contained after schemas. */
  35. export interface PersistenceHistoryEntry {
  36. readonly record: PersistenceChangeRecord
  37. readonly snapshot: PersistenceSchemaInventory
  38. }
  39. /** One detected type change, with a path that reviewers can locate. */
  40. export interface PersistenceTypeChange {
  41. readonly kind: PersistenceTypeChangeKind
  42. readonly path: string
  43. readonly description: string
  44. readonly requiresVersionBump: boolean
  45. }
  46. const CHANGE_DESCRIPTIONS = {
  47. 'root-added': 'root added',
  48. 'root-removed': 'root removed',
  49. 'root-classification-changed': 'root classification changed',
  50. 'type-changed': 'type changed',
  51. 'property-removed': 'property removed',
  52. 'property-made-optional': 'property made optional',
  53. 'property-made-required': 'property made required',
  54. 'optional-property-added': 'optional property added',
  55. 'required-property-added': 'required property added',
  56. 'index-signature-changed': 'index signature changed',
  57. 'tuple-length-changed': 'tuple length changed',
  58. 'tuple-element-cardinality-changed': 'tuple element cardinality changed',
  59. 'union-variants-changed': 'union variants changed',
  60. } as const
  61. /** Stable structural classification independent of diagnostic prose. */
  62. export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS
  63. /** Author-supplied paragraphs used to complete one language of a change record. */
  64. export interface PersistenceChangeProse {
  65. readonly summary: string
  66. readonly compatibility: string
  67. readonly verification: string
  68. }
  69. /** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */
  70. export interface PersistenceChangeProsePair {
  71. readonly en: PersistenceChangeProse
  72. readonly zh: PersistenceChangeProse
  73. }
  74. interface ReportedChange extends PersistenceTypeChange {
  75. readonly root: string
  76. }
  77. interface RootTransition {
  78. readonly root: string
  79. readonly kind: PersistenceRoot['kind']
  80. readonly before: string | null
  81. readonly after: string | null
  82. }
  83. class PersistenceChangeFailure extends Error {
  84. constructor(
  85. message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [],
  86. readonly roots: readonly RootTransition[] = [],
  87. ) {
  88. super(message)
  89. }
  90. }
  91. interface CommandResult {
  92. readonly schemaVersion: 1
  93. readonly ok: boolean
  94. readonly operation: 'check' | 'baseline' | 'record' | 'update'
  95. readonly message: string
  96. readonly changes: readonly ReportedChange[]
  97. readonly roots: readonly RootTransition[]
  98. readonly files: readonly string[]
  99. readonly recordId?: string
  100. readonly code?: string
  101. }
  102. interface Tip {
  103. readonly id: string
  104. readonly root: PersistenceRoot | null
  105. }
  106. /** Verified per-root history tips; historical schemas need not match the current tree. */
  107. export interface PersistenceHistory {
  108. readonly entries: readonly PersistenceHistoryEntry[]
  109. readonly tips: ReadonlyMap<string, Tip>
  110. }
  111. function record(value: unknown, label: string): Record<string, unknown> {
  112. if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
  113. return value as Record<string, unknown>
  114. }
  115. function keys(value: Record<string, unknown>, expected: readonly string[], label: string, optional: readonly string[] = []): void {
  116. const missing = expected.find(key => !Object.hasOwn(value, key))
  117. const unexpected = Object.keys(value).find(key => !expected.includes(key) && !optional.includes(key))
  118. if (missing !== undefined || unexpected !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${unexpected}` : `missing field ${missing}`}`)
  119. }
  120. function array(value: unknown, label: string): unknown[] {
  121. if (!Array.isArray(value)) throw new Error(`${label} must be an array`)
  122. return value
  123. }
  124. function textValue(value: unknown, label: string): string {
  125. if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be a non-empty string`)
  126. return value
  127. }
  128. function bool(value: unknown, label: string): boolean {
  129. if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
  130. return value
  131. }
  132. function identifier(value: unknown, label: string): string {
  133. const id = textValue(value, label)
  134. if (!ID_PATTERN.test(id)) throw new Error(`${label} must be YYYY-MM-DD-slug`)
  135. return id
  136. }
  137. function digest(value: unknown, label: string): string {
  138. const result = textValue(value, label)
  139. if (!DIGEST_PATTERN.test(result)) throw new Error(`${label} must be a SHA-256 digest`)
  140. return result
  141. }
  142. function reference(value: unknown, count: number, label: string): number {
  143. if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`)
  144. return value as number
  145. }
  146. function parseSchema(value: unknown, label: string): CanonicalSchema {
  147. const input = record(value, label)
  148. keys(input, ['root', 'nodes'], label)
  149. if (input.root !== 0) throw new Error(`${label}.root must be zero`)
  150. const nodes = array(input.nodes, `${label}.nodes`)
  151. if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`)
  152. const ref = (value: unknown): number => reference(value, nodes.length, label)
  153. for (const [index, raw] of nodes.entries()) {
  154. const node = record(raw, `${label}.nodes[${index}]`)
  155. switch (node.kind) {
  156. case 'primitive':
  157. keys(node, ['kind', 'type'], label)
  158. if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
  159. break
  160. case 'literal':
  161. keys(node, ['kind', 'value'], label)
  162. if (!['string', 'boolean', 'number'].includes(typeof node.value)
  163. || typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
  164. break
  165. case 'opaque':
  166. keys(node, ['kind', 'reason'], label)
  167. if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
  168. break
  169. case 'array':
  170. keys(node, ['kind', 'element'], label)
  171. ref(node.element)
  172. break
  173. case 'tuple':
  174. keys(node, ['kind', 'elements'], label)
  175. for (const rawElement of array(node.elements, label)) {
  176. const element = record(rawElement, label)
  177. keys(element, ['type', 'optional', 'rest'], label)
  178. ref(element.type)
  179. bool(element.optional, label)
  180. bool(element.rest, label)
  181. }
  182. break
  183. case 'object': {
  184. keys(node, ['kind', 'properties', 'indices'], label)
  185. const names = new Set<string>()
  186. for (const rawProperty of array(node.properties, label)) {
  187. const property = record(rawProperty, label)
  188. keys(property, ['name', 'type', 'optional'], label)
  189. if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
  190. const name = property.name
  191. if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
  192. names.add(name)
  193. ref(property.type)
  194. bool(property.optional, label)
  195. }
  196. for (const rawIndex of array(node.indices, label)) {
  197. const entry = record(rawIndex, label)
  198. keys(entry, ['key', 'value'], label)
  199. ref(entry.key)
  200. ref(entry.value)
  201. }
  202. break
  203. }
  204. case 'union':
  205. keys(node, ['kind', 'types'], label)
  206. if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
  207. for (const item of node.types as unknown[]) ref(item)
  208. break
  209. default:
  210. throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
  211. }
  212. }
  213. const schema = input as unknown as CanonicalSchema
  214. const canonical = canonicalizeSchema(schema.nodes, schema.root)
  215. if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
  216. return schema
  217. }
  218. /** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
  219. * @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
  220. * @returns the validated inventory.
  221. */
  222. export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
  223. return parseSnapshot(value, false)
  224. }
  225. /** Parse a historical inventory with path-only source references and optional surface operations.
  226. * @param value - JSON captured from a historical source tree.
  227. * @returns the validated inventory; current acknowledgements use the strict parser.
  228. */
  229. export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
  230. return parseSnapshot(value, true)
  231. }
  232. function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
  233. const input = record(value, 'schema inventory')
  234. keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
  235. if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
  236. const names = new Set<string>()
  237. for (const rawRoot of array(input.roots, 'schema roots')) {
  238. const root = record(rawRoot, 'schema root')
  239. keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
  240. const key = textValue(root.key, 'root key')
  241. if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
  242. names.add(key)
  243. if (root.kind === 'event') {
  244. if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`)
  245. bool(root.surface, 'event surface membership')
  246. } else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
  247. && (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
  248. if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
  249. const schema = parseSchema(root.schema, key)
  250. if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
  251. if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
  252. }
  253. for (const rawType of array(input.types, 'schema types')) {
  254. const type = record(rawType, 'schema type')
  255. keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
  256. const schema = parseSchema(type.schema, 'shared schema')
  257. if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
  258. for (const name of array(type.names, 'type names')) textValue(name, 'type name')
  259. for (const source of array(type.sources, 'type sources')) {
  260. const location = textValue(source, 'type source')
  261. if (historical && /:\d+(?::\d+)?$|#L\d+(?:-L\d+)?$/u.test(location)) {
  262. throw new Error('historical schema sources must omit line numbers')
  263. }
  264. }
  265. }
  266. return input as unknown as PersistenceSchemaInventory
  267. }
  268. function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
  269. const pending = [0]
  270. const visited = new Set<number>()
  271. while (pending.length > 0) {
  272. const index = pending.pop() as number
  273. if (visited.has(index)) continue
  274. visited.add(index)
  275. const node = schema.nodes[index] as SchemaNode
  276. if (node.kind === 'union') { pending.push(...node.types); continue }
  277. if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`)
  278. const tag = node.properties.find(property => property.name === 'type')
  279. const value = tag === undefined ? undefined : schema.nodes[tag.type]
  280. if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`)
  281. const operation = node.properties.find(property => property.name === 'surfaceOp')
  282. if (surface ? operation === undefined || !historical && operation.optional : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`)
  283. }
  284. }
  285. function subDigest(schema: CanonicalSchema, node: number): string {
  286. return schemaDigest(canonicalizeSchema(schema.nodes, node))
  287. }
  288. function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
  289. const owners = new Map<number, number>()
  290. function assign(previous: number, visited: Set<number>): boolean {
  291. for (const next of candidates[previous] ?? []) {
  292. if (visited.has(next)) continue
  293. visited.add(next)
  294. const owner = owners.get(next)
  295. if (owner === undefined || assign(owner, visited)) {
  296. owners.set(next, previous)
  297. return true
  298. }
  299. }
  300. return false
  301. }
  302. for (let previous = 0; previous < candidates.length; previous += 1) {
  303. if (!assign(previous, new Set())) return undefined
  304. }
  305. const matches: number[] = []
  306. for (const [next, previous] of owners) matches[previous] = next
  307. return matches
  308. }
  309. /** Classify structural differences; only optional payload properties and ordinary event additions are additive.
  310. * @param before - predecessor root, or absence for an addition.
  311. * @param after - successor root, or absence for deletion.
  312. * @returns concrete changes and their format-bump requirement.
  313. */
  314. export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
  315. if (before === null) {
  316. return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
  317. requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
  318. }
  319. if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
  320. const key = after.key
  321. const oldRoot = before
  322. const newRoot = after
  323. const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
  324. ({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
  325. const changes: PersistenceTypeChange[] = []
  326. if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
  327. if (before.digest === after.digest) return changes
  328. const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
  329. const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
  330. let result = fingerprints[side].get(index)
  331. if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
  332. return result
  333. }
  334. type Scope = 'event' | 'body' | 'strict'
  335. function compare(
  336. oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
  337. ): PersistenceTypeChange[] {
  338. if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
  339. const pair = `${oldIndex}:${newIndex}:${scope}`
  340. if (ancestors.has(pair)) return []
  341. // Recursive pairs are assumptions for this candidate only. A failed sibling
  342. // or unmatched union arm cannot leave a cached success for another candidate.
  343. const active = new Set(ancestors).add(pair)
  344. const differences: PersistenceTypeChange[] = []
  345. const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
  346. differences.push(describe(path, kind, requiresVersionBump))
  347. }
  348. const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
  349. differences.push(...compare(oldType, newType, child, childScope, active))
  350. }
  351. const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
  352. const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
  353. if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
  354. if (oldNode.kind === 'object' && newNode.kind === 'object') {
  355. const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
  356. const newProps = new Map(newNode.properties.map(property => [property.name, property]))
  357. for (const [name, property] of oldProps) {
  358. const next = newProps.get(name)
  359. const child = `${path}.${name}`
  360. if (next === undefined) { add(child, 'property-removed'); continue }
  361. if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
  362. descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
  363. }
  364. for (const [name, property] of newProps) {
  365. if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
  366. }
  367. const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
  368. const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
  369. if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
  370. for (const [index, entry] of oldIndices) {
  371. const next = newIndices.get(index)
  372. if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
  373. }
  374. return differences
  375. }
  376. if (oldNode.kind === 'array' && newNode.kind === 'array') {
  377. return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
  378. }
  379. if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
  380. if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
  381. for (const [index, element] of oldNode.elements.entries()) {
  382. const next = newNode.elements[index] as SchemaTupleElement
  383. if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
  384. descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
  385. }
  386. return differences
  387. }
  388. if (oldNode.kind === 'union' && newNode.kind === 'union') {
  389. if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
  390. const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
  391. const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
  392. candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
  393. if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
  394. const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
  395. const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
  396. const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
  397. const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
  398. if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
  399. return [describe(path, 'union-variants-changed')]
  400. }
  401. return [describe(path, 'type-changed')]
  402. }
  403. changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
  404. if (changes.length === 0) changes.push(describe(key, 'type-changed'))
  405. return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
  406. }
  407. function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
  408. const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
  409. 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`)
  410. const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
  411. const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source)
  412. if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`)
  413. const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename)
  414. keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename)
  415. if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`)
  416. const id = identifier(input.id, filename)
  417. if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`)
  418. bool(input.baseline, filename)
  419. const roots = new Set<string>()
  420. for (const value of array(input.changes, `${filename} changes`)) {
  421. const change = record(value, filename)
  422. keys(change, ['root', 'previous', 'after', 'decision'], filename)
  423. const root = textValue(change.root, 'changed root')
  424. if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`)
  425. roots.add(root)
  426. if (change.previous !== null) identifier(change.previous, 'previous record')
  427. if (change.after !== null) digest(change.after, 'after digest')
  428. if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
  429. }
  430. if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
  431. if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
  432. return input as unknown as PersistenceChangeRecord
  433. }
  434. function headerVersion(root: PersistenceRoot | null): number | undefined {
  435. if (root === null) return undefined
  436. const node = root.schema.nodes[0]
  437. if (node?.kind !== 'object') return undefined
  438. const property = node.properties.find(item => item.name === 'version')
  439. if (property === undefined) return undefined
  440. const version = root.schema.nodes[property.type]
  441. return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined
  442. }
  443. /** Check every historical transition and return each root's unique current tip.
  444. * @param entries - parsed documents and their self-contained schema snapshots.
  445. * @returns validated history and tips, without consulting Git or current source.
  446. */
  447. export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory {
  448. if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline')
  449. const records = new Map<string, PersistenceHistoryEntry>()
  450. for (const entry of entries) {
  451. if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`)
  452. records.set(entry.record.id, entry)
  453. const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort()
  454. 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`)
  455. for (const change of entry.record.changes) {
  456. const root = entry.snapshot.roots.find(root => root.key === change.root)
  457. if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`)
  458. if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`)
  459. }
  460. }
  461. const states = new Map<string, 'visiting' | 'visited'>()
  462. const successors = new Map<string, string>()
  463. const nodes = new Map<string, { entry: PersistenceHistoryEntry; change: PersistenceChange }>()
  464. const tips = new Map<string, Tip>()
  465. const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root])
  466. for (const entry of entries) for (const change of entry.record.changes) {
  467. const parentKey = nodeKey(change.previous, change.root)
  468. if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`)
  469. successors.set(parentKey, entry.record.id)
  470. nodes.set(nodeKey(entry.record.id, change.root), { entry, change })
  471. }
  472. function visit(id: string, root: string): PersistenceRoot | null {
  473. const key = nodeKey(id, root)
  474. const found = nodes.get(key)
  475. if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`)
  476. if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`)
  477. const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null
  478. if (states.get(key) === 'visited') return after
  479. states.set(key, 'visiting')
  480. const before = found.change.previous === null ? null : visit(found.change.previous, root)
  481. if (!found.entry.record.baseline) {
  482. const differences = classifyPersistenceChange(before, after)
  483. if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
  484. if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
  485. throw new PersistenceChangeFailure(
  486. `${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
  487. 'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
  488. )
  489. }
  490. if (found.change.decision === 'version-bump') {
  491. const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
  492. const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
  493. const from = headerVersion(oldHeader)
  494. const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
  495. if (from === undefined || to === undefined || to <= from) {
  496. throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
  497. 'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
  498. }
  499. }
  500. }
  501. states.set(key, 'visited')
  502. if (!successors.has(key)) tips.set(root, { id, root: after })
  503. return after
  504. }
  505. for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root)
  506. const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry
  507. if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader')
  508. || !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope')
  509. || !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) {
  510. throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots')
  511. }
  512. return { entries, tips }
  513. }
  514. /** Read and validate all current-tree persistence acknowledgement files.
  515. * @param root - checkout or isolated fixture root.
  516. * @returns checked history without comparing its tips to current source.
  517. */
  518. export function loadPersistenceHistory(root: string): PersistenceHistory {
  519. return validatePersistenceHistory(readPersistenceEntries(root))
  520. }
  521. function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
  522. const directory = join(root, HISTORY_DIRECTORY)
  523. if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
  524. const files = readdirSync(directory).sort()
  525. const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md')
  526. const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
  527. const entries = documents.map((filename) => {
  528. const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
  529. const allowIncomplete = filename === `${allowIncompleteId}.md`
  530. const change = parseDocument(source, filename, allowIncomplete)
  531. const snapshotName = `${change.id}.schema.json`
  532. if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
  533. const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
  534. const translatedName = `${change.id}.zh.md`
  535. if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
  536. const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
  537. const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
  538. const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
  539. if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
  540. if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
  541. return { record: change, snapshot }
  542. })
  543. if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
  544. return entries
  545. }
  546. function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
  547. const roots = new Map(current.roots.map(root => [root.key, root]))
  548. const differences: string[] = []
  549. for (const key of new Set([...history.tips.keys(), ...roots.keys()])) {
  550. if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key)
  551. }
  552. return differences.sort()
  553. }
  554. /** Verify current generated output and acknowledgement tips together.
  555. * @param root - checkout or isolated fixture root.
  556. * @param current - freshly extracted current-source inventory.
  557. * @returns verified history.
  558. */
  559. export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
  560. const history = loadPersistenceHistory(root)
  561. const differences = reportedDifferences(history, current)
  562. const transitions = rootTransitions(history, current)
  563. const committedPath = join(root, CURRENT_SCHEMA)
  564. if (!existsSync(committedPath)) {
  565. throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
  566. }
  567. const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
  568. if (JSON.stringify(committed) !== JSON.stringify(current)) {
  569. throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
  570. }
  571. if (differences.length !== 0) {
  572. const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
  573. throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
  574. }
  575. return history
  576. }
  577. function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
  578. const root = (after ?? before) as PersistenceRoot
  579. return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
  580. }
  581. function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
  582. const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
  583. return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
  584. }
  585. function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
  586. return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
  587. history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
  588. ).map(change => ({ root, ...change })))
  589. }
  590. function machineBlock(change: PersistenceChangeRecord): string {
  591. return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
  592. ...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')
  593. }
  594. function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
  595. const summary = chinese ? '概述' : 'Summary'
  596. const compatibility = chinese ? '兼容性' : 'Compatibility'
  597. const verification = chinese ? '验证' : 'Verification'
  598. return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
  599. `# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
  600. `## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
  601. `- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
  602. '<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
  603. '<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
  604. '<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
  605. }
  606. /** Parse explicit authored prose without supplying compatibility or validation claims.
  607. * @param value - decoded JSON supplied through --prose.
  608. * @returns complete English and Chinese section text.
  609. */
  610. export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
  611. const pair = record(value, 'persistence prose')
  612. keys(pair, ['en', 'zh'], 'persistence prose')
  613. for (const locale of ['en', 'zh']) {
  614. const sections = record(pair[locale], `persistence prose ${locale}`)
  615. keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
  616. for (const [name, value] of Object.entries(sections)) {
  617. const text = textValue(value, `${locale}.${name}`)
  618. if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
  619. throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
  620. }
  621. }
  622. }
  623. return pair as unknown as PersistenceChangeProsePair
  624. }
  625. function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
  626. source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
  627. if (prose === undefined) return source
  628. const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
  629. for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
  630. const lines = source.split('\n')
  631. const heading = `## ${headings[index]}`
  632. const start = lines.indexOf(heading)
  633. if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
  634. let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
  635. if (end < 0) end = lines.length
  636. let anchor = end
  637. while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
  638. if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
  639. lines.splice(start + 1, end - start - 1, '', text.trim(), '')
  640. source = lines.join('\n')
  641. }
  642. return source
  643. }
  644. function commandOperation(args: readonly string[]): CommandResult['operation'] {
  645. if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
  646. if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
  647. if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
  648. return 'check'
  649. }
  650. function executeCommand(
  651. args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
  652. artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
  653. ): CommandResult {
  654. const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
  655. check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
  656. decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
  657. } })
  658. if (values.root !== undefined) root = resolve(values.root)
  659. const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
  660. if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
  661. if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
  662. const operation = commandOperation(args)
  663. if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
  664. const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
  665. const current = parsePersistenceSnapshot(extract(root))
  666. if (operation === 'check') {
  667. const history = verifyPersistenceChanges(root, current)
  668. return { schemaVersion: 1, ok: true, operation,
  669. message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
  670. }
  671. const baseline = operation === 'baseline'
  672. const update = operation === 'update'
  673. const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
  674. const directory = join(root, HISTORY_DIRECTORY)
  675. 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')
  676. if (values.decision !== undefined && values.decision !== 'same-version' && values.decision !== 'version-bump') {
  677. throw new Error('--decision must be same-version or version-bump')
  678. }
  679. const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
  680. const existing = update ? entries.find(entry => entry.record.id === id) : undefined
  681. if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
  682. if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
  683. if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
  684. throw new Error(`${id}: cannot update an acknowledgement with successors`)
  685. }
  686. const prior = entries.filter(entry => entry !== existing)
  687. const history = baseline ? undefined : validatePersistenceHistory(prior)
  688. const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
  689. if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
  690. const differences = history === undefined ? [] : reportedDifferences(history, current)
  691. const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version')
  692. const roots = current.roots.filter(root => changed.includes(root.key))
  693. const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
  694. root: key, previous: history?.tips.get(key)?.id ?? null,
  695. after: roots.find(root => root.key === key)?.digest ?? null, decision,
  696. })) }
  697. const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
  698. validatePersistenceHistory([...prior, { record: change, snapshot }])
  699. const document = (chinese: boolean): string => {
  700. const supplied = chinese ? prose?.zh : prose?.en
  701. return existing === undefined ? scaffold(change, chinese, supplied)
  702. : updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
  703. }
  704. const english = document(false)
  705. const chinese = document(true)
  706. parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
  707. parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
  708. const recordFiles = [
  709. ...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
  710. { path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
  711. ]
  712. if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
  713. const outputs = [...artifacts(root, current), ...recordFiles]
  714. for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
  715. for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
  716. const completion = baseline
  717. ? 'Complete both record documents and refresh their translation pairing.'
  718. : `Complete the compatibility and verification prose with --update ${id} --prose FILE.`
  719. const message = existing === undefined && prose === undefined
  720. ? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
  721. : `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
  722. return { schemaVersion: 1, ok: true, operation, recordId: id, message,
  723. changes: differences,
  724. roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
  725. }
  726. /** Execute tree-only verification or author an explicit persistence acknowledgement.
  727. * @param args - check, baseline, record, or update arguments; --json selects structured output.
  728. * @param root - checkout root; defaults to this script's repository.
  729. * @param extract - current-source extraction function; fixtures supply their own source reader.
  730. * @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
  731. * @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
  732. */
  733. export function runPersistenceChanges(
  734. args: readonly string[],
  735. root: string = resolve(import.meta.dirname, '..'),
  736. extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
  737. artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
  738. ): string {
  739. try {
  740. const result = executeCommand(args, root, extract, artifacts)
  741. return args.includes('--json') ? JSON.stringify(result) : result.message
  742. } catch (error: unknown) {
  743. if (!args.includes('--json')) throw error
  744. const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
  745. message: error instanceof Error ? error.message : String(error),
  746. code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
  747. changes: error instanceof PersistenceChangeFailure ? error.changes : [],
  748. roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
  749. return JSON.stringify(result)
  750. }
  751. }
  752. if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
  753. try {
  754. const output = runPersistenceChanges(process.argv.slice(2))
  755. console.log(output)
  756. if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
  757. } catch (error: unknown) {
  758. console.error(error instanceof Error ? error.message : String(error))
  759. process.exitCode = 1
  760. }
  761. }