OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / conflict.js

277 lines · javascript ref:
/**
 * conflict — detect and format dep conflicts for pgh publish.
 *
 * P.10 Phase 0.5d. Two classes of conflict get hard-blocked:
 *
 *   1. VERSION_CONFLICT — the same dep name appears with different
 *      versions across apps. Silent acceptance of this is how the
 *      four-udx-native disaster happened.
 *
 *   2. BUILD_SKEW — the same dep name+version appears with different
 *      tree hashes. This means the build was non-deterministic —
 *      something bakes machine state into the artifact. Even harder
 *      to debug than a version mismatch because it's invisible.
 *
 * Override path: `--accept-conflict <reason>` records the override
 * in the lockfile. The reason is mandatory; no silent flag-only bypass.
 */

export const CONFLICT_VERSION = 'VERSION_CONFLICT'
export const CONFLICT_BUILD_SKEW = 'BUILD_SKEW'

/**
 * Compare a candidate lockfile against the existing publish history.
 *
 * @param {object} newLockfile - from computeLockfile()
 * @param {PublishedIndex} index
 * @returns {Array<{type, dep, newVersion, newTree, existing: [{ appName, version, tree }]}>}
 */
// Group a lockfile's deps by name -> Map(version -> {tree, norm}). With the
// name@version dedup in computeLockfile, a dep name can legitimately appear
// at several versions (npm hoisting), so conflict detection compares the
// full version SETS instead of a single lossy representative. `norm` is the
// normalized (runtime-content-only, mode-independent) hash used for skew
// comparison when present on both sides; `tree` is the raw fallback.
function groupByName (deps) {
  const m = new Map()
  for (const d of (deps || [])) {
    if (!d.name) continue
    if (!m.has(d.name)) m.set(d.name, new Map())
    if (!m.get(d.name).has(d.version)) m.get(d.name).set(d.version, { tree: d.tree, norm: d.normHash || null })
  }
  return m
}

// Do two copies of the same dep version differ in a way that matters? Prefer
// the normalized hash (ignores dev/doc/meta + modes) when BOTH sides recorded
// one; otherwise fall back to the raw tree hash (older index entries that
// predate normHash). Returns true on a real skew.
function skewed (a, b) {
  if (a.norm && b.norm) return a.norm !== b.norm
  return a.tree !== b.tree
}

function semverPick (versions) {
  // Highest version, for display only (detection uses set membership).
  return versions.slice().sort((a, b) => {
    const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number)
    for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0) }
    return 0
  }).pop()
}

