/**
 * Parity Validator
 *
 * Enforces demo <-> spec <-> QA-markdown parity across all packages under
 * showcase/integrations/. For each package:
 *   1. Reads manifest.yaml to extract declared demo IDs.
 *   2. Lists tests/e2e/*.spec.ts files.
 *   3. Lists qa/*.md files.
 *   4. Lists src/app/demos/<id>/ directories.
 *
 * MUST checks (fail -> exit 1):
 *   - manifest.yaml exists and is parseable.
 *   - Every declared demo has a matching src/app/demos/<id>/ directory.
 *
 * SHOULD checks (warn on stderr, do not fail):
 *   - Every declared demo has a matching tests/e2e/<id>.spec.ts.
 *   - Every declared demo has a matching qa/<id>.md.
 *   - Package demo count matches the baseline (default: BASELINE_DEMO_COUNT).
 *   - spec count >= demo count (spec count exceeding demo count is
 *     legitimate — e.g. when a cross-demo spec covers renderer selection
 *     for multiple demos — so only UNDER-coverage is flagged).
 *   - qa count >= demo count.
 *
 * Usage (from showcase/ or showcase/scripts/):
 *   npx tsx scripts/validate-parity.ts
 *   npx tsx scripts/validate-parity.ts --baseline=9
 *   VALIDATE_PARITY_REPO_ROOT=/tmp/fixture npx tsx scripts/validate-parity.ts
 *
 * Exit codes (aligned with audit.ts on codes 0/1/3/4; intentionally
 * diverges from validate-pins.ts on code 2 — validate-pins.ts uses
 * 2=internal error, whereas this tool uses 2=invalid CLI input):
 *   0 — no MUST failures
 *   1 — one or more MUST failures
 *   2 — invalid CLI input (bad --baseline / VALIDATE_PARITY_BASELINE)
 *   3 — unreadable (packages dir missing or readdir threw)
 *   4 — internal error (uncaught exception)
 *
 * The script resolves packages relative to its own file location by
 * default, so the invocation cwd does not matter.
 */
/* eslint-disable @typescript-eslint/no-use-before-define */

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import {
  parseManifest,
  type Manifest,
  type ManifestDemo,
} from "./lib/manifest.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ROOT = showcase/ (NOT the repo root). validate-parity.ts lives at
// showcase/scripts/validate-parity.ts, so path.resolve(__dirname, "..")
// resolves to showcase/. DEFAULT_PACKAGES_DIR is showcase/integrations/.
const ROOT = path.resolve(__dirname, "..");
const DEFAULT_PACKAGES_DIR = path.join(ROOT, "integrations");

/**
 * Baseline expected demo count per package. Packages that deviate from
 * this are flagged as warnings (e.g. ones being built out).
 *
 * RECIPROCAL: the CI workflow .github/workflows/showcase_validate.yml
 * reads `baselineDemoCount` from showcase/scripts/fail-baseline.json
 * when enforcing the per-package e2e-spec-count floor — there is no
 * hardcoded MIN in the workflow anymore. Keep this default in sync
 * with `baselineDemoCount` in fail-baseline.json; if one moves,
 * move both. The sync is enforced by
 * __tests__/baseline-sync.test.ts so drift is caught in CI rather than
 * relying on the comment above.
 */
export const BASELINE_DEMO_COUNT = 9;

// Exit code taxonomy aligned with audit.ts on codes 0/1/3/4 so CI callers
// can disambiguate "no anomalies" / "anomalies" / "unreadable" / "internal".
// Intentionally diverges from validate-pins.ts on code 2 (invalid-input here
// vs internal-error in validate-pins.ts) — the tools are NOT aligned on 2.
// `as const` narrows each to its literal type so the union below
// (`ValidateParityExitCode`) is a literal union — guarding callers that
// switch on the value from accidentally matching arbitrary numbers.
const EXIT_OK = 0 as const;
const EXIT_MUST_FAILURE = 1 as const;
const EXIT_INVALID_INPUT = 2 as const;
const EXIT_UNREADABLE = 3 as const;
const EXIT_INTERNAL = 4 as const;

/**
 * Strip the leading "/demos/" prefix from a demo route and return the
 * on-disk directory name (the segment under src/app/demos/). Returns
 * `undefined` when the route is `undefined` OR when the resulting
 * segment is empty. parseManifest rejects routes of exactly "/demos/"
 * at validation time (demos[i].route must have a non-empty tail), so
 * the empty-segment fallback here is defence-in-depth for callers that
 * bypass parseManifest — not a primary validation.
 *
 * Keep in sync with bundle-demo-content.ts, which applies the same
 * route → dir transformation when copying per-demo READMEs into the
 * bundled content payload.
 */
export function routeToDirName(route: string | undefined): string | undefined {
  if (route === undefined) return undefined;
  const stripped = route.replace(/^\/demos\//, "");
  return stripped.length > 0 ? stripped : undefined;
}

/**
 * Literal union of every exit code `runParity` can return. Exposed so
 * in-process callers (tests, composed CLIs) can pattern-match against
 * the taxonomy without re-declaring magic numbers.
 */
export type ValidateParityExitCode =
  | typeof EXIT_OK
  | typeof EXIT_MUST_FAILURE
  | typeof EXIT_INVALID_INPUT
  | typeof EXIT_UNREADABLE
  | typeof EXIT_INTERNAL;

/**
 * Render an error (and any nested `.cause` chain) for stderr. Walks the
 * cause chain depth-first and indents each successive cause so operators
 * see both the outer wrapping context (e.g. "audit of <slug> crashed")
 * AND the root-cause message/stack without rebuilding the chain by hand.
 * Rendering only `err.stack || err.message` drops the chain entirely
 * because `Error#stack` does NOT include causes — this helper is the
 * missing piece.
 *
 * Guards against pathological or malicious cyclic cause graphs by
 * tracking visited references and capping depth — a self-referential
 * `.cause` (or a long synthetic chain) can't hang the validator.
 */
function formatErrorChain(err: unknown): string {
  const MAX_DEPTH = 16;
  const seen = new WeakSet<object>();
  const lines: string[] = [];

  const render = (e: unknown): string =>
    e instanceof Error ? e.stack || e.message : String(e);

  let current: unknown = err;
  let depth = 0;
  let truncated = false;
  while (current !== undefined && current !== null && depth < MAX_DEPTH) {
    const prefix = depth === 0 ? "" : `${"  ".repeat(depth)}caused by: `;
    lines.push(
      prefix + render(current).replace(/\n/g, `\n${"  ".repeat(depth)}`),
    );

    if (typeof current !== "object") break;
    if (seen.has(current as object)) {
      lines.push(`${"  ".repeat(depth + 1)}[cyclic cause — stopping]`);
      break;
    }
    seen.add(current as object);

    const next = (current as { cause?: unknown }).cause;
    if (next === undefined) break;
    // If we're at the last allowed depth and there IS still a cause to
    // follow, the chain is being truncated. Mark it so the emitter
    // below fires — but don't emit when the chain ended naturally at
    // MAX_DEPTH (cur.cause undefined), which the `break` above handles.
    if (depth + 1 >= MAX_DEPTH) {
      truncated = true;
      break;
    }
    current = next;
    depth++;
  }

  if (truncated) {
    lines.push(
      `${"  ".repeat(depth + 1)}[cause chain truncated at depth ${MAX_DEPTH}]`,
    );
  }

  return lines.join("\n");
}

// Re-exported for test callers importing these types from
// "../validate-parity.js"; the canonical definitions live in
// ./lib/manifest.ts.
export type { Manifest, ManifestDemo };

/**
 * Typed error raised when `manifest.yaml` exists and could be read but
 * its contents do not form a valid Manifest (YAML syntax error, wrong
 * top-level shape, non-array demos, missing id, etc.). Callers catch
 * with `instanceof ManifestMalformedError` — no string-prefix sniffing.
 */
export class ManifestMalformedError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ManifestMalformedError";
  }
}

