OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / audit-ecosystem-consumers.mjs

1488 lines · javascript ref:
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

const here = path.dirname(fileURLToPath(import.meta.url))
const hiverelayRoot = path.resolve(here, '..')
const workspaceRootDefault = path.resolve(hiverelayRoot, '..', '..')
export const CURRENT_HIVERELAY_VERSION = readJson(path.join(hiverelayRoot, 'package.json')).version

export const HIVERELAY_DEPS = [
  'p2p-hiverelay',
  'p2p-hiverelay-client',
  'p2p-hiverelay-verifier',
  'p2p-hiveservices'
]

const DEPENDENCY_MODES = new Set(['local', 'npm-latest'])
export const DEFAULT_DEPENDENCY_MODE = 'npm-latest'
const CONSUMER_SCOPES = new Set(['all', 'release'])
const NPM_LATEST_SPEC = 'latest'

const DEP_SECTIONS = [
  'dependencies',
  'devDependencies',
  'optionalDependencies',
  'peerDependencies'
]

const SKIP_DIRS = new Set([
  '.git',
  '.forge-cache',
  '.gradle',
  '.next',
  '.turbo',
  'build',
  'coverage',
  'dist',
  'node_modules',
  'out'
])

// The sibling workspace also contains a read-only research corpus under
// 00-brain. That corpus mirrors upstream repositories, including deliberate
// malformed-package fixtures, and is not an installable ecosystem consumer.
// Skip it at the workspace root before parsing package.json files; otherwise a
// fixture intended to test another project can crash the release audit before
// the actual consumer inventory is examined.
const SKIP_WORKSPACE_ROOT_DIRS = new Set([
  '00-brain'
])

const IGNORED_PREFIXES = [
  '00-core/hiverelay/',
  '00-core/hr-acct/',
  '00-core/hr-fleet/',
  '00-core/hr-release/'
]

export const EXPECTED_CURRENT_SNAPSHOT_ROOTS = [
  {
    path: '00-core/hr-acct',
    role: 'accounting release snapshot'
  },
  {
    path: '00-core/hr-fleet',
    role: 'fleet release snapshot'
  },
  {
    path: '00-core/hr-release',
    role: 'release promotion snapshot'
  }
]

const SNAPSHOT_PACKAGE_SPECS = [
  {
    file: 'package.json',
    lockKey: '',
    name: 'p2p-hiverelay-monorepo'
  },
  {
    file: 'packages/core/package.json',
    lockKey: 'packages/core',
    name: 'p2p-hiverelay'
  },
  {
    file: 'packages/client/package.json',
    lockKey: 'packages/client',
    name: 'p2p-hiverelay-client',
    coreRange: true
  },
  {
    file: 'packages/services/package.json',
    lockKey: 'packages/services',
    name: 'p2p-hiveservices',
    coreRange: true
  },
  {
    file: 'packages/verifier/package.json',
    lockKey: 'packages/verifier',
    name: 'p2p-hiverelay-verifier'
  }
]

