/* Copyright © 2024-2025 Voxgig Ltd, MIT License. */ import Fs from 'node:fs' import Path from 'node:path' import Pino from 'pino' import PinoPretty from 'pino-pretty' import { Shape } from 'shape' type DiveMapper = (path: any[], leaf: any) => any[] const CWD = process.cwd() type FST = typeof Fs type Log = { trace: (...args: any[]) => void debug: (...args: any[]) => void info: (...args: any[]) => void warn: (...args: any[]) => void error: (...args: any[]) => void fatal: (...args: any[]) => void } function prettyPino(name: string, opts: { pino?: ReturnType debug?: boolean | string }) { let pino = opts.pino if (null == pino) { let pretty = PinoPretty({ sync: true, ignore: 'name,pid,hostname', hideObject: true, messageFormat: (log: any, _messageKey: any, levelLabel: any, _extra: any) => { const fullname = `${log.name}${log.cmp === log.name ? '' : '/' + log.cmp}` let note = ( 'string' == typeof log.note ? log.note : null != log.note ? stringify(log.note, null, 2) : '' ).replaceAll(CWD, '.') if (log.err && !log.err.__logged__) { log.err.__logged__ = true // May not be an actual Error instance. log.err.message = log.err.message ?? log.err.msg if (log.err.stack) { note += '\n \n ' + log.err.stack } else if (log.err.message) { note += '\n ' + log.err.message.replace(/\n/g, ' \n') } for (let ek in log.err) { if (!(ek in { type: 1, name: 1, message: 1, stack: 1, __logged__: 1 })) { note += `\n ${ek}: ${stringify(log.err[ek]).replace(/\n/g, ' \n')}` } } note += '\n ' } const point = (log.point || '').padEnd(20) let msg = `${fullname.padEnd(22)} ${point} ` + `${log.fail ? log.fail + ' ' : ''}${note}` if (true == log.break) { msg += '\n' } return msg }, customPrettifiers: { // name: (name: any, _key: any, _log: any, extra: any) => `${extra.colors.blue(name)}`, } }) const level = null == opts.debug ? 'info' : true === opts.debug ? 'debug' : 'string' == typeof opts.debug ? opts.debug : 'info' pino = Pino({ name, level, }, pretty ) } return pino } function hasOwnKeys(obj: any): boolean { // Own enumerable keys only, matching the Object.keys() iteration in // diveInternal (a for..in would also see inherited keys, which would make a // node with only inherited keys recurse yet contribute nothing). for (const k in obj) if (Object.prototype.hasOwnProperty.call(obj, k)) return true return false } function diveInternal(node: any, d: number, prefix: string[], items: any[]): void { const obj = node || {} // Object keys are visited in sorted order for deterministic, cross-language // output; array indices are visited in numeric order (0,1,…,10,11 — not the // lexicographic 0,1,10,11,2 that Object.keys(arr).sort() would give), so the // Go port can reproduce the exact same order. // Object.keys already yields array indices in ascending numeric order and // skips holes in a sparse array; object keys are sorted for a deterministic, // cross-language-stable order. const keys: string[] = Array.isArray(obj) ? Object.keys(obj) : Object.keys(obj).sort() for (const key of keys) { const child = (obj as any)[key] if ('$' === key) { items.push([prefix.slice(), child]) } else if ( d <= 1 || null == child || 'object' !== typeof child || !hasOwnKeys(child) ) { items.push([[...prefix, key], child]) } else { diveInternal(child, d - 1, [...prefix, key], items) } } } function dive(node: any, mapper: DiveMapper): Record function dive(node: any, depth?: number, mapper?: DiveMapper): [any[], any][] function dive(node: any, depth?: number | DiveMapper, mapper?: DiveMapper): any { let d = (null == depth || 'number' != typeof depth) ? 2 : depth mapper = 'function' === typeof depth ? depth : mapper let items: any[] = [] diveInternal(node, d, [], items) if (mapper) { return items.reduce(((a, entry) => { entry = (mapper as any)(entry[0], entry[1]) if (null != entry[0]) { a[entry[0]] = entry[1] } return a }), {} as any) } return items } // Recursively sort object keys so an object/array element renders identically to // the Go port (whose json.Marshal sorts map keys). Cycle-aware: a revisited node // is returned as-is, so JSON.stringify still throws on a genuine cycle. A DAG // (shared non-cyclic sibling) is expanded each time, matching JSON.stringify. function canonicalize(v: any, seen: WeakSet = new WeakSet()): any { if (null == v || 'object' !== typeof v) { return v } if (seen.has(v)) { return v } seen.add(v) const out = Array.isArray(v) ? v.map((e: any) => canonicalize(e, seen)) : Object.keys(v).sort().reduce((a: any, k: string) => (a[k] = canonicalize(v[k], seen), a), {}) seen.delete(v) return out } // Render a single joins element to a string. Primitives coerce as JS would // (numbers/booleans via String, null/undefined to ''), while objects and arrays // serialise to JSON with sorted keys — matching the Go port's toString // (json.Marshal), rather than JS's default '[object Object]' / recursive // comma-join. Non-finite numbers serialise as null (JSON.stringify). A value // that cannot be serialised (a cycle, a function) yields '' (as Go's // json.Marshal error path does). function joinValue(v: any): string { if (null == v) return '' const t = typeof v if ('string' === t) return v if ('number' === t || 'boolean' === t) return '' + v try { const s = JSON.stringify(canonicalize(v)) return null == s ? '' : s } catch (_e) { return '' } } /* * , => a,b * :, => a:1,b:2 * :,/ => a:1,b:2/c:3,d:4/e:5,f:6 */ function joins(arr: any[], ...seps: string[]) { arr = arr || [] let sa = [] for (let i = 0; i < arr.length; i++) { sa.push(joinValue(arr[i])) if (i < arr.length - 1) { for (let j = seps.length - 1; -1 < j; j--) { if (0 === (i + 1) % (1 << j)) { sa.push(seps[j]) break } } } } return sa.join('') } function get(root: any, path: string | string[]): any { path = 'string' === typeof path ? path.split('.') : path let node = root for (let i = 0; i < path.length && null != node; i++) { node = node[path[i]] } return node } function camelify(input: any[] | string) { let parts = 'string' == typeof input ? input.split('-') : input.map(n => '' + n) return parts .map((p: string) => ('' === p ? '' : (p[0].toUpperCase() + p.substring(1)))) .join('') } function pinify(path: string[]) { let pin = path .map((p: string, i: number) => p + (i % 2 ? (i === path.length - 1 ? '' : ',') : ':')) .join('') return pin } // TODO: only works on base/name style entities - generalize function entity(model: any) { let entries = dive(model?.main?.ent) let entMap: any = {} for (let i = 0; i < entries.length; i++) { const entry = entries[i] let path = entry[0] // Skip malformed entries that don't resolve to a base/name pair. if (path.length < 2) { continue } // TODO: move EntShape to @voxgig/model // let ent = EntShape(entry[1]) let ent = entry[1] if (null == ent || 'object' !== typeof ent) { continue } // A field map must be a plain object; an array (or missing/non-object) is // skipped rather than iterated by index. let field = ent.field if (null == field || 'object' !== typeof field || Array.isArray(field)) { continue } // Copy so the caller's input is never mutated. let valid: any = { ...(ent.valid || {}) } Object.entries(field).forEach((n: any[]) => { let name = n[0] let fld = n[1] as any // Skip a null/primitive field value rather than throwing on fld.kind. if (null == fld || 'object' !== typeof fld) { return } let fv = fld.kind if (fld.valid) { let vt = typeof fld.valid if ('string' === vt) { // A missing kind must not leak the literal string 'undefined' into // the validation ('undefined.Foo'); treat a null/undefined kind as ''. fv = (null == fld.kind ? '' : fld.kind) + '.' + fld.valid } else { fv = fld.valid } } // An undefined derived value (no kind and no usable valid) is omitted, so // the JSON output matches the Go port (which stores no key in that case). if (undefined !== fv) { valid[name] = fv } }) entMap[path[0] + '/' + path[1]] = { valid_json: valid } } return entMap } function order(itemMap: Record, spec: { order?: { sort?: string exclude?: string include?: string } }): any[] { // Shallow-copy each item so the caller's input map is never mutated. let items: any[] = Object .entries(itemMap) .map(([k, v]: [string, any]) => ({ ...v, key: v.key ?? k })) const ops: ((items: any[], order_spec: typeof spec['order']) => any[])[] = [ order_sort, order_exclude, order_include, ] for (let op of ops) { items = op(items, spec?.order || {}) } return items } function order_sort(items: any[], order_spec: any): any[] { if (order_spec.sort) { // Look items up by key so the sorted result reuses the same (copied) // item objects, preserving any title$ added below. const byKey = new Map(items.map((it: any) => [it.key, it])) let key_order = 'string' === typeof order_spec.sort ? order_spec.sort.split(/\s*,\s*/).map((s: string) => s.trim()) : order_spec.sort const key_order_set = new Set( key_order.filter((k: string) => k !== 'human$' && k !== 'alpha$') ) key_order = key_order .map((k: string, _: any) => 'human$' === k ? ( _ = 1 + items.reduce((a: number, n: any) => (Math.max(a, ('' + (n.title ?? '')).length)), 0), items .filter((item: any) => !key_order_set.has(item.key)) .map((item: any) => (item.title$ = ('' + (item.title ?? '')).padStart(_, '0'), item)) .sort((a: any, b: any) => a.title$ > b.title$ ? 1 : a.title$ < b.title$ ? -1 : 0) .map((item: any) => item.key) ) : 'alpha$' === k ? ( items .filter((item: any) => !key_order_set.has(item.key)) .sort((a: any, b: any) => { const at = '' + (a.title ?? ''), bt = '' + (b.title ?? '') return at > bt ? 1 : at < bt ? -1 : 0 }) .map((item: any) => item.key) ) : k ) .flat() items = key_order .map((k: string) => byKey.get(k)) .filter((item: any) => null != item) } return items } function order_exclude(items: any[], order_spec: any): any[] { if (order_spec.exclude) { let excludes = 'string' === typeof order_spec.exclude ? order_spec.exclude.split(/\s*,\s*/).map((s: string) => s.trim()) : order_spec.exclude const excludeSet = new Set(excludes) items = items.filter((item: any) => !excludeSet.has(item.key)) } return items } function order_include(items: any[], order_spec: any): any[] { if (order_spec.include) { let includes = 'string' === typeof order_spec.include ? order_spec.include.split(/\s*,\s*/).map((s: string) => s.trim()) : order_spec.include const includeSet = new Set(includes) items = items.filter((item: any) => includeSet.has(item.key)) } return items } function showChanges( log: Log, point: string, // Subset of JostracaResult jres: { files: { merged: string[], conflicted: string[], } }, cwd?: string ) { cwd = null == cwd ? CWD : cwd cwd = cwd.endsWith(Path.sep) ? cwd : cwd + Path.sep for (let file of jres.files.merged) { log.info({ point, file, merge: true, note: 'merged: ' + file.replace(cwd, '') }) } for (let file of jres.files.conflicted) { log.info({ point, file, conflict: true, note: '** CONFLICT: ' + file.replace(cwd, '') }) } } function getdlog( tagin?: string, filepath?: string) : ((...args: any[]) => void) & { tag: string, file: string, log: (fp?: string) => any[] } { const tag = tagin || '-' const file = Path.basename(filepath || '-') const g = global as any g.__dlog__ = (g.__dlog__ || []) const dlog = (...args: any[]) => g.__dlog__.push([tag, file, Date.now(), ...args]) dlog.tag = tag dlog.file = file dlog.log = (filepath?: string, __f?: string | null) => (__f = null == filepath ? null : Path.basename(filepath), g.__dlog__.filter((n: any[]) => n[0] === tag && (null == __f || n[1] === __f))) return dlog } function stringify(val?: any, replacer?: any, indent?: any) { return JSON.stringify(decircular(val), replacer, indent) } // Based on: // Copyright (c) Sindre Sorhus (https://sindresorhus.com) // MIT License Version 1.0.0 function decircular(object?: any) { const seenObjects = new WeakMap() const path: string[] = [] function internalDecircular(value: any): any { if (!(value !== null && typeof value === 'object')) { return value } const existingPath = seenObjects.get(value) if (existingPath) { return `[Circular *${existingPath.join('.')}]` } seenObjects.set(value, path.slice()) // For errors, de-cycle onto a clone (same prototype, so `instanceof Error` // still holds) rather than mutating the caller's error in place. const newValue: any = value instanceof Error ? Object.create(Object.getPrototypeOf(value)) : Array.isArray(value) ? [] : {} for (const [key2, value2] of Object.entries(value)) { path.push(key2) newValue[key2] = internalDecircular(value2) path.pop() } seenObjects.delete(value) return newValue } return internalDecircular(object) } export type { FST, Log, } export { camelify, decircular, dive, entity, get, getdlog, joins, order, pinify, showChanges, stringify, prettyPino, Pino, Shape, }