export function detectConflicts (newLockfile, index, opts = {}) {
  const conflicts = []

  // Category scoping (opt-in via opts.categoryOf). Conflicts are only
  // meaningful between apps in the SAME compartment; cross-category
  // divergence (experimental vs production, external canaries) is expected.
  // No categoryOf ⇒ behavior unchanged (compare against every app).
  const categoryOf = opts.categoryOf || null
  const myCategory = categoryOf ? categoryOf(newLockfile.name, newLockfile) : null

  // Self-publishes: skip conflict-checking against prior versions of the
  // SAME app. Previous versions of pear-git-v3 are expected to differ
  // from current pear-git-v3; that's not a conflict, that's an upgrade.
  const rivals = index.allLockfiles()
    .filter(p => p.appName !== newLockfile.name)
    .filter(p => !categoryOf || categoryOf(p.appName, p.lockfile) === myCategory)
    .map(p => ({ appName: p.appName, byName: groupByName(p.lockfile.deps) }))

  const newByName = groupByName(newLockfile.deps)

  for (const [name, newVers] of newByName) {
    const newResolved = [...newVers.entries()].filter(([, val]) => val && val.tree) // drop unresolved walks
    if (newResolved.length === 0) continue
    const newVersionSet = new Set(newResolved.map(([v]) => v))

    for (const rival of rivals) {
      const rivalVers = rival.byName.get(name)
      if (!rivalVers) continue

      // BUILD_SKEW: a version BOTH apps ship, but with different bytes — using
      // the normalized hash when available (ignores dev/doc/meta + modes), so
      // only genuine runtime-content differences are flagged.
      for (const [v, val] of newResolved) {
        const rv = rivalVers.get(v)
        if (rivalVers.has(v) && rv && rv.tree && skewed(val, rv)) {
          conflicts.push({
            type: CONFLICT_BUILD_SKEW, dep: name,
            newVersion: v, newTree: val.tree,
            existing: [{ appName: rival.appName, version: v, tree: rv.tree }]
          })
        }
      }

      // VERSION_CONFLICT: the apps share NO common version of this dep. If
      // they share at least one version, they are compatible — extra
      // transitive copies on either side are not a conflict (this is what
      // killed the phantom conflicts from the old name-only dedup).
      const shares = [...rivalVers.keys()].some(v => newVersionSet.has(v))
      if (!shares) {
        const nv = semverPick([...newVersionSet])
        const rv = semverPick([...rivalVers.keys()])
        const nvVal = newVers.get(nv)
        const rvVal = rivalVers.get(rv)
        conflicts.push({
          type: CONFLICT_VERSION, dep: name,
          newVersion: nv, newTree: nvVal && nvVal.tree,
          existing: [{ appName: rival.appName, version: rv, tree: rvVal && rvVal.tree }]
        })
      }
    }
  }

  // Collapse duplicates: one conflict per (dep, type), merging existing lists
  const merged = new Map()
  for (const c of conflicts) {
    const key = c.type + ':' + c.dep
    if (!merged.has(key)) {
      merged.set(key, c)
    } else {
      merged.get(key).existing.push(...c.existing)
    }
  }
  return [...merged.values()]
}

/**
 * Format conflicts as a human-readable loud error message. Follows the
 * "enumerate, explain, suggest, offer override" pattern from 0.5a's
 * validateForPublish.
 */
export function formatConflicts (conflicts, appName) {
  if (conflicts.length === 0) return ''

  const lines = []
  lines.push('')
  lines.push('ERROR: pgh publish — ' + conflicts.length + ' dep conflict' + (conflicts.length === 1 ? '' : 's') + ' blocking ' + appName)
  lines.push('')

  for (const c of conflicts) {
    if (c.type === CONFLICT_VERSION) {
      lines.push('  ' + c.dep)
      lines.push('    ' + appName + ' wants: ' + c.newVersion + '  (tree ' + shortHash(c.newTree) + ')')
      for (const m of c.existing) {
        lines.push('    ' + m.appName + ' wants: ' + m.version + '  (tree ' + shortHash(m.tree) + ')')
      }
      lines.push('    Fix: align on one version (upgrade one, pin the other, or accept split)')
      lines.push('')
    } else if (c.type === CONFLICT_BUILD_SKEW) {
      lines.push('  ' + c.dep + ' @ ' + c.newVersion + '   — NON-DETERMINISTIC BUILD')
      lines.push('    ' + appName + ' built: tree ' + shortHash(c.newTree))
      for (const m of c.existing) {
        lines.push('    ' + m.appName + ' built: tree ' + shortHash(m.tree))
      }
      lines.push('    Fix: same version should produce same tree hash. Check for:')
      lines.push('      - build-time timestamps baked into output')
      lines.push('      - absolute paths in compiled files')
      lines.push('      - non-portable caches in the dep tree')
      lines.push('')
    }
  }

  lines.push('Publish blocked. Resolve the conflicts above or override:')
  lines.push('')
  lines.push('  pgh publish --accept-conflict "reason here"')
  lines.push('')
  lines.push('  The reason is recorded in .pgh-lock.json under acceptedConflicts')
  lines.push('  so consumers and future-you can see why the split was accepted.')
  lines.push('')

  return lines.join('\n')
}

function shortHash (h) {
  return h ? h.slice(0, 12) : '<none>'
}