export const EXPECTED_CURRENT_CONSUMERS = [
  {
    path: '01-browser/pearbrowser-desktop/package.json',
    role: 'release-critical bundled PearBrowser desktop',
    release: {
      repository: 'bigdestiny2/pearbrowser-desktop',
      ref: 'feat/p2p-infra-naming',
      checkoutPath: '01-browser/pearbrowser-desktop'
    },
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../00-core/hiverelay/packages/client',
      'p2p-hiverelay-verifier': 'file:../../00-core/hiverelay/packages/verifier'
    },
    sourceChecks: [
      {
        file: '01-browser/pearbrowser-desktop/catalog-source/pearbrowser-network.catalog.json',
        label: 'PearBrowser catalog advertises the current Hiverelay app release',
        termTemplate: '"id": "hiverelay",\n      "name": "HiveRelay",\n      "description": "Always-on relay infrastructure for the Pear and Hyperswarm ecosystem. Blind peering, NAT traversal, HTTP gateway. Apache-2.0.",\n      "author": "bigdestiny2",\n      "version": "{version}"',
        rejectTerms: [
          '"version": "0.16.3"'
        ]
      },
      {
        file: '01-browser/pearbrowser-desktop/backend/catalogue-seed.js',
        label: 'PearBrowser bundled catalog seed advertises the current Hiverelay app release',
        termTemplate: '"name": "HiveRelay",\n      "type": "hypersite",\n      "driveKey": "9f2b34aad8cd1a681d5f07d8a76768f0dc92a5008251d02a8600eb0751ad6b5f",\n      "author": "bigdestiny2",\n      "categories": [\n        "infrastructure",\n        "site"\n      ],\n      "description": "Always-on relay infrastructure for the Pear and Hyperswarm ecosystem. Blind peering, NAT traversal, HTTP gateway. Apache-2.0.",\n      "version": "{version}"',
        rejectTerms: [
          '"version": "0.16.3"'
        ]
      },
      {
        file: '01-browser/pearbrowser-desktop/docs/HIVERELAY-BACKBONE-HANDOVER.md',
        label: 'PearBrowser backbone handover names the current Hiverelay package line',
        termTemplate: '`p2p-hiverelay` `{version}` from the local HiveRelay workspace',
        termTemplateByDependencyMode: {
          'npm-latest': '`p2p-hiverelay` `{version}` from the npm latest release line'
        },
        replaceTerms: [
          '`p2p-hiverelay` (npm v0.8.12)'
        ],
        rejectTerms: [
          'npm v0.8.12',
          '^0.8.12'
        ]
      },
      {
        file: '01-browser/pearbrowser-desktop/README.md',
        label: 'PearBrowser README advertises npm latest Hiverelay defaults',
        term: 'desktop packages default to npm `latest` for HiveRelay',
        rejectTerms: [
          'desktop packages pinned to local',
          'local `0.20.0` workspace packages',
          'sibling checkout is required for community source installs'
        ]
      },
      {
        file: '01-browser/pearbrowser-desktop/scripts/check-hiverelay-layout.mjs',
        label: 'PearBrowser install guard permits npm latest defaults',
        term: 'usesNpmLatestDefaults',
        rejectTerms: [
          'PearBrowser desktop currently uses local HiveRelay workspace packages.'
        ]
      }
    ]
  },
  {
    path: '02-apps/pearpaste/package.json',
    role: 'customer encrypted-availability app',
    release: {
      repository: 'bigdestiny2/pearpaste',
      ref: 'feat/pear-runtime-electron-forge',
      checkoutPath: '02-apps/pearpaste'
    },
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../00-core/hiverelay/packages/client'
    },
    sourceChecks: [
      {
        file: '02-apps/pearpaste/docs/RECOVERY_DESIGN.md',
        label: 'recovery design uses current core package default guidance',
        term: '"p2p-hiverelay": "file:../../00-core/hiverelay/packages/core"',
        termByDependencyMode: {
          'npm-latest': '"p2p-hiverelay": "latest"'
        },
        rejectTermsByDependencyMode: {
          'npm-latest': [
            '"p2p-hiverelay": "file:../../00-core/hiverelay/packages/core"',
            '"p2p-hiverelay-client": "file:../../00-core/hiverelay/packages/client"'
          ]
        },
        rejectTerms: [
          '"p2p-hiverelay-client": "^0.9.2"',
          'Blocked on upstream npm publish',
          'Other 0.8.x',
          'changes we\'d pick up'
        ]
      },
      {
        file: '02-apps/pearpaste/docs/RECOVERY_DESIGN.md',
        label: 'recovery design uses current client package default guidance',
        term: '"p2p-hiverelay-client": "file:../../00-core/hiverelay/packages/client"',
        termByDependencyMode: {
          'npm-latest': '"p2p-hiverelay-client": "latest"'
        },
        rejectTermsByDependencyMode: {
          'npm-latest': [
            '"p2p-hiverelay-client": "file:../../00-core/hiverelay/packages/client"'
          ]
        },
        rejectTerms: [
          '"p2p-hiverelay-client": "^0.9.2"',
          'Blocked on upstream npm publish',
          'Other 0.8.x',
          'changes we\'d pick up'
        ]
      },
      {
        file: '02-apps/pearpaste/docs/PEARPASTE_TECHNICAL_SPEC.md',
        label: 'technical spec names current Hiverelay package integration',
        termTemplate: 'HiveRelay `{version}` local workspace packages by default',
        termTemplateByDependencyMode: {
          'npm-latest': 'HiveRelay `{version}` packages through npm `latest` by default'
        },
        rejectTerms: [
          'Integrate `p2p-hiverelay-client` `0.8.13`'
        ]
      },
      {
        file: '02-apps/pearpaste/REVIEW.md',
        label: 'customer relay review names the current split-client package line',
        termTemplate: 'p2p-hiverelay-client@{version}',
        rejectTerms: [
          'p2p-hiverelay-client@0.9.2',
          'Real 0.9.2 API match',
          'real 0.9.2 client'
        ]
      },
      {
        file: '02-apps/pearpaste/scripts/probe-circuit.mjs',
        label: 'circuit probe asks for current fleet capability, not a stale release line',
        term: 'current HiveRelay fleet',
        rejectTerms: [
          'v0.8.14',
          '0.8.14'
        ]
      },
      {
        file: '02-apps/pearpaste/test/unit/hiverelay-upgrade.test.js',
        label: 'PearPaste customer test asserts npm latest Hiverelay defaults',
        term: 'HiveRelay customer path uses npm latest defaults',
        rejectTerms: [
          'HiveRelay customer path is pinned to the local',
          'file:../../00-core/hiverelay/packages/core',
          'file:../../00-core/hiverelay/packages/client'
        ]
      }
    ]
  },
  {
    path: '02-apps/pear-pos/package.json',
    role: 'local app consumer',
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../00-core/hiverelay/packages/client'
    },
    sourceChecks: [
      {
        file: '02-apps/pear-pos/app/backend/hiverelay-client.js',
        label: 'POS CJS bridge comment names the current split-client package line',
        termTemplate: 'p2p-hiverelay-client@{version}',
        rejectTerms: [
          'p2p-hiverelay-client@0.16.3'
        ]
      },
      {
        file: '02-apps/pear-pos/app/backend/hiverelay-sync.js',
        label: 'POS seed lifecycle comment names the current release line',
        termTemplate: 'Seed lifecycle (v{version})',
        rejectTerms: [
          'Seed lifecycle (v0.16.3)'
        ]
      },
      {
        file: '02-apps/pear-pos/ARCHITECTURE.md',
        label: 'POS architecture note names the current split-client package line',
        termTemplate: 'p2p-hiverelay-client@{version}',
        rejectTerms: [
          'p2p-hiverelay-client@0.16.3'
        ]
      },
      {
        file: '02-apps/pear-pos/PLAN.md',
        label: 'POS plan names current Hiverelay workspace package defaults',
        termTemplate: 'current HiveRelay {version} split packages',
        rejectTerms: [
          'v0.16.3',
          'p2p-hiverelay-client@0.16.3'
        ]
      },
      {
        file: '02-apps/pear-pos/RESEARCH.md',
        label: 'POS research note names the current split-client package line',
        termTemplate: 'p2p-hiverelay-client@{version}',
        rejectTerms: [
          'p2p-hiverelay-client@0.16.3'
        ]
      },
      {
        file: '02-apps/pear-pos/scripts/validate-publish-surface.mjs',
        label: 'POS publish validator accepts npm latest Hiverelay defaults',
        term: 'package.json optionalDependencies.p2p-hiverelay should default to npm latest',
        rejectTerms: [
          'package.json optionalDependencies.p2p-hiverelay should point at ../../00-core/hiverelay/packages/core',
          'package.json optionalDependencies.p2p-hiverelay-client should point at ../../00-core/hiverelay/packages/client'
        ]
      }
    ]
  },
  {
    path: '02-apps/pear-tickets/package.json',
    role: 'local app consumer',
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../00-core/hiverelay/packages/client'
    },
    sourceChecks: [
      {
        file: '02-apps/pear-tickets/scripts/validate-publish-surface.mjs',
        label: 'Pear Tickets publish validator accepts npm latest Hiverelay defaults',
        term: 'package.json must default p2p-hiverelay to npm latest',
        rejectTerms: [
          'package.json must pin p2p-hiverelay to ../../00-core/hiverelay/packages/core',
          'package.json must pin p2p-hiverelay-client to ../../00-core/hiverelay/packages/client'
        ]
      },
      {
        file: '02-apps/pear-tickets/README.md',
        label: 'Pear Tickets README advertises npm latest Hiverelay defaults',
        term: 'defaults `p2p-hiverelay` and `p2p-hiverelay-client` to npm `latest`',
        rejectTerms: [
          'local file dependencies'
        ]
      }
    ]
  },
  {
    path: '02-apps/peerit/package.json',
    role: 'local-only Peerit publish client compatibility',
    sourceOnly: true,
    deps: {},
    sourceChecks: [
      {
        file: '02-apps/peerit/publish.mjs',
        label: 'Peerit publish path loads the split HiveRelay client package',
        term: "const HIVERELAY_CLIENT_PACKAGE = 'p2p-hiverelay-client'",
        rejectTerms: [
          'p2p-hiverelay/client',
          "from '~/pear-ecosystem/00-core/hiverelay/packages/client/index.js'"
        ]
      },
      {
        file: '02-apps/peerit/publish.mjs',
        label: 'Peerit publish path accepts explicit HiveRelay checkout overrides',
        term: "['HIVERELAY_ROOT', process.env.HIVERELAY_ROOT]"
      },
      {
        file: '02-apps/peerit/publish.mjs',
        label: 'Peerit publish path validates the HiveRelayClient export',
        term: 'does not export HiveRelayClient'
      },
      {
        file: '02-apps/peerit/README.md',
        label: 'Peerit README preserves dependency-free app runtime and publish-only client scope',
        term: 'The app itself has no install step and no runtime npm dependencies. Publishing is\nthe only workflow that needs the HiveRelay client.',
        rejectTerms: [
          'p2p-hiverelay/client'
        ]
      },
      {
        file: '02-apps/peerit/README.md',
        label: 'Peerit README documents installed split-client package plus local checkout fallback',
        term: 'The publisher loads `p2p-hiverelay-client` from an installed package, an explicit\nHiveRelay env path, or a discoverable sibling/workspace checkout.'
      }
    ]
  },
  {
    path: '03-sites/pearbrowser-publishers/src/p2pbuilders/package.json',
    role: 'publisher-site consumer',
    release: {
      repository: 'bigdestiny2/p2pbuilders',
      ref: 'main',
      checkoutPath: '03-sites/pearbrowser-publishers/src/p2pbuilders'
    },
    deps: {
      'p2p-hiverelay': 'file:../../../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../../../00-core/hiverelay/packages/client'
    },
    sourceChecks: [
      {
        file: '03-sites/pearbrowser-publishers/src/p2pbuilders/test/m12-hiverelay-client-migration.js',
        label: 'p2pbuilders migration guard asserts npm latest Hiverelay defaults',
        term: 'p2pbuilders should default to the npm latest HiveRelay core package',
        rejectTerms: [
          'p2pbuilders should use the local HiveRelay',
          'file:../../../../00-core/hiverelay/packages/core',
          'file:../../../../00-core/hiverelay/packages/client'
        ]
      }
    ]
  },
  {
    path: '04-experiments/Opengit/packages/opengit-relay/package.json',
    role: 'optional Opengit blind-relay bridge',
    release: {
      repository: 'bigdestiny2/Opengit',
      ref: 'main',
      checkoutPath: '04-experiments/Opengit'
    },
    deps: {
      'p2p-hiverelay': 'file:../../../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../../../00-core/hiverelay/packages/client'
    },
    sourceChecks: [
      {
        file: '04-experiments/Opengit/HIVERELAY-INTEGRATION.md',
        label: 'Opengit integration note names current package defaults',
        termTemplate: 'Current package default:** `packages/opengit-relay` uses local workspace links to `p2p-hiverelay` and `p2p-hiverelay-client` from `../../../../00-core/hiverelay/packages/*`. Release builds should move to the matching `^{version}` semver range only after the HiveRelay release is promoted.',
        termTemplateByDependencyMode: {
          'npm-latest': 'Current package default:** `packages/opengit-relay` uses npm `latest` for `p2p-hiverelay` and `p2p-hiverelay-client`, guarded to resolve `{version}` after the HiveRelay release is promoted.'
        },
        rejectTermsByDependencyMode: {
          'npm-latest': [
            'uses local workspace links'
          ]
        },
        rejectTerms: [
          'npm-publish lag',
          '0.7.3',
          'v0.8.x',
          '0.8.0',
          '0.8.1',
          '^0.8',
          '^0.7.3'
        ]
      },
      {
        file: '04-experiments/Opengit/packages/opengit-relay/test/relay.test.js',
        label: 'Opengit relay test asserts npm latest Hiverelay defaults',
        term: 'optional HiveRelay bridge defaults to npm latest packages',
        rejectTerms: [
          'optional HiveRelay bridge is pinned to local ESM packages',
          'file:../../../../00-core/hiverelay/packages/core',
          'file:../../../../00-core/hiverelay/packages/client'
        ]
      }
    ]
  },
  {
    path: '04-experiments/anongpt-native/package.json',
    role: 'customer relay/onion AI app',
    release: {
      repository: 'bigdestiny2/anongpt',
      ref: 'main',
      checkoutPath: '04-experiments/anongpt-native'
    },
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core'
    },
    sourceChecks: [
      {
        file: '04-experiments/anongpt-native/docs/ARCHITECTURE.md',
        label: 'anonGPT architecture documents HiveRelay relay/onion transport',
        term: 'Network privacy (hide your IP) rides on **HiveRelay**\nrelays — relay (1 hop) or onion (2 hops).'
      },
      {
        file: '04-experiments/anongpt-native/backend/forward-transport.js',
        label: 'anonGPT forward transport targets production HiveRelay forward service',
        term: "routes inference through the production HiveRelay relays' forward service"
      },
      {
        file: '04-experiments/anongpt-native/test/hiverelay-upgrade.test.cjs',
        label: 'anonGPT customer test asserts npm latest Hiverelay defaults',
        term: 'HiveRelay customer transport path uses npm latest by default',
        rejectTerms: [
          'HiveRelay customer transport path uses the local',
          'file:../../00-core/hiverelay/packages/core'
        ]
      }
    ]
  },
  {
    path: '04-experiments/hiverelay-test/package.json',
    role: 'local experiment consumer',
    deps: {
      'p2p-hiverelay': 'file:../../00-core/hiverelay/packages/core',
      'p2p-hiverelay-client': 'file:../../00-core/hiverelay/packages/client'
    }
  }
]

