// C — normalized tree hash. A content fingerprint for conflict comparison
// that ignores everything which makes byte-identical packages look different:
// dev/doc/meta files AND file modes. The real stored tree hash is unaffected.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import path from '#node:path'
import { walkTree } from 'hyper-overlay/tree'
import { detectConflicts, CONFLICT_BUILD_SKEW } from '../lib/conflict.js'
import { normalizedTreeHash as normFromDetail } from '../lib/conflict-detail.js'
function mk (files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-norm-'))
for (const [rel, content] of Object.entries(files)) {
const p = path.join(dir, rel)
fs.mkdirSync(path.dirname(p), { recursive: true })
fs.writeFileSync(p, content)
}
return dir
}
const norm = (dir) => normFromDetail(walkTree(dir))
const rawHash = (dir) => walkTree(dir).get('').hash
const RUNTIME = { 'index.js': 'module.exports = 1\n', 'package.json': '{"name":"x","version":"1.0.0"}\n' }
test('normalizedTreeHash: identical runtime code, different dev/doc/meta ⇒ same norm hash', (t) => {
const a = mk({ ...RUNTIME, '.npmignore': 'test\n', 'README.md': 'hi\n', 'test/all.js': 'T\n', '.github/workflows/ci.yml': 'on: push\n' })
const b = mk({ ...RUNTIME }) // bare runtime only
t.is(norm(a), norm(b), 'dev/doc/meta dropped from the fingerprint')
t.not(rawHash(a), rawHash(b), 'raw stored tree hash still differs (storage unaffected)')
})
test('normalizedTreeHash: a real code change DOES change the fingerprint', (t) => {
const a = mk({ ...RUNTIME })
const c = mk({ ...RUNTIME, 'index.js': 'module.exports = 2\n' })
t.not(norm(a), norm(c), 'runtime code difference still surfaces')
})
test('normalizedTreeHash: file mode (exec bit) is ignored (canonicalized)', (t) => {
const a = mk({ ...RUNTIME, 'lib/run.js': '#!/usr/bin/env bare\n' })
const b = mk({ ...RUNTIME, 'lib/run.js': '#!/usr/bin/env bare\n' })
fs.chmodSync(path.join(b, 'lib/run.js'), 0o755) // exec in b, not a
t.is(norm(a), norm(b), 'exec-bit-only difference does not change the fingerprint')
t.not(rawHash(a), rawHash(b), 'raw tree hash DOES differ on the exec bit (real mode preserved)')
})
test('detectConflicts: same version + same normHash + different tree ⇒ NO skew', (t) => {
const mine = { name: 'pgh', deps: [{ name: 'dep', version: '1.0.0', tree: 'rawA', normHash: 'N' }] }
const index = { allLockfiles: () => [{ appName: 'sib', lockfile: { deps: [{ name: 'dep', version: '1.0.0', tree: 'rawB', normHash: 'N' }] } }] }
const c = detectConflicts(mine, index)
t.is(c.length, 0, 'matching normHash suppresses the build-skew even though raw trees differ')
})
test('detectConflicts: same version + different normHash ⇒ skew', (t) => {
const mine = { name: 'pgh', deps: [{ name: 'dep', version: '1.0.0', tree: 'rawA', normHash: 'N1' }] }
const index = { allLockfiles: () => [{ appName: 'sib', lockfile: { deps: [{ name: 'dep', version: '1.0.0', tree: 'rawB', normHash: 'N2' }] } }] }
const c = detectConflicts(mine, index)
t.is(c.length, 1)
t.is(c[0].type, CONFLICT_BUILD_SKEW)
})
test('detectConflicts: missing normHash on one side ⇒ falls back to raw tree (back-compat)', (t) => {
const mine = { name: 'pgh', deps: [{ name: 'dep', version: '1.0.0', tree: 'rawA', normHash: 'N' }] }
const index = { allLockfiles: () => [{ appName: 'sib', lockfile: { deps: [{ name: 'dep', version: '1.0.0', tree: 'rawA' }] } }] } // no normHash, same raw tree
t.is(detectConflicts(mine, index).length, 0, 'same raw tree, no skew')
const index2 = { allLockfiles: () => [{ appName: 'sib', lockfile: { deps: [{ name: 'dep', version: '1.0.0', tree: 'rawZ' }] } }] }
t.is(detectConflicts(mine, index2).length, 1, 'different raw tree (no normHash) still skews')
})