/**
 * Thrown by `auditPackage` when called with an invalid baseline value
 * (non-positive, non-integer, NaN, Infinity). Defence-in-depth: the
 * CLI wrapper validates via coerceBaseline, but direct programmatic
 * callers of auditPackage should also fail fast rather than silently
 * comparing against NaN / 0.
 */
export class InvalidBaselineError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "InvalidBaselineError";
  }
}

/**
 * Typed error raised when `manifest.yaml` exists on disk but
 * readFileSync threw (EACCES, I/O race, etc.). Distinct from
 * ManifestMalformedError because the file's contents are not actually
 * known to be invalid. Callers catch with `instanceof
 * ManifestUnreadableError`.
 */
export class ManifestUnreadableError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ManifestUnreadableError";
  }
}

/**
 * Tagged union of per-package entries in `mustErrors` / `warnings`. Each
 * variant carries only the structured fields the category needs.
 * Categories that want a user-facing string render via `deriveMessage`
 * at display time so the struct has a single source of truth — no
 * pre-formatted `message` field duplicating the structured data.
 */
export type PackageIssue =
  | { category: "missing-manifest" }
  | { category: "unreadable-manifest"; error: string }
  | { category: "malformed-manifest"; error: string }
  | { category: "missing-demo-dir"; demoId: string; expectedDir: string }
  | { category: "unreadable-demos-dir"; path: string; error: string }
  | { category: "unreadable-specs-dir"; path: string; error: string }
  | { category: "unreadable-qa-dir"; path: string; error: string }
  | { category: "missing-spec"; demoId: string }
  | { category: "missing-qa"; demoId: string }
  | { category: "baseline-deviation"; demoCount: number; baseline: number }
  | {
      category: "spec-under-coverage";
      specCount: number;
      demoCount: number;
    }
  | { category: "qa-under-coverage"; qaCount: number; demoCount: number }
  | { category: "listing-failed"; path: string; error: string }
  | { category: "crashed"; error: string };

/**
 * Render a PackageIssue as a user-facing string. Kept as the single
 * source of truth for issue rendering so the stderr emitter, JSON
 * summary, and any future consumer produce identical text.
 *
 * Using a discriminated union + exhaustive switch keeps this
 * future-proof — adding a new PackageIssue variant without a matching
 * case here is a TypeScript error.
 */
export function deriveMessage(issue: PackageIssue): string {
  switch (issue.category) {
    case "missing-manifest":
      return "missing manifest.yaml";
    case "unreadable-manifest":
      return `unreadable manifest.yaml: ${issue.error}`;
    case "malformed-manifest":
      return `unparseable manifest.yaml: ${issue.error}`;
    case "missing-demo-dir":
      // Include both the catalog id AND the expected on-disk directory
      // because they can differ: `id` is the catalog key (matched
      // against qa/spec filenames) while the on-disk directory comes
      // from `demo.route` ("/demos/<dir>" → `src/app/demos/<dir>/`).
      // Rendering only the id here hid route/id mismatches — operators
      // saw the error but not the path the validator actually probed.
      // Mention both the new per-column layout (<pkg>/demos/<dir>/) and
      // the legacy shared-tree layout (<pkg>/src/app/demos/<dir>/) since
      // auditPackage unions both locations when checking existence.
      return issue.demoId === issue.expectedDir
        ? `demo '${issue.demoId}' declared in manifest but no demos/${issue.expectedDir}/ (or legacy src/app/demos/${issue.expectedDir}/) directory`
        : `demo '${issue.demoId}' declared in manifest but no demos/${issue.expectedDir}/ (or legacy src/app/demos/${issue.expectedDir}/) directory (resolved from route)`;
    case "unreadable-demos-dir":
      return `unreadable demos directory: failed to read directory ${issue.path}: ${issue.error}`;
    case "unreadable-specs-dir":
      return `unreadable specs directory: failed to read directory ${issue.path}: ${issue.error}`;
    case "unreadable-qa-dir":
      return `unreadable qa directory: failed to read directory ${issue.path}: ${issue.error}`;
    case "missing-spec":
      return `demo '${issue.demoId}' has no tests/e2e/${issue.demoId}.spec.ts`;
    case "missing-qa":
      return `demo '${issue.demoId}' has no qa/${issue.demoId}.md`;
    case "baseline-deviation":
      return `demo count ${issue.demoCount} deviates from baseline ${issue.baseline}`;
    case "spec-under-coverage":
      return `spec count ${issue.specCount} < demo count ${issue.demoCount}`;
    case "qa-under-coverage":
      return `qa count ${issue.qaCount} < demo count ${issue.demoCount}`;
    case "listing-failed":
      return `failed to read directory ${issue.path}: ${issue.error}`;
    case "crashed":
      return `audit crashed: ${issue.error}`;
  }
}

