persistence-changes.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  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 a current or historical schema file.
  220. * @returns the validated inventory.
  221. */
  222. export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
  223. const input = record(value, 'schema inventory')
  224. keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
  225. if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
  226. const names = new Set<string>()
  227. for (const rawRoot of array(input.roots, 'schema roots')) {
  228. const root = record(rawRoot, 'schema root')
  229. keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
  230. const key = textValue(root.key, 'root key')
  231. if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
  232. names.add(key)
  233. if (root.kind === 'event') {
  234. if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`)
  235. bool(root.surface, 'event surface membership')
  236. } else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
  237. && (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
  238. if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
  239. const schema = parseSchema(root.schema, key)
  240. if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true)
  241. if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
  242. }
  243. for (const rawType of array(input.types, 'schema types')) {
  244. const type = record(rawType, 'schema type')
  245. keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
  246. const schema = parseSchema(type.schema, 'shared schema')
  247. if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
  248. for (const name of array(type.names, 'type names')) textValue(name, 'type name')
  249. for (const source of array(type.sources, 'type sources')) textValue(source, 'type source')
  250. }
  251. return input as unknown as PersistenceSchemaInventory
  252. }
  253. function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean): void {
  254. const pending = [0]
  255. const visited = new Set<number>()
  256. while (pending.length > 0) {
  257. const index = pending.pop() as number
  258. if (visited.has(index)) continue
  259. visited.add(index)
  260. const node = schema.nodes[index] as SchemaNode
  261. if (node.kind === 'union') { pending.push(...node.types); continue }
  262. if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`)
  263. const tag = node.properties.find(property => property.name === 'type')
  264. const value = tag === undefined ? undefined : schema.nodes[tag.type]
  265. if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`)
  266. const operation = node.properties.find(property => property.name === 'surfaceOp')
  267. if (surface ? operation?.optional !== false : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`)
  268. }
  269. }
  270. function subDigest(schema: CanonicalSchema, node: number): string {
  271. return schemaDigest(canonicalizeSchema(schema.nodes, node))
  272. }
  273. function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
  274. const owners = new Map<number, number>()
  275. function assign(previous: number, visited: Set<number>): boolean {
  276. for (const next of candidates[previous] ?? []) {
  277. if (visited.has(next)) continue
  278. visited.add(next)
  279. const owner = owners.get(next)
  280. if (owner === undefined || assign(owner, visited)) {
  281. owners.set(next, previous)
  282. return true
  283. }
  284. }
  285. return false
  286. }
  287. for (let previous = 0; previous < candidates.length; previous += 1) {
  288. if (!assign(previous, new Set())) return undefined
  289. }
  290. const matches: number[] = []
  291. for (const [next, previous] of owners) matches[previous] = next
  292. return matches
  293. }
  294. /** Classify structural differences; only optional payload properties and ordinary event additions are additive.
  295. * @param before - predecessor root, or absence for an addition.
  296. * @param after - successor root, or absence for deletion.
  297. * @returns concrete changes and their format-bump requirement.
  298. */
  299. export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
  300. if (before === null) {
  301. return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
  302. requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
  303. }
  304. if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
  305. const key = after.key
  306. const oldRoot = before
  307. const newRoot = after
  308. const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
  309. ({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
  310. const changes: PersistenceTypeChange[] = []
  311. if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
  312. if (before.digest === after.digest) return changes
  313. const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
  314. const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
  315. let result = fingerprints[side].get(index)
  316. if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
  317. return result
  318. }
  319. type Scope = 'event' | 'body' | 'strict'
  320. function compare(
  321. oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
  322. ): PersistenceTypeChange[] {
  323. if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
  324. const pair = `${oldIndex}:${newIndex}:${scope}`
  325. if (ancestors.has(pair)) return []
  326. // Recursive pairs are assumptions for this candidate only. A failed sibling
  327. // or unmatched union arm cannot leave a cached success for another candidate.
  328. const active = new Set(ancestors).add(pair)
  329. const differences: PersistenceTypeChange[] = []
  330. const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
  331. differences.push(describe(path, kind, requiresVersionBump))
  332. }
  333. const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
  334. differences.push(...compare(oldType, newType, child, childScope, active))
  335. }
  336. const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
  337. const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
  338. if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
  339. if (oldNode.kind === 'object' && newNode.kind === 'object') {
  340. const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
  341. const newProps = new Map(newNode.properties.map(property => [property.name, property]))
  342. for (const [name, property] of oldProps) {
  343. const next = newProps.get(name)
  344. const child = `${path}.${name}`
  345. if (next === undefined) { add(child, 'property-removed'); continue }
  346. if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
  347. descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
  348. }
  349. for (const [name, property] of newProps) {
  350. if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
  351. }
  352. const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
  353. const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
  354. if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
  355. for (const [index, entry] of oldIndices) {
  356. const next = newIndices.get(index)
  357. if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
  358. }
  359. return differences
  360. }
  361. if (oldNode.kind === 'array' && newNode.kind === 'array') {
  362. return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
  363. }
  364. if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
  365. if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
  366. for (const [index, element] of oldNode.elements.entries()) {
  367. const next = newNode.elements[index] as SchemaTupleElement
  368. if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
  369. descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
  370. }
  371. return differences
  372. }
  373. if (oldNode.kind === 'union' && newNode.kind === 'union') {
  374. if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
  375. const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
  376. const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
  377. candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
  378. if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
  379. const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
  380. const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
  381. const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
  382. const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
  383. if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
  384. return [describe(path, 'union-variants-changed')]
  385. }
  386. return [describe(path, 'type-changed')]
  387. }
  388. changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
  389. if (changes.length === 0) changes.push(describe(key, 'type-changed'))
  390. return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
  391. }
  392. function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
  393. const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
  394. 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`)
  395. const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
  396. const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source)
  397. if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`)
  398. const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename)
  399. keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename)
  400. if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`)
  401. const id = identifier(input.id, filename)
  402. if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`)
  403. bool(input.baseline, filename)
  404. const roots = new Set<string>()
  405. for (const value of array(input.changes, `${filename} changes`)) {
  406. const change = record(value, filename)
  407. keys(change, ['root', 'previous', 'after', 'decision'], filename)
  408. const root = textValue(change.root, 'changed root')
  409. if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`)
  410. roots.add(root)
  411. if (change.previous !== null) identifier(change.previous, 'previous record')
  412. if (change.after !== null) digest(change.after, 'after digest')
  413. if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
  414. }
  415. if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
  416. if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
  417. return input as unknown as PersistenceChangeRecord
  418. }
  419. function headerVersion(root: PersistenceRoot | null): number | undefined {
  420. if (root === null) return undefined
  421. const node = root.schema.nodes[0]
  422. if (node?.kind !== 'object') return undefined
  423. const property = node.properties.find(item => item.name === 'version')
  424. if (property === undefined) return undefined
  425. const version = root.schema.nodes[property.type]
  426. return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined
  427. }
  428. /** Check every historical transition and return each root's unique current tip.
  429. * @param entries - parsed documents and their self-contained schema snapshots.
  430. * @returns validated history and tips, without consulting Git or current source.
  431. */
  432. export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory {
  433. if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline')
  434. const records = new Map<string, PersistenceHistoryEntry>()
  435. for (const entry of entries) {
  436. if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`)
  437. records.set(entry.record.id, entry)
  438. const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort()
  439. 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`)
  440. for (const change of entry.record.changes) {
  441. const root = entry.snapshot.roots.find(root => root.key === change.root)
  442. if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`)
  443. if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`)
  444. }
  445. }
  446. const states = new Map<string, 'visiting' | 'visited'>()
  447. const successors = new Map<string, string>()
  448. const nodes = new Map<string, { entry: PersistenceHistoryEntry; change: PersistenceChange }>()
  449. const tips = new Map<string, Tip>()
  450. const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root])
  451. for (const entry of entries) for (const change of entry.record.changes) {
  452. const parentKey = nodeKey(change.previous, change.root)
  453. if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`)
  454. successors.set(parentKey, entry.record.id)
  455. nodes.set(nodeKey(entry.record.id, change.root), { entry, change })
  456. }
  457. function visit(id: string, root: string): PersistenceRoot | null {
  458. const key = nodeKey(id, root)
  459. const found = nodes.get(key)
  460. if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`)
  461. if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`)
  462. const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null
  463. if (states.get(key) === 'visited') return after
  464. states.set(key, 'visiting')
  465. const before = found.change.previous === null ? null : visit(found.change.previous, root)
  466. if (!found.entry.record.baseline) {
  467. const differences = classifyPersistenceChange(before, after)
  468. if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
  469. if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
  470. throw new PersistenceChangeFailure(
  471. `${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
  472. 'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
  473. )
  474. }
  475. if (found.change.decision === 'version-bump') {
  476. const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
  477. const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
  478. const from = headerVersion(oldHeader)
  479. const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
  480. if (from === undefined || to === undefined || to <= from) {
  481. throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
  482. 'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
  483. }
  484. }
  485. }
  486. states.set(key, 'visited')
  487. if (!successors.has(key)) tips.set(root, { id, root: after })
  488. return after
  489. }
  490. for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root)
  491. const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry
  492. if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader')
  493. || !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope')
  494. || !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) {
  495. throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots')
  496. }
  497. return { entries, tips }
  498. }
  499. /** Read and validate all current-tree persistence acknowledgement files.
  500. * @param root - checkout or isolated fixture root.
  501. * @returns checked history without comparing its tips to current source.
  502. */
  503. export function loadPersistenceHistory(root: string): PersistenceHistory {
  504. return validatePersistenceHistory(readPersistenceEntries(root))
  505. }
  506. function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
  507. const directory = join(root, HISTORY_DIRECTORY)
  508. if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
  509. const files = readdirSync(directory).sort()
  510. const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md')
  511. const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
  512. const entries = documents.map((filename) => {
  513. const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
  514. const allowIncomplete = filename === `${allowIncompleteId}.md`
  515. const change = parseDocument(source, filename, allowIncomplete)
  516. const snapshotName = `${change.id}.schema.json`
  517. if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
  518. const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
  519. const translatedName = `${change.id}.zh.md`
  520. if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
  521. const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
  522. const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
  523. const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
  524. if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
  525. if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
  526. return { record: change, snapshot }
  527. })
  528. if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
  529. return entries
  530. }
  531. function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
  532. const roots = new Map(current.roots.map(root => [root.key, root]))
  533. const differences: string[] = []
  534. for (const key of new Set([...history.tips.keys(), ...roots.keys()])) {
  535. if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key)
  536. }
  537. return differences.sort()
  538. }
  539. /** Verify current generated output and acknowledgement tips together.
  540. * @param root - checkout or isolated fixture root.
  541. * @param current - freshly extracted current-source inventory.
  542. * @returns verified history.
  543. */
  544. export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
  545. const history = loadPersistenceHistory(root)
  546. const differences = reportedDifferences(history, current)
  547. const transitions = rootTransitions(history, current)
  548. const committedPath = join(root, CURRENT_SCHEMA)
  549. if (!existsSync(committedPath)) {
  550. throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
  551. }
  552. const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
  553. if (JSON.stringify(committed) !== JSON.stringify(current)) {
  554. throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
  555. }
  556. if (differences.length !== 0) {
  557. const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
  558. throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
  559. }
  560. return history
  561. }
  562. function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
  563. const root = (after ?? before) as PersistenceRoot
  564. return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
  565. }
  566. function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
  567. const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
  568. return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
  569. }
  570. function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
  571. return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
  572. history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
  573. ).map(change => ({ root, ...change })))
  574. }
  575. function machineBlock(change: PersistenceChangeRecord): string {
  576. return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
  577. ...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')
  578. }
  579. function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
  580. const summary = chinese ? '概述' : 'Summary'
  581. const compatibility = chinese ? '兼容性' : 'Compatibility'
  582. const verification = chinese ? '验证' : 'Verification'
  583. return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
  584. `# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
  585. `## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
  586. `- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
  587. '<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
  588. '<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
  589. '<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
  590. }
  591. /** Parse explicit authored prose without supplying compatibility or validation claims.
  592. * @param value - decoded JSON supplied through --prose.
  593. * @returns complete English and Chinese section text.
  594. */
  595. export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
  596. const pair = record(value, 'persistence prose')
  597. keys(pair, ['en', 'zh'], 'persistence prose')
  598. for (const locale of ['en', 'zh']) {
  599. const sections = record(pair[locale], `persistence prose ${locale}`)
  600. keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
  601. for (const [name, value] of Object.entries(sections)) {
  602. const text = textValue(value, `${locale}.${name}`)
  603. if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
  604. throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
  605. }
  606. }
  607. }
  608. return pair as unknown as PersistenceChangeProsePair
  609. }
  610. function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
  611. source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
  612. if (prose === undefined) return source
  613. const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
  614. for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
  615. const lines = source.split('\n')
  616. const heading = `## ${headings[index]}`
  617. const start = lines.indexOf(heading)
  618. if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
  619. let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
  620. if (end < 0) end = lines.length
  621. let anchor = end
  622. while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
  623. if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
  624. lines.splice(start + 1, end - start - 1, '', text.trim(), '')
  625. source = lines.join('\n')
  626. }
  627. return source
  628. }
  629. function commandOperation(args: readonly string[]): CommandResult['operation'] {
  630. if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
  631. if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
  632. if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
  633. return 'check'
  634. }
  635. function executeCommand(
  636. args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
  637. artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
  638. ): CommandResult {
  639. const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
  640. check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
  641. decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
  642. } })
  643. if (values.root !== undefined) root = resolve(values.root)
  644. const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
  645. if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
  646. if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
  647. const operation = commandOperation(args)
  648. if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
  649. const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
  650. const current = parsePersistenceSnapshot(extract(root))
  651. if (operation === 'check') {
  652. const history = verifyPersistenceChanges(root, current)
  653. return { schemaVersion: 1, ok: true, operation,
  654. message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
  655. }
  656. const baseline = operation === 'baseline'
  657. const update = operation === 'update'
  658. const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
  659. const directory = join(root, HISTORY_DIRECTORY)
  660. 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')
  661. const decision = baseline ? 'same-version' : values.decision
  662. if (decision !== 'same-version' && decision !== 'version-bump') throw new Error('--record and --update require --decision same-version|version-bump')
  663. const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
  664. const existing = update ? entries.find(entry => entry.record.id === id) : undefined
  665. if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
  666. if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
  667. if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
  668. throw new Error(`${id}: cannot update an acknowledgement with successors`)
  669. }
  670. const prior = entries.filter(entry => entry !== existing)
  671. const history = baseline ? undefined : validatePersistenceHistory(prior)
  672. const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
  673. if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
  674. const roots = current.roots.filter(root => changed.includes(root.key))
  675. const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
  676. root: key, previous: history?.tips.get(key)?.id ?? null,
  677. after: roots.find(root => root.key === key)?.digest ?? null, decision,
  678. })) }
  679. const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
  680. validatePersistenceHistory([...prior, { record: change, snapshot }])
  681. const document = (chinese: boolean): string => {
  682. const supplied = chinese ? prose?.zh : prose?.en
  683. return existing === undefined ? scaffold(change, chinese, supplied)
  684. : updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
  685. }
  686. const english = document(false)
  687. const chinese = document(true)
  688. parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
  689. parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
  690. const recordFiles = [
  691. ...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
  692. { path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
  693. ]
  694. if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
  695. const outputs = [...artifacts(root, current), ...recordFiles]
  696. for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
  697. for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
  698. const completion = baseline
  699. ? 'Complete both record documents and refresh their translation pairing.'
  700. : `Complete the compatibility and verification prose with --update ${id} --prose FILE --decision ${decision}.`
  701. const message = existing === undefined && prose === undefined
  702. ? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
  703. : `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
  704. return { schemaVersion: 1, ok: true, operation, recordId: id, message,
  705. changes: history === undefined ? [] : reportedDifferences(history, current),
  706. roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
  707. }
  708. /** Execute tree-only verification or author an explicit persistence acknowledgement.
  709. * @param args - check, baseline, record, or update arguments; --json selects structured output.
  710. * @param root - checkout root; defaults to this script's repository.
  711. * @param extract - current-source extraction function; fixtures supply their own source reader.
  712. * @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
  713. * @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
  714. */
  715. export function runPersistenceChanges(
  716. args: readonly string[],
  717. root: string = resolve(import.meta.dirname, '..'),
  718. extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
  719. artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
  720. ): string {
  721. try {
  722. const result = executeCommand(args, root, extract, artifacts)
  723. return args.includes('--json') ? JSON.stringify(result) : result.message
  724. } catch (error: unknown) {
  725. if (!args.includes('--json')) throw error
  726. const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
  727. message: error instanceof Error ? error.message : String(error),
  728. code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
  729. changes: error instanceof PersistenceChangeFailure ? error.changes : [],
  730. roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
  731. return JSON.stringify(result)
  732. }
  733. }
  734. if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
  735. try {
  736. const output = runPersistenceChanges(process.argv.slice(2))
  737. console.log(output)
  738. if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
  739. } catch (error: unknown) {
  740. console.error(error instanceof Error ? error.message : String(error))
  741. process.exitCode = 1
  742. }
  743. }