2026-07-04 22:58:28 +08:00
/**
2026-07-12 03:36:43 +08:00
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
2026-07-18 15:11:51 +08:00
* the owning event-envelope types. This is the durable-record vocabulary, not
* the live Cordis bus. Event declarations must be unique, explicitly typed,
2026-07-13 23:27:00 +08:00
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
* surface-union member must resolve to one. `--check` verifies the artifact.
2026-07-04 22:58:28 +08:00
*/
import { globSync , readFileSync , writeFileSync } from 'node:fs'
2026-07-06 02:28:44 +08:00
import { resolve , sep } from 'node:path'
2026-07-04 22:58:28 +08:00
import ts from 'typescript'
2026-07-14 00:24:04 +08:00
import { parseJsDoc , pointer , rawJsDoc , reportViolations } from './jsdoc.ts'
2026-07-04 22:58:28 +08:00
const root = resolve ( import . meta . dirname , '..' )
2026-07-06 22:26:06 +08:00
const OUT = 'docs/persistence-catalog.md'
2026-07-04 22:58:28 +08:00
2026-07-18 15:11:51 +08:00
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
2026-07-04 22:58:28 +08:00
const FENCE = 'ts persistence-catalog'
/** The package whose module id plugin merges augment (`declare module '…'`). */
const SESSION_MODULE = '@deepseek-ai/dsh-session'
2026-07-18 15:11:51 +08:00
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType' ,
'SurfaceEventType' ,
'SurfaceOp' ,
'SessionEvent' ,
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES [ number ]
2026-07-12 03:36:43 +08:00
/** Primary core-data-structures page for linked payload types. */
2026-07-04 22:58:28 +08:00
const LINK_MAP : Record < string , string > = {
CallId : 'core.md' ,
ContentBlock : 'core.md' ,
MessageSource : 'core.md' ,
StreamChunk : 'llm-streaming.md' ,
TokenUsage : 'llm-streaming.md' ,
TodoItem : 'session.md' ,
TurnTrigger : 'session.md' ,
TurnEndReason : 'session.md' ,
2026-07-21 01:54:00 +08:00
SessionTitleEventData : 'session-title.md' ,
2026-07-21 12:08:00 +08:00
SessionTitleLlmRequestEventData : 'session-title.md' ,
2026-07-21 01:54:00 +08:00
SessionTitleModelProvenance : 'session-title.md' ,
SessionTitleProviderId : 'session-title.md' ,
SessionTitleSource : 'session-title.md' ,
2026-07-04 22:58:28 +08:00
}
/** One log event, extracted from a `SessionEventMap` declaration. */
export interface LogEventEntry {
/** Scoped name, e.g. `turn/start`. */
name : string
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
scope : string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload : string
2026-07-18 15:11:51 +08:00
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration : string
2026-07-04 22:58:28 +08:00
/** Description prose (the member's JSDoc), one line per paragraph. */
doc : string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source : string
}
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
export interface AnnotatedLogEventEntry extends LogEventEntry {
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
surface : boolean
}
2026-07-18 15:11:51 +08:00
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name : EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration : string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source : string
}
2026-07-04 22:58:28 +08:00
const printer = ts . createPrinter ( { removeComments : true } )
/**
2026-07-14 00:47:30 +08:00
* Render a member type on one line through the TypeScript printer, which adds
* semicolon separators. Drop its trailing semicolon before `}` to match the
* repository's inline-literal style.
2026-07-04 22:58:28 +08:00
*/
function payloadText ( type : ts . TypeNode , sf : ts.SourceFile ) : string {
return printer . printNode ( ts . EmitHint . Unspecified , type , sf )
. replace ( /\s+/g , ' ' )
. replace ( /;\s*\}/g , ' }' )
. trim ( )
}
2026-07-18 15:11:51 +08:00
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText ( text : string , sf : ts.SourceFile , node : ts.Node ) : string {
const raw = rawJsDoc ( text , node )
const nodeStart = node . getStart ( sf )
const start = raw ? text . lastIndexOf ( raw , nodeStart ) : nodeStart
const { line } = sf . getLineAndCharacterOfPosition ( start )
const lineStart = sf . getPositionOfLineAndCharacter ( line , 0 )
const indent = text . slice ( lineStart , start )
return text . slice ( lineStart , node . end )
. split ( '\n' )
. map ( lineText = > lineText . startsWith ( indent ) ? lineText . slice ( indent . length ) : lineText )
. join ( '\n' )
. trimEnd ( )
}
2026-07-04 22:58:28 +08:00
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
* merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms
* declare members of the SAME merged interface, so both are catalogued
2026-07-04 23:11:42 +08:00
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
* it actually lives in the owning package — an unrelated local interface that
* happens to share the name must not be catalogued as the on-disk vocabulary.
2026-07-04 22:58:28 +08:00
*/
2026-07-04 23:11:42 +08:00
function sessionEventMapDecls ( sf : ts.SourceFile ) : { decl : ts.InterfaceDeclaration ; topLevel : boolean } [ ] {
const decls : { decl : ts.InterfaceDeclaration ; topLevel : boolean } [ ] = [ ]
2026-07-04 22:58:28 +08:00
for ( const stmt of sf . statements ) {
2026-07-04 23:11:42 +08:00
if ( ts . isInterfaceDeclaration ( stmt ) && stmt . name . text === 'SessionEventMap' ) decls . push ( { decl : stmt , topLevel : true } )
2026-07-04 22:58:28 +08:00
if ( ts . isModuleDeclaration ( stmt ) && ts . isStringLiteral ( stmt . name ) && stmt . name . text === SESSION_MODULE
&& stmt . body && ts . isModuleBlock ( stmt . body ) ) {
for ( const inner of stmt . body . statements ) {
2026-07-04 23:11:42 +08:00
if ( ts . isInterfaceDeclaration ( inner ) && inner . name . text === 'SessionEventMap' ) decls . push ( { decl : inner , topLevel : false } )
2026-07-04 22:58:28 +08:00
}
}
}
return decls
}
2026-07-04 23:11:42 +08:00
/**
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
* from that package's manifest — or null when the manifest is missing or
* unparseable (the caller treats null as "ownership unverifiable").
*/
function packageNameFor ( rel : string , scanRoot : string ) : string | null {
const dir = rel . split ( '/' ) . slice ( 0 , 3 ) . join ( '/' )
try {
const manifest = JSON . parse ( readFileSync ( resolve ( scanRoot , dir , 'package.json' ) , 'utf8' ) ) as { name? : string }
return typeof manifest . name === 'string' ? manifest.name : null
} catch {
// Missing or malformed package.json — every real workspace package has one,
// so this only arises in stripped-down fixture trees; either way ownership
// cannot be verified and the caller reports the declaration.
return null
}
}
2026-07-13 23:27:00 +08:00
/**
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
*/
2026-07-04 22:58:28 +08:00
export function collectLogEvents ( scanRoot : string = root ) : LogEventEntry [ ] {
const entries : LogEventEntry [ ] = [ ]
const violations : string [ ] = [ ]
const seen = new Map < string , string > ( )
2026-07-04 23:21:23 +08:00
let owningDecl : string | null = null
2026-07-06 02:28:44 +08:00
for ( const rel of globSync ( 'packages/*/*/src/**/*.ts' , { cwd : scanRoot } ) . map ( s = > s . split ( sep ) . join ( '/' ) ) . sort ( ) ) {
2026-07-04 22:58:28 +08:00
const abs = resolve ( scanRoot , rel )
const text = readFileSync ( abs , 'utf8' )
if ( ! text . includes ( 'SessionEventMap' ) ) continue
const sf = ts . createSourceFile ( abs , text , ts . ScriptTarget . Latest , true )
2026-07-04 23:11:42 +08:00
for ( const { decl , topLevel } of sessionEventMapDecls ( sf ) ) {
2026-07-04 23:21:23 +08:00
const declSrc = pointer ( rel , sf , decl )
2026-07-04 23:11:42 +08:00
if ( topLevel ) {
2026-07-13 23:27:00 +08:00
// The top-level form has one home: the single exported declaration in
// the owning package. Same-named interfaces elsewhere are different
// types and must not enter the on-disk catalog.
2026-07-04 23:11:42 +08:00
const pkg = packageNameFor ( rel , scanRoot )
if ( pkg !== SESSION_MODULE ) {
2026-07-04 23:21:23 +08:00
violations . push ( ` top-level interface SessionEventMap ( ${ declSrc } ) is outside ${ SESSION_MODULE } (package ${ pkg ? ? 'unknown' } ). Rename the interface, or contribute events via declare module ' ${ SESSION_MODULE } '. ` )
2026-07-04 23:11:42 +08:00
continue
}
2026-07-04 23:21:23 +08:00
const exported = decl . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . ExportKeyword ) ? ? false
if ( ! exported ) {
violations . push ( ` top-level interface SessionEventMap ( ${ declSrc } ) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface. ` )
continue
}
if ( owningDecl ) {
violations . push ( ` top-level interface SessionEventMap ( ${ declSrc } ) is already declared at ${ owningDecl } ; the owning vocabulary has exactly one home. ` )
continue
}
owningDecl = declSrc
}
if ( decl . heritageClauses ? . length ) {
violations . push ( ` SessionEventMap declaration ( ${ declSrc } ) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly. ` )
2026-07-04 23:11:42 +08:00
}
2026-07-04 22:58:28 +08:00
for ( const member of decl . members ) {
const src = pointer ( rel , sf , member )
2026-07-04 23:11:42 +08:00
if ( ! ts . isPropertySignature ( member ) || ! member . type ) {
// A method-form or type-less member still joins `keyof SessionEventMap`,
// so skipping it silently would be exactly the undocumented-event hole
// this catalog exists to close.
const label = ( member as { name? : ts.Node } ) . name ? . getText ( sf ) ? ? member . getText ( sf ) . replace ( /\s+/g , ' ' )
violations . push ( ` SessionEventMap member ${ label } ( ${ src } ) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>. ` )
continue
}
2026-07-04 22:58:28 +08:00
if ( ! ts . isStringLiteral ( member . name ) ) {
violations . push ( ` log event at ${ src } has a non-literal name; the catalog needs string-literal event names. ` )
continue
}
const name = member . name . text
const where = ` log event ' ${ name } ' ( ${ src } ) `
const prior = seen . get ( name )
if ( prior ) {
violations . push ( ` ${ where } is already declared at ${ prior } ; an event type has exactly one declaration. ` )
continue
}
seen . set ( name , src )
const payload = payloadText ( member . type , sf )
const { doc , hasMode } = parseJsDoc ( rawJsDoc ( text , member ) )
if ( hasMode ) {
violations . push ( ` ${ where } carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag. ` )
}
if ( ! doc ) {
violations . push ( ` ${ where } has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry. ` )
}
2026-07-18 15:11:51 +08:00
const declaration = declarationText ( text , sf , member )
entries . push ( { name , scope : name.split ( '/' ) [ 0 ] ? ? name , payload , declaration , doc , source : src } )
2026-07-04 22:58:28 +08:00
}
}
}
2026-07-14 00:24:04 +08:00
reportViolations ( 'gen-persistence-catalog' , violations )
2026-07-04 22:58:28 +08:00
return entries
}
2026-07-18 15:11:51 +08:00
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes ( scanRoot : string = root ) : EventEnvelopeTypeEntry [ ] {
const found = new Map < EventEnvelopeTypeName , EventEnvelopeTypeEntry > ( )
const violations : string [ ] = [ ]
const wanted = new Set < string > ( EVENT_ENVELOPE_TYPE_NAMES )
for ( const rel of globSync ( 'packages/*/*/src/**/*.ts' , { cwd : scanRoot } ) . map ( s = > s . split ( sep ) . join ( '/' ) ) . sort ( ) ) {
const abs = resolve ( scanRoot , rel )
const text = readFileSync ( abs , 'utf8' )
if ( ! EVENT_ENVELOPE_TYPE_NAMES . some ( name = > text . includes ( name ) ) ) continue
if ( packageNameFor ( rel , scanRoot ) !== SESSION_MODULE ) continue
const sf = ts . createSourceFile ( abs , text , ts . ScriptTarget . Latest , true )
for ( const stmt of sf . statements ) {
if ( ! ts . isTypeAliasDeclaration ( stmt ) || ! wanted . has ( stmt . name . text ) ) continue
const name = stmt . name . text as EventEnvelopeTypeName
const src = pointer ( rel , sf , stmt )
const where = ` event-envelope type ' ${ name } ' ( ${ src } ) `
const prior = found . get ( name )
if ( prior ) {
violations . push ( ` ${ where } is already declared at ${ prior . source } ; the persisted envelope type has exactly one owner. ` )
continue
}
if ( ! ( stmt . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . ExportKeyword ) ? ? false ) ) {
violations . push ( ` ${ where } is not exported. ` )
}
const { doc , hasMode } = parseJsDoc ( rawJsDoc ( text , stmt ) )
if ( hasMode ) violations . push ( ` ${ where } carries an @mode tag, but a persisted type has no dispatch mode. ` )
if ( ! doc ) violations . push ( ` ${ where } has no description prose. The full JSDoc is part of the generated catalog. ` )
found . set ( name , { name , declaration : declarationText ( text , sf , stmt ) , source : src } )
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES . filter ( name = > ! found . has ( name ) )
if ( missing . length > 0 ) {
violations . push ( ` missing event-envelope declaration(s): ${ missing . join ( ', ' ) } . ` )
}
reportViolations ( 'gen-persistence-catalog' , violations )
return EVENT_ENVELOPE_TYPE_NAMES . map ( ( name ) = > {
const entry = found . get ( name )
if ( ! entry ) throw new Error ( ` gen-persistence-catalog: missing checked event-envelope declaration ' ${ name } '. ` )
return entry
} )
}
2026-07-04 22:58:28 +08:00
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
* than once, or contains a non-string-literal member: the badge derivation
* relies on the union being a closed set of literal event names.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectSurfaceEventTypes ( scanRoot : string = root ) : string [ ] {
const found : { names : string [ ] ; source : string } [ ] = [ ]
2026-07-06 02:28:44 +08:00
for ( const rel of globSync ( 'packages/*/*/src/**/*.ts' , { cwd : scanRoot } ) . map ( s = > s . split ( sep ) . join ( '/' ) ) . sort ( ) ) {
2026-07-04 22:58:28 +08:00
const abs = resolve ( scanRoot , rel )
const text = readFileSync ( abs , 'utf8' )
if ( ! text . includes ( 'SurfaceEventType' ) ) continue
const sf = ts . createSourceFile ( abs , text , ts . ScriptTarget . Latest , true )
for ( const stmt of sf . statements ) {
if ( ! ts . isTypeAliasDeclaration ( stmt ) || stmt . name . text !== 'SurfaceEventType' ) continue
const src = pointer ( rel , sf , stmt )
const members = ts . isUnionTypeNode ( stmt . type ) ? [ . . . stmt . type . types ] : [ stmt . type ]
const names : string [ ] = [ ]
for ( const m of members ) {
if ( ts . isLiteralTypeNode ( m ) && ts . isStringLiteral ( m . literal ) ) names . push ( m . literal . text )
else throw new Error ( ` gen-persistence-catalog: SurfaceEventType ( ${ src } ) has a non-string-literal member; the badge derivation needs a closed literal union. ` )
}
found . push ( { names , source : src } )
}
}
const only = found [ 0 ]
if ( ! only ) throw new Error ( 'gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.' )
if ( found . length > 1 ) throw new Error ( ` gen-persistence-catalog: SurfaceEventType is declared more than once ( ${ found . map ( f = > f . source ) . join ( ', ' ) } ); the surface subset has exactly one owner. ` )
return only . names
}
/**
* Attach the surface/log-only badge to each event. Hard-errors when a
* `SurfaceEventType` union member names no collected event — a stale union
* member would otherwise silently badge nothing.
*/
export function annotateSurface ( events : LogEventEntry [ ] , surfaceTypes : string [ ] ) : AnnotatedLogEventEntry [ ] {
const names = new Set ( events . map ( e = > e . name ) )
const stale = surfaceTypes . filter ( t = > ! names . has ( t ) )
if ( stale . length > 0 ) {
throw new Error ( ` gen-persistence-catalog: SurfaceEventType member(s) ${ stale . map ( t = > ` ' ${ t } ' ` ) . join ( ', ' ) } name no declared log event (stale union member?). ` )
}
const surface = new Set ( surfaceTypes )
return events . map ( e = > ( { . . . e , surface : surface.has ( e . name ) } ) )
}
/** Render the cross-link "Types:" line for a payload, or '' if none apply. */
function typeLinks ( payload : string ) : string {
const seen = new Set < string > ( )
for ( const name of Object . keys ( LINK_MAP ) ) {
if ( new RegExp ( ` \\ b ${ name } \\ b ` ) . test ( payload ) ) seen . add ( name )
}
if ( seen . size === 0 ) return ''
2026-07-06 22:26:06 +08:00
const links = [ . . . seen ] . sort ( ) . map ( n = > ` [ ${ n } ](core-data-structures/ ${ LINK_MAP [ n ] } ) ` )
2026-07-04 22:58:28 +08:00
return ` Types: ${ links . join ( ' · ' ) } `
}
/** Render one log event entry. */
function renderEvent ( e : AnnotatedLogEventEntry ) : string [ ] {
const out = [ ` #### \` ${ e . name } \` — ${ e . surface ? 'surface' : 'log-only' } ` , '' ]
2026-07-18 15:11:51 +08:00
out . push ( '```' + FENCE , e . declaration , '```' , '' )
2026-07-04 22:58:28 +08:00
const links = typeLinks ( e . payload )
if ( links ) out . push ( links , '' )
2026-07-06 22:26:06 +08:00
out . push ( ` Source: [ \` ${ e . source } \` ](../ ${ e . source . split ( ':' ) [ 0 ] } ) ` , '' )
2026-07-04 22:58:28 +08:00
return out
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
2026-07-18 15:11:51 +08:00
export function render ( events : AnnotatedLogEventEntry [ ] , envelopeTypes : EventEnvelopeTypeEntry [ ] ) : string {
2026-07-04 22:58:28 +08:00
const lines : string [ ] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.' ,
' Run `pnpm run gen-persistence-catalog` to regenerate. -->' ,
'' ,
2026-07-18 15:11:51 +08:00
'# Session Persistence Event Catalog' ,
'' ,
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).' ,
'' ,
2026-07-27 23:56:18 +08:00
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).' ,
2026-07-18 15:11:51 +08:00
'' ,
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.' ,
2026-07-04 22:58:28 +08:00
'' ,
2026-07-18 15:11:51 +08:00
'## Event envelope' ,
2026-07-04 22:58:28 +08:00
'' ,
2026-07-18 15:11:51 +08:00
'```' + FENCE ,
envelopeTypes . map ( entry = > entry . declaration ) . join ( '\n\n' ) ,
'```' ,
2026-07-04 22:58:28 +08:00
'' ,
2026-07-18 15:11:51 +08:00
` Sources: ${ envelopeTypes . map ( entry = > ` [ \` ${ entry . source } \` ](../ ${ entry . source . split ( ':' ) [ 0 ] } ) ` ) . join ( ' · ' ) } ` ,
2026-07-04 22:58:28 +08:00
'' ,
'## Events' ,
'' ,
]
const scopes = [ . . . new Set ( events . map ( e = > e . scope ) ) ] . sort ( )
for ( const scope of scopes ) {
lines . push ( ` ### \` ${ scope } /* \` ` , '' )
for ( const e of events . filter ( x = > x . scope === scope ) . sort ( ( a , b ) = > a . name . localeCompare ( b . name ) ) ) {
lines . push ( . . . renderEvent ( e ) )
}
}
return lines . join ( '\n' )
}
/** CLI entry: default writes the catalog, `--check` fails if the committed copy
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
function main ( ) : void {
2026-07-18 15:11:51 +08:00
const content = render ( annotateSurface ( collectLogEvents ( ) , collectSurfaceEventTypes ( ) ) , collectEventEnvelopeTypes ( ) )
2026-07-04 22:58:28 +08:00
if ( process . argv . includes ( '--check' ) ) {
let committed : string | null = null
try {
committed = readFileSync ( resolve ( root , OUT ) , 'utf8' )
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
if ( committed === content ) {
console . log ( ` gen-persistence-catalog: ${ OUT } is up to date. ` )
process . exit ( 0 )
}
console . error ( ` gen-persistence-catalog: ${ OUT } is stale. Run \` pnpm run gen-persistence-catalog \` and commit ${ OUT } . ` )
process . exit ( 1 )
}
writeFileSync ( resolve ( root , OUT ) , content )
console . log ( ` gen-persistence-catalog: wrote ${ OUT } . ` )
}
// Run only when invoked as a script, not when imported by a test.
if ( process . argv [ 1 ] && import . meta . filename === resolve ( process . argv [ 1 ] ) ) {
main ( )
}