export const EXPECTED_STALE_CONSUMERS = [
]

export function normalizeDependencyMode (value = DEFAULT_DEPENDENCY_MODE) {
  if (!DEPENDENCY_MODES.has(value)) {
    throw new Error(`Invalid dependency mode ${JSON.stringify(value)}. Expected local or npm-latest.`)
  }
  return value
}

export function normalizeConsumerScope (value = 'all') {
  if (!CONSUMER_SCOPES.has(value)) {
    throw new Error(`Invalid consumer scope ${JSON.stringify(value)}. Expected all or release.`)
  }
  return value
}

export function getExpectedCurrentConsumers (opts = {}) {
  const dependencyMode = normalizeDependencyMode(opts.dependencyMode)
  const consumerScope = normalizeConsumerScope(opts.consumerScope)
  return EXPECTED_CURRENT_CONSUMERS
    .filter(consumer => consumerScope === 'all' || consumer.release)
    .map(consumer => ({
      ...consumer,
      deps: dependencyMode === 'npm-latest'
        ? Object.fromEntries(Object.keys(consumer.deps).map(dep => [dep, NPM_LATEST_SPEC]))
        : { ...consumer.deps },
      sourceChecks: resolveSourceChecksForMode(consumer.sourceChecks, dependencyMode),
      dependencyMode,
      consumerScope
    }))
}

