/**
* conflict-detail — classify WHAT differs in a BUILD_SKEW.
*
* `detectConflicts` (conflict.js) tells you a dep skews: the same
* name@version resolved to different tree hashes across apps. That alone
* can't tell you whether the difference is dangerous (compiled/source code
* genuinely differs → non-deterministic build) or harmless (npm shipped a
* stray `.npmignore` in one install but not the other → identical code).
*
* This module diffs the two trees file-by-file and classifies every
* differing file so a publish can be triaged instead of accepted blind:
*
* CODE — runtime code: index.js, lib/**, src/**, root *.js, *.node,
* *.wasm, package.json. A diff here is REAL → review.
* OTHER — files we can't positively classify (root binding.gyp, stray
* extensions). Treated as significant → review.
* DEV — top-level test/ .github/ example/ bench/ docs/ coverage/ … —
* never part of the runtime module; npm/bundling ships them
* inconsistently. Benign.
* DOC — README / LICENSE / CHANGELOG / *.md. Benign.
* META — .npmignore / dotfiles / packaging config. Never executed;
* npm includes these non-deterministically → pure noise.
*
* Verdict per conflict:
* 'code-differs' — at least one CODE/OTHER file differs → REVIEW.
* 'docs-only' — only DOC/DEV (+maybe META) differ → benign (docs/tests/CI).
* 'noise' — only META files differ → benign packaging artifact.
* 'unknown' — a tree isn't present locally, can't classify.
*
* The cut is: does the difference change the RUNTIME module? Test/CI/doc
* files don't, so they don't gate a publish — but every byte of actual
* runtime code does. We never downgrade real code; we only stop counting
* dev cruft as a code conflict.
*/
import fs from '#node:fs'
import path from '#node:path'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import { KIND_DIR, KIND_SYMLINK } from 'hyper-overlay/tree'
// Top-level directories that are never part of the runtime module. Matched
// on the FIRST path segment only (a nested lib/test/ helper stays CODE).
const DEV_DIRS = new Set([
'test', 'tests', '__tests__', 'example', 'examples', 'demo', 'demos',
'bench', 'benchmark', 'benchmarks', 'docs', 'doc', 'coverage',
'.github', '.circleci', '.husky', 'fixtures'
])
const META_NAMES = new Set([
'.npmignore', '.gitignore', '.gitattributes', '.editorconfig', '.npmrc',
'.travis.yml', '.eslintrc', '.eslintignore', '.prettierrc', '.prettierignore',
'.jshintrc', '.babelrc', '.nvmrc'
])
const DOC_RE = /^(readme|license|licence|changelog|history|notice|authors|contributors|copying)(\.|$)/i
const CODE_EXT = new Set([
'.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.json',
'.node', '.wasm', '.so', '.dylib', '.dll', '.a', '.bare'
])
export function classifyFile (relPath) {
const segs = relPath.split('/')
if (segs.length > 1 && DEV_DIRS.has(segs[0])) return 'DEV'
const base = segs[segs.length - 1]
const ext = path.extname(base).toLowerCase()
if (META_NAMES.has(base) || (base.startsWith('.') && ext !== '.js' && ext !== '.cjs' && ext !== '.mjs')) return 'META'
if (DOC_RE.test(base) || ext === '.md' || ext === '.markdown' || ext === '.txt') return 'DOC'
if (CODE_EXT.has(ext)) return 'CODE'
return 'OTHER'
}
/**
* Normalized tree hash — a content fingerprint for CONFLICT comparison only.
*
* Derived from walkTree()'s output (the {hash, entries[]} Map it returns), so
* it costs nothing extra — no re-reading files. It folds in only the bytes
* that define the RUNTIME module and deliberately ignores everything that
* makes two byte-identical packages look different:
* - dev/doc/meta files (test/, .github/, README, .npmignore, …) are dropped
* (classifyFile → DEV/DOC/META) — they're not runtime content.
* - file MODE (exec bit) is ignored — only the content hash + path matter.
* So a 644-vs-755 difference (npm/bundling non-determinism, the bin/bare
* class) no longer flips the fingerprint. The REAL stored tree hash keeps
* true modes, so materialization + executables are unaffected.
*
* Two packages with the same runtime files (same content, any modes, any dev
* cruft) ⇒ identical normalizedTreeHash ⇒ no BUILD_SKEW. Genuine code/asset
* differences still change it, so the real signal survives.
*
* @param trees Map<relPath, {hash, entries}> from walkTree()
* @returns hex string
*/
export function normalizedTreeHash (trees) {
const lines = []
const walk = (rel) => {
const node = trees.get(rel)
if (!node) return
for (const e of node.entries) {
const full = rel ? rel + '/' + e.name : e.name
if (e.kind === KIND_DIR) {
if (!rel && DEV_DIRS.has(e.name)) continue // prune top-level dev dirs
walk(full)
continue
}
// File or symlink: keep only runtime-significant entries; ignore exec.
const kind = classifyFile(full)
if (kind === 'CODE' || kind === 'OTHER') {
lines.push(full + '\0' + (e.kind === KIND_SYMLINK ? 'L:' : '') + e.childHash)
}
}
}
walk('')
lines.sort()
return crypto.data(b4a.from(lines.join('\n'), 'utf8')).toString('hex')
}
// Recursively list files in a tree dir as relPath → { size, mode }.
function listFiles (dir) {
const out = new Map()
const walk = (abs, rel) => {
let entries
try { entries = fs.readdirSync(abs, { withFileTypes: true }) } catch { return }
for (const e of entries) {
const childAbs = path.join(abs, e.name)
const childRel = rel ? rel + '/' + e.name : e.name
if (e.isDirectory()) walk(childAbs, childRel)
else {
let st
try { st = fs.statSync(childAbs) } catch { continue }
out.set(childRel, { size: st.size })
}
}
}
walk(dir, '')
return out
}
function sameBytes (a, b) {
try {
const ba = fs.readFileSync(a)
const bb = fs.readFileSync(b)
return ba.equals(bb)
} catch {
return false
}
}
/**
* Diff two tree directories. Returns { files: [{path, kind, status}], verdict }.
* status: 'only-new' | 'only-existing' | 'content'
*/
export function diffTrees (newDir, existingDir) {
const a = listFiles(newDir)
const b = listFiles(existingDir)
const files = []
const seen = new Set([...a.keys(), ...b.keys()])
for (const rel of seen) {
const inA = a.has(rel)
const inB = b.has(rel)
let status = null
if (inA && !inB) status = 'only-new'
else if (!inA && inB) status = 'only-existing'
else {
// In both. Cheap size check first; only read bytes when sizes match
// (different size ⇒ definitely different content).
if (a.get(rel).size !== b.get(rel).size) status = 'content'
else if (!sameBytes(path.join(newDir, rel), path.join(existingDir, rel))) status = 'content'
}
if (status) files.push({ path: rel, kind: classifyFile(rel), status })
}
files.sort((x, y) => x.path.localeCompare(y.path))
const verdict = verdictFor(files)
return { files, verdict }
}
function verdictFor (files) {
if (files.some(f => f.kind === 'CODE' || f.kind === 'OTHER')) return 'code-differs'
if (files.some(f => f.kind === 'DOC' || f.kind === 'DEV')) return 'docs-only'
if (files.length > 0) return 'noise'
return 'identical' // no on-disk diff found (hash differed for a non-file reason, e.g. mode/symlink)
}
/**
* Enrich detectConflicts() output with file-level diffs for BUILD_SKEW.
*
* @param conflicts array from detectConflicts()
* @param resolveTree (hash) => absolute dir path | null (null = not local)
* @returns same array; each BUILD_SKEW conflict gains `.detail`:
* { verdict, perRival: [{ appName, version, tree, ...diff }] }
* verdict is the WORST verdict across rivals (code-differs >
* docs-only > noise > identity > unknown).
*/
const VERDICT_RANK = { 'code-differs': 4, unknown: 3, 'docs-only': 2, noise: 1, identical: 0 }
export function classifyConflicts (conflicts, resolveTree) {
for (const c of conflicts) {
if (c.type !== 'BUILD_SKEW') continue
const newDir = resolveTree(c.newTree)
const perRival = []
let worst = 'identical'
for (const e of c.existing) {
const exDir = resolveTree(e.tree)
if (!newDir || !exDir) {
perRival.push({ appName: e.appName, version: e.version, tree: e.tree, verdict: 'unknown', files: [] })
if (VERDICT_RANK.unknown > VERDICT_RANK[worst]) worst = 'unknown'
continue
}
const d = diffTrees(newDir, exDir)
perRival.push({ appName: e.appName, version: e.version, tree: e.tree, verdict: d.verdict, files: d.files })
if (VERDICT_RANK[d.verdict] > VERDICT_RANK[worst]) worst = d.verdict
}
c.detail = { verdict: worst, perRival }
}
return conflicts
}
/**
* Self-skew detector (B). A BUILD_SKEW that survives the normalized-hash
* comparison means the dep's RUNTIME content genuinely changed under an
* unchanged version. When that dep is one of our editable workspace packages
* (lockfile dep.workspace), the fix is unambiguous: bump its version. Returns
* a deduped list of { pkg, version, sourcePath } to recommend bumping — the
* actionable counterpart to the A pre-commit guard for anything that slipped
* through (or was committed with --no-verify).
*/
export function bumpRecommendations (conflicts, lockfile) {
const byKey = new Map()
for (const d of (lockfile.deps || [])) byKey.set(d.name + '@' + d.version, d)
const recs = []
const seen = new Set()
for (const c of conflicts) {
if (c.type !== 'BUILD_SKEW') continue
const key = c.dep + '@' + c.newVersion
if (seen.has(key)) continue
const d = byKey.get(key)
if (d && d.workspace) {
seen.add(key)
recs.push({ pkg: c.dep, version: c.newVersion, sourcePath: d._sourcePath || null })
}
}
return recs
}
/** Tally conflicts by category for a one-line summary. */
export function summarize (conflicts) {
const t = { version: 0, codeDiffers: 0, docsOnly: 0, noise: 0, unknown: 0, identical: 0 }
for (const c of conflicts) {
if (c.type === 'VERSION_CONFLICT') { t.version++; continue }
const v = c.detail ? c.detail.verdict : 'unknown'
if (v === 'code-differs') t.codeDiffers++
else if (v === 'docs-only') t.docsOnly++
else if (v === 'noise') t.noise++
else if (v === 'identical') t.identical++
else t.unknown++
}
return t
}