export interface PackageReport {
  readonly slug: string;
  // All arrays are `readonly` uniformly: surface any accidental post-
  // return push/splice as a compile error. Callers that need a mutable
  // copy should clone. Marking mustErrors/warnings readonly while
  // leaving demoIds/specFiles/qaFiles/demoDirs mutable would be an
  // asymmetric contract and invites drift.
  readonly demoIds: readonly string[];
  readonly specFiles: readonly string[];
  readonly qaFiles: readonly string[];
  readonly demoDirs: readonly string[];
  readonly mustErrors: readonly PackageIssue[];
  readonly warnings: readonly PackageIssue[];
}

/**
 * Return shape for `listDirs` / `listFiles`. The tuple keeps entries and
 * per-listing warnings correlated without a side-effect parameter —
 * callers merge `warnings` into their PackageReport explicitly.
 */
export interface ListResult {
  entries: string[];
  warnings: PackageIssue[];
}

/**
 * Best-effort "does this path exist and is it readable" probe used by
 * listDirs/listFiles to distinguish genuinely-missing paths (ENOENT
 * → silent, return empty) from permission/I/O failures (EACCES et al
 * → surface as listing-failed so the caller can escalate).
 *
 * `fs.existsSync` CONFLATES these: it returns false for ENOENT AND for
 * EACCES (and every other statSync failure), so a package whose
 * tests/e2e/ is chmod 0 silently registers as "no tests/e2e/" and the
 * whole per-slug cascade gets suppressed. That's the bug this probe
 * exists to close — do NOT replace with existsSync.
 *
 * ENOTDIR handling: ENOTDIR from statSync means "a component of the
 * path is a regular file, not a directory" (e.g. stray file committed
 * at integrations/foo/tests so walking to integrations/foo/tests/e2e fails).
 * That is a misconfiguration signal, NOT a legitimately-absent
 * directory. Classifying it as `missing` silently drops the whole
 * subtree from parity checks with zero diagnostic. Instead, surface
 * it as `unreadable` so the caller emits a listing-failed warning
 * and callers upstream can escalate (unreadable-demos-dir / etc.).
 *
 * Return value:
 *   { kind: "missing" }    — ENOENT only
 *   { kind: "ok" }         — stat succeeded (target may be dir or file; caller decides)
 *   { kind: "unreadable"; error: string } — ENOTDIR or any other statSync failure
 */
type ProbeResult =
  | { kind: "missing" }
  | { kind: "ok" }
  | { kind: "unreadable"; error: string };

function probeDir(p: string): ProbeResult {
  try {
    // Deliberately do NOT check isDirectory() here — a non-directory
    // at the path (regular file / socket / etc.) should flow through
    // to the caller's readdirSync, which will throw ENOTDIR and land
    // in the existing listing-failed / "could not read" path. Doing
    // the isDirectory check here would reclassify "file at
    // integrations/" as "missing" and produce a confusing "not found"
    // diagnostic when the real problem is ENOTDIR.
    fs.statSync(p);
    return { kind: "ok" };
  } catch (err) {
    const code = (err as NodeJS.ErrnoException)?.code;
    if (code === "ENOENT") return { kind: "missing" };
    // ENOTDIR (a path component is a regular file) is a
    // misconfiguration, not a legitimately-absent path — surface it
    // as unreadable so it becomes a listing-failed warning rather
    // than a silent empty result.
    const msg = err instanceof Error ? err.message : String(err);
    return { kind: "unreadable", error: msg };
  }
}

/**
 * List subdirectories of `p`. Non-existent paths (ENOENT) return
 * { entries: [], warnings: [] }. Read/permission errors (EACCES, I/O,
 * etc.) return empty entries AND a `listing-failed` warning so the
 * caller can include it in the PackageReport's `warnings` array. The
 * caller is responsible for emitting the stderr `[WARN]` line via
 * deriveMessage — helpers do NOT log directly, otherwise operators
 * see a duplicated warning line (once from the helper, once from the
 * caller's iteration over `warnings[]`).
 *
 * NB: uses `statSync` (not `existsSync`) to distinguish ENOENT from
 * EACCES — see probeDir docstring for why that matters.
 */
export function listDirs(p: string): ListResult {
  const probe = probeDir(p);
  if (probe.kind === "missing") return { entries: [], warnings: [] };
  if (probe.kind === "unreadable") {
    const issue: PackageIssue = {
      category: "listing-failed",
      path: p,
      error: probe.error,
    };
    return { entries: [], warnings: [issue] };
  }
  try {
    const entries = fs
      .readdirSync(p, { withFileTypes: true })
      .filter((d) => d.isDirectory())
      .map((d) => d.name)
      .sort();
    return { entries, warnings: [] };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    const issue: PackageIssue = {
      category: "listing-failed",
      path: p,
      error: msg,
    };
    return { entries: [], warnings: [issue] };
  }
}

/**
 * List files in `p` with the given suffix. Same error-handling contract
 * as listDirs: ENOENT → empty ListResult; EACCES / other stat failure
 * → empty entries + listing-failed warning. Caller emits the stderr
 * `[WARN]` line.
 *
 * Bare-suffix filenames (e.g. a file literally named `.spec.ts` or
 * `.md`) are silently skipped: after stripping the suffix they would
 * map to an empty demo-id and could accidentally match a declared demo
 * on the empty-string side of the Set comparison. Such files aren't a
 * legitimate package-layout artefact, so dropping them is quieter and
 * safer than warning about them.
 *
 * NB: uses `statSync` (not `existsSync`) to distinguish ENOENT from
 * EACCES — see probeDir docstring.
 */
export function listFiles(p: string, suffix: string): ListResult {
  const probe = probeDir(p);
  if (probe.kind === "missing") return { entries: [], warnings: [] };
  if (probe.kind === "unreadable") {
    const issue: PackageIssue = {
      category: "listing-failed",
      path: p,
      error: probe.error,
    };
    return { entries: [], warnings: [issue] };
  }
  try {
    const entries = fs
      .readdirSync(p, { withFileTypes: true })
      .filter((d) => {
        if (!d.isFile()) return false;
        if (!d.name.endsWith(suffix)) return false;
        // Reject files whose entire name IS the suffix (stem length 0).
        return d.name.length > suffix.length;
      })
      .map((d) => d.name)
      .sort();
    return { entries, warnings: [] };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    const issue: PackageIssue = {
      category: "listing-failed",
      path: p,
      error: msg,
    };
    return { entries: [], warnings: [issue] };
  }
}

