OverviewFilesCommitsIssuesDocsReleasesPeers

pear-dep-audit / ecosystem-audit.cjs

140 lines · javascript ref:
#!/usr/bin/env node
// ecosystem-audit.cjs — find packages where 2+ pgh apps' latest releases
// disagree on version. Reads ~/.config/pgh/published-index.json.
//
// This is the EXACT data pgh publish enumerates per-project as "dep
// conflicts"; centralising it lets you align the ecosystem once instead
// of resolving each app's publish friction separately.
//
// Usage:
//   node ecosystem-audit.cjs [--index=PATH] [--filter=REGEX] [--apps=a,b,c]
//   node ecosystem-audit.cjs --snapshot=../snapshots/keet-4.14.2.json
//     ^ also flags pkgs that disagree with a reference snapshot
//
// Flags:
//   --index=PATH     path to published-index.json
//                    (default: ~/.config/pgh/published-index.json)
//   --filter=RE      only show packages matching this regex
//   --apps=a,b,c     only audit these apps (comma-sep; default: all)
//   --snapshot=PATH  cross-check ecosystem versions against a snapshot
//   --verbose        include matching packages too

const fs = require('fs')
const path = require('path')
const os = require('os')

function usage (msg) {
  if (msg) process.stderr.write('error: ' + msg + '\n')
  process.stderr.write('usage: ecosystem-audit.cjs [--index=PATH] [--filter=RE] [--apps=a,b,c] [--snapshot=PATH] [--verbose]\n')
  process.exit(msg ? 1 : 0)
}

let indexPath = path.join(os.homedir(), '.config', 'pgh', 'published-index.json')
let filterRe = null
let onlyApps = null
let snapshotPath = null
let verbose = false
for (const a of process.argv.slice(2)) {
  if (a === '--help' || a === '-h') usage()
  else if (a === '--verbose') verbose = true
  else if (a.startsWith('--index=')) indexPath = a.slice(8)
  else if (a.startsWith('--filter=')) filterRe = new RegExp(a.slice(9))
  else if (a.startsWith('--apps=')) onlyApps = new Set(a.slice(7).split(',').filter(Boolean))
  else if (a.startsWith('--snapshot=')) snapshotPath = a.slice(11)
  else usage('unknown flag: ' + a)
}

if (!fs.existsSync(indexPath)) usage('no published-index at ' + indexPath)
const data = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
const apps = data.apps || {}

const snapshot = snapshotPath ? JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) : null
const snapshotPkgs = snapshot ? (snapshot.packages || {}) : null

// Use the LATEST published version of each app as its current ecosystem footprint.
const latest = {}
for (const [name, app] of Object.entries(apps)) {
  if (onlyApps && !onlyApps.has(name)) continue
  const versions = app.versions || []
  if (!versions.length) continue
  latest[name] = versions[versions.length - 1]
}

// Build pkg → { version → [appNames...] }
const matrix = {}
for (const [appName, rel] of Object.entries(latest)) {
  const deps = (rel.lockfile && rel.lockfile.deps) || []
  for (const dep of deps) {
    if (!dep.name || !dep.version) continue
    if (filterRe && !filterRe.test(dep.name)) continue
    if (!matrix[dep.name]) matrix[dep.name] = {}
    if (!matrix[dep.name][dep.version]) matrix[dep.name][dep.version] = []
    matrix[dep.name][dep.version].push(appName)
  }
}

// Partition into conflicts (>1 version) + agreements (1 version, optionally
// vs snapshot)
const conflicts = []
const snapshotMismatches = []
const matches = []
for (const [pkg, vers] of Object.entries(matrix)) {
  const verKeys = Object.keys(vers)
  if (verKeys.length > 1) {
    conflicts.push({ pkg, vers })
  } else {
    const v = verKeys[0]
    if (snapshotPkgs && snapshotPkgs[pkg] && snapshotPkgs[pkg] !== v) {
      snapshotMismatches.push({ pkg, ecoVersion: v, snapshotVersion: snapshotPkgs[pkg], apps: vers[v] })
    } else {
      matches.push({ pkg, v, apps: vers[v] })
    }
  }
}
conflicts.sort((a, b) => a.pkg.localeCompare(b.pkg))
snapshotMismatches.sort((a, b) => a.pkg.localeCompare(b.pkg))

console.log('# ecosystem-audit: ' + indexPath)
console.log('  apps:                  ' + Object.keys(latest).length + '  (' + Object.keys(latest).join(', ') + ')')
console.log('  unique packages:       ' + Object.keys(matrix).length)
console.log('  conflicting packages:  ' + conflicts.length)
if (snapshotPath) {
  console.log('  vs snapshot:           ' + path.relative(process.cwd(), snapshotPath))
  console.log('  snapshot mismatches:   ' + snapshotMismatches.length + ' (eco agrees internally but differs from snapshot)')
}
console.log()

if (conflicts.length === 0) {
  console.log('  ✓ no internal conflicts')
} else {
  console.log('## conflicts (pkg disagreed across apps)')
  console.log()
  for (const c of conflicts) {
    console.log(c.pkg + (snapshotPkgs && snapshotPkgs[c.pkg] ? '  (snapshot: ' + snapshotPkgs[c.pkg] + ')' : ''))
    const rows = Object.entries(c.vers).sort()
    for (const [v, appList] of rows) {
      const mark = snapshotPkgs && snapshotPkgs[c.pkg] === v ? '  ←snapshot' : ''
      console.log('  ' + v.padEnd(12) + ' ' + appList.join(', ') + mark)
    }
    console.log()
  }
}

if (snapshotPath && snapshotMismatches.length) {
  console.log('## ecosystem agrees but drifts from snapshot')
  console.log()
  for (const m of snapshotMismatches) {
    console.log('  ' + m.pkg.padEnd(40) + ' eco=' + m.ecoVersion.padEnd(12) + ' snapshot=' + m.snapshotVersion + '  (' + m.apps.join(', ') + ')')
  }
  console.log()
}

if (verbose) {
  console.log('## matching (' + matches.length + ' packages aligned)')
  for (const m of matches.sort((a, b) => a.pkg.localeCompare(b.pkg))) {
    console.log('  ' + m.pkg.padEnd(40) + ' ' + m.v + '  (' + m.apps.join(', ') + ')')
  }
}

process.exit(conflicts.length > 0 ? 1 : 0)