function resolveSourceChecksForMode (sourceChecks = [], dependencyMode) {
  return sourceChecks.map(spec => {
    const resolved = { ...spec }
    const termByMode = spec.termByDependencyMode || {}
    const termTemplateByMode = spec.termTemplateByDependencyMode || {}
    const rejectTermsByMode = spec.rejectTermsByDependencyMode || {}

    if (typeof termByMode[dependencyMode] === 'string') resolved.term = termByMode[dependencyMode]
    if (typeof termTemplateByMode[dependencyMode] === 'string') resolved.termTemplate = termTemplateByMode[dependencyMode]
    resolved.rejectTerms = uniqueStrings([
      ...(spec.rejectTerms || []),
      ...(rejectTermsByMode[dependencyMode] || [])
    ])
    resolved.replaceTerms = uniqueStrings([
      spec.term,
      ...(spec.replaceTerms || []),
      ...Object.values(termByMode),
      resolved.term
    ])
    resolved.replaceTermTemplates = uniqueStrings([
      spec.termTemplate,
      ...(spec.replaceTermTemplates || []),
      ...Object.values(termTemplateByMode),
      resolved.termTemplate
    ])
    return resolved
  })
}

const usage = `
Usage:
  node scripts/audit-ecosystem-consumers.mjs [--workspace-root <path>] [--expected-version <semver>] [--dependency-mode <local|npm-latest>] [--consumer-scope <all|release>] [--json] [--check]

Scans package.json files outside the Hiverelay source tree and verifies the
known direct p2p-hiverelay consumers plus local release snapshot defaults. The
default dependency mode is npm-latest, which verifies the published-app contract
where app manifests request the npm latest dist-tag and lockfiles resolve that
tag to the expected Hiverelay version. Use --dependency-mode local for
development workspace links. Use --consumer-scope release to check only app
repos the release workflow can checkout, while still classifying local-only
consumers so new pins cannot hide. The command fails on new unclassified pins,
stale package metadata, or inventory/source-plan drift.
`

if (isMain()) main()

export function scanHiverelayConsumers (opts = {}) {
  const workspaceRoot = path.resolve(opts.workspaceRoot || workspaceRootDefault)
  const packageFiles = findPackageFiles(workspaceRoot)
  const rows = []

  for (const file of packageFiles) {
    const relPath = slash(path.relative(workspaceRoot, file))
    const pkg = readJson(file)
    const deps = collectHiverelayDeps(pkg)
    if (Object.keys(deps).length === 0) continue

    rows.push({
      path: relPath,
      name: typeof pkg.name === 'string' ? pkg.name : '',
      deps,
      ignored: IGNORED_PREFIXES.some(prefix => relPath.startsWith(prefix))
    })
  }

  rows.sort((a, b) => a.path.localeCompare(b.path))
  return rows
}

export function checkConsumerState (rows, opts = {}) {
  const expectedVersion = opts.expectedVersion || '0.0.0'
  const expectedCurrent = opts.expectedCurrent || getExpectedCurrentConsumers(opts)
  const expectedClassifiedCurrent = opts.expectedClassifiedCurrent || expectedCurrent
  const expectedStale = opts.expectedStale || EXPECTED_STALE_CONSUMERS
  const sourceChecks = opts.sourceChecks || []
  const lockChecks = opts.lockChecks || []
  const snapshotChecks = opts.snapshotChecks || []
  const npmLatestChecks = opts.npmLatestChecks || []
  const npmLatestWarnings = opts.npmLatestWarnings || []
  const errors = []
  const warnings = [...npmLatestWarnings]
  const byPath = new Map(rows.map(row => [row.path, row]))
  const expectedPaths = new Set([
    ...expectedClassifiedCurrent.map(row => row.path),
    ...expectedStale.map(row => row.path)
  ])

  for (const expected of expectedCurrent) {
    const row = byPath.get(expected.path)
    if (!row) {
      if (!isSourceOnlyConsumer(expected)) {
        errors.push(`Missing expected current Hiverelay consumer: ${expected.path}`)
      }
      continue
    }
    compareDeps(errors, row, expected.deps, 'current local consumer')
  }

  for (const expected of expectedStale) {
    const row = byPath.get(expected.path)
    if (!row) {
      errors.push(`Known stale Hiverelay consumer disappeared or moved; update the inventory: ${expected.path}`)
      continue
    }
    compareDeps(errors, row, expected.deps, 'known stale consumer')
  }

  const activeRows = rows.filter(row => !row.ignored)
  for (const row of activeRows) {
    if (expectedPaths.has(row.path)) continue
    errors.push(`Unclassified Hiverelay package consumer: ${row.path} (${formatDeps(row.deps)})`)
  }

  const staleRows = expectedStale
    .map(expected => byPath.get(expected.path))
    .filter(Boolean)
  if (staleRows.length > 0) {
    warnings.push(`Known stale non-bundled consumers need separate ${expectedVersion} upgrade loops: ${staleRows.map(row => row.path).join(', ')}`)
  }

  for (const check of sourceChecks) {
    if (!check.present) {
      errors.push(`Missing expected source-level migration marker for ${check.consumerPath}: ${check.file} (${check.label})`)
    }
    if (check.rejectedTermsFound?.length > 0) {
      errors.push(`Disallowed source-level migration marker found for ${check.consumerPath}: ${check.file} (${check.label}) includes ${check.rejectedTermsFound.map(term => JSON.stringify(term)).join(', ')}`)
    }
  }

  for (const check of lockChecks) {
    if (!check.ok) errors.push(check.error)
  }

  for (const check of snapshotChecks) {
    if (!check.ok) errors.push(check.error)
  }

  for (const check of npmLatestChecks) {
    if (!check.ok) errors.push(check.error)
  }

  return {
    ok: errors.length === 0,
    expectedVersion,
    dependencyMode: expectedCurrent[0]?.dependencyMode || opts.dependencyMode || 'local',
    consumerScope: expectedCurrent[0]?.consumerScope || opts.consumerScope || 'all',
    errors,
    warnings,
    current: expectedCurrent.map(expected => decorateExpectedRow(byPath.get(expected.path), expected)).filter(Boolean),
    stale: expectedStale.map(expected => decorateExpectedRow(byPath.get(expected.path), expected)).filter(Boolean),
    sourceChecks,
    lockChecks,
    snapshotChecks,
    npmLatestChecks,
    ignored: rows.filter(row => row.ignored)
  }
}

