#!/usr/bin/env node
/**
 * The no-binary-art gate: `src/**` holds no committed binary art, ever.
 *
 * This is the check that backs the licensing argument in ARCHITECTURE.md §3 —
 * every mesh is a function composing cached unit primitives and every texture is
 * drawn on a 2D canvas from seeded noise, which is what gives the repo zero
 * asset-licensing exposure. A committed `.glb` or `.png` is a claim about
 * provenance that nobody in CI can verify, so the answer is that they do not
 * arrive at all.
 *
 * Two things about the scope, both of them corrections of an earlier design that
 * CONTRACT.md §7 calls out by name.
 *
 * The enumeration is `git ls-files` and never a filesystem walk. A self-hoster
 * is told, by CONTRIBUTING.md and by the office packs, to drop their own
 * legally-clean art into `public/props/` and friends; a walk would find it and
 * fail their build over files this repo does not distribute and has no opinion
 * about. Tracked files are the only files a licence claim can be made about, so
 * tracked files are the only files enumerated. If git is missing, this exits
 * non-zero rather than falling back to a walk — a gate that quietly changes what
 * it measures is worse than one that stops.
 *
 * And it is strict over `src/**` only. That is where the procedural-assets
 * promise lives. `public/props/`, `public/kits/`, `public/offices/` and `docs/`
 * are hard-exempt below and git-ignored besides: they are self-hoster space.
 *
 * Runs by hand as `node scripts/check-no-binaries.mjs` from anywhere in the
 * working tree.
 */

import { execFileSync } from "node:child_process";

// ---- The rule ----

/**
 * Art and binary-asset extensions. Lowercased at the comparison, so a `.PNG`
 * off a Windows checkout is caught too.
 */
const DENIED_EXTENSIONS = new Set([
  ".glb",
  ".gltf",
  ".png",
  ".jpg",
  ".jpeg",
  ".webp",
  ".gif",
  ".svg",
  ".ico",
  ".hdr",
  ".exr",
  ".ttf",
  ".otf",
  ".woff",
  ".woff2",
  ".mp3",
  ".wav",
  ".ogg",
  ".mp4",
  ".webm",
  ".fbx",
  ".obj",
]);

/** The pathspec handed to `git ls-files`. Everything outside it is unexamined. */
const SCOPE = "src";

/**
 * Self-hoster space, exempt even if something puts it inside the scope later.
 * These are repo-relative prefixes, matched against the paths git reports.
 */
const EXEMPT_PREFIXES = [
  "public/props/",
  "public/kits/",
  "public/offices/",
  "docs/",
];

// ---- Enumeration ----

function repoRoot() {
  try {
    return execFileSync("git", ["rev-parse", "--show-toplevel"], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"],
    }).trim();
  } catch {
    fail(
      "could not ask git for the repo root.",
      "This check enumerates tracked files with `git ls-files` and deliberately has no",
      "filesystem-walk fallback, because a walk would fail a self-hoster's build over",
      "their own untracked art. Run it inside a git checkout.",
    );
  }
}

function trackedFiles(root, pathspec) {
  const out = execFileSync("git", ["ls-files", "-z", "--", pathspec], {
    cwd: root,
    encoding: "utf8",
    // A NUL-separated listing of a repo this size is tiny, but the default 1 MB
    // ceiling is close enough to be worth not thinking about again.
    maxBuffer: 64 * 1024 * 1024,
  });
  return out.split("\0").filter((path) => path !== "");
}

/**
 * The extension, lowercased, or the empty string. A leading dot with no stem —
 * `src/.keep` — is a dotfile and has no extension, which is why this compares
 * against the last slash rather than reaching for `path.extname`.
 */
function extensionOf(path) {
  const dot = path.lastIndexOf(".");
  const slash = path.lastIndexOf("/");
  if (dot <= slash + 1) return "";
  return path.slice(dot).toLowerCase();
}

function isExempt(path) {
  return EXEMPT_PREFIXES.some((prefix) => path.startsWith(prefix));
}

// ---- Reporting ----

function fail(...lines) {
  console.error(`\ncheck-no-binaries: FAIL — ${lines[0]}\n`);
  for (const line of lines.slice(1)) console.error(line);
  console.error("");
  process.exit(1);
}

// ---- Run ----

const root = repoRoot();
const tracked = trackedFiles(root, SCOPE);
const offenders = tracked
  .filter((path) => !isExempt(path))
  .filter((path) => DENIED_EXTENSIONS.has(extensionOf(path)));

if (offenders.length > 0) {
  const listed = offenders.map((path) => `  ${path}  (${extensionOf(path)})`);
  fail(
    `${offenders.length} binary asset${offenders.length === 1 ? "" : "s"} tracked under ${SCOPE}/.`,
    ...listed,
    "",
    "Apache 2.0 is a promise that everything in this repo is ours to give away, and a",
    "committed binary asset is a provenance claim no reviewer can check. Assets here are",
    "procedural TypeScript: meshes compose cached unit primitives, textures are drawn on a",
    "2D canvas from seeded noise. See ARCHITECTURE.md §3 and CONTRACT.md §3.",
    "",
    "If this is your own art for your own deployment, it does not belong in src/ at all —",
    `put it under ${EXEMPT_PREFIXES.join(", ")}, which are git-ignored self-hoster space, and`,
    "this check will never see it.",
  );
}

console.log(
  `check-no-binaries: ok — ${tracked.length} tracked file${tracked.length === 1 ? "" : "s"} under ` +
    `${SCOPE}/, none matching ${DENIED_EXTENSIONS.size} denied extensions.`,
);
console.log(
  `  enumerated with \`git ls-files\`, so untracked local art is invisible to this check.`,
);
console.log(`  denied: ${[...DENIED_EXTENSIONS].join(" ")}`);
console.log(`  exempt: ${EXEMPT_PREFIXES.join(" ")}`);
