/**
* 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')
}