import { type Hash, createHash } from 'node:crypto'; import { isFunction, isSymbol } from '@sindresorhus/is'; // Returns true when `value` would appear in the canonical JSON produced by // `safe-stable-stringify`. We pre-check before emitting because objects must // drop keys whose value resolves to nothing (incl. via toJSON → undefined), // and incremental hashing can't roll back already-emitted bytes. // // `cache` memoizes the result per object reference so callers that invoke // isEmittable just before recursing don't re-walk the toJSON chain twice. function isEmittable(value: unknown, cache: WeakMap): boolean { if (value === undefined || isFunction(value) || isSymbol(value)) { return false; } // oxlint-disable-next-line renovate/prefer-is-object -- byte-exact JSON canonicalization: functions are handled explicitly above and must not take the object path if (value === null || typeof value !== 'object') { return true; } const cached = cache.get(value); if (cached !== undefined) { return cached; } const obj = value as { toJSON?: () => unknown }; const result = isFunction(obj.toJSON) ? isEmittable(obj.toJSON(), cache) : true; cache.set(value, result); return result; } // Walks `value` and emits canonical JSON chunks directly into `h`, // matching `safe-stable-stringify` (deterministic mode) byte-for-byte // without materializing the full string. Required to avoid V8's max // string length when the input is large (e.g. a PR carrying upgrades // across hundreds of manifest files). function fingerprintInto( h: Hash, value: unknown, seen: WeakSet, emittableCache: WeakMap, ): void { // oxlint-disable-next-line renovate/prefer-is-object -- byte-exact JSON canonicalization: functions must take the JSON.stringify primitive path, but isObject() matches them if (value === null || typeof value !== 'object') { h.update(JSON.stringify(value)); return; } const obj = value as { toJSON?: () => unknown }; if (isFunction(obj.toJSON)) { fingerprintInto(h, obj.toJSON(), seen, emittableCache); return; } if (seen.has(value)) { h.update('"[Circular]"'); return; } seen.add(value); if (Array.isArray(value)) { h.update('['); for (let i = 0; i < value.length; i++) { if (i > 0) { h.update(','); } const item: unknown = value[i]; if (isEmittable(item, emittableCache)) { fingerprintInto(h, item, seen, emittableCache); } else { h.update('null'); } } h.update(']'); } else { const entries = value as Record; const keys = Object.keys(entries).sort(); h.update('{'); let first = true; for (const k of keys) { const v = entries[k]; if (!isEmittable(v, emittableCache)) { continue; } if (!first) { h.update(','); } first = false; h.update(JSON.stringify(k)); h.update(':'); fingerprintInto(h, v, seen, emittableCache); } h.update('}'); } seen.delete(value); } export function fingerprint(input: unknown): string { const emittableCache = new WeakMap(); if (!isEmittable(input, emittableCache)) { return ''; } const h = createHash('sha512'); fingerprintInto(h, input, new WeakSet(), emittableCache); return h.digest('hex'); }