/**
 * Load and parse a package's manifest.yaml.
 *
 * Returns:
 *   - null if the file does not exist ("missing manifest" — caller flags).
 *   - the parsed Manifest on success.
 *
 * Throws:
 *   - `ManifestMalformedError` — file exists but YAML shape is invalid;
 *   - `ManifestUnreadableError` — readFileSync failed (permissions, I/O
 *     race).
 *   Callers discriminate with `instanceof` (see auditPackage).
 *
 * Delegates to lib/manifest.ts :: parseManifest for shape validation so
 * audit.ts / validate-parity.ts / capture-previews.ts apply identical rules.
 */
export function loadManifest(
  slug: string,
  packagesDir: string = DEFAULT_PACKAGES_DIR,
): Manifest | null {
  const manifestPath = path.join(packagesDir, slug, "manifest.yaml");
  // Pass the directory slug to parseManifest so its slug-mismatch
  // guard fires: if the manifest's `slug:` field disagrees with the
  // directory on disk, we get a shape-malformed result instead of
  // silently validating a copy-paste / rename mistake.
  const parsed = parseManifest(manifestPath, slug);
  switch (parsed.kind) {
    case "missing":
      return null;
    case "ok":
      return parsed.manifest;
    case "malformed":
      throw new ManifestMalformedError(parsed.error);
    case "unreadable":
      throw new ManifestUnreadableError(parsed.error);
  }
}

