OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / conflict-detail.js

259 lines · javascript ref:
/**
 * 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
}