Files
renovate/lib/util/fingerprint.ts
Jamie Tanna a9c8f7431f fix(lint): extend prefer-is-helpers to catch more cases (#45474)
This now covers everything that isn't `isObject` or `typeof x === 'number'`.

Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
2026-08-26 10:48:40 +00:00

106 lines
3.3 KiB
TypeScript

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<object, boolean>): 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<object>,
emittableCache: WeakMap<object, boolean>,
): 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<string, unknown>;
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<object, boolean>();
if (!isEmittable(input, emittableCache)) {
return '';
}
const h = createHash('sha512');
fingerprintInto(h, input, new WeakSet(), emittableCache);
return h.digest('hex');
}