Ctrl K
ComunidadGuías…

scripts/check-ui-wildcard-exports.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Reports files in packages/ui that nothing reaches.
 *
 * This exists because knip structurally cannot see them. knip derives entry
 * points from a workspace's package.json `exports` (graph/build.js calls
 * `getEntrySpecifiersFromManifest` unconditionally — there is no config flag to
 * opt out), and packages/ui exports four wildcards:
 *
 *     "./components/ui/*"     -> "./components/ui/*.tsx"
 *     "./components/common/*" -> "./components/common/*.tsx"
 *     "./markdown/*"          -> "./markdown/*.tsx"
 *     "./hooks/*"             -> "./hooks/*.ts"
 *
 * Each expands to a glob covering every file in the directory, so all of them
 * register as entry points and none can ever be reported unused. Setting
 * `entry: []`, negating the paths in `entry`, and `--production` were all
 * tried; manifest entries are a separate code path from configured ones and
 * none of them subtract from it. packages/views is unaffected — 44 of its 45
 * exports name a specific file — which is why knip does flag dead files there.
 *
 * That blind spot is exactly where the 16 dead components removed in MUL-6353
 * lived, so without this check the cleanup has no regression guard at all.
 *
 * Specifiers are resolved against the importing file's own directory, never
 * matched by basename. 17 of the 59 files guarded here share a basename with
 * some other file in the repo (`button`, `card`, `input`, `label`, …), so
 * basename matching would silently pass a dead file whenever any unrelated
 * module imported a same-named sibling.
 *
 * Liveness is reachability, not a reference count: the walk starts from every
 * file outside the guarded directories and follows imports inward, so a pair
 * of dead components importing only each other stays dead.
 *
 * Edges come from real syntax nodes via the TypeScript parser, not from a
 * regex over source text. Text matching counts a commented-out import as a
 * live edge, which is precisely the state a file passes through when its last
 * real importer is removed. 52 specifiers in this repo appear only in comments
 * or prose today; none currently point into packages/ui, but that is a
 * coincidence rather than a property.
 *
 * Run: node scripts/check-ui-wildcard-exports.mjs [repo-root]
 */

import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import ts from "typescript";

const repoRoot = resolve(process.argv[2] ?? resolve(import.meta.dirname, ".."));
const uiRoot = join(repoRoot, "packages", "ui");
const pkgName = "@multica/ui";

const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", ".turbo", "out", "dist", "build"]);

/**
 * Module specifiers this file really depends on: static import/export,
 * `import()`, `require()`, and `import("x")` type nodes. A bare string that
 * happens to sit next to the word `from` is not one of them.
 *
 * `vi.mock("...")` is deliberately excluded. It names a module without
 * importing it, so a component whose only remaining mention is a test mock is
 * dead — counting it would let a component outlive its last real use.
 */
function importedSpecifiers(sourceText, filePath) {
  const source = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
  const specifiers = new Set();

  const visit = (node) => {
    if (
      (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
      node.moduleSpecifier &&
      ts.isStringLiteral(node.moduleSpecifier)
    ) {
      specifiers.add(node.moduleSpecifier.text);
    } else if (
      ts.isCallExpression(node) &&
      (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
        (ts.isIdentifier(node.expression) && node.expression.text === "require")) &&
      node.arguments.length > 0 &&
      ts.isStringLiteral(node.arguments[0])
    ) {
      specifiers.add(node.arguments[0].text);
    } else if (
      ts.isImportTypeNode(node) &&
      ts.isLiteralTypeNode(node.argument) &&
      ts.isStringLiteral(node.argument.literal)
    ) {
      specifiers.add(node.argument.literal.text);
    }
    ts.forEachChild(node, visit);
  };

  visit(source);
  return specifiers;
}

function walk(dir, out = []) {
  for (const dirent of readdirSync(dir, { withFileTypes: true })) {
    if (dirent.name.startsWith(".")) continue;
    const full = join(dir, dirent.name);
    if (dirent.isDirectory()) {
      if (!SKIP_DIRS.has(dirent.name)) walk(full, out);
    } else if (SOURCE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) {
      out.push(full);
    }
  }
  return out;
}

/** Every on-disk path a specifier could mean, extensionless forms included. */
function resolutionCandidates(base) {
  return [base, ...SOURCE_EXTENSIONS.flatMap((ext) => [`${base}${ext}`, join(base, `index${ext}`)])];
}

const manifest = JSON.parse(readFileSync(join(uiRoot, "package.json"), "utf8"));
const exportEntries = Object.entries(manifest.exports ?? {});

// Derived from the manifest rather than hard-coded: narrow a wildcard export
// and this check narrows with it instead of going quietly stale.
const wildcards = exportEntries
  .filter(([subpath, target]) => subpath.includes("*") && String(target).includes("*"))
  .map(([subpath, target]) => {
    const targetPath = String(target).replace(/^\.\//, "");
    return {
      dir: targetPath.slice(0, targetPath.lastIndexOf("/")),
      extension: targetPath.slice(targetPath.lastIndexOf("*") + 1),
      prefix: subpath.replace(/^\.\//, "").replace("*", ""),
    };
  });

if (wildcards.length === 0) {
  console.log("No wildcard exports in packages/ui — knip covers this workspace on its own.");
  process.exit(0);
}

// A file named by a non-wildcard export is an entry point in its own right, so
// it is exempt. This is what makes the remedy the failure message suggests
// actually work.
const exactExportTargets = new Set(
  exportEntries
    .filter(([subpath, target]) => !subpath.includes("*") && !String(target).includes("*"))
    .map(([, target]) => join(uiRoot, String(target).replace(/^\.\//, ""))),
);

const guarded = new Set();
for (const { dir, extension } of wildcards) {
  let entries;
  try {
    entries = readdirSync(join(uiRoot, dir));
  } catch {
    continue; // Exported directory does not exist yet.
  }
  for (const entry of entries) {
    if (!entry.endsWith(extension)) continue;
    const file = join(uiRoot, dir, entry);
    if (statSync(file).isFile() && !exactExportTargets.has(file)) guarded.add(file);
  }
}

const sourceFiles = walk(repoRoot);
const known = new Set(sourceFiles);

/** Resolve one specifier from one importer to a guarded file, or null. */
function resolveSpecifier(specifier, importer) {
  let base;
  if (specifier.startsWith(".")) {
    base = resolve(dirname(importer), specifier);
  } else if (specifier === pkgName || specifier.startsWith(`${pkgName}/`)) {
    const subpath = specifier.slice(pkgName.length + 1);
    const wildcard = wildcards.find((w) => subpath.startsWith(w.prefix) && w.prefix !== "");
    if (!wildcard) return null;
    base = join(uiRoot, wildcard.dir, subpath.slice(wildcard.prefix.length));
  } else {
    return null; // A bare external package.
  }
  for (const candidate of resolutionCandidates(base)) {
    if (known.has(candidate)) return candidate;
  }
  return null;
}

// Reachability: roots are everything outside the guarded set, so a guarded file
// counts as live only when the walk actually arrives at it.
const imports = new Map();
for (const file of sourceFiles) {
  const found = new Set();
  for (const specifier of importedSpecifiers(readFileSync(file, "utf8"), file)) {
    const target = resolveSpecifier(specifier, file);
    if (target !== null) found.add(target);
  }
  imports.set(file, found);
}

const reached = new Set();
const queue = sourceFiles.filter((file) => !guarded.has(file));
while (queue.length > 0) {
  for (const target of imports.get(queue.pop()) ?? []) {
    if (!reached.has(target)) {
      reached.add(target);
      queue.push(target);
    }
  }
}

const dead = [...guarded]
  // UI Vitest discovers these tests without a product import. Keep them in
  // guarded so their imports cannot become new reachability roots.
  .filter((file) => !/\.test\.(ts|tsx)$/.test(file))
  .filter((file) => !reached.has(file))
  .map((file) => relative(repoRoot, file).split(sep).join("/"))
  .sort();

if (dead.length > 0) {
  console.error(`Unused files in packages/ui wildcard exports (${dead.length})`);
  for (const file of dead) console.error(`  ${file}`);
  console.error(
    "\nNothing imports these. Delete them — shadcn components come back with" +
      "\n`pnpm ui:add <name>`. If a file is genuinely entry-like, give it a" +
      "\nnon-wildcard entry in packages/ui/package.json `exports`.",
  );
  process.exit(1);
}

console.log(
  `packages/ui wildcard exports clean (${guarded.size} files under ` +
    `${wildcards.map((w) => w.dir).join(", ")}).`,
);