export function auditPackage(
  slug: string,
  packagesDir: string = DEFAULT_PACKAGES_DIR,
  baselineDemoCount: number = BASELINE_DEMO_COUNT,
): PackageReport {
  // Defence-in-depth: runParity validates baseline via coerceBaseline,
  // but auditPackage is exported for direct use by tests / future
  // callers. A NaN / 0 / negative / non-integer baseline silently
  // produces nonsense `demoIds.length !== baseline` warnings otherwise.
  if (
    typeof baselineDemoCount !== "number" ||
    !Number.isFinite(baselineDemoCount) ||
    !Number.isInteger(baselineDemoCount) ||
    baselineDemoCount <= 0
  ) {
    throw new InvalidBaselineError(
      `baselineDemoCount must be a positive integer, got ${String(baselineDemoCount)}`,
    );
  }

  const pkgDir = path.join(packagesDir, slug);
  const mustErrors: PackageIssue[] = [];
  const warnings: PackageIssue[] = [];

  // Pre-compute spec/qa/demo-dir listings up-front so the reporter row
  // still shows accurate counts even if manifest parsing fails. MUST
  // errors still gate the exit code — this only affects the table.
  //
  // Demos can live at either <pkg>/demos/<cell>/ (per-column container
  // layout with one folder per cell containing frontend/ + backend/) OR
  // <pkg>/src/app/demos/<cell>/ (legacy shared-tree layout). Union the
  // two listings so the check is permissive during the transition — a
  // cell declared in manifest passes as long as either directory exists.
  const specResult = listFiles(path.join(pkgDir, "tests", "e2e"), ".spec.ts");
  const qaResult = listFiles(path.join(pkgDir, "qa"), ".md");
  const topLevelDemosDir = path.join(pkgDir, "demos");
  const legacyDemosDir = path.join(pkgDir, "src", "app", "demos");
  const topLevelDemoDirResult = listDirs(topLevelDemosDir);
  const legacyDemoDirResult = listDirs(legacyDemosDir);
  const demoDirResult: ListResult = {
    entries: Array.from(
      new Set([
        ...topLevelDemoDirResult.entries,
        ...legacyDemoDirResult.entries,
      ]),
    ),
    // Forward ALL listing-failed warnings from both probes regardless of
    // whether the other probe returned entries. An EACCES on one path
    // must not be silently swallowed just because the other path
    // happened to be readable — callers rely on these warnings to
    // elevate to unreadable-demos-dir MUSTs. A genuinely-missing path
    // (ENOENT) produces no warning from listDirs, so this does not add
    // noise for columns using only one of the two layouts.
    warnings: [
      ...topLevelDemoDirResult.warnings,
      ...legacyDemoDirResult.warnings,
    ],
  };

  const specFiles = specResult.entries;
  const qaFiles = qaResult.entries;
  const demoDirs = demoDirResult.entries;

  // When src/app/demos/, tests/e2e/, or qa/ is unreadable, elevate to a
  // MUST error under category "unreadable-{demos,specs,qa}-dir" and
  // SUPPRESS the downstream missing-{demo-dir,spec,qa} cascade so the
  // EACCES root cause isn't buried. We detect this by checking whether
  // the respective list call returned a listing-failed warning for the
  // target path specifically (not an unrelated path).
  // For the unreadable-demos-dir elevation, accept a listing-failed
  // warning at EITHER candidate path (new top-level demos/ or legacy
  // src/app/demos/). Report the failing path verbatim so the operator
  // sees which location couldn't be read.
  const specsDirPath = path.join(pkgDir, "tests", "e2e");
  const qaDirPath = path.join(pkgDir, "qa");
  const findListingFailed = (
    result: ListResult,
    target: string,
  ): Extract<PackageIssue, { category: "listing-failed" }> | undefined =>
    result.warnings.find(
      (w): w is Extract<PackageIssue, { category: "listing-failed" }> =>
        w.category === "listing-failed" && w.path === target,
    );
  const topLevelDemosUnreadable = findListingFailed(
    demoDirResult,
    topLevelDemosDir,
  );
  const legacyDemosUnreadable = findListingFailed(
    demoDirResult,
    legacyDemosDir,
  );
  // Collect all demos-dir listing failures so BOTH paths are surfaced on
  // the MUST when both fail (previously only the first match was
  // reported, hiding the second EACCES root cause).
  const demosDirUnreadableAll: Extract<
    PackageIssue,
    { category: "listing-failed" }
  >[] = [];
  if (topLevelDemosUnreadable)
    demosDirUnreadableAll.push(topLevelDemosUnreadable);
  if (legacyDemosUnreadable) demosDirUnreadableAll.push(legacyDemosUnreadable);
  const demosDirUnreadable = demosDirUnreadableAll[0];
  const specsDirUnreadable = findListingFailed(specResult, specsDirPath);
  const qaDirUnreadable = findListingFailed(qaResult, qaDirPath);

  // Merge listing warnings only when the underlying path was NOT elevated
  // to a MUST error. For the elevated paths, push the typed MUST variant
  // so consumers see a single authoritative signal instead of a warning
  // plus N cascaded per-demo errors.
  if (!specsDirUnreadable) {
    warnings.push(...specResult.warnings);
  } else {
    mustErrors.push({
      category: "unreadable-specs-dir",
      path: specsDirPath,
      error: specsDirUnreadable.error,
    });
  }
  if (!qaDirUnreadable) {
    warnings.push(...qaResult.warnings);
  } else {
    mustErrors.push({
      category: "unreadable-qa-dir",
      path: qaDirPath,
      error: qaDirUnreadable.error,
    });
  }
  if (demosDirUnreadableAll.length === 0) {
    warnings.push(...demoDirResult.warnings);
  } else {
    // Emit one unreadable-demos-dir MUST per failing path — if BOTH the
    // top-level and legacy demos/ directories EACCES'd, report both so
    // operators see every root cause rather than just the first.
    for (const failing of demosDirUnreadableAll) {
      mustErrors.push({
        category: "unreadable-demos-dir",
        path: failing.path,
        error: failing.error,
      });
    }
  }

  let manifest: Manifest | null;
  try {
    manifest = loadManifest(slug, packagesDir);
  } catch (err) {
    // Distinguish unreadable from malformed via typed error classes —
    // parseManifest is the single source of truth for the classification,
    // loadManifest wraps that in ManifestMalformedError /
    // ManifestUnreadableError, and we match on the class here.
    if (err instanceof ManifestUnreadableError) {
      mustErrors.push({
        category: "unreadable-manifest",
        error: err.message,
      });
    } else if (err instanceof ManifestMalformedError) {
      mustErrors.push({
        category: "malformed-manifest",
        error: err.message,
      });
    } else {
      // Unknown error class (TypeError, OOM, bug surfacing from a
      // future loadManifest refactor, etc.) — re-throw so the CLI's
      // top-level catch surfaces [INTERNAL ERROR] with EXIT_INTERNAL.
      // Silently bucketing these as malformed-manifest would hide real
      // defects behind a legitimate-looking taxonomy entry. Wrap with
      // the package slug so operators see which package triggered the
      // crash; the original error rides along via `cause` so stacks /
      // errno / etc. remain inspectable.
      // Outer message is context-only — formatErrorChain unfurls `cause`
      // so including the inner message here would render it twice.
      throw new Error(`audit of ${slug} crashed`, { cause: err });
    }
    // Don't early-return: we still return the report with spec/qa/demo
    // dir counts populated so the table row is accurate. MUST error
    // already gates the exit code.
    return {
      slug,
      demoIds: [],
      specFiles,
      qaFiles,
      demoDirs,
      mustErrors,
      warnings,
    };
  }

  if (!manifest) {
    mustErrors.push({ category: "missing-manifest" });
    return {
      slug,
      demoIds: [],
      specFiles,
      qaFiles,
      demoDirs,
      mustErrors,
      warnings,
    };
  }

  // Shape validation (top-level mapping, demos array-of-objects-with-id,
  // etc.) is performed by parseManifest in ./lib/manifest.ts. By the
  // time we reach this point, `manifest.demos` is guaranteed to be
  // `readonly ManifestDemo[]` (parseManifest normalizes missing /
  // null-valued `demos:` to a shared EMPTY_DEMOS sentinel), so no
  // nullish fallback is needed here — dropping the `?? []` keeps the
  // Manifest contract single-sourced at parseManifest.
  const demos = manifest.demos;
  // Informational-only demos (e.g. cli-start with a `command:` field)
  // live in the registry but have no on-disk folder to audit. They're
  // identified by the `command` field; exclude them from parity checks.
  const auditableDemos = demos.filter(
    (d) => !(d as { command?: string }).command,
  );
  const demoIds = auditableDemos.map((d) => d.id);

  const demoDirSet = new Set(demoDirs);
  const specIdSet = new Set(specFiles.map((f) => f.replace(/\.spec\.ts$/, "")));
  const qaIdSet = new Set(qaFiles.map((f) => f.replace(/\.md$/, "")));

  // MUST: every declared demo has a demos/<dir>/ directory. The
  // expected directory is resolved from `demo.route` ("/demos/<dir>")
  // when present, falling back to `demo.id` when absent. `id` is the
  // CATALOG identifier (matched against qa/spec filenames); `route`
  // is the URL + filesystem path. They are DELIBERATELY separate — a
  // manifest with `id: hitl-in-chat` and `route: /demos/hitl` resolves
  // to `src/app/demos/hitl/`. bundle-demo-content.ts uses the same
  // idiom. Suppressed entirely when the demos/ dir itself is unreadable
  // — a single unreadable-demos-dir MUST is clearer than N cascaded
  // missing-demo-dir errors that all trace to the same EACCES root cause.
  if (!demosDirUnreadable) {
    for (const demo of auditableDemos) {
      const expectedDir = routeToDirName(demo.route) ?? demo.id;
      if (!demoDirSet.has(expectedDir)) {
        mustErrors.push({
          category: "missing-demo-dir",
          demoId: demo.id,
          expectedDir,
        });
      }
    }
  }

  // SHOULD: every declared demo has a spec file. Suppressed when the
  // tests/e2e/ dir itself is unreadable — a single unreadable-specs-dir
  // MUST is clearer than N cascaded missing-spec warnings that all trace
  // to the same EACCES root cause.
  if (!specsDirUnreadable) {
    for (const id of demoIds) {
      if (!specIdSet.has(id)) {
        warnings.push({ category: "missing-spec", demoId: id });
      }
    }
  }

  // SHOULD: every declared demo has a QA doc. Suppressed when qa/ is
  // unreadable — see unreadable-qa-dir rationale above.
  if (!qaDirUnreadable) {
    for (const id of demoIds) {
      if (!qaIdSet.has(id)) {
        warnings.push({ category: "missing-qa", demoId: id });
      }
    }
  }

  // SHOULD: demo count matches baseline
  if (demoIds.length !== baselineDemoCount) {
    warnings.push({
      category: "baseline-deviation",
      demoCount: demoIds.length,
      baseline: baselineDemoCount,
    });
  }

  // SHOULD: spec count >= demo count. Spec count EXCEEDING demo count is
  // legitimate (e.g. a cross-demo spec covers renderer selection for
  // multiple demos and is intentionally not tied to a single declared
  // demo), so we only warn on UNDER-coverage. Suppressed when the
  // tests/e2e/ dir itself is unreadable — the elevated
  // `unreadable-specs-dir` MUST is the authoritative signal; a spurious
  // "0 < N" coverage warning on top of it would bury the EACCES root
  // cause, same rationale as the per-demo missing-spec suppression above.
  if (!specsDirUnreadable && specFiles.length < demoIds.length) {
    warnings.push({
      category: "spec-under-coverage",
      specCount: specFiles.length,
      demoCount: demoIds.length,
    });
  }

  // SHOULD: qa count >= demo count. Suppressed when qa/ is unreadable —
  // see spec-under-coverage rationale above.
  if (!qaDirUnreadable && qaFiles.length < demoIds.length) {
    warnings.push({
      category: "qa-under-coverage",
      qaCount: qaFiles.length,
      demoCount: demoIds.length,
    });
  }

  return {
    slug,
    demoIds,
    specFiles,
    qaFiles,
    demoDirs,
    mustErrors,
    warnings,
  };
}