export function verifyNpmLatestDistTags ({ expectedCurrent, expectedVersion, npmLatestVersions } = {}) {
  const errors = []
  const warnings = []
  const checks = []
  const names = Array.from(new Set([
    ...HIVERELAY_DEPS,
    ...(expectedCurrent || []).flatMap(consumer => Object.keys(consumer.deps || {}))
  ])).sort()
  const envVersions = npmLatestVersions || npmLatestVersionsFromEnv(process.env)

  for (const name of names) {
    let latest = envVersions?.[name]
    if (latest == null) {
      try {
        latest = execFileSync('npm', ['view', name, 'dist-tags.latest'], {
          encoding: 'utf8',
          stdio: ['ignore', 'pipe', 'pipe'],
          timeout: 20000
        }).trim()
      } catch (err) {
        const error = `Could not verify npm latest dist-tag for ${name}: ${stderrMessage(err) || err.message}. Set HIVERELAY_NPM_LATEST_JSON to trusted npm view results when running in an offline or DNS-restricted environment.`
        errors.push(error)
        checks.push({ ok: false, name, latest: null, expectedVersion, error })
        continue
      }
    }

    if (latest !== expectedVersion) {
      const error = `${name} npm latest dist-tag is ${latest || '(missing)'}; expected ${expectedVersion}. Refusing to treat app defaults as current npm latest because that would not install the current HiveRelay release.`
      errors.push(error)
      checks.push({ ok: false, name, latest: latest || '', expectedVersion, error })
    } else {
      checks.push({ ok: true, name, latest, expectedVersion })
    }
  }

  if (names.length > 0 && errors.length === 0) {
    warnings.push(`npm latest dist-tags verified for ${names.join(', ')}`)
  }

  return { ok: errors.length === 0, errors, warnings, checks }
}

function npmLatestVersionsFromEnv (env) {
  if (env.HIVERELAY_NPM_LATEST_JSON) {
    try {
      const parsed = JSON.parse(env.HIVERELAY_NPM_LATEST_JSON)
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
    } catch {
      return {}
    }
  }
  return {}
}