/**
 * Detailed, triage-oriented formatter. Expects conflicts enriched by
 * classifyConflicts() (BUILD_SKEW entries carry a `.detail`). Groups
 * BUILD_SKEW by verdict so the reviewer can ignore the noise and focus on
 * the diffs that actually matter, and lists WHICH files differ for each.
 *
 * `summary` is the object from summarize() (optional; recomputed-free header).
 */
export function formatConflictsDetailed (conflicts, appName, summary) {
  if (conflicts.length === 0) return 'No dep conflicts.'

  const version = conflicts.filter(c => c.type === CONFLICT_VERSION)
  const skew = conflicts.filter(c => c.type === CONFLICT_BUILD_SKEW)
  const byVerdict = (v) => skew.filter(c => (c.detail && c.detail.verdict) === v)
  const review = byVerdict('code-differs')
  const unknown = byVerdict('unknown')
  const docs = byVerdict('docs-only')
  const noise = byVerdict('noise')
  const identical = byVerdict('identical')

  const L = []
  L.push('')
  L.push('pgh publish — ' + conflicts.length + ' dep conflict' + (conflicts.length === 1 ? '' : 's') + ' for ' + appName)
  if (summary) {
    const parts = [
      summary.version + ' version-split',
      summary.codeDiffers + ' code-differs (REVIEW)',
      summary.docsOnly + ' docs-only',
      summary.noise + ' noise',
      summary.unknown + ' unknown'
    ]
    if (summary.identical) parts.push(summary.identical + ' no-file-diff')
    L.push('  ' + parts.join(' · '))
  }

  const fileLines = (c) => {
    for (const r of c.detail.perRival) {
      if (r.verdict === 'unknown') { L.push('      vs ' + r.appName + ': (tree not local — cannot classify)'); continue }
      const diffs = r.files.length
        ? r.files.map(f => f.path + ' [' + f.kind + '] ' + f.status).join(', ')
        : '(no on-disk file diff — mode/symlink only)'
      L.push('      vs ' + r.appName + ': ' + diffs)
    }
  }

  if (review.length) {
    L.push('')
    L.push('━━ BUILD_SKEW — REVIEW: code differs under the same version (' + review.length + ') ━━')
    for (const c of review) { L.push('  ' + c.dep + ' @ ' + c.newVersion); fileLines(c) }
  }
  if (unknown.length) {
    L.push('')
    L.push('── BUILD_SKEW — unclassified (tree not local) (' + unknown.length + ') ──')
    for (const c of unknown) L.push('  ' + c.dep + ' @ ' + c.newVersion + '  vs ' + c.detail.perRival.map(r => r.appName).join(', '))
  }
  if (docs.length) {
    L.push('')
    L.push('── BUILD_SKEW — docs / tests / CI, not runtime code, benign (' + docs.length + ') ──')
    for (const c of docs) { L.push('  ' + c.dep + ' @ ' + c.newVersion); fileLines(c) }
  }
  if (noise.length) {
    L.push('')
    L.push('── BUILD_SKEW — noise: packaging metadata only (' + noise.length + ') ──')
    for (const c of noise) { L.push('  ' + c.dep + ' @ ' + c.newVersion); fileLines(c) }
  }
  if (identical.length) {
    L.push('')
    L.push('── BUILD_SKEW — no on-disk diff (mode/symlink) (' + identical.length + ') ──')
    for (const c of identical) L.push('  ' + c.dep + ' @ ' + c.newVersion)
  }
  if (version.length) {
    L.push('')
    L.push('── VERSION split: independent apps on different versions (' + version.length + ') ──')
    for (const c of version) {
      L.push('  ' + c.dep + ': ' + appName + ' ' + c.newVersion + '  vs  ' +
        c.existing.map(m => m.appName + ' ' + m.version).join(', '))
    }
  }

  L.push('')
  if (review.length || unknown.length) {
    L.push('⚠ ' + (review.length + unknown.length) + ' conflict(s) need review (code-differs / unclassified).')
  } else {
    L.push('✓ No code-level skew — all conflicts are version-splits, docs, or packaging noise.')
  }
  L.push('')
  return L.join('\n')
}