interface MainOptions {
  /**
   * Override for the expected demo count per package. Must be a positive
   * integer (> 0). NaN / non-integer / non-positive values are rejected
   * by `main()` / `runParity()` before they reach `auditPackage`.
   */
  baseline?: number;
}

/**
 * Discriminated union of rejection reasons emitted by `coerceBaseline`.
 * Consumers map the reason into a specific diagnostic message so bad
 * input is actionable (e.g. "1.5" → float, "0x10" → hex).
 */
export type CoerceBaselineReason =
  | "empty"
  | "whitespace"
  | "zero"
  | "negative"
  | "float"
  | "hex"
  | "non-numeric";

export type CoerceBaselineResult =
  | { ok: true; value: number }
  | { ok: false; reason: CoerceBaselineReason };

/**
 * Validate a candidate baseline value. Returns a discriminated union so
 * callers can surface a specific reason in user-facing error messages
 * (distinguishing e.g. "1.5" from "0x10" from "abc" matters when the
 * CI operator has to guess what they typed wrong).
 *
 * Used to guard both `--baseline=N` CLI parsing and
 * `VALIDATE_PARITY_BASELINE` env-var parsing.
 */
export function coerceBaseline(raw: unknown): CoerceBaselineResult {
  if (typeof raw === "number") {
    if (!Number.isFinite(raw)) return { ok: false, reason: "non-numeric" };
    if (!Number.isInteger(raw)) return { ok: false, reason: "float" };
    if (raw === 0) return { ok: false, reason: "zero" };
    if (raw < 0) return { ok: false, reason: "negative" };
    return { ok: true, value: raw };
  }
  if (typeof raw !== "string") return { ok: false, reason: "non-numeric" };
  if (raw.length === 0) return { ok: false, reason: "empty" };
  const trimmed = raw.trim();
  if (trimmed.length === 0) return { ok: false, reason: "whitespace" };
  // Distinguish specific bad shapes so the error message can be clearer.
  if (/^0x[0-9a-fA-F]+$/.test(trimmed)) return { ok: false, reason: "hex" };
  if (/^-\d+$/.test(trimmed)) return { ok: false, reason: "negative" };
  if (/^-?\d+\.\d+$/.test(trimmed)) return { ok: false, reason: "float" };
  if (trimmed === "0" || /^0+$/.test(trimmed))
    return { ok: false, reason: "zero" };
  // Strict digits-only: rejects leading +, exponent (1e2), leading 0,
  // and anything else Number() would otherwise coerce.
  if (!/^[1-9]\d*$/.test(trimmed)) return { ok: false, reason: "non-numeric" };
  const n = Number(trimmed);
  if (!Number.isFinite(n) || !Number.isInteger(n)) {
    return { ok: false, reason: "non-numeric" };
  }
  if (n === 0) return { ok: false, reason: "zero" };
  if (n < 0) return { ok: false, reason: "negative" };
  return { ok: true, value: n };
}

function parseMainArgs(argv: string[]): MainOptions {
  const opts: MainOptions = {};
  // Collect all parse errors, mirroring audit.ts parseArgs. Unrecognised
  // arguments (typos like `--basline=10`, space-separated `--baseline 9`,
  // stray positionals) are flagged loudly instead of being silently
  // ignored — otherwise the user thinks they set baseline but the
  // validator uses the default.
  //
  // Track `sawBaseline` and reject duplicate --baseline=. CI shell
  // concatenation is a common source of accidental duplicates and
  // "last wins" silently hides the user's first intent — mirror
  // audit.ts parseArgs which rejects duplicate --json / --slug /
  // --strict / --columns for the same reason.
  const errors: string[] = [];
  let sawBaseline = false;
  for (const a of argv) {
    // Match anything after --baseline= (including non-digits) so we can
    // emit a clear error, rather than silently ignoring e.g.
    // `--baseline=abc`.
    const m = /^--baseline=(.*)$/.exec(a);
    if (m) {
      if (sawBaseline) {
        errors.push(
          `--baseline specified more than once (duplicate value "${m[1]}")`,
        );
        continue;
      }
      sawBaseline = true;
      const coerced = coerceBaseline(m[1]);
      if (!coerced.ok) {
        errors.push(
          `invalid --baseline value "${m[1]}" (${coerced.reason}; expected a positive integer)`,
        );
        continue;
      }
      opts.baseline = coerced.value;
    } else {
      errors.push(`unrecognised argument: ${a}`);
    }
  }
  if (errors.length > 0) {
    // Join all errors so the user sees every problem at once, rather
    // than fixing them one at a time across reruns. Matches audit.ts's
    // error-collection pattern.
    throw new Error(errors.join("; "));
  }
  return opts;
}

/**
 * Column spec for the pass/summary table. Both header and data rows
 * are rendered from this single array so widths can never drift — add
 * or remove a column here and `buildHeader` / `formatRow` both update
 * in lockstep.
 *
 * `width` is the minimum display width for the column. The first
 * column ("package") uses a runtime-derived slug width instead.
 *
 * `align`: "left" → padEnd, "right" → padStart. Numeric columns align
 * right so counts line up visually; string labels align left.
 *
 * `render(report)` projects a PackageReport into its cell string. The
 * same cells are emitted for the header (labels padded to `width`) so
 * both sides are character-for-character identical width per column.
 */
export interface HeaderColumn {
  readonly label: string;
  readonly width: number;
  readonly align: "left" | "right";
  readonly render: (r: PackageReport) => string;
}

export const HEADER_COLUMNS: readonly HeaderColumn[] = [
  {
    label: "package",
    width: 0 /* derived from slugs at runtime */,
    align: "left",
    render: (r) => r.slug,
  },
  {
    label: "status",
    width: 6,
    align: "left",
    render: (r) => (r.mustErrors.length > 0 ? "[FAIL]" : "[PASS]"),
  },
  {
    label: "demos",
    width: 5,
    align: "right",
    render: (r) => String(r.demoIds.length),
  },
  {
    label: "specs",
    width: 5,
    align: "right",
    render: (r) => String(r.specFiles.length),
  },
  {
    label: "qa",
    width: 3,
    align: "right",
    render: (r) => String(r.qaFiles.length),
  },
  {
    label: "notes",
    width: 10,
    align: "left",
    render: (r) => (r.warnings.length > 0 ? `${r.warnings.length} warn` : ""),
  },
] as const;

function padCell(s: string, width: number, align: "left" | "right"): string {
  return align === "right" ? s.padStart(width) : s.padEnd(width);
}

export function buildHeader(slugWidth: number): string {
  const cols = HEADER_COLUMNS.map((c, i) => {
    const w = i === 0 ? slugWidth : c.width;
    return padCell(c.label, w, c.align);
  });
  return cols.join("  ");
}

/**
 * Render a data row using the same column widths and alignment the
 * header uses. Driving both from HEADER_COLUMNS guarantees they stay
 * in sync — changing a width in one place updates both.
 */
export function formatRow(report: PackageReport, slugWidth: number): string {
  const cols = HEADER_COLUMNS.map((c, i) => {
    const w = i === 0 ? slugWidth : c.width;
    return padCell(c.render(report), w, c.align);
  });
  return cols.join("  ");
}

/**
 * Core parity run. Returns a numeric exit code instead of mutating
 * process state, so tests and other in-process callers can invoke it
 * without tearing down vitest. The CLI-facing `main()` wrapper (defined
 * below and NOT exported) sets `process.exitCode` from the return
 * value — neither function calls `process.exit` synchronously, which
 * preserves stdout/stderr drain.
 *
 * This function is the stable in-process boundary: it NEVER throws.
 * Any unexpected exception surfaced from inner helpers (auditPackage,
 * I/O, etc.) is caught, rendered to stderr with the full cause chain,
 * and converted into `EXIT_INTERNAL`. That way in-process callers
 * (tests, composed CLIs) always receive a numeric exit code and can
 * pattern-match on the `ValidateParityExitCode` taxonomy — they don't
 * have to re-implement the top-level try/catch that `main()` uses.
 */
export function runParity(
  packagesDir?: string,
  baselineDemoCount?: number,
): ValidateParityExitCode {
  try {
    return runParityImpl(packagesDir, baselineDemoCount);
  } catch (err) {
    console.error(
      `[INTERNAL ERROR] validate-parity crashed: ${formatErrorChain(err)}`,
    );
    return EXIT_INTERNAL;
  }
}

/**
 * Inner implementation of `runParity`. Separate function (not exported)
 * so the `try/catch` in `runParity` has a crisp body to guard — keeping
 * the boundary at a single call site avoids `return` vs `throw`
 * interleaving if the catch ever grows.
 */