export function scanCurrentConsumerLockChecks (opts = {}) {
  const workspaceRoot = path.resolve(opts.workspaceRoot || workspaceRootDefault)
  const expectedCurrent = opts.expectedCurrent || getExpectedCurrentConsumers(opts)
  const expectedVersion = opts.expectedVersion || '0.0.0'
  const checks = []

  for (const consumer of expectedCurrent) {
    if (!hasExpectedHiverelayDeps(consumer)) continue
    const packageFile = path.join(workspaceRoot, consumer.path)
    const packageDir = path.dirname(packageFile)
    const lockFile = findNearestLockfile(packageDir, workspaceRoot)
    if (!lockFile) {
      checks.push({
        ok: false,
        consumerPath: consumer.path,
        lockFile: null,
        label: 'package-lock exists',
        error: `${consumer.path} current local consumer has no package-lock.json to pin local Hiverelay package metadata`
      })
      continue
    }

    let lock
    try {
      lock = readJson(lockFile)
    } catch (err) {
      checks.push({
        ok: false,
        consumerPath: consumer.path,
        lockFile: slash(path.relative(workspaceRoot, lockFile)),
        label: 'package-lock parses',
        error: `${slash(path.relative(workspaceRoot, lockFile))} cannot be parsed for ${consumer.path}: ${err.message}`
      })
      continue
    }

    const relLockFile = slash(path.relative(workspaceRoot, lockFile))
    const lockRoot = path.dirname(lockFile)
    const packageKey = slash(path.relative(lockRoot, packageDir))
    const packageEntryKey = packageKey === '' ? '' : packageKey
    const packageEntry = lock.packages?.[packageEntryKey]
    if (!packageEntry) {
      checks.push({
        ok: false,
        consumerPath: consumer.path,
        lockFile: relLockFile,
        label: 'consumer package entry',
        error: `${relLockFile} is missing the package-lock entry for ${consumer.path}`
      })
      continue
    }

    const lockDeps = collectHiverelayDeps(packageEntry)
    for (const [dep, expectedValue] of Object.entries(consumer.deps).sort(([a], [b]) => a.localeCompare(b))) {
      if (lockDeps[dep] !== expectedValue) {
        checks.push({
          ok: false,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} lock dependency`,
          error: `${relLockFile} has ${consumer.path} lock dependency ${dep}=${JSON.stringify(lockDeps[dep])}; expected ${JSON.stringify(expectedValue)}`
        })
      } else {
        checks.push({
          ok: true,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} lock dependency`
        })
      }

      if (expectedValue === NPM_LATEST_SPEC) {
        const targetKey = `node_modules/${dep}`
        const targetEntry = lock.packages?.[targetKey]
        if (!targetEntry) {
          checks.push({
            ok: false,
            consumerPath: consumer.path,
            lockFile: relLockFile,
            label: `${dep} npm package entry`,
            error: `${relLockFile} is missing npm package metadata for ${dep} at ${targetKey}`
          })
          continue
        }

        if (lockPackageName(targetKey, targetEntry) !== dep) {
          checks.push({
            ok: false,
            consumerPath: consumer.path,
            lockFile: relLockFile,
            label: `${dep} npm package name`,
            error: `${relLockFile} npm package ${targetKey} is named ${JSON.stringify(lockPackageName(targetKey, targetEntry))}; expected ${JSON.stringify(dep)}`
          })
          continue
        }

        if (targetEntry.version !== expectedVersion) {
          checks.push({
            ok: false,
            consumerPath: consumer.path,
            lockFile: relLockFile,
            label: `${dep} npm package version`,
            error: `${relLockFile} npm package ${targetKey} has ${dep}@${targetEntry.version}; expected ${expectedVersion}`
          })
        } else {
          checks.push({
            ok: true,
            consumerPath: consumer.path,
            lockFile: relLockFile,
            label: `${dep} npm package version`
          })
        }
        continue
      }

      if (!expectedValue.startsWith('file:')) continue
      const target = path.resolve(packageDir, expectedValue.slice('file:'.length))
      const targetKey = slash(path.relative(lockRoot, target))
      const targetEntry = lock.packages?.[targetKey]
      if (!targetEntry) {
        checks.push({
          ok: false,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} linked package entry`,
          error: `${relLockFile} is missing linked package metadata for ${dep} at ${targetKey}`
        })
        continue
      }
      if (targetEntry.name !== dep) {
        checks.push({
          ok: false,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} linked package name`,
          error: `${relLockFile} linked package ${targetKey} is named ${JSON.stringify(targetEntry.name)}; expected ${JSON.stringify(dep)}`
        })
        continue
      }
      if (targetEntry.version !== expectedVersion) {
        checks.push({
          ok: false,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} linked package version`,
          error: `${relLockFile} linked package ${targetKey} has ${dep}@${targetEntry.version}; expected ${expectedVersion}`
        })
      } else {
        checks.push({
          ok: true,
          consumerPath: consumer.path,
          lockFile: relLockFile,
          label: `${dep} linked package version`
        })
      }
    }

    for (const issue of findStaleLockEntries(lock, relLockFile, expectedVersion)) {
      checks.push({
        ok: false,
        consumerPath: consumer.path,
        lockFile: relLockFile,
        label: 'stale Hiverelay lock entry',
        error: issue
      })
    }
  }

  checks.sort((a, b) => `${a.consumerPath}\0${a.lockFile || ''}\0${a.label}`.localeCompare(`${b.consumerPath}\0${b.lockFile || ''}\0${b.label}`))
  return checks
}

export function scanSnapshotVersionChecks (opts = {}) {
  const workspaceRoot = path.resolve(opts.workspaceRoot || workspaceRootDefault)
  const expectedVersion = opts.expectedVersion || '0.0.0'
  const snapshotRoots = opts.snapshotRoots || EXPECTED_CURRENT_SNAPSHOT_ROOTS
  const checks = []

  for (const snapshot of snapshotRoots) {
    const rootPath = typeof snapshot === 'string' ? snapshot : snapshot.path
    const role = typeof snapshot === 'string' ? null : snapshot.role
    for (const spec of SNAPSHOT_PACKAGE_SPECS) {
      checks.push(...checkSnapshotPackage({
        workspaceRoot,
        rootPath,
        role,
        spec,
        expectedVersion
      }))
    }
  }

  checks.sort((a, b) => `${a.rootPath}\0${a.file}\0${a.label}`.localeCompare(`${b.rootPath}\0${b.file}\0${b.label}`))
  return checks
}

export function scanStaleConsumerSourceChecks (opts = {}) {
  return scanConsumerSourceChecks({
    ...opts,
    expectedCurrent: []
  })
}

export function scanConsumerSourceChecks (opts = {}) {
  const workspaceRoot = path.resolve(opts.workspaceRoot || workspaceRootDefault)
  const expectedVersion = opts.expectedVersion || CURRENT_HIVERELAY_VERSION
  const expectedCurrent = opts.expectedCurrent || []
  const expectedStale = opts.expectedStale || EXPECTED_STALE_CONSUMERS
  const checks = []

  for (const consumer of [...expectedCurrent, ...expectedStale]) {
    for (const spec of consumer.sourceChecks || []) {
      const file = slash(spec.file)
      const abs = path.join(workspaceRoot, file)
      const term = renderSourceTerm(spec, expectedVersion)
      let text = ''
      let present = false
      let rejectedTermsFound = []
      try {
        text = fs.readFileSync(abs, 'utf8')
        present = text.includes(term)
        rejectedTermsFound = (spec.rejectTerms || []).filter(term => text.includes(term))
      } catch {
        present = false
      }
      checks.push({
        consumerPath: consumer.path,
        file,
        label: spec.label,
        term,
        present,
        rejectedTermsFound
      })
    }
  }

  checks.sort((a, b) => (a.consumerPath + a.file).localeCompare(b.consumerPath + b.file))
  return checks
}

function renderSourceTerm (spec, version) {
  if (typeof spec.termTemplate === 'string') {
    return spec.termTemplate.replaceAll('{version}', version)
  }
  return spec.term
}

function uniqueStrings (values) {
  return Array.from(new Set(values.filter(value => typeof value === 'string' && value.length > 0)))
}

export function formatConsumerReport (summary) {
  const lines = [
    `HiveRelay ecosystem consumer audit (expected ${summary.expectedVersion}, mode ${summary.dependencyMode || 'local'}, scope ${summary.consumerScope || 'all'})`,
    ''
  ]

  lines.push(summary.dependencyMode === 'npm-latest' ? 'Current npm-latest consumers:' : 'Current local consumers:')
  for (const row of summary.current) {
    const role = row.role ? ` [${row.role}]` : ''
    const deps = formatDeps(row.deps) || (row.sourceOnly ? 'no package deps; source-only compatibility guard' : '(none)')
    lines.push(`- ${row.path}${role}: ${deps}`)
  }
  if (summary.current.length === 0) lines.push('- none')

  lines.push('', 'Known stale non-bundled consumers:')
  for (const row of summary.stale) {
    const action = row.action ? ` (${row.action})` : ''
    lines.push(`- ${row.path}: ${formatDeps(row.deps)}${action}`)
  }
  if (summary.stale.length === 0) lines.push('- none')

  lines.push('', 'Direct-consumer scan exclusions:')
  const ignoredRoots = Array.from(new Set(summary.ignored.map(row => row.path.split('/').slice(0, 2).join('/')))).sort()
  for (const root of ignoredRoots) lines.push(`- ${root}`)
  if (ignoredRoots.length === 0) lines.push('- none')

  if (summary.warnings.length > 0) {
    lines.push('', 'Warnings:')
    for (const warning of summary.warnings) lines.push(`- ${warning}`)
  }

  if (summary.sourceChecks.length > 0) {
    lines.push('', 'Source-level migration checks:')
    for (const check of summary.sourceChecks) {
      const status = check.present ? 'found' : 'missing'
      const rejected = check.rejectedTermsFound?.length > 0
        ? `, rejected terms found: ${check.rejectedTermsFound.map(term => JSON.stringify(term)).join(', ')}`
        : ''
      lines.push(`- ${check.file}: ${check.label} (${status}${rejected})`)
    }
  }

  if (summary.lockChecks.length > 0) {
    lines.push('', 'Lockfile migration checks:')
    const failed = summary.lockChecks.filter(check => !check.ok)
    if (failed.length === 0) {
      const lockfiles = Array.from(new Set(summary.lockChecks.map(check => check.lockFile).filter(Boolean))).sort()
      for (const lockfile of lockfiles) lines.push(`- ${lockfile}: ok`)
    } else {
      for (const check of failed) lines.push(`- ${check.lockFile || check.consumerPath}: ${check.label} (failed)`)
    }
  }

  if (summary.snapshotChecks.length > 0) {
    lines.push('', 'Snapshot/default version checks:')
    const failed = summary.snapshotChecks.filter(check => !check.ok)
    if (failed.length === 0) {
      const roots = Array.from(new Map(summary.snapshotChecks.map(check => [check.rootPath, check])).values())
      for (const check of roots) {
        const role = check.role ? ` [${check.role}]` : ''
        lines.push(`- ${check.rootPath}${role}: ok`)
      }
    } else {
      for (const check of failed) lines.push(`- ${check.file}: ${check.label} (failed)`)
    }
  }

  if (summary.npmLatestChecks.length > 0) {
    lines.push('', 'NPM latest dist-tag checks:')
    const failed = summary.npmLatestChecks.filter(check => !check.ok)
    if (failed.length === 0) {
      for (const check of summary.npmLatestChecks) lines.push(`- ${check.name}: ${check.latest}`)
    } else {
      for (const check of failed) lines.push(`- ${check.name}: ${check.latest || '(unverified)'} (failed)`)
    }
  }

  if (summary.errors.length > 0) {
    lines.push('', 'Errors:')
    for (const error of summary.errors) lines.push(`- ${error}`)
  }

  return lines.join('\n')
}

function decorateExpectedRow (row, expected) {
  if (!row && isSourceOnlyConsumer(expected)) {
    return {
      path: expected.path,
      name: '',
      deps: { ...(expected.deps || {}) },
      ignored: false,
      sourceOnly: true,
      role: expected.role || null,
      action: expected.action || null
    }
  }
  if (!row) return null
  return {
    ...row,
    sourceOnly: Boolean(expected.sourceOnly),
    role: expected.role || null,
    action: expected.action || null
  }
}

function hasExpectedHiverelayDeps (consumer) {
  return Object.keys(consumer.deps || {}).length > 0
}

function isSourceOnlyConsumer (consumer) {
  return consumer.sourceOnly === true || !hasExpectedHiverelayDeps(consumer)
}

function compareDeps (errors, row, expectedDeps, label) {
  const actual = row.deps
  const actualKeys = Object.keys(actual).sort()
  const expectedKeys = Object.keys(expectedDeps).sort()
  if (actualKeys.join('\0') !== expectedKeys.join('\0')) {
    errors.push(`${row.path} ${label} has Hiverelay deps ${actualKeys.join(', ') || '(none)'}; expected ${expectedKeys.join(', ') || '(none)'}`)
    return
  }

  for (const key of expectedKeys) {
    if (actual[key] !== expectedDeps[key]) {
      errors.push(`${row.path} ${label} has ${key}=${JSON.stringify(actual[key])}; expected ${JSON.stringify(expectedDeps[key])}`)
    }
  }
}

function stderrMessage (err) {
  return Buffer.isBuffer(err?.stderr) ? err.stderr.toString().trim() : String(err?.stderr || '').trim()
}

function collectHiverelayDeps (pkg) {
  const out = {}
  for (const section of DEP_SECTIONS) {
    const deps = pkg?.[section]
    if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue
    for (const dep of HIVERELAY_DEPS) {
      if (typeof deps[dep] === 'string') out[dep] = deps[dep]
    }
  }
  return out
}

function findNearestLockfile (startDir, stopDir) {
  let dir = path.resolve(startDir)
  const stop = path.resolve(stopDir)

  while (dir.startsWith(stop)) {
    const candidate = path.join(dir, 'package-lock.json')
    if (fs.existsSync(candidate)) return candidate
    if (dir === stop) break
    const parent = path.dirname(dir)
    if (parent === dir) break
    dir = parent
  }

  return null
}

function findStaleLockEntries (lock, relLockFile, expectedVersion) {
  const issues = []
  for (const [entryPath, entry] of Object.entries(lock.packages || {})) {
    if (!entry || typeof entry !== 'object') continue
    const packageName = lockPackageName(entryPath, entry)
    const isHiverelayPath = entryPath.includes('00-core/hiverelay')
    const isHiverelayPackage = HIVERELAY_DEPS.includes(packageName)
    const isOldRootPackage = packageName === 'p2p-hiverelay-monorepo' && isHiverelayPath

    if (isOldRootPackage) {
      issues.push(`${relLockFile} contains stale monorepo-root Hiverelay lock entry ${entryPath}@${entry.version}; current apps must link packages/core, packages/client, or packages/verifier directly`)
      continue
    }

    if (isHiverelayPackage && entry.link === true && entry.version == null) {
      continue
    }

    if (isHiverelayPackage && entry.version !== expectedVersion) {
      issues.push(`${relLockFile} contains ${packageName}@${entry.version} at ${entryPath}; expected ${expectedVersion}`)
    }

    const deps = collectHiverelayDeps(entry)
    for (const [dep, value] of Object.entries(deps)) {
      if (value.startsWith('file:')) continue
      const expectedRange = `^${expectedVersion}`
      if (value !== expectedRange && value !== NPM_LATEST_SPEC) {
        issues.push(`${relLockFile} contains ${entryPath} dependency ${dep}=${JSON.stringify(value)}; expected ${JSON.stringify(expectedRange)}, ${JSON.stringify(NPM_LATEST_SPEC)}, or a local file: link`)
      }
    }
  }
  return issues
}

function lockPackageName (entryPath, entry) {
  if (typeof entry.name === 'string' && entry.name) return entry.name
  const marker = 'node_modules/'
  const index = entryPath.lastIndexOf(marker)
  if (index === -1) return ''
  return entryPath.slice(index + marker.length).split('/')[0]
}

function checkSnapshotPackage ({ workspaceRoot, rootPath, role, spec, expectedVersion }) {
  const checks = []
  const file = slash(path.join(rootPath, spec.file))
  const abs = path.join(workspaceRoot, file)
  let pkg

  try {
    pkg = readJson(abs)
  } catch (err) {
    checks.push({
      ok: false,
      rootPath,
      role,
      file,
      label: 'package manifest exists',
      error: `${file} cannot be read as JSON: ${err.message}`
    })
    return checks
  }

  checks.push(checkSnapshotPackageEntry({
    rootPath,
    role,
    file,
    label: 'package manifest',
    entry: pkg,
    spec,
    expectedVersion
  }))

  const lockFile = slash(path.join(rootPath, 'package-lock.json'))
  const absLockFile = path.join(workspaceRoot, lockFile)
  let lock
  try {
    lock = readJson(absLockFile)
  } catch (err) {
    checks.push({
      ok: false,
      rootPath,
      role,
      file: lockFile,
      label: 'package lock exists',
      error: `${lockFile} cannot be read as JSON: ${err.message}`
    })
    return checks
  }

  const lockEntry = lock.packages?.[spec.lockKey]
  if (!lockEntry) {
    checks.push({
      ok: false,
      rootPath,
      role,
      file: lockFile,
      label: `${spec.lockKey || 'root'} lock entry`,
      error: `${lockFile} is missing snapshot lock entry ${JSON.stringify(spec.lockKey)} for ${spec.name}`
    })
    return checks
  }

  checks.push(checkSnapshotPackageEntry({
    rootPath,
    role,
    file: lockFile,
    label: `${spec.lockKey || 'root'} lock entry`,
    entry: lockEntry,
    spec,
    expectedVersion
  }))

  return checks
}

function checkSnapshotPackageEntry ({ rootPath, role, file, label, entry, spec, expectedVersion }) {
  if (entry.name !== spec.name) {
    return {
      ok: false,
      rootPath,
      role,
      file,
      label,
      error: `${file} ${label} is named ${JSON.stringify(entry.name)}; expected ${JSON.stringify(spec.name)}`
    }
  }

  if (entry.version !== expectedVersion) {
    return {
      ok: false,
      rootPath,
      role,
      file,
      label,
      error: `${file} ${label} has ${spec.name}@${entry.version}; expected ${expectedVersion}`
    }
  }

  if (spec.coreRange) {
    const expectedRange = `^${expectedVersion}`
    const deps = collectHiverelayDeps(entry)
    if (deps['p2p-hiverelay'] !== expectedRange) {
      return {
        ok: false,
        rootPath,
        role,
        file,
        label,
        error: `${file} ${label} has p2p-hiverelay=${JSON.stringify(deps['p2p-hiverelay'])}; expected ${JSON.stringify(expectedRange)}`
      }
    }
  }

  return {
    ok: true,
    rootPath,
    role,
    file,
    label
  }
}

function findPackageFiles (root) {
  const out = []
  walk(root, root, out, true)
  return out
}

function walk (root, dir, out, workspaceRoot = false) {
  let entries
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true })
  } catch {
    return
  }

  for (const entry of entries) {
    if (entry.name === 'package.json' && entry.isFile()) {
      out.push(path.join(dir, entry.name))
      continue
    }
    if (!entry.isDirectory()) continue
    if (SKIP_DIRS.has(entry.name)) continue
    if (workspaceRoot && SKIP_WORKSPACE_ROOT_DIRS.has(entry.name)) continue
    const child = path.join(dir, entry.name)
    const childRelative = slash(path.relative(root, child))
    if (!isDeclaredIgnoredRoot(childRelative) && isHiveRelayMonorepoRoot(child)) continue
    walk(root, child, out, false)
  }
}

function isDeclaredIgnoredRoot (relativePath) {
  const prefix = `${relativePath}/`
  return IGNORED_PREFIXES.some(ignored => ignored === prefix)
}

function isHiveRelayMonorepoRoot (directory) {
  const packageFile = path.join(directory, 'package.json')
  if (!fs.existsSync(packageFile)) return false
  return readJson(packageFile).name === 'p2p-hiverelay-monorepo'
}

function readJson (file) {
  try {
    return JSON.parse(fs.readFileSync(file, 'utf8'))
  } catch (err) {
    throw new Error(`Cannot parse JSON file ${file}: ${err.message}`)
  }
}

function formatDeps (deps) {
  return Object.entries(deps)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([name, value]) => `${name}@${value}`)
    .join(', ')
}

function slash (value) {
  return value.split(path.sep).join('/')
}

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      console.log(usage.trim())
      process.exit(0)
    }
    if (arg === '--json') {
      out.json = true
      continue
    }
    if (arg === '--check') {
      out.check = true
      continue
    }
    if (arg === '--workspace-root') {
      out.workspaceRoot = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--expected-version') {
      out.expectedVersion = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--dependency-mode') {
      out.dependencyMode = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--consumer-scope') {
      out.consumerScope = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--npm-latest') {
      out.dependencyMode = 'npm-latest'
      continue
    }
    throw new Error(`Unknown argument: ${arg}`)
  }
  return out
}

function readValue (argv, index, flag) {
  const value = argv[index]
  if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}`)
  return value
}

function main () {
  let args
  try {
    args = parseArgs(process.argv.slice(2))
  } catch (err) {
    console.error(err.message)
    process.exit(1)
  }

  const rootPackage = readJson(path.join(hiverelayRoot, 'package.json'))
  const expectedVersion = args.expectedVersion || rootPackage.version
  const workspaceRoot = args.workspaceRoot || workspaceRootDefault
  const dependencyMode = normalizeDependencyMode(args.dependencyMode || DEFAULT_DEPENDENCY_MODE)
  const consumerScope = normalizeConsumerScope(args.consumerScope || 'all')
  const expectedCurrent = getExpectedCurrentConsumers({ dependencyMode, consumerScope })
  const expectedClassifiedCurrent = getExpectedCurrentConsumers({ dependencyMode, consumerScope: 'all' })
  const npmLatestCheck = dependencyMode === 'npm-latest'
    ? verifyNpmLatestDistTags({ expectedCurrent, expectedVersion })
    : { checks: [], warnings: [] }
  const rows = scanHiverelayConsumers({ workspaceRoot })
  const sourceChecks = scanConsumerSourceChecks({
    workspaceRoot,
    expectedVersion,
    expectedCurrent,
    expectedStale: EXPECTED_STALE_CONSUMERS
  })
  const lockChecks = scanCurrentConsumerLockChecks({ workspaceRoot, expectedVersion, expectedCurrent })
  const snapshotChecks = scanSnapshotVersionChecks({ workspaceRoot, expectedVersion })
  const summary = checkConsumerState(rows, {
    expectedVersion,
    expectedCurrent,
    expectedClassifiedCurrent,
    consumerScope,
    sourceChecks,
    lockChecks,
    snapshotChecks,
    npmLatestChecks: npmLatestCheck.checks,
    npmLatestWarnings: npmLatestCheck.warnings
  })

  if (args.json) {
    console.log(JSON.stringify({ rows, summary }, null, 2))
  } else {
    console.log(formatConsumerReport(summary))
  }

  if (!summary.ok) process.exit(1)
}

function isMain () {
  return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
}