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.
92%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
#!/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();