Ctrl K
ComunidadGuías…

scripts/check-dependency-catalog.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Keeps shared external dependencies on the pnpm catalog (AGENTS.md).
 *
 * An external dependency declared by two or more workspaces must use
 * `catalog:` everywhere, so one edit to pnpm-workspace.yaml moves every
 * consumer together. A dependency the catalog already defines must use it even
 * in a single workspace, or the two versions drift silently.
 *
 * Peer ranges state compatibility rather than the installed version, so they
 * are not checked. apps/mobile is exempt: it pins Expo/React Native-aligned
 * versions in its own manifest (apps/mobile/AGENTS.md).
 *
 * Run: node scripts/check-dependency-catalog.mjs
 */

import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(import.meta.dirname, "..");
const checkedFields = ["dependencies", "devDependencies", "optionalDependencies"];
const exemptWorkspaces = new Set(["apps/mobile"]);

// Known drift that needs its own upgrade. Each entry must still be a live
// violation; the check fails once the drift is gone so the entry gets removed.
export const allowlist = new Map([
  [
    "@vitejs/plugin-react",
    "apps/desktop bundles with plugin-react 5; moving it to the catalog's 6 changes how pnpm resolves vite for electron-vite and needs its own verification.",
  ],
]);

const unquote = (value) => value.trim().replace(/^(['"])(.*)\1$/, "$2");

/** Reads `packages` and the default `catalog` from pnpm-workspace.yaml. */
export function parseWorkspaceManifest(source) {
  const packages = [];
  const catalog = new Map();
  let section = null;
  for (const raw of source.split("\n")) {
    const line = raw.replace(/\s+#.*$/, "");
    if (!line.trim() || line.trimStart().startsWith("#")) continue;
    if (!/^\s/.test(line)) {
      section = line.replace(/:.*$/, "").trim();
      continue;
    }
    if (section === "packages") {
      const item = line.match(/^\s+-\s+(.+)$/);
      if (item) packages.push(unquote(item[1]));
    } else if (section === "catalog") {
      const entry = line.match(/^\s+('[^']+'|"[^"]+"|[^\s:]+):\s*(.+)$/);
      if (entry) catalog.set(unquote(entry[1]), unquote(entry[2]));
    }
  }
  return { packages, catalog };
}

const isCatalogSpec = (spec) => spec.startsWith("catalog:");
const isWorkspaceSpec = (spec) => spec.startsWith("workspace:");

/**
 * @param {{ catalog: Map<string, string>, workspaces: { dir: string, manifest: object }[], allowlist?: Map<string, string> }} input
 * @returns {string[]} human-readable violations
 */
export function catalogViolations({ catalog, workspaces, allowlist: allowed = new Map() }) {
  const declarations = new Map();
  for (const { dir, manifest } of workspaces) {
    if (exemptWorkspaces.has(dir)) continue;
    for (const field of checkedFields) {
      for (const [name, spec] of Object.entries(manifest[field] ?? {})) {
        if (isWorkspaceSpec(spec)) continue;
        const list = declarations.get(name) ?? [];
        list.push({ dir, field, spec });
        declarations.set(name, list);
      }
    }
  }

  const violations = [];
  const liveAllowlist = new Set();
  for (const [name, list] of [...declarations].sort(([a], [b]) => a.localeCompare(b))) {
    const plain = list.filter(({ spec }) => !isCatalogSpec(spec));
    if (plain.length === 0) continue;
    if (!catalog.has(name) && list.length < 2) continue;
    if (allowed.has(name)) {
      liveAllowlist.add(name);
      continue;
    }

    const where = plain.map(({ dir, field, spec }) => `${dir} ${field} ${spec}`).join(", ");
    violations.push(
      catalog.has(name)
        ? `${name}: catalog defines ${catalog.get(name)}; use "catalog:" in ${where}`
        : `${name}: declared by ${list.length} workspaces; add it to the catalog and use "catalog:" in ${where}`,
    );
  }

  for (const name of allowed.keys()) {
    if (!liveAllowlist.has(name)) {
      violations.push(`${name}: allowlisted but no longer a non-catalog duplicate; remove it from the allowlist`);
    }
  }
  return violations;
}

function workspaceDirs(patterns) {
  const dirs = [""];
  for (const pattern of patterns) {
    if (!pattern.endsWith("/*")) {
      dirs.push(pattern);
      continue;
    }
    const parent = pattern.slice(0, -2);
    for (const entry of readdirSync(join(repoRoot, parent), { withFileTypes: true })) {
      if (entry.isDirectory()) dirs.push(`${parent}/${entry.name}`);
    }
  }
  return dirs.filter((dir) => existsSync(join(repoRoot, dir, "package.json")));
}

function main() {
  const { packages, catalog } = parseWorkspaceManifest(
    readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8"),
  );
  const workspaces = workspaceDirs(packages).map((dir) => ({
    dir: dir || ".",
    manifest: JSON.parse(readFileSync(join(repoRoot, dir, "package.json"), "utf8")),
  }));
  const violations = catalogViolations({ catalog, workspaces, allowlist });

  if (violations.length > 0) {
    console.error(`Dependencies off the pnpm catalog (${violations.length})`);
    for (const violation of violations) console.error(`  ${violation}`);
    console.error(
      "\nShared external dependencies use \"catalog:\" with the version in pnpm-workspace.yaml. apps/mobile pins its own versions and is exempt.",
    );
    process.exit(1);
  }

  console.log(`Dependency catalog clean (${workspaces.length} workspaces checked).`);
}

if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) main();