function runParityImpl(
  packagesDir?: string,
  baselineDemoCount?: number,
): ValidateParityExitCode {
  // Env-var override keyed to this validator (mirrors SHOWCASE_AUDIT_ROOT
  // in audit.ts and VALIDATE_PINS_REPO_ROOT in validate-pins.ts).
  const envRoot = process.env.VALIDATE_PARITY_REPO_ROOT;
  const resolvedPackagesDir =
    packagesDir ??
    (envRoot && envRoot.length > 0
      ? path.join(envRoot, "integrations")
      : DEFAULT_PACKAGES_DIR);

  // Only read process.argv when invoked from the top-level
  // CLI entrypoint (i.e. when NEITHER packagesDir NOR baselineDemoCount
  // was passed explicitly). In-process callers that hand in either
  // parameter must not have their behaviour perturbed by argv they
  // didn't write (e.g. vitest's own argv → "unrecognised argument"
  // errors for tests passing only packagesDir). The env var
  // (VALIDATE_PARITY_BASELINE) is also skipped when the caller passes
  // an explicit value — the parameter is the highest-precedence source.
  let cliOpts: MainOptions = {};
  let envBaselineCoerced: number | null = null;
  if (packagesDir === undefined && baselineDemoCount === undefined) {
    // CLI-flag baseline overrides env default. Both --baseline= and
    // VALIDATE_PARITY_BASELINE are validated as positive integers —
    // NaN / "abc" / "0" / "-1" are rejected with a clear reason rather
    // than silently coerced.
    try {
      cliOpts = parseMainArgs(process.argv.slice(2));
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`[FAIL] ${msg}`);
      return EXIT_INVALID_INPUT;
    }

    const envBaseline = process.env.VALIDATE_PARITY_BASELINE;
    if (envBaseline !== undefined && envBaseline.length > 0) {
      const coerced = coerceBaseline(envBaseline);
      if (!coerced.ok) {
        console.error(
          `[FAIL] invalid VALIDATE_PARITY_BASELINE value "${envBaseline}" (${coerced.reason}; expected a positive integer)`,
        );
        return EXIT_INVALID_INPUT;
      }
      envBaselineCoerced = coerced.value;
    }
  }

  const resolvedBaseline =
    baselineDemoCount ??
    cliOpts.baseline ??
    envBaselineCoerced ??
    BASELINE_DEMO_COUNT;

  // Use statSync (not existsSync) so ENOENT and EACCES surface with
  // distinct diagnostics — existsSync returns false in both cases and
  // produces a misleading "not found" message for a perms failure.
  // Both still exit EXIT_UNREADABLE (3), but the operator-facing
  // message is actionable.
  const pkgDirProbe = probeDir(resolvedPackagesDir);
  if (pkgDirProbe.kind === "missing") {
    console.error(
      `[FAIL] packages directory not found: ${resolvedPackagesDir}`,
    );
    return EXIT_UNREADABLE;
  }
  if (pkgDirProbe.kind === "unreadable") {
    console.error(
      `[FAIL] packages directory ${resolvedPackagesDir} is unreadable: ${pkgDirProbe.error}`,
    );
    return EXIT_UNREADABLE;
  }

  // Readdir on the packages dir can fail with EACCES / I/O — treat that
  // as unreadable (exit 3), not as an internal error. Matches audit.ts
  // behaviour.
  let slugs: string[];
  try {
    slugs = fs
      .readdirSync(resolvedPackagesDir, { withFileTypes: true })
      .filter((d) => d.isDirectory())
      .map((d) => d.name)
      .sort();
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.error(
      `[FAIL] could not read packages directory ${resolvedPackagesDir}: ${msg}`,
    );
    return EXIT_UNREADABLE;
  }

  if (slugs.length === 0) {
    console.error(`[FAIL] no packages found under ${resolvedPackagesDir}`);
    return EXIT_MUST_FAILURE;
  }

  // Per-slug isolation: each slug's audit is wrapped in try/catch so a
  // crash in one slug (e.g. a bug that surfaces a
  // non-Manifest{Malformed,Unreadable}Error the auditPackage catch-all
  // re-wraps with the slug) does not abort the batch. Without isolation
  // a single throw would propagate through `slugs.map` and silently
  // drop every later package, masking drift. Crashes surface as a
  // typed "crashed" PackageIssue on a synthetic PackageReport so they
  // appear in the summary table and per-slug FAIL lines alongside
  // legitimate failures. `hasCrash` drives EXIT_INTERNAL so CI fails
  // loud on an internal bug (distinct from EXIT_MUST_FAILURE so
  // operators can disambiguate "tests tell me a demo is missing" from
  // "the tool itself crashed").
  let hasCrash = false;
  const reports: PackageReport[] = [];
  for (const s of slugs) {
    try {
      reports.push(auditPackage(s, resolvedPackagesDir, resolvedBaseline));
    } catch (err) {
      hasCrash = true;
      // Surface the full cause chain so the per-slug [FAIL] line still
      // carries the underlying message (auditPackage wraps throws with
      // `new Error("audit of <slug> crashed", { cause: err })`).
      const message = formatErrorChain(err);
      reports.push({
        slug: s,
        demoIds: [],
        specFiles: [],
        qaFiles: [],
        demoDirs: [],
        mustErrors: [{ category: "crashed", error: message }],
        warnings: [],
      });
    }
  }

  let hasMustFailure = false;
  let totalWarnings = 0;

  const slugWidth = Math.max(
    ...reports.map((r) => r.slug.length),
    "package".length,
  );

  const header = buildHeader(slugWidth);
  console.log(`\n${header}`);
  // Divider width derived from the header string so adding/removing a
  // column doesn't require re-summing magic numbers.
  console.log("-".repeat(header.length));

  for (const r of reports) {
    if (r.mustErrors.length > 0) hasMustFailure = true;
    totalWarnings += r.warnings.length;
    // Drive both header and row from HEADER_COLUMNS so widths / alignment
    // can never drift (regression guard — see formatRow).
    console.log(formatRow(r, slugWidth));
  }

  // Emit MUST errors to stderr (failures belong on stderr, not stdout —
  // stdout is reserved for the pass/summary table).
  for (const r of reports) {
    for (const issue of r.mustErrors) {
      console.error(`[FAIL] ${r.slug}: ${deriveMessage(issue)}`);
    }
  }

  // Emit warnings to stderr
  for (const r of reports) {
    for (const w of r.warnings) {
      console.error(`[WARN] ${r.slug}: ${deriveMessage(w)}`);
    }
  }

  console.log(
    `\n${reports.length} package(s) checked, ${reports.filter((r) => r.mustErrors.length === 0).length} pass, ${reports.filter((r) => r.mustErrors.length > 0).length} fail, ${totalWarnings} warning(s)`,
  );

  // Ordering of the exit-code checks is deliberate: a slug crash is an
  // internal defect (EXIT_INTERNAL / 4) and must NOT be downgraded to
  // EXIT_MUST_FAILURE (1) even if other slugs have legitimate MUST
  // failures. Operators seeing exit 4 know the tool itself hit an
  // unexpected code path, distinct from "tests tell me a demo dir is
  // missing".
  //
  // Emit the top-level `[INTERNAL ERROR]` banner in addition to the
  // per-slug `[FAIL] <slug>: audit crashed: ...` lines so operators
  // scanning a large log still see a single unambiguous "tool crashed"
  // signal. The banner is emitted AFTER the summary line so it's the
  // last thing on stderr — matches the behaviour `runParity`'s outer
  // try/catch had before per-slug isolation moved the catch inward.
  if (hasCrash) {
    console.error(
      `[INTERNAL ERROR] validate-parity crashed: one or more packages failed to audit; see per-slug [FAIL] lines above`,
    );
    return EXIT_INTERNAL;
  }
  return hasMustFailure ? EXIT_MUST_FAILURE : EXIT_OK;
}

/**
 * CLI entrypoint. File-internal (NOT exported) because it owns the
 * `process.exitCode` side-effect — callers who want to unit-test or
 * compose the validator should use `runParity` which returns a numeric
 * exit code without touching the process.
 *
 * Setting `process.exitCode` and returning (rather than calling
 * `process.exit(code)`) lets Node drain buffered stdout/stderr before
 * tearing down the process. Synchronous `process.exit` truncates the
 * pass/summary table that `runParity` wrote just above — audit.ts and
 * validate-pins.ts follow the same convention.
 */
function main(packagesDir?: string, baselineDemoCount?: number): void {
  const code = runParity(packagesDir, baselineDemoCount);
  process.exitCode = code;
}

// Only invoke main() when this file is run directly (not when imported by
// tests). Matches the isMain guard pattern used by audit.ts. Call
// path.resolve on BOTH sides to normalise relative segments and ".." /
// "." quirks in argv[1] / import.meta.url — note this does NOT
// canonicalise symlinks (use fs.realpathSync for that); the guard
// tolerates non-canonical-but-equivalent argv shapes, not symlinks
// pointing at the same inode.
if (
  process.argv[1] &&
  path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
  try {
    main();
  } catch (err) {
    // Top-level safety net: surface internal errors with a distinct exit
    // code so they are distinguishable from legitimate MUST failures
    // (exit 1) and from unreadable infrastructure failures (exit 3).
    // Use `process.exitCode = N` (not `process.exit(N)`) so any buffered
    // stdout/stderr gets a chance to drain — matches audit.ts /
    // validate-pins.ts.
    // Walk the `.cause` chain so wrapped errors (auditPackage throws
    // `new Error("audit of <slug> crashed", { cause })`) render both
    // the outer context and the underlying root cause. Bare
    // `.stack || .message` drops the chain because `Error#stack`
    // doesn't include causes.
    console.error(
      `[INTERNAL ERROR] validate-parity crashed: ${formatErrorChain(err)}`,
    );
    process.exitCode = EXIT_INTERNAL;
  }
}
