mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
a9c8f7431f
This now covers everything that isn't `isObject` or `typeof x === 'number'`. Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
106 lines
3.3 KiB
TypeScript
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');
|
|
}
|