CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

tessl/factory-setup

Set up, understand, and change a Tessl factory on a repository: pick automations that fit how you work, such as shipping tickets as pull requests, answering reviews and fixing checks on those pull requests, code review, and scheduled jobs; prepare the repository for agents; write issues for the factory to build; then see what the factory has done, find out why a run failed or never started, and change or extend it.

Quality

91%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

score-repo.mjsskills/prepare-repo/scripts/

#!/usr/bin/env node
// Scores a repository against the agent readiness rubric without a subagent.
//
// Most rubric criteria are facts about files: a config exists, CI runs a tool,
// a lockfile is committed. This script checks those for every app and writes
// the assessment JSON that build-report.mjs reads. The few criteria that need
// a reader (does the README explain setup, is the agent context any good) are
// written with `needsJudgement: true` and the snippets to judge them from, so
// the agent fills them in from one short file instead of reading the repo.
//
// It reads only files Git tracks, changes nothing in the repository, and makes
// no network calls.
//
// Usage:
//   node score-repo.mjs --out DIR [--root DIR]
//     writes DIR/agent-readiness.json, the baseline draft.
//   node score-repo.mjs --out DIR --baseline FILE --changed FILE [--root DIR]
//     re-checks the branch against the baseline FILE (a finished
//     agent-readiness.json) and the branch's changed files, and writes
//     DIR/rejudged.json for rescore.mjs plus DIR/rejudge-needed.json listing
//     the judgement criteria whose sources the branch changed.

import { spawnSync } from 'node:child_process';
import {
  existsSync,
  mkdirSync,
  readFileSync,
  realpathSync,
  writeFileSync,
} from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { RUBRIC } from './build-report.mjs';

const MAX_FILE_BYTES = 512 * 1024;
const MAX_APPS = 40;
const MAX_SOURCE_SCAN = 1500;
const SNIPPET_LINES = 60;

// Path segments that never hold an app: tests, fixtures, examples, vendored
// and generated trees. A manifest under one of these is not an app.
const NON_APP_SEGMENT =
  /^(node_modules|test|tests|__tests__|fixtures?|testdata|examples?|benchmarks?|bench|docs?|vendor|third_party|dist|build|\.tessl)$/i;

const MANIFESTS = new Set([
  'package.json',
  'pyproject.toml',
  'setup.py',
  'go.mod',
  'Cargo.toml',
  'pom.xml',
  'build.gradle',
  'build.gradle.kts',
  'Gemfile',
  'composer.json',
]);

// ---------------------------------------------------------------- repo view

function git(root, args) {
  const r = spawnSync('git', args, {
    cwd: root,
    encoding: 'utf8',
    maxBuffer: 256 * 1024 * 1024,
  });
  return r.status === 0 ? r.stdout : null;
}

/** Everything the detectors read: tracked files and cached file text. */
export function openRepo(root) {
  const listed = git(root, ['ls-files', '-z']);
  if (listed === null) throw new Error(`${root} is not a Git repository`);
  const files = listed.split('\0').filter(Boolean);
  const set = new Set(files);
  const realRoot = realpathSync(root);
  const cache = new Map();
  const read = (path) => {
    if (cache.has(path)) return cache.get(path);
    let text = '';
    try {
      const real = realpathSync(join(realRoot, path));
      const rel = relative(realRoot, real);
      if (!rel.startsWith('..') && !isAbsolute(rel)) {
        const buf = readFileSync(real);
        if (buf.length <= MAX_FILE_BYTES && !buf.includes(0))
          text = buf.toString('utf8');
      }
    } catch {
      text = '';
    }
    cache.set(path, text);
    return text;
  };
  return { root, files, has: (p) => set.has(p), read };
}

const segments = (path) => path.split('/');
const base = (path) => segments(path).at(-1);
const dirOf = (path) => {
  const i = path.lastIndexOf('/');
  return i === -1 ? '.' : path.slice(0, i);
};
const under = (dir, path) => dir === '.' || path.startsWith(`${dir}/`);
const inApp = (dir, path) => (dir === '.' ? path : `${dir}/${path}`);

function globToRegExp(glob) {
  let re = '';
  for (let i = 0; i < glob.length; i++) {
    const c = glob[i];
    if (c === '*' && glob[i + 1] === '*') {
      re += '.*';
      i++;
      if (glob[i + 1] === '/') i++;
    } else if (c === '*') re += '[^/]*';
    else if (c === '?') re += '[^/]';
    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
  }
  return new RegExp(`^${re}$`);
}

function parseJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------- apps

function workspacePatterns(repo) {
  const patterns = [];
  const pkg = parseJson(repo.read('package.json'));
  const ws = pkg?.workspaces;
  if (Array.isArray(ws)) patterns.push(...ws);
  else if (Array.isArray(ws?.packages)) patterns.push(...ws.packages);
  const pnpm = repo.read('pnpm-workspace.yaml');
  for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n#]+)['"]?/gm))
    patterns.push(m[1].trim());
  const cargo = repo.read('Cargo.toml');
  const members = /\[workspace\][\s\S]*?members\s*=\s*\[([\s\S]*?)\]/.exec(
    cargo,
  );
  if (members)
    for (const m of members[1].matchAll(/"([^"]+)"/g)) patterns.push(m[1]);
  const gowork = repo.read('go.work');
  for (const m of gowork.matchAll(/^\s*(?:use\s+)?(\.\/[^\s)]+)/gm))
    patterns.push(m[1].replace(/^\.\//, ''));
  return patterns.map((p) => p.replace(/\/$/, '')).filter(Boolean);
}

/**
 * Whether the root manifest describes a package of its own, not only the
 * workspace glue around its members: a published npm package (hono, vite), a
 * Cargo crate beside its workspace, a Python project, or a Go module. A root
 * with no entry points is glue, and scoring it would judge the members twice.
 * `private` says nothing here: an unpublished root application still has an
 * entry point, and still needs scoring.
 */
function rootIsPackage(repo) {
  const pkg = parseJson(repo.read('package.json'));
  if (
    pkg &&
    ['main', 'module', 'exports', 'bin'].some((k) => pkg[k] !== undefined)
  )
    return true;
  if (/^\s*\[package\]/m.test(repo.read('Cargo.toml'))) return true;
  if (/^\s*\[project\]/m.test(repo.read('pyproject.toml'))) return true;
  return repo.has('go.mod');
}

/** The repository's apps: workspace members, or the root, or each manifest dir. */
export function discoverApps(repo) {
  const manifestDirs = [
    ...new Set(
      repo.files
        .filter((f) => MANIFESTS.has(base(f)))
        .map(dirOf)
        .filter(
          (d) => d === '.' || !segments(d).some((s) => NON_APP_SEGMENT.test(s)),
        ),
    ),
  ].sort();
  const patterns = workspacePatterns(repo);
  let apps;
  if (patterns.length > 0) {
    const include = patterns
      .filter((p) => !p.startsWith('!'))
      .map(globToRegExp);
    const exclude = patterns
      .filter((p) => p.startsWith('!'))
      .map((p) => globToRegExp(p.slice(1)));
    apps = manifestDirs.filter(
      (d) =>
        d !== '.' &&
        include.some((r) => r.test(d)) &&
        !exclude.some((r) => r.test(d)),
    );
    // A workspace whose root is itself the main package (hono and its
    // adapters, a crate and its helper crates) scores the root as an app too.
    if (manifestDirs.includes('.') && rootIsPackage(repo)) apps.unshift('.');
  } else if (manifestDirs.includes('.')) {
    apps = ['.'];
  } else {
    // No root manifest: each top-most manifest directory is an app.
    apps = manifestDirs.filter(
      (d) => !manifestDirs.some((o) => o !== d && under(o, d)),
    );
  }
  if (apps.length === 0) apps = ['.'];
  return { apps: apps.slice(0, MAX_APPS), truncated: apps.length > MAX_APPS };
}

// ---------------------------------------------------------------- app facts

const CI_FILE =
  /^(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|Jenkinsfile|bitbucket-pipelines\.yml|\.buildkite\/.+\.ya?ml|\.drone\.yml|\.travis\.yml)$/;
const HOOK_FILE =
  /^(\.pre-commit-config\.yaml|\.husky\/[^_/][^/]*|lefthook\.ya?ml|\.lefthook\.ya?ml|\.githooks\/.+|\.simple-git-hooks\.(json|js|cjs))$/;

const SOURCE_FILE = /\.(c|m)?[jt]sx?$|\.(py|go|rs|rb|java|kt|cs|php|swift)$/;
const TEST_FILE =
  /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$|_test\.(py|go)$|\.tst\.ts$/;

function scriptsOf(pkg) {
  return pkg && typeof pkg.scripts === 'object' ? pkg.scripts : {};
}

/** Script names a command text runs: npm run x, yarn x, pnpm x, bun run x. */
function scriptRefs(text) {
  const refs = new Set();
  for (const m of text.matchAll(
    /\b(?:npm|pnpm|bun)\s+(?:run(?:-script)?\s+)?([\w:.-]+)|\byarn\s+(?:run\s+)?([\w:.-]+)/g,
  ))
    refs.add(m[1] ?? m[2]);
  for (const m of text.matchAll(/\bnpm\s+(test|t|start)\b/g))
    refs.add(m[1] === 't' ? 'test' : m[1]);
  return refs;
}

/** CI text plus every package script CI reaches, followed transitively. */
function effectiveText(seedText, scripts, repo) {
  let text = seedText;
  const seen = new Set();
  let frontier = [...scriptRefs(seedText)];
  while (frontier.length) {
    const next = [];
    for (const name of frontier) {
      if (seen.has(name) || !(name in scripts)) continue;
      seen.add(name);
      const body = String(scripts[name]);
      text += `\n${body}`;
      next.push(...scriptRefs(body));
    }
    frontier = next;
  }
  if (/\bmake\b/.test(seedText)) text += `\n${repo.read('Makefile')}`;
  if (/\btox\b/.test(seedText)) text += `\n${repo.read('tox.ini')}`;
  if (/\bnox\b/.test(seedText)) text += `\n${repo.read('noxfile.py')}`;
  if (/\bjust\b/.test(seedText)) text += `\n${repo.read('justfile')}`;
  return text;
}

function appFacts(repo, dir, apps) {
  // Files of this app: under its directory and not under a nested app.
  const nested = apps.filter((a) => a !== dir && under(dir, a));
  const files = repo.files.filter(
    (f) => under(dir, f) && !nested.some((n) => under(n, f)),
  );
  const pkg = parseJson(repo.read(inApp(dir, 'package.json')));
  const rootPkg = dir === '.' ? null : parseJson(repo.read('package.json'));
  const scripts = { ...scriptsOf(rootPkg), ...scriptsOf(pkg) };
  const depNames = [pkg, rootPkg]
    .filter(Boolean)
    .flatMap((p) =>
      [
        'dependencies',
        'devDependencies',
        'peerDependencies',
        'optionalDependencies',
      ].flatMap((k) => Object.keys(p[k] ?? {})),
    );
  const depText = [
    depNames.join('\n'),
    repo.read(inApp(dir, 'pyproject.toml')),
    repo.read(inApp(dir, 'go.mod')),
    repo.read(inApp(dir, 'Cargo.toml')),
    repo.read(inApp(dir, 'Gemfile')),
    ...files
      .filter((f) => /(^|\/)requirements[^/]*\.txt$/.test(f))
      .map(repo.read),
  ]
    .join('\n')
    .toLowerCase();
  const ciFiles = repo.files.filter((f) => CI_FILE.test(f));
  const hookFiles = repo.files.filter((f) => HOOK_FILE.test(f));
  const ciText = ciFiles.map(repo.read).join('\n');
  const hookText = [
    ...hookFiles.map(repo.read),
    JSON.stringify(pkg?.['simple-git-hooks'] ?? ''),
    JSON.stringify(pkg?.['lint-staged'] ?? ''),
  ].join('\n');
  const enforcedText = [
    effectiveText(ciText, scripts, repo),
    effectiveText(hookText, scripts, repo),
  ].join('\n');
  const sources = files.filter(
    (f) => SOURCE_FILE.test(f) && !TEST_FILE.test(f),
  );
  const tests = files.filter(
    (f) => TEST_FILE.test(f) && /\.[a-z]+$/.test(f) && !/fixtures?\//.test(f),
  );
  const lang = {
    ts: files.some((f) => /\.tsx?$/.test(f) && !f.endsWith('.d.ts')),
    js: files.some((f) => /\.[cm]?jsx?$/.test(f)),
    py: files.some((f) => f.endsWith('.py')),
    go: files.some((f) => f.endsWith('.go')),
    rs: files.some((f) => f.endsWith('.rs')),
    typedOther: files.some((f) => /\.(java|kt|cs|swift|scala)$/.test(f)),
  };
  const find = (re) =>
    files.filter((f) => re.test(dir === '.' ? f : f.slice(dir.length + 1)));
  const findHere = (re) => [
    ...find(re),
    ...(dir === '.'
      ? []
      : repo.files.filter((f) => !f.includes('/') && re.test(f))),
  ];
  const deployFiles = findHere(
    /(^|\/)(Dockerfile[^/]*|Containerfile|Procfile|fly\.toml|app\.ya?ml|render\.yaml|vercel\.json|netlify\.toml|serverless\.ya?ml|wrangler\.toml|railway\.json|Chart\.yaml|kustomization\.ya?ml)$/,
  );
  const serverDep =
    /\b(express|fastify|koa|hono|@nestjs\/core|next|flask|fastapi|django|starlette|gin-gonic|labstack\/echo|axum|actix-web|rocket|rails|sinatra|spring-boot)\b/.test(
      depText,
    );
  const isLibrary =
    (pkg && !pkg.private && (pkg.main || pkg.exports || pkg.types)) ||
    /\[project\]/.test(repo.read(inApp(dir, 'pyproject.toml')));
  const dbDep =
    /\b(pg|postgres|postgresql|psycopg2?|asyncpg|mysql2?|mariadb|mongodb|mongoose|sqlite3|better-sqlite3|redis|ioredis|prisma|@prisma\/client|drizzle-orm|typeorm|sequelize|knex|sqlalchemy|django|gorm|sqlx|diesel|kafkajs|amqplib|elasticsearch)\b/.test(
      depText,
    );
  return {
    dir,
    files,
    pkg,
    scripts,
    depText,
    ciFiles,
    hookFiles,
    ciText,
    enforcedText,
    sources,
    tests,
    lang,
    find,
    findHere,
    deployable: deployFiles.length > 0,
    deployFiles,
    serverDep,
    isLibrary: Boolean(isLibrary),
    dbDep,
  };
}

// ---------------------------------------------------------------- criteria
//
// Each detector returns, for one app:
//   { applicable: false, why }                      not applicable
//   { pass: true|false, evidence: [paths], note }    decided
//   { judge: true, sources: [paths], snippets }      needs a reader

const yes = (evidence, note) => ({ pass: true, evidence, note });
const no = (note, evidence = []) => ({ pass: false, evidence, note });
const na = (why) => ({ applicable: false, why });

/**
 * When the app has a package script matching `nameRe` (such as `lint` or
 * `format`) but no configuration a detector recognises, the tool is one this
 * script does not know, so the answer is not "no" but "ask the reader". Returns
 * a judge result with the scripts and CI files to read, or null.
 */
function unknownToolScripts(a, nameRe) {
  const names = Object.keys(a.scripts).filter((n) => nameRe.test(n));
  if (names.length === 0) return null;
  const text = names.map((n) => `${n}: ${a.scripts[n]}`).join('\n');
  // The CI and hook lines that name one of those scripts, so the reader can
  // see whether the tool is enforced without opening every workflow.
  const runs = names.map((n) => new RegExp(`\\b${n}\\b`));
  const enforced = a.enforcedText
    .split('\n')
    .filter((line) => runs.some((re) => re.test(line)))
    .slice(0, SNIPPET_LINES);
  return {
    judge: true,
    sources: [inApp(a.dir, 'package.json'), ...a.ciFiles.slice(0, 3)],
    snippets: [
      { path: '(package scripts)', text },
      {
        path: '(CI and hook lines naming them)',
        text: enforced.join('\n') || '(none found)',
      },
    ],
  };
}

function snippet(repo, path, lines = SNIPPET_LINES) {
  const text = repo.read(path);
  if (!text) return null;
  return { path, text: text.split('\n').slice(0, lines).join('\n') };
}

/** Lines under the first heading matching `re`, up to the next heading. */
function section(repo, path, re) {
  const lines = repo.read(path).split('\n');
  const start = lines.findIndex((l) => /^#{1,4}\s/.test(l) && re.test(l));
  if (start === -1) return null;
  const end = lines.findIndex((l, i) => i > start && /^#{1,4}\s/.test(l));
  return {
    path,
    text: lines
      .slice(start, end === -1 ? start + SNIPPET_LINES : end)
      .slice(0, SNIPPET_LINES)
      .join('\n'),
  };
}

function readmeOf(repo, a) {
  return a.findHere(/^README(\.(md|rst|txt))?$/i)[0] ?? null;
}

function contextFiles(repo, a) {
  return [
    ...new Set([
      ...a.findHere(/^(AGENTS|CLAUDE)\.md$/),
      ...repo.files.filter((f) => /^(AGENTS|CLAUDE)\.md$/.test(f)),
    ]),
  ];
}

/** Context file text with a bare `@AGENTS.md` style include followed once. */
function contextText(repo, path) {
  let text = repo.read(path);
  for (const m of text.matchAll(/^@([\w./-]+\.md)\s*$/gm)) {
    const target = join(dirOf(path), m[1]).replace(/^\.\//, '');
    if (repo.has(target)) text += `\n${repo.read(target)}`;
  }
  return text;
}

const COMMAND_IN_TEXT =
  /`[^`\n]*\b(npm|yarn|pnpm|bun|npx|make|just|pytest|tox|nox|uv|poetry|pip|go (test|build|run)|cargo|gradle|mvn|bundle|rake|docker compose)\b[^`\n]*`|^\s*\$?\s*(npm|yarn|pnpm|bun|make|pytest|uv|poetry|go|cargo)\s/m;

const DETECTORS = {
  'Linter Configured': (repo, a) => {
    const configs = a.findHere(
      /(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?|biome\.jsonc?|\.oxlintrc\.json|ruff\.toml|\.ruff\.toml|\.flake8|\.pylintrc|pylintrc|\.golangci\.ya?ml|\.rubocop\.yml|\.swiftlint\.yml|detekt\.yml)$/,
    );
    const pyproject = inApp(a.dir, 'pyproject.toml');
    if (/\[tool\.(ruff|pylint|flake8)/.test(repo.read(pyproject)))
      configs.push(pyproject);
    if (a.lang.rs && /clippy/.test(a.ciText)) configs.push('(cargo clippy)');
    if (a.pkg?.eslintConfig || /\b(standard|xo)\b/.test(a.depText))
      configs.push(inApp(a.dir, 'package.json'));
    if (configs.length === 0) {
      // A lint script with a tool this list does not know (vp, nx, a
      // wrapper) is not evidence of absence: hand the app to the reader.
      const unknown = unknownToolScripts(a, /^lint(:|$)/);
      if (unknown) return unknown;
      return no('no linter configuration found');
    }
    if (
      !/\b(eslint|biome|oxlint|ruff|flake8|pylint|golangci-lint|clippy|rubocop|swiftlint|detekt|standard|xo)\b/.test(
        a.enforcedText,
      )
    )
      return no('a linter is configured but no CI step or hook runs it', [
        ...configs,
        ...a.ciFiles,
      ]);
    return yes(
      [...configs, ...a.ciFiles.slice(0, 3)],
      'configured and run in CI or a hook',
    );
  },

  'Code Formatter': (repo, a) => {
    const configs = a
      .findHere(
        /(^|\/)(\.prettierrc(\.[a-z]+)?|prettier\.config\.[cm]?[jt]s|biome\.jsonc?|dprint\.json|\.oxfmtrc\.json|rustfmt\.toml|\.rustfmt\.toml|\.clang-format|\.editorconfig)$/,
      )
      .filter((f) => !f.endsWith('.editorconfig'));
    if (a.pkg?.prettier) configs.push(inApp(a.dir, 'package.json'));
    const pyproject = inApp(a.dir, 'pyproject.toml');
    if (/\[tool\.(black|ruff\.format)/.test(repo.read(pyproject)))
      configs.push(pyproject);
    const runs =
      /\b(prettier|biome|dprint|oxfmt|ruff format|black|rustfmt|cargo fmt|gofmt|goimports|gofumpt)\b/.test(
        a.enforcedText,
      );
    if ((a.lang.go || a.lang.rs) && runs)
      return yes(a.ciFiles.slice(0, 3), 'formatter run in CI');
    // An ESLint style preset (StandardJS, @stylistic, the Prettier plugin)
    // formats code when CI runs ESLint, so it counts as the formatter.
    const eslintConfigs = a.findHere(
      /(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/,
    );
    const presetDep =
      /^(neostandard|standard|eslint-config-standard(-[\w-]+)?|@stylistic\/eslint-plugin[\w-]*|eslint-plugin-prettier|eslint-config-xo|xo)$/m;
    const presetImport =
      /['"](neostandard|standard|eslint-config-standard|@stylistic\/eslint-plugin|eslint-plugin-prettier|eslint-config-xo)['"/]/;
    if (
      configs.length === 0 &&
      (presetDep.test(a.depText) ||
        eslintConfigs.some((f) => presetImport.test(repo.read(f)))) &&
      /\beslint\b/.test(a.enforcedText)
    )
      return yes(
        [...eslintConfigs, ...a.ciFiles.slice(0, 2)],
        'ESLint style preset enforced in CI',
      );
    if (configs.length === 0) {
      const unknown = unknownToolScripts(a, /^(format|fmt)(:|$)/);
      if (unknown) return unknown;
      return no('no formatter configuration found');
    }
    if (!runs)
      return no(
        'a formatter is configured but no CI step or hook checks it',
        configs,
      );
    return yes(configs, 'configured and checked in CI or a hook');
  },

  'Type Checker / Strict Typing': (repo, a) => {
    if (a.lang.go || a.lang.rs || a.lang.typedOther)
      return yes([], 'statically typed language');
    const tsconfigs = a.findHere(/(^|\/)tsconfig[^/]*\.json$/);
    const strict = tsconfigs.filter((f) =>
      /"strict"\s*:\s*true|@tsconfig\/strictest/.test(repo.read(f)),
    );
    if (strict.length) return yes(strict, 'TypeScript strict mode');
    if (a.lang.py) {
      const cfg = a.findHere(
        /(^|\/)(mypy\.ini|\.mypy\.ini|pyrightconfig\.json)$/,
      );
      const pyproject = inApp(a.dir, 'pyproject.toml');
      if (/\[tool\.(mypy|pyright)/.test(repo.read(pyproject)))
        cfg.push(pyproject);
      if (cfg.length && /\b(mypy|pyright)\b/.test(a.enforcedText))
        return yes(cfg, 'type checker configured and run');
      return no(
        cfg.length
          ? 'type checker configured but not run in CI'
          : 'no Python type checker',
        cfg,
      );
    }
    if (tsconfigs.length)
      return no('tsconfig without "strict": true', tsconfigs);
    return no('untyped JavaScript with no type checking');
  },

  'Pre-commit Hooks': (repo, a) => {
    const hooks = [...a.hookFiles];
    if (a.pkg?.['simple-git-hooks'] || a.pkg?.husky)
      hooks.push(inApp(a.dir, 'package.json'));
    return hooks.length
      ? yes(hooks, 'local Git hooks configured')
      : no('no pre-commit hook configuration');
  },

  'Dead Code Detection': (repo, a) => {
    const re =
      /\b(knip|ts-prune|ts-unused-exports|unimported|depcheck|vulture|deadcode|staticcheck|cargo-udeps|cargo-machete|unused)\b/;
    const cfg = a.findHere(
      /(^|\/)(knip\.jsonc?|\.knip\.jsonc?|knip\.config\.[cm]?[jt]s)$/,
    );
    if (cfg.length || re.test(a.depText) || re.test(a.enforcedText))
      return yes(cfg, 'dead code tool configured');
    return no('no dead code detection tool');
  },

  'Single Command Setup': (repo, a) => {
    const readme = readmeOf(repo, a);
    const sources = [
      readme,
      ...repo.files.filter((f) => /^CONTRIBUTING(\.md)?$/i.test(f)),
    ].filter(Boolean);
    const snippets = [
      ...sources.map((s) =>
        section(
          repo,
          s,
          /install|setup|set up|getting started|quick ?start|develop|local/i,
        ),
      ),
      { path: '(package scripts)', text: Object.keys(a.scripts).join(', ') },
      a
        .findHere(/(^|\/)(Makefile|justfile|Taskfile\.ya?ml)$/)
        .map((f) => snippet(repo, f, 30))[0],
    ].filter(Boolean);
    return { judge: true, sources, snippets };
  },

  'Dependencies Pinned': (repo, a) => {
    const locks = a.findHere(
      /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|poetry\.lock|uv\.lock|Pipfile\.lock|pdm\.lock|go\.sum|Cargo\.lock|Gemfile\.lock|composer\.lock)$/,
    );
    return locks.length
      ? yes(locks, 'lockfile committed')
      : no('no committed lockfile');
  },

  'Build Command Documented': (repo, a) => {
    const docs = [
      readmeOf(repo, a),
      ...contextFiles(repo, a),
      ...repo.files.filter((f) => /^CONTRIBUTING(\.md)?$/i.test(f)),
    ].filter(Boolean);
    const snippets = [
      ...docs.map(
        (d) =>
          section(repo, d, /build|run|test|develop|usage|command|script/i) ??
          snippet(repo, d, 30),
      ),
      {
        path: '(package scripts)',
        text: JSON.stringify(a.scripts, null, 1).slice(0, 2000),
      },
    ].filter(Boolean);
    return { judge: true, sources: docs, snippets };
  },

  'Task / Monorepo Tooling': (repo, a) => {
    const tools = a.findHere(
      /(^|\/)(turbo\.json|nx\.json|lerna\.json|pnpm-workspace\.yaml|rush\.json|Makefile|justfile|Taskfile\.ya?ml|tox\.ini|noxfile\.py|mise\.toml)$/,
    );
    if (Object.keys(a.scripts).length >= 2)
      tools.push(inApp(a.dir, 'package.json'));
    return tools.length
      ? yes(tools, 'task runner or package scripts')
      : no('no task runner or scripts');
  },

  'CI Configured': (repo, a) => {
    const ci = a.ciFiles.filter(
      (f) =>
        !f.startsWith('.github/workflows/') ||
        /\b(pull_request|push)\b/.test(repo.read(f)),
    );
    return ci.length
      ? yes(ci, 'CI runs on pushes or pull requests')
      : no('no CI configuration runs on pushes or pull requests');
  },

  'Unit Tests Exist': (repo, a) => {
    if (a.tests.length)
      return yes(a.tests.slice(0, 5), `${a.tests.length} test files`);
    if (
      a.lang.rs &&
      a.sources
        .slice(0, 300)
        .some((f) => f.endsWith('.rs') && repo.read(f).includes('#[test]'))
    )
      return yes([], 'Rust #[test] functions');
    return no('no test files');
  },

  'Tests Runnable': (repo, a) => {
    const test = a.scripts.test;
    if (test && !/no test specified/.test(test))
      return yes([inApp(a.dir, 'package.json')], `test script: ${test}`);
    if (a.lang.go && a.tests.some((f) => f.endsWith('_test.go')))
      return yes([], 'go test');
    if (a.lang.rs) return yes([], 'cargo test');
    const py = a.findHere(
      /(^|\/)(pytest\.ini|conftest\.py|tox\.ini|noxfile\.py)$/,
    );
    if (/\[tool\.pytest/.test(repo.read(inApp(a.dir, 'pyproject.toml'))))
      py.push(inApp(a.dir, 'pyproject.toml'));
    if (py.length) return yes(py, 'pytest configuration');
    if (/^test:/m.test(repo.read(inApp(a.dir, 'Makefile'))))
      return yes([inApp(a.dir, 'Makefile')], 'make test');
    return no('no command runs the tests');
  },

  'Integration / E2E Tests': (repo, a) => {
    const paths = a.files.filter(
      (f) =>
        /(^|\/|[-_.])(e2e|integration|acceptance)([-_./]|$)/i.test(f) &&
        (TEST_FILE.test(f) || /\.(ya?ml)$/.test(f)),
    );
    const cfg = a.findHere(
      /(^|\/)(playwright\.config\.[cm]?[jt]s|cypress\.config\.[cm]?[jt]s|cypress\.json)$/,
    );
    const ci = a.ciFiles.filter((f) => /integration|e2e/i.test(f));
    const dep =
      /\b(@playwright\/test|playwright|cypress|puppeteer|supertest|testcontainers|light-my-request|webdriverio)\b/.test(
        a.depText,
      );
    const evidence = [...cfg, ...ci, ...paths.slice(0, 3)];
    if (evidence.length || dep)
      return yes(evidence, 'integration or end-to-end tests');
    // A second test tree beside the unit tests (hono's runtime-tests/, a
    // tests/ directory next to src/**/*.test.ts) may be integration tests
    // under another name. The script cannot tell, so the reader decides.
    const rel = (f) => (a.dir === '.' ? f : f.slice(a.dir.length + 1));
    const treeOf = (f) => segments(rel(f))[0];
    const trees = [...new Set(a.tests.map(treeOf))];
    // Unit tests live beside the sources or in the conventional test tree.
    const sourceTrees = a.sources.reduce(
      (m, s) => m.set(treeOf(s), (m.get(treeOf(s)) ?? 0) + 1),
      new Map(),
    );
    const mainSourceTree = [...sourceTrees.entries()].sort(
      (x, y) => y[1] - x[1],
    )[0]?.[0];
    const unitTree = (dir) =>
      /^(src|lib|app|packages?|tests?|__tests__|spec)$/.test(dir) ||
      dir === mainSourceTree;
    const other = trees.filter((t) => !unitTree(t));
    if (trees.length > 1 && other.length) {
      const listing = a.tests.filter((f) => treeOf(f) === other[0]);
      return {
        judge: true,
        sources: listing.slice(0, 5),
        snippets: [
          {
            path: '(test trees)',
            text: trees
              .map(
                (t) =>
                  `${t}/: ${a.tests.filter((f) => treeOf(f) === t).length} test files`,
              )
              .join('\n'),
          },
          {
            path: `(${other[0]}/ listing)`,
            text: listing.slice(0, 40).join('\n'),
          },
        ],
      };
    }
    return no('no integration or end-to-end tests');
  },

  'Test File Conventions': (repo, a) => {
    if (a.tests.length < 3) return na('fewer than 3 test files');
    const kind = (f) =>
      /\.test\.[cm]?[jt]sx?$/.test(f)
        ? 'name.test.ext'
        : /\.spec\.[cm]?[jt]sx?$/.test(f)
          ? 'name.spec.ext'
          : /\.tst\.ts$/.test(f)
            ? 'name.tst.ts'
            : /(^|\/)test_[^/]+\.py$/.test(f)
              ? 'test_name.py'
              : /_test\.(go|py)$/.test(f)
                ? 'name_test.ext'
                : 'other';
    const counts = {};
    for (const f of a.tests) counts[kind(f)] = (counts[kind(f)] ?? 0) + 1;
    const [top, n] = Object.entries(counts).sort((x, y) => y[1] - x[1])[0];
    const share = n / a.tests.length;
    const note = `${Math.round(share * 100)}% of ${a.tests.length} test files use ${top}`;
    return share >= 0.8 && top !== 'other' ? yes([], note) : no(note);
  },

  'Coverage Thresholds': (repo, a) => {
    const gate =
      /--check-coverage|--cov-fail-under|fail_under|coverageThreshold|thresholds\s*[:=]|"check-coverage"\s*:\s*true|--100\b|--branches[= ]\d|--lines[= ]\d/;
    const cfgs = a
      .findHere(
        /(^|\/)(\.nycrc(\.json)?|\.c8rc(\.json)?|jest\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|\.coveragerc|codecov\.ya?ml|\.taprc)$/,
      )
      .filter(
        (f) =>
          gate.test(repo.read(f)) ||
          (/codecov/.test(f) && /target:/.test(repo.read(f))),
      );
    if (a.pkg?.jest?.coverageThreshold) cfgs.push(inApp(a.dir, 'package.json'));
    if (
      /\[tool\.coverage[\s\S]*fail_under/.test(
        repo.read(inApp(a.dir, 'pyproject.toml')),
      )
    )
      cfgs.push(inApp(a.dir, 'pyproject.toml'));
    if (gate.test(a.enforcedText))
      return yes(
        [...cfgs, ...a.ciFiles.slice(0, 2)],
        'coverage gate runs in CI',
      );
    if (cfgs.length && /cov|c8|nyc|borp|vitest|jest/.test(a.enforcedText))
      return yes(cfgs, 'coverage threshold configured and run');
    return no(
      cfgs.length
        ? 'threshold configured but CI does not run coverage'
        : 'no coverage gate',
      cfgs,
    );
  },

  README: (repo, a) => {
    const readme = a.find(/^README(\.(md|rst|txt))?$/i)[0];
    if (!readme) return no('no README in the app directory');
    const lines = repo
      .read(readme)
      .split('\n')
      .filter((l) => l.trim()).length;
    return lines >= 5
      ? yes([readme], `${lines} lines`)
      : no('README is nearly empty', [readme]);
  },

  'Agent Context File': (repo, a) => {
    const ctx = contextFiles(repo, a);
    if (!ctx.length) return no('no committed AGENTS.md or CLAUDE.md');
    const useful = ctx.filter((f) =>
      COMMAND_IN_TEXT.test(contextText(repo, f)),
    );
    return useful.length
      ? yes(useful, 'committed and names commands')
      : no('committed but names no build, test, or lint command', ctx);
  },

  'Architecture Docs': (repo, a) => {
    const strong = a.findHere(
      /(^|\/)(ARCHITECTURE\.md|DESIGN\.md|docs\/(architecture|design|adr|adrs|decisions)[^/]*(\/.*)?)$/i,
    );
    if (strong.length)
      return yes(strong.slice(0, 5), 'architecture or design docs');
    const docs = a.files.filter((f) =>
      /(^|\/)docs?\/.+\.(md|mdx|rst)$/i.test(f),
    );
    if (!docs.length) return no('no docs directory or architecture doc');
    return {
      judge: true,
      sources: docs.slice(0, 40),
      snippets: [
        { path: '(docs listing)', text: docs.slice(0, 80).join('\n') },
      ],
    };
  },

  'API / Schema Docs': (repo, a) => {
    if (!a.isLibrary && !a.serverDep && !a.deployable)
      return na('not a library or service');
    const docs = a
      .findHere(
        /(^|\/)(openapi[^/]*\.(json|ya?ml)|swagger[^/]*\.(json|ya?ml)|[^/]+\.graphqls?|typedoc\.json|conf\.py)$/i,
      )
      .concat(
        a.files
          .filter((f) => /(^|\/)docs?\/(api|reference)\//i.test(f))
          .slice(0, 3),
      );
    return docs.length
      ? yes(docs.slice(0, 5), 'API or schema docs')
      : no('no API or schema docs');
  },

  'Documentation Freshness': (repo, a) => {
    // Links are checked in every doc. Script names only in developer docs,
    // and only in code with an explicit `run`: a README often shows commands
    // for the user's own app, not this repository's scripts.
    const devDocs = [
      ...contextFiles(repo, a),
      ...repo.files.filter((f) =>
        /^(\.github\/)?CONTRIBUTING(\.md)?$/i.test(f),
      ),
    ];
    const docs = [readmeOf(repo, a), ...devDocs].filter(Boolean);
    const missing = [];
    let checked = 0;
    for (const d of devDocs) {
      const code = [...repo.read(d).matchAll(/```[\s\S]*?```|`[^`\n]+`/g)]
        .map((m) => m[0])
        .join('\n');
      for (const m of code.matchAll(
        /\b(?:npm|pnpm|bun|yarn)\s+run\s+([\w:.-]+)/g,
      )) {
        checked++;
        if (!(m[1] in a.scripts)) missing.push(`${d}: script "${m[1]}"`);
      }
    }
    for (const d of docs) {
      const text = repo.read(d);
      for (const m of text.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
        const target = join(dirOf(d), m[1]).replace(/^\.\//, '');
        // `tessl install` writes .tessl/ and Git ignores it; its links are
        // not the team's to keep fresh.
        if (target.startsWith('.tessl/')) continue;
        checked++;
        if (
          !repo.has(target) &&
          !repo.files.some((f) => f.startsWith(`${target}/`))
        )
          missing.push(`${d}: link ${m[1]}`);
      }
    }
    if (!checked)
      return {
        judge: true,
        sources: docs,
        snippets: docs.map((d) => snippet(repo, d, 40)).filter(Boolean),
      };
    return missing.length
      ? no(
          `${missing.length} of ${checked} references are stale: ${missing.slice(0, 4).join('; ')}`,
          docs,
        )
      : yes(docs, `all ${checked} referenced scripts and links exist`);
  },

  'Environment Template': (repo, a) => {
    const names = new Set();
    for (const f of a.sources.slice(0, MAX_SOURCE_SCAN)) {
      for (const m of repo
        .read(f)
        .matchAll(
          /process\.env\.([A-Z][A-Z0-9_]+)|process\.env\[['"]([A-Z][A-Z0-9_]+)|os\.environ(?:\.get)?\(?\[?['"]([A-Z][A-Z0-9_]+)|os\.getenv\(['"]([A-Z][A-Z0-9_]+)|os\.Getenv\("([A-Z][A-Z0-9_]+)|env::var\("([A-Z][A-Z0-9_]+)/g,
        ))
        names.add(m.slice(1).find(Boolean));
    }
    for (const n of [
      'NODE_ENV',
      'CI',
      'DEBUG',
      'HOME',
      'PATH',
      'TZ',
      'PWD',
      'TERM',
      'NODE_DEBUG',
    ])
      names.delete(n);
    if (names.size < 2)
      return na('the app reads fewer than two environment variables');
    const tpl = a.findHere(
      /(^|\/)(\.env\.(example|sample|template|dist|defaults)|example\.env|env\.example)$/,
    );
    return tpl.length
      ? yes(tpl, `${names.size} variables read; template committed`)
      : no(
          `reads ${names.size} environment variables but has no .env template`,
        );
  },

  'Local Services Setup': (repo, a) => {
    if (!a.dbDep) return na('no database or service dependency');
    const setup = a.findHere(
      /(^|\/)(docker-compose[^/]*\.ya?ml|compose[^/]*\.ya?ml|Tiltfile|devbox\.json|\.devcontainer\/docker-compose[^/]*\.ya?ml)$/,
    );
    return setup.length
      ? yes(setup, 'local services reproducible')
      : no('depends on a database or service with no local setup');
  },

  'Database Schema & Migrations': (repo, a) => {
    if (!a.dbDep) return na('no database dependency');
    const mig = a.files.filter((f) =>
      /(^|\/)(migrations?|alembic|db\/migrate|drizzle)\/|schema\.prisma$|drizzle\.config\.[cm]?[jt]s$|knexfile\.[cm]?[jt]s$/.test(
        f,
      ),
    );
    return mig.length
      ? yes(mig.slice(0, 3), 'versioned schema or migrations')
      : no('database dependency with no versioned migrations');
  },

  'Dev Container': (repo, a) => {
    // A dev container, or another reproducible environment definition:
    // Gitpod, Devbox, Nix, Flox, or Codespaces.
    const dc = a.findHere(
      /(^|\/)(\.devcontainer\/[^/]*devcontainer\.json|\.devcontainer\.json|\.gitpod\.ya?ml|devbox\.json|flake\.nix|shell\.nix|\.flox\/env\/manifest\.toml)$/,
    );
    return dc.length
      ? yes(dc, 'reproducible development environment')
      : no('no dev container or reproducible environment definition');
  },

  'Structured Logging': (repo, a) => {
    const re =
      /\b(pino|winston|bunyan|loglevel|consola|structlog|loguru|python-json-logger|zap|zerolog|logrus|log\/slog|tracing|slog|serilog|logback|log4j)\b/;
    if (!a.deployable && !a.serverDep && !re.test(a.depText))
      return na('not a service and no logger dependency');
    return re.test(a.depText)
      ? yes([], 'structured logger dependency')
      : no('no structured logger');
  },

  'Health Checks': (repo, a) => {
    if (!a.deployable) return na('not a deployable service');
    const hit = a.files
      .filter((f) => SOURCE_FILE.test(f) || /Dockerfile|\.ya?ml$/.test(f))
      .slice(0, MAX_SOURCE_SCAN)
      .filter((f) =>
        /['"`]\/(healthz?|readyz?|livez?|ping|status)['"`]|HEALTHCHECK|livenessProbe|readinessProbe|health_check/.test(
          repo.read(f),
        ),
      );
    return hit.length
      ? yes(hit.slice(0, 3), 'health endpoint or probe')
      : no('deployable with no health endpoint or probe', a.deployFiles);
  },

  'Metrics / Telemetry': (repo, a) => {
    if (!a.deployable) return na('not a deployable service');
    return /\b(prom-client|prometheus|opentelemetry|@opentelemetry\/[\w-]*metrics|statsd|hot-shots|datadog|dd-trace|posthog|segment|amplitude|mixpanel|newrelic|micrometer)\b/.test(
      a.depText,
    )
      ? yes([], 'metrics or telemetry dependency')
      : no('no metrics or telemetry');
  },

  'Error Tracking': (repo, a) => {
    if (!a.deployable) return na('not a deployable service');
    return /\b(sentry|@sentry\/[\w-]+|bugsnag|rollbar|honeybadger|airbrake|raygun)\b/.test(
      a.depText,
    )
      ? yes([], 'error tracking dependency')
      : no('no error tracking');
  },

  'Distributed Tracing': (repo, a) => {
    if (!a.deployable) return na('not a deployable service');
    return /\b(opentelemetry|@opentelemetry\/[\w-]+|dd-trace|jaeger|zipkin|honeycomb|elastic-apm|newrelic)\b/.test(
      a.depText,
    )
      ? yes([], 'tracing dependency')
      : no('no tracing');
  },

  'Secret Scanning': (repo, a) => {
    const re =
      /\b(gitleaks|trufflehog|detect-secrets|ggshield|secretlint|git-secrets|talisman)\b/;
    const cfg = repo.files.filter((f) =>
      /^(\.gitleaks\.toml|\.secrets\.baseline|\.secretlintrc[^/]*|\.talismanrc)$/.test(
        f,
      ),
    );
    return re.test(a.enforcedText)
      ? yes(
          [...cfg, ...a.ciFiles.filter((f) => re.test(repo.read(f)))].slice(
            0,
            3,
          ),
          'secret scanner in CI or a hook',
        )
      : no(
          cfg.length
            ? 'scanner configured but not run'
            : 'no secret scanning in CI or hooks',
          cfg,
        );
  },

  'Dependency Update Automation': (repo) => {
    const f = repo.files.filter((p) =>
      /^(\.github\/dependabot\.ya?ml|renovate\.json5?|\.renovaterc(\.json)?|\.github\/renovate\.json5?)$/.test(
        p,
      ),
    );
    return f.length
      ? yes(f, 'dependency updates automated')
      : no('no Dependabot or Renovate configuration');
  },

  'Gitignore Comprehensive': (repo, a) => {
    const gi =
      repo.read('.gitignore') +
      '\n' +
      (a.dir === '.' ? '' : repo.read(inApp(a.dir, '.gitignore')));
    if (!gi.trim()) return no('no .gitignore');
    const missing = [];
    if (!/^\/?\.env/m.test(gi) && !/^\*\.env/m.test(gi))
      missing.push('.env files');
    if ((a.lang.js || a.lang.ts) && !/node_modules/.test(gi))
      missing.push('node_modules');
    if (a.lang.py && !/__pycache__|\.pyc|\.venv/.test(gi))
      missing.push('Python caches');
    if (a.lang.rs && !/target/.test(gi)) missing.push('target/');
    return missing.length
      ? no(`.gitignore misses ${missing.join(', ')}`, ['.gitignore'])
      : yes(['.gitignore'], 'ignores env files and build output');
  },

  'Secrets Management': (repo, a) => {
    const leaked = a.files.filter((f) => {
      if (
        /(^|\/)(test|tests|fixtures?|testdata|examples?|__tests__)\//i.test(f)
      )
        return false;
      const b = base(f);
      return (
        (/^\.env(\..+)?$/.test(b) &&
          !/\.(example|sample|template|dist|defaults)$/.test(b)) ||
        /\.(pem|key|p12|pfx|jks)$/.test(b) ||
        /^(id_rsa|id_ed25519|credentials\.json|service-account[^/]*\.json)$/.test(
          b,
        )
      );
    });
    return leaked.length
      ? no(
          `secret-shaped files committed: ${leaked.slice(0, 3).join(', ')}`,
          leaked.slice(0, 5),
        )
      : yes([], 'no secret-shaped files committed');
  },

  CODEOWNERS: (repo) => {
    const f = repo.files.filter((p) =>
      /^(CODEOWNERS|\.github\/CODEOWNERS|docs\/CODEOWNERS)$/.test(p),
    );
    return f.length ? yes(f, 'CODEOWNERS present') : no('no CODEOWNERS file');
  },

  'Agent Context Quality': (repo, a) => {
    const ctx = contextFiles(repo, a);
    if (!ctx.length) return no('no committed agent context file');
    return {
      judge: true,
      sources: ctx,
      snippets: ctx.map((f) => ({
        path: f,
        text: contextText(repo, f).split('\n').slice(0, 150).join('\n'),
      })),
    };
  },

  'Skills / Reusable Context': (repo, a) => {
    const f = repo.files.filter(
      (p) =>
        !/(^|\/)(\.tessl|tessl__)/.test(p) &&
        (/(^|\/)SKILL\.md$/.test(p) ||
          /^\.claude\/(commands|agents)\/.+\.md$/.test(p) ||
          /^\.github\/prompts\/.+\.prompt\.md$/.test(p)),
    );
    const tessl = parseJson(repo.read('tessl.json'));
    if (Object.keys(tessl?.dependencies ?? {}).length) f.push('tessl.json');
    return f.length
      ? yes(f.slice(0, 5), 'skills or reusable prompts')
      : no('no skills, commands, or prompt library');
  },

  'MCP Configuration': (repo) => {
    const f = repo.files.filter(
      (p) =>
        /^(\.mcp\.json|\.cursor\/mcp\.json|\.vscode\/mcp\.json|\.gemini\/settings\.json)$/.test(
          p,
        ) ||
        (p === '.codex/config.toml' && /mcp_servers/.test(repo.read(p))),
    );
    return f.length
      ? yes(f, 'MCP servers configured')
      : no('no MCP configuration');
  },

  'Editor / Agent Rules': (repo) => {
    const f = repo.files.filter((p) =>
      /^(\.cursor\/rules\/|\.cursorrules$|\.claude\/|\.github\/copilot-instructions\.md$|\.github\/instructions\/|\.windsurfrules$|\.clinerules|\.roo\/|\.continue\/)/.test(
        p,
      ),
    );
    return f.length
      ? yes(f.slice(0, 5), 'agent or editor rules')
      : no('no agent or editor rule files');
  },

  'Task Discovery': (repo) => {
    const f = repo.files.filter(
      (p) =>
        /(^|\/)(ISSUE_TEMPLATE(\/|\.md$)|PULL_REQUEST_TEMPLATE(\/|\.md$)|pull_request_template\.md$)/i.test(
          p,
        ) ||
        /^CONTRIBUTING(\.md)?$/i.test(p) ||
        /^\.github\/CONTRIBUTING\.md$/i.test(p),
    );
    return f.length
      ? yes(f.slice(0, 5), 'issue or PR templates or a contributing guide')
      : no('no issue templates, PR template, or contributing guide');
  },
};

// The default action for each criterion that fails, when the agent has no
// better one. One concrete step, as the rubric asks.
const ACTIONS = {
  'Linter Configured':
    'Add the ecosystem linter (ESLint, Ruff, golangci-lint, Clippy) and run it as a CI step on pull requests.',
  'Code Formatter':
    'Add a formatter (Prettier, Ruff format, gofmt, rustfmt) and a CI step that checks formatting.',
  'Type Checker / Strict Typing':
    'Turn on strict type checking (tsconfig "strict": true, or mypy or pyright) and run it in CI.',
  'Pre-commit Hooks':
    'Add a pre-commit hook (pre-commit, Husky, or Lefthook) that runs the linter and formatter.',
  'Dead Code Detection':
    'Add a dead code tool (knip, vulture, staticcheck) and run it in CI.',
  'Single Command Setup':
    'Add one documented command, such as `make setup` or an `npm run setup` script, that takes a fresh clone to a working build.',
  'Dependencies Pinned': 'Commit the package manager lockfile.',
  'Build Command Documented':
    'Document the install, build, and test commands in the README.',
  'Task / Monorepo Tooling':
    'Add package scripts or a Makefile for the common tasks.',
  'CI Configured':
    'Add a CI workflow that runs lint and tests on every pull request.',
  'Unit Tests Exist': 'Add unit tests and a test runner.',
  'Tests Runnable': 'Add a `test` script or command that runs the test suite.',
  'Integration / E2E Tests':
    'Add an integration or end-to-end test suite and run it in CI.',
  'Test File Conventions':
    'Pick one test file naming pattern and rename outliers to it.',
  'Coverage Thresholds':
    'Set a coverage threshold in the test runner config and fail CI below it.',
  README: 'Add a README that says what the app is and how to run it.',
  'Agent Context File':
    'Commit an AGENTS.md with the install, build, test, and lint commands, and a CLAUDE.md that includes it.',
  'Architecture Docs':
    'Add an ARCHITECTURE.md describing the main modules and how data flows between them.',
  'API / Schema Docs':
    'Generate or write API reference docs (OpenAPI, TypeDoc, Sphinx) and keep them in the repo.',
  'Documentation Freshness':
    'Fix the stale commands and links in the docs, and check them in CI.',
  'Environment Template':
    'Commit a .env.example listing every environment variable the app reads.',
  'Local Services Setup':
    'Add a docker-compose file that starts the services the app needs locally.',
  'Database Schema & Migrations':
    'Version the schema with a migration tool and commit the migrations.',
  'Dev Container':
    'Add a .devcontainer/devcontainer.json with the toolchain the repo needs.',
  'Structured Logging': 'Replace ad hoc prints with a structured logger.',
  'Health Checks':
    'Add a /healthz endpoint and wire it to the deployment readiness probe.',
  'Metrics / Telemetry':
    'Instrument request metrics with OpenTelemetry or Prometheus.',
  'Error Tracking': 'Add an error tracking integration such as Sentry.',
  'Distributed Tracing': 'Add OpenTelemetry tracing with context propagation.',
  'Secret Scanning':
    'Add gitleaks or trufflehog as a CI step or pre-commit hook.',
  'Dependency Update Automation':
    'Add a .github/dependabot.yml with a weekly update schedule.',
  'Gitignore Comprehensive': 'Add the missing patterns to .gitignore.',
  'Secrets Management':
    'Remove the committed secret files, rotate their values, and load secrets from the environment.',
  CODEOWNERS: 'Add a CODEOWNERS file assigning reviewers by path.',
  'Agent Context Quality':
    'Make the agent context specific: commands, a module map, and hazards such as generated files.',
  'Skills / Reusable Context':
    'Add reusable agent skills or rules for the common tasks in the repo.',
  'MCP Configuration':
    'Add an .mcp.json with the tool servers agents need for this repo.',
  'Editor / Agent Rules':
    'Add agent rules (.cursor/rules or .claude/) for the conventions of the repo.',
  'Task Discovery': 'Add issue and pull request templates.',
};

// ---------------------------------------------------------------- scoring

function label(app) {
  return app === '.' ? 'root' : app;
}

/** Run every detector for every app and build the rubric-shaped draft. */
/**
 * Run one detector on one app. A detector that throws (an odd manifest, a
 * file it did not expect) does not fail the score or guess: the app goes to
 * the reader with the error as its only snippet.
 */
function detect(name, repo, a) {
  try {
    return DETECTORS[name](repo, a);
  } catch (error) {
    return {
      judge: true,
      sources: [],
      snippets: [
        {
          path: '(detector failed)',
          text: `${name} could not be checked by script: ${error?.message ?? error}`,
        },
      ],
    };
  }
}

export function scoreRepo(root) {
  const repo = openRepo(root);
  const { apps, truncated } = discoverApps(repo);
  const facts = apps.map((dir) => appFacts(repo, dir, apps));
  const names = apps.map(label);
  const categories = Object.entries(RUBRIC).map(([category, criteria]) => ({
    name: category,
    criteria: criteria.map((name) => {
      const results = facts.map((a) => ({
        app: label(a.dir),
        ...detect(name, repo, a),
      }));
      const applicable = results.filter((r) => r.applicable !== false);
      const judge = applicable.filter((r) => r.judge);
      const entry = {
        name,
        applicableApps: applicable.length,
        applicableAppNames: applicable.map((r) => r.app),
      };
      if (judge.length) {
        return {
          ...entry,
          passingApps: null,
          passingAppNames: null,
          needsJudgement: true,
          // Apps the script already decided, to keep as decided.
          decided: applicable
            .filter((r) => !r.judge)
            .map((r) => ({ app: r.app, passes: r.pass, note: r.note })),
          judge: judge.map((r) => ({
            app: r.app,
            sources: r.sources,
            snippets: r.snippets,
          })),
          sources: [...new Set(judge.flatMap((r) => r.sources))],
        };
      }
      const passing = applicable.filter((r) => r.pass);
      const failing = applicable.filter((r) => !r.pass);
      const rationale =
        applicable.length === 0
          ? `Not applicable: ${results[0]?.why ?? 'no app'}.`
          : [
              passing.length
                ? `Passes in ${passing.map((r) => r.app).join(', ')} (${passing[0].note}).`
                : '',
              failing.length
                ? `Fails in ${failing.map((r) => r.app).join(', ')}: ${failing[0].note}.`
                : '',
            ]
              .filter(Boolean)
              .join(' ');
      return {
        ...entry,
        passingApps: passing.length,
        passingAppNames: passing.map((r) => r.app),
        rationale,
        ...(failing.length ? { action: ACTIONS[name] } : {}),
        method: 'checked',
        evidence: [
          ...new Set(applicable.flatMap((r) => r.evidence ?? [])),
        ].slice(0, 8),
      };
    }),
  }));
  return {
    repo: basenameOf(root),
    apps: names,
    appsTruncated: truncated,
    categories,
  };
}

function basenameOf(root) {
  const remote = git(root, ['remote', 'get-url', 'origin']);
  const m = remote && /[/:]([^/:]+\/[^/]+?)(\.git)?\s*$/.exec(remote);
  return m ? m[1] : root.split('/').filter(Boolean).at(-1);
}

/**
 * Re-check a branch against a finished baseline. Checked criteria are re-run;
 * each app whose result changed becomes a flip for rescore.mjs, citing the
 * changed files among the detector's evidence. Judgement criteria whose
 * sources the branch changed are listed for the agent to re-judge.
 */
export function compareToBaseline(root, baseline, changedFiles) {
  const now = scoreRepo(root);
  const changed = new Set(changedFiles);
  const rejudged = [];
  const needed = [];
  const unattributed = [];
  for (const category of now.categories) {
    for (const crit of category.criteria) {
      const was = baseline.categories
        .find((c) => c.name === category.name)
        ?.criteria.find((c) => c.name === crit.name);
      if (!was) continue;
      if (crit.needsJudgement) {
        const touched = crit.sources.filter((s) => changed.has(s));
        if (touched.length)
          needed.push({
            category: category.name,
            name: crit.name,
            changedSources: touched,
            judge: crit.judge,
            baseline: {
              passingAppNames: was.passingAppNames,
              rationale: was.rationale,
            },
          });
        continue;
      }
      const flips = was.applicableAppNames
        .filter((app) => crit.applicableAppNames.includes(app))
        .filter(
          (app) =>
            was.passingAppNames.includes(app) !==
            crit.passingAppNames.includes(app),
        )
        .map((app) => ({ app, passes: crit.passingAppNames.includes(app) }));
      if (!flips.length) continue;
      // The change may have removed the file the baseline pointed at, so the
      // baseline's evidence attributes a flip as well as the branch's.
      const evidence = [
        ...new Set([...crit.evidence, ...(was.evidence ?? [])]),
      ].filter((e) => changed.has(e));
      if (!evidence.length) {
        unattributed.push(`${category.name} / ${crit.name}`);
        continue;
      }
      rejudged.push({
        category: category.name,
        name: crit.name,
        rationale: crit.rationale,
        evidence,
        changes: flips.map((f) => ({ ...f, evidence })),
        ...(crit.action ? { action: crit.action } : {}),
      });
    }
  }
  return { rejudged: { criteria: rejudged }, needed, unattributed };
}

// ---------------------------------------------------------------- CLI

/** Where a path really lands: its nearest existing ancestor, resolved. */
function realDestination(path) {
  if (existsSync(path)) return realpathSync(path);
  const parent = resolve(path, '..');
  return parent === path
    ? path
    : join(realDestination(parent), path.slice(parent.length + 1));
}

function parseArgs(argv) {
  const opts = {
    root: process.cwd(),
    out: null,
    baseline: null,
    changed: null,
  };
  for (let i = 0; i < argv.length; i += 2) {
    const [flag, value] = [argv[i], argv[i + 1]];
    if (flag === '-h' || flag === '--help') return { help: true };
    if (value === undefined) throw new Error(`${flag} needs a value`);
    if (flag === '--root') opts.root = resolve(value);
    else if (flag === '--out') opts.out = resolve(value);
    else if (flag === '--baseline') opts.baseline = resolve(value);
    else if (flag === '--changed') opts.changed = resolve(value);
    else throw new Error(`unknown flag ${flag}`);
  }
  if (!opts.out) throw new Error('--out DIR is required');
  const top = git(opts.root, ['rev-parse', '--show-toplevel'])?.trim();
  if (top) {
    const rel = relative(realpathSync(top), realDestination(opts.out));
    if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)))
      throw new Error(`--out must be outside the repository (${top})`);
  }
  if (Boolean(opts.baseline) !== Boolean(opts.changed))
    throw new Error('--baseline and --changed go together');
  return opts;
}

function main() {
  let opts;
  try {
    opts = parseArgs(process.argv.slice(2));
  } catch (error) {
    console.error(`score-repo: ${error.message}`);
    process.exit(2);
  }
  if (opts.help) {
    console.log(
      'Usage: node score-repo.mjs --out DIR [--root DIR] [--baseline FILE --changed FILE]',
    );
    return;
  }
  mkdirSync(opts.out, { recursive: true });
  if (opts.baseline) {
    const baseline = JSON.parse(readFileSync(opts.baseline, 'utf8'));
    const changed = readFileSync(opts.changed, 'utf8')
      .split('\n')
      .map((l) => l.trim())
      .filter(Boolean);
    const { rejudged, needed, unattributed } = compareToBaseline(
      opts.root,
      baseline,
      changed,
    );
    writeFileSync(
      join(opts.out, 'rejudged.json'),
      `${JSON.stringify(rejudged, null, 2)}\n`,
    );
    writeFileSync(
      join(opts.out, 'rejudge-needed.json'),
      `${JSON.stringify(needed, null, 2)}\n`,
    );
    console.log(
      `${rejudged.criteria.length} checked criteria changed; written to rejudged.json`,
    );
    console.log(
      `${needed.length} judgement criteria need re-judging: ${needed.map((n) => n.name).join(', ') || 'none'}`,
    );
    if (unattributed.length)
      console.log(
        `Changed without a changed file as evidence (left at baseline): ${unattributed.join(', ')}`,
      );
    return;
  }
  const draft = scoreRepo(opts.root);
  const outPath = join(opts.out, 'agent-readiness.json');
  writeFileSync(outPath, `${JSON.stringify(draft, null, 2)}\n`);
  const all = draft.categories.flatMap((c) => c.criteria);
  const judge = all.filter((c) => c.needsJudgement);
  console.log(outPath);
  console.log(
    `${draft.apps.length} app(s): ${draft.apps.join(', ')}${draft.appsTruncated ? ' (truncated)' : ''}`,
  );
  console.log(
    `${all.length - judge.length} criteria checked; ${judge.length} need judgement: ${judge.map((c) => c.name).join(', ')}`,
  );
}

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

README.md

tile.json