#!/usr/bin/env node
// Drift detection for paperclip-docs.
//
// The cumulative-diff sync flow catches "what's new" but not "what we document
// that no longer exists in parent". This script scans docs/** for references
// to parent code surfaces (file paths, CLI commands, env vars, REST routes)
// and verifies each one still exists in the parent repo at <--against ref>.
//
// Drift is always SURFACED to the human reviewer — never auto-resolved.
// Exit code is always 0 (drift is a warning, not a hard failure).
//
// Usage:
// node scripts/sync/check-drift.mjs [--json] [--repo OWNER/REPO] [--against REF]
//
// Testing hook: if PAPERCLIP_SYNC_FIXTURE_DIR is set, all `gh api` calls are
// redirected to read JSON fixtures from that directory:
// - contents:
/contents--[.json (slug = path with / → __)
// fixture body: { status: 200|404, content_base64?: "..." }
// (or {} for 200 with no body needed; or a string for the SHA)
// - repo info: /repo.json (contents: { "default_branch": "master" })
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SELF_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(SELF_DIR, "../..");
const DOCS = join(ROOT, "docs");
const CACHE_ROOT = process.env.PAPERCLIP_SYNC_CACHE_DIR || "/tmp/paperclip-sync";
const FIXTURE_DIR = process.env.PAPERCLIP_SYNC_FIXTURE_DIR || null;
// Caching is normally off in fixture mode (fixtures are already deterministic
// per ref, so there's nothing to save). Tests that need to exercise the
// cache-key path opt in explicitly by setting PAPERCLIP_SYNC_CACHE_DIR.
const CACHE_ENABLED = !FIXTURE_DIR || !!process.env.PAPERCLIP_SYNC_CACHE_DIR;
// --- gh wrapper -------------------------------------------------------------
function pathSlug(p) {
return p.replace(/[/]/g, "__");
}
function ghContents(repo, path, ref) {
// Returns { status: 200|404, content?: }.
if (FIXTURE_DIR) {
const file = join(FIXTURE_DIR, `contents-${pathSlug(path)}-${ref}.json`);
if (!existsSync(file)) {
// Treat missing fixture as 404 to keep tests succinct.
return { status: 404 };
}
const body = JSON.parse(readFileSync(file, "utf8"));
if (body && typeof body === "object") {
if (body.status === 404) return { status: 404 };
if (typeof body.content_base64 === "string") {
return {
status: 200,
content: Buffer.from(body.content_base64, "base64").toString("utf8"),
};
}
if (typeof body.content === "string") {
return { status: 200, content: body.content };
}
return { status: 200 };
}
return { status: 200 };
}
// Live: use gh api with -i to capture the status code in the headers.
const apiPath = `repos/${repo}/contents/${path}?ref=${ref}`;
const r = spawnSync("gh", ["api", "-i", apiPath], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (r.error && r.error.code === "ENOENT") {
process.stderr.write("error: `gh` CLI not found on PATH.\n");
process.exit(2);
}
// gh api -i returns exit 0 for 2xx and non-zero for 4xx/5xx but still emits headers + body.
const out = (r.stdout || "") + (r.stderr || "");
const statusMatch = out.match(/^HTTP\/[\d.]+\s+(\d+)/m);
const status = statusMatch ? Number(statusMatch[1]) : null;
if (status === 404) return { status: 404 };
if (status && status >= 200 && status < 300) {
// Body is JSON: { content: "", encoding: "base64", ... }
// Find the JSON body after headers (blank line separator).
const sepIdx = out.indexOf("\r\n\r\n") >= 0 ? out.indexOf("\r\n\r\n") + 4 : out.indexOf("\n\n") + 2;
const body = sepIdx > 1 ? out.slice(sepIdx) : out;
try {
const parsed = JSON.parse(body);
if (parsed && typeof parsed.content === "string") {
const decoded = Buffer.from(parsed.content, "base64").toString("utf8");
return { status: 200, content: decoded };
}
// Directory listing — return status only.
return { status: 200, list: parsed };
} catch {
return { status: 200 };
}
}
if (status && status >= 400) {
// Authentication / rate limit: bail out loudly.
if (status === 401 || status === 403) {
process.stderr.write(`error: gh api ${apiPath} returned ${status}. Check auth / rate limit.\n`);
process.exit(2);
}
return { status };
}
// Could not parse status; fall back to exit code: 0 → assume 200.
if (r.status === 0) {
try {
const parsed = JSON.parse(out);
if (parsed && typeof parsed.content === "string") {
return { status: 200, content: Buffer.from(parsed.content, "base64").toString("utf8") };
}
return { status: 200 };
} catch {
return { status: 200 };
}
}
return { status: 404 };
}
function ghDefaultBranch(repo) {
if (FIXTURE_DIR) {
const file = join(FIXTURE_DIR, "repo.json");
if (existsSync(file)) {
const body = JSON.parse(readFileSync(file, "utf8"));
return body.default_branch || "master";
}
return "master";
}
const r = spawnSync("gh", ["api", `repos/${repo}`, "-q", ".default_branch"], { encoding: "utf8" });
if (r.status !== 0) {
process.stderr.write(`error: gh api repos/${repo} failed: ${(r.stderr || "").trim()}\n`);
process.exit(2);
}
return r.stdout.trim() || "master";
}
function ghResolveSha(repo, ref) {
// Resolve a ref (branch, tag, or SHA) to a full immutable commit SHA.
// Returns null if it cannot be resolved (caller fails closed on null).
if (FIXTURE_DIR) {
// Fixture stub: _fixtures/commit-][.json → { "sha": "" }.
// When absent, the ref is returned as-is — this keeps ref-keyed fixtures
// working and means tests that don't care about SHA resolution are
// unaffected by pinning.
const file = join(FIXTURE_DIR, `commit-${ref}.json`);
if (existsSync(file)) {
try {
const body = JSON.parse(readFileSync(file, "utf8"));
if (body && typeof body.sha === "string" && body.sha) return body.sha;
} catch {
// fall through to returning the ref unchanged
}
}
return ref;
}
const r = spawnSync("gh", ["api", `repos/${repo}/commits/${ref}`, "-q", ".sha"], { encoding: "utf8" });
if (r.status !== 0) return null;
const sha = r.stdout.trim();
return sha || null;
}
function ghTreeFiles(repo, ref) {
if (FIXTURE_DIR) {
const file = join(FIXTURE_DIR, `tree-${ref}.json`);
if (!existsSync(file)) return null;
const body = JSON.parse(readFileSync(file, "utf8"));
if (Array.isArray(body)) return body;
if (body && Array.isArray(body.tree)) {
return body.tree.filter((item) => item.type === "blob").map((item) => item.path);
}
return null;
}
const r = spawnSync("gh", ["api", `repos/${repo}/git/trees/${ref}?recursive=1`], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (r.error && r.error.code === "ENOENT") {
process.stderr.write("error: `gh` CLI not found on PATH.\n");
process.exit(2);
}
if (r.status !== 0) {
return null;
}
try {
const body = JSON.parse(r.stdout);
if (!Array.isArray(body.tree)) return null;
return body.tree.filter((item) => item.type === "blob").map((item) => item.path);
} catch {
return null;
}
}
// --- caching ---------------------------------------------------------------
function cacheGet(refSlug, key) {
if (!CACHE_ENABLED) return null;
const dir = join(CACHE_ROOT, `drift-${refSlug}`);
const file = join(dir, `${pathSlug(key)}.json`);
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function cacheSet(refSlug, key, value) {
if (!CACHE_ENABLED) return;
const dir = join(CACHE_ROOT, `drift-${refSlug}`);
try {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${pathSlug(key)}.json`), JSON.stringify(value));
} catch {
// non-fatal
}
}
function cachedContents(repo, path, ref, refSlug) {
const cached = cacheGet(refSlug, `contents:${path}`);
if (cached) return cached;
const result = ghContents(repo, path, ref);
cacheSet(refSlug, `contents:${path}`, result);
return result;
}
// --- docs walk -------------------------------------------------------------
function walk(dir, acc = []) {
if (!existsSync(dir)) return acc;
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) walk(p, acc);
else if (entry.endsWith(".md")) acc.push(p);
}
return acc;
}
function locate(content, needle) {
// Return 1-based line number of first occurrence of needle in content, or null.
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(needle)) return i + 1;
}
return null;
}
function lineOfOffset(content, offset) {
let line = 1;
for (let i = 0; i < offset && i < content.length; i++) {
if (content[i] === "\n") line++;
}
return line;
}
function frontmatterEnd(content) {
// If the file opens with a YAML frontmatter block (--- ... ---), return the
// character offset just past the closing delimiter; otherwise 0. Prose inside
// seo_title / seo_description regularly contains phrases like "the single
// paperclipai binary" or "wiring paperclipai into a script" that the CLI and
// parent-path scanners would otherwise misread as command invocations or file
// references. Skipping the frontmatter region (rather than stripping it) keeps
// the 1-based line numbers of real, body-text hits intact.
if (!content.startsWith("---")) return 0;
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
return m ? m[0].length : 0;
}
// --- Class 1: parent file paths --------------------------------------------
const PARENT_PATH_RE = /\b((?:cli\/src|server\/src|skills\/paperclip|packages\/[a-z0-9-]+(?:\/[a-z0-9-]+)?)\/[A-Za-z0-9_./-]+\.(?:ts|mjs|js))\b/g;
function collectParentPaths(docFiles) {
// Map }>
const refs = new Map();
for (const file of docFiles) {
const content = readFileSync(file, "utf8");
const fmEnd = frontmatterEnd(content);
PARENT_PATH_RE.lastIndex = 0;
let m;
while ((m = PARENT_PATH_RE.exec(content))) {
if (m.index < fmEnd) continue; // skip seo_title / seo_description prose
const p = m[1];
const line = locate(content, p);
if (!refs.has(p)) refs.set(p, { docs: [] });
// Only add the first hit per doc file for this path (dedupe).
const entry = refs.get(p);
if (!entry.docs.some((d) => d.file === file)) {
entry.docs.push({ file, line });
}
}
}
return refs;
}
// --- Class 2: CLI commands -------------------------------------------------
const CLI_INVOCATION_RE = /(?:^|\s)(?:pnpm\s+)?paperclipai\s+([a-z][a-z-]*)(?:\s+([a-z][a-z-]*))?/gm;
// Common subcommand directories in cli/src/commands to consult.
const CLI_CMD_DIRS = ["cli/src/commands", "cli/src/commands/client"];
// Words that follow `paperclipai` but are flags or non-commands.
const CLI_FALSE_POSITIVES = new Set(["--help", "--version", "-V", "-h", "-v"]);
function collectCliCommands(docFiles) {
const refs = new Map();
for (const file of docFiles) {
if (!file.includes("/docs/reference/cli/")) continue;
const content = readFileSync(file, "utf8");
const fmEnd = frontmatterEnd(content);
CLI_INVOCATION_RE.lastIndex = 0;
let m;
while ((m = CLI_INVOCATION_RE.exec(content))) {
if (m.index < fmEnd) continue; // skip seo_title / seo_description prose
const head = m[1];
const sub = m[2];
if (CLI_FALSE_POSITIVES.has(head)) continue;
// We treat the top-level token as the documented command name. Subcommands
// are also valuable, but the parent file layout is one file per top-level
// group (e.g. cli/src/commands/secrets.ts containing all `secrets `).
const key = head;
const line = locate(content, m[0].trim());
if (!refs.has(key)) refs.set(key, { docs: [], subcommands: new Set() });
const entry = refs.get(key);
if (sub) entry.subcommands.add(sub);
if (!entry.docs.some((d) => d.file === file)) {
entry.docs.push({ file, line });
}
}
}
return refs;
}
function fetchCliCommandFiles(repo, ref, refSlug) {
// Returns Array<{ path, content }> for every .ts file under CLI_CMD_DIRS.
const acc = [];
const seen = new Set();
function visit(dir) {
if (seen.has(dir)) return;
seen.add(dir);
const dirRes = cachedContents(repo, dir, ref, refSlug);
if (dirRes.status !== 200 || !Array.isArray(dirRes.list)) return;
for (const item of dirRes.list) {
if (item.type === "file" && item.name.endsWith(".ts")) {
const fileRes = cachedContents(repo, item.path, ref, refSlug);
if (fileRes.status === 200 && typeof fileRes.content === "string") {
acc.push({ path: item.path, content: fileRes.content });
}
} else if (item.type === "dir") {
visit(item.path);
}
}
}
for (const dir of CLI_CMD_DIRS) visit(dir);
// Also include cli/src/index.ts — top-level Commander registrations often
// live here, and the file lists every command name as a literal string.
const indexRes = cachedContents(repo, "cli/src/index.ts", ref, refSlug);
if (indexRes.status === 200 && typeof indexRes.content === "string") {
acc.push({ path: "cli/src/index.ts", content: indexRes.content });
}
return acc;
}
function cliCommandIsDefined(name, files) {
// Primary signal: a Commander `.command(""...)` registration.
const re1 = new RegExp(`\\.command\\(["']${escapeRegex(name)}\\b`);
let matches = 0;
for (const f of files) {
if (re1.test(f.content)) matches++;
}
if (matches > 0) return matches;
// Fallback: parent often defines each command in its own file and wires it
// up in cli/src/index.ts. If we see commands/.ts (or
// commands/-<...>.ts where the leading token equals ) treat it
// as defined. This avoids false positives while accepting the layout we see
// in practice (e.g. onboard.ts, run.ts, configure.ts).
for (const f of files) {
const base = f.path.split("/").pop().replace(/\.ts$/, "");
if (base === name) return 1;
}
return 0;
}
// --- Class 3: env vars -----------------------------------------------------
const ENV_VAR_ROW_RE = /^\|\s*`([A-Z_][A-Z0-9_]*)`/gm;
function collectEnvVars(docFiles) {
// The watcher targets exactly one file.
const target = docFiles.find((f) => f.endsWith("docs/reference/deploy/environment-variables.md"));
if (!target) return new Map();
const refs = new Map();
const content = readFileSync(target, "utf8");
ENV_VAR_ROW_RE.lastIndex = 0;
let m;
while ((m = ENV_VAR_ROW_RE.exec(content))) {
const name = m[1];
if (refs.has(name)) continue;
const line = locate(content, m[0].trim());
refs.set(name, { docs: [{ file: target, line }] });
}
return refs;
}
function envVarSourcesToCheck() {
const sources = [".env.example", "server/src/config.ts"];
// Pull packages config files from anchor-map watchers if static.
try {
const anchor = JSON.parse(readFileSync(join(SELF_DIR, "anchor-map.json"), "utf8"));
const envWatcher = (anchor.watchers || []).find((w) => w.name === "env-vars");
if (envWatcher) {
for (const p of envWatcher.parent_paths || []) {
if (!p.includes("*") && !sources.includes(p)) sources.push(p);
}
}
} catch {
// ignore
}
return sources;
}
function shouldFetchEnvSource(path) {
if (path === ".env.example") return true;
if (path.includes("/__tests__/") || /\.(?:test|spec)\./.test(path)) return false;
if (!/\.(?:ts|tsx|js|mjs|json|md)$/.test(path)) return false;
if (!/^(cli\/src|server\/src|packages|ui\/src)\//.test(path)) return false;
const base = path.split("/").pop();
// Scan every non-test server/src TypeScript file. Server feature files read
// process.env directly all over the place (e.g. TRUST_PROXY in app.ts, the
// OTEL_* vars in instrumentation.ts, request middleware under middleware/),
// so any narrower server allowlist leaves blind spots that surface as
// env-var-missing false positives. Tests are already excluded above.
if (/^server\/src\/.+\.ts$/.test(path)) return true;
if (/^cli\/src\/config\/.+\.ts$/.test(path)) return true;
if (/^cli\/src\/checks\/.+(?:auth|config|secret|env).+\.ts$/.test(path)) return true;
if (/^cli\/src\/commands\/(?:env|env-lab|configure|client\/secrets|client\/auth)\.ts$/.test(path)) return true;
if (path.startsWith("packages/plugins/sandbox-providers/") && /(^|\/)(README\.md|manifest\.ts|config\.ts|plugin\.ts|worker\.ts)$/.test(path)) return true;
// Adapter packages read env vars (e.g. SANDBOX_INSTALL_COMMAND, per-runtime
// API-key fallbacks) directly in their server/index modules — scan all
// non-test adapter package source, same reasoning as server/src above.
if (/^packages\/adapters\/[^/]+\/src\/.+\.ts$/.test(path)) return true;
if (/^packages\/adapter-utils\/src\/(?:.*env.*|execution-target.*|sandbox.*|remote.*|workspace.*)\.ts$/.test(path)) return true;
if (/^packages\/shared\/src\/.*(?:config|environment|secret|workspace|runtime).*\.ts$/.test(path)) return true;
if (/^packages\/[^/]+\/src\/(?:env|config|runtime-config|worktree-config)\.ts$/.test(path)) return true;
return /^(?:env|config|manifest|runtime-config|worktree-config)\.(?:ts|tsx|js|mjs|json|md)$/.test(base);
}
function envVarSourcesToCheckFromTree(repo, ref, refSlug) {
const tree = ghTreeFiles(repo, ref);
if (!tree) return envVarSourcesToCheck();
const sources = new Set(envVarSourcesToCheck());
for (const p of tree) {
if (shouldFetchEnvSource(p)) sources.add(p);
}
// Keep source fetching bounded if the parent tree grows unexpectedly.
return [...sources].slice(0, 700);
}
function envVarPresent(name, contentsByPath) {
if (isExternallySuppliedEnvVar(name) || isRuntimeInjectedEnvVar(name)) return true;
const envExample = contentsByPath[".env.example"];
if (envExample && new RegExp(`^${name}\\b`, "m").test(envExample)) return true;
for (const [path, content] of Object.entries(contentsByPath)) {
if (path === ".env.example") continue;
if (content && content.includes(name)) return true;
}
return false;
}
function isExternallySuppliedEnvVar(name) {
return /^(?:ANTHROPIC|OPENAI|GEMINI|GOOGLE|DAYTONA|EXE|E2B|CURSOR|XAI|GITHUB|SLACK|DISCORD)_[A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)$/.test(name);
}
function isRuntimeInjectedEnvVar(name) {
return new Set([
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_API_URL",
"PAPERCLIP_API_KEY",
"PAPERCLIP_RUN_ID",
"PAPERCLIP_TASK_ID",
"PAPERCLIP_WAKE_REASON",
"PAPERCLIP_WAKE_COMMENT_ID",
"PAPERCLIP_WAKE_PAYLOAD_JSON",
"PAPERCLIP_APPROVAL_ID",
"PAPERCLIP_APPROVAL_STATUS",
"PAPERCLIP_LINKED_ISSUE_IDS",
"PAPERCLIP_WORKSPACE_CWD",
"PAPERCLIP_WORKSPACE_PATH",
"PAPERCLIP_WORKSPACE_REPO_ROOT",
"PAPERCLIP_WORKSPACE_BRANCH",
"PAPERCLIP_PROJECT_ID",
"PAPERCLIP_ISSUE_ID",
]).has(name);
}
// --- Class 4: REST routes --------------------------------------------------
const REST_ROUTE_RE = /(?:^|[\s|`])((GET|POST|PUT|PATCH|DELETE)\s+(\/api\/[^\s`|)\]]+))/gm;
function normalizeRoute(path) {
// Strip querystring and trailing punctuation.
let p = path.split("?")[0].split("#")[0];
p = p.replace(/[.,;:`)\]]+$/g, "");
// Normalize {foo} and :foo to :id (placeholder match).
p = p.replace(/\{[^}]+\}/g, ":id");
p = p.replace(/:[A-Za-z][A-Za-z0-9_]*/g, ":id");
return p;
}
function surfaceFromDocPath(file) {
// docs/reference/api/companies.md → companies
const m = file.match(/docs\/reference\/api\/([^/]+)\.md$/);
return m ? m[1] : null;
}
function collectRestRoutes(docFiles) {
// Map>
const perDoc = new Map();
for (const file of docFiles) {
if (!/\/docs\/reference\/api\/[^/]+\.md$/.test(file)) continue;
if (file.endsWith("overview.md") || file.endsWith("authentication.md")) continue;
const content = readFileSync(file, "utf8");
REST_ROUTE_RE.lastIndex = 0;
let m;
const seen = new Set();
const routes = [];
while ((m = REST_ROUTE_RE.exec(content))) {
const method = m[2];
const rawPath = m[3].replace(/[.,;:`)\]]+$/g, "");
const normalized = normalizeRoute(rawPath);
const key = `${method} ${normalized}`;
if (seen.has(key)) continue;
seen.add(key);
const line = lineOfOffset(content, m.index + m[0].indexOf(m[1]));
routes.push({ method, path: rawPath, normalized, line });
}
if (routes.length) perDoc.set(file, routes);
}
return perDoc;
}
function routeIsDefined(method, normalized, routeFileContent, surface = null) {
if (!routeFileContent) return false;
const m = method.toLowerCase();
// Build candidate matchers. Parent route files often mount with a path
// prefix applied externally (e.g. app.use("/api/companies", router)), so the
// strings inside the file are typically relative to that prefix.
const candidates = new Set();
candidates.add(normalized);
if (normalized.startsWith("/api/")) candidates.add(normalized.slice(4)); // /api/foo → /foo
if (normalized.startsWith("/api")) candidates.add(normalized.slice(4));
const withoutApi = normalized.replace(/^\/api/, "");
candidates.add(withoutApi);
// Strip the surface segment too (most common mount).
if (surface) {
const surfacePrefix = `/api/${surface}`;
if (normalized === surfacePrefix) {
candidates.add("/");
} else if (normalized.startsWith(`${surfacePrefix}/`)) {
candidates.add(normalized.slice(surfacePrefix.length)); // → /:id/logo
}
// Also strip /api/ singular form (e.g. /api/issues/{issueId} mounted under /issues
// but sometimes other plurals exist — keep generic).
}
// Some routes are documented under one surface doc but mounted on a sibling
// path (e.g. /api/goals/{goalId} living in routes/goals-and-projects.ts but
// mounted at "/api/goals"). For each "/api//..." prefix the route
// itself starts with, also add the variant with that stripped.
const apiSegMatch = normalized.match(/^\/api\/([a-zA-Z0-9_-]+)(\/.*)?$/);
if (apiSegMatch) {
const rest = apiSegMatch[2] || "/";
candidates.add(rest);
}
// Method patterns: app.get("..."), router.get('...'), fastify .route({ method: 'GET', url: '...' })
for (const cand of candidates) {
// Build variants: with :id and with {id}-style we already normalized to :id; check both forms.
const variants = new Set([cand, cand.replace(/:id/g, "{id}")]);
// Also try without trailing slash.
for (const v of [...variants]) variants.add(v.replace(/\/$/, ""));
for (const v of variants) {
if (!v) continue;
// Build a regex pattern from the candidate that treats :id placeholders
// as "any param name" (so a doc's :id can match a parent's :companyId).
// Also tolerate the {param} brace form on the parent side.
const pattern = candidateToPattern(v);
// Match the candidate as a complete quoted path (anchored to quote, and
// ended by quote or a query/anchor character) to avoid substring hits.
const reMethod = new RegExp(
`\\.${m}\\(\\s*["'\`]${pattern}(?:[?#]|["'\`])`,
"i"
);
if (reMethod.test(routeFileContent)) return true;
const reFastify = new RegExp(
`method\\s*:\\s*["'\`]${method}["'\`][^}]*url\\s*:\\s*["'\`]${pattern}(?:[?#]|["'\`])`,
"is"
);
if (reFastify.test(routeFileContent)) return true;
const reFastifyRev = new RegExp(
`url\\s*:\\s*["'\`]${pattern}(?:[?#]|["'\`])[^}]*method\\s*:\\s*["'\`]${method}["'\`]`,
"is"
);
if (reFastifyRev.test(routeFileContent)) return true;
}
}
return false;
}
function fetchRouteFiles(repo, ref, refSlug) {
const acc = [];
const seen = new Set();
const tree = ghTreeFiles(repo, ref);
if (tree) {
for (const p of tree) {
if (!/^server\/src\/routes\/.+\.ts$/.test(p)) continue;
const res = cachedContents(repo, p, ref, refSlug);
if (res.status === 200 && typeof res.content === "string") {
acc.push({ path: p, content: res.content });
}
}
return acc;
}
function visit(dir) {
if (seen.has(dir)) return;
seen.add(dir);
const dirRes = cachedContents(repo, dir, ref, refSlug);
if (dirRes.status !== 200 || !Array.isArray(dirRes.list)) return;
for (const item of dirRes.list) {
if (item.type === "file" && item.name.endsWith(".ts")) {
const fileRes = cachedContents(repo, item.path, ref, refSlug);
if (fileRes.status === 200 && typeof fileRes.content === "string") {
acc.push({ path: item.path, content: fileRes.content });
}
} else if (item.type === "dir") {
visit(item.path);
}
}
}
visit("server/src/routes");
return acc;
}
function routeIsDefinedInAnyFile(method, normalized, routeFiles, surface) {
for (const file of routeFiles) {
if (routeIsDefined(method, normalized, file.content, surface)) return file.path;
}
return null;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Build a regex pattern that matches the candidate path with placeholder
// tolerance: each ":id" or "{id}" segment in the candidate becomes a pattern
// that matches ANY param style on the parent — either `:` or `{}`.
function candidateToPattern(v) {
// Split on :id / {id} tokens, escape each literal piece, join with the
// param-style pattern.
const PARAM_TOKEN = /(:id|\{id\})/g;
const parts = v.split(PARAM_TOKEN);
let out = "";
for (const part of parts) {
if (part === ":id" || part === "{id}") {
out += `(?::[A-Za-z][A-Za-z0-9_]*|\\{[A-Za-z][A-Za-z0-9_]*\\})`;
} else {
out += escapeRegex(part);
}
}
return out;
}
// --- argparse --------------------------------------------------------------
// --- permission catalog & role-default grants ------------------------------
//
// The human permission model is documented in two mirrored tables in
// docs/administration/roles-and-permissions.md. Their source of truth is:
// - packages/shared/src/constants.ts → PERMISSION_KEYS (the catalog)
// - server/src/services/company-member-roles.ts → grantsForHumanRole() (role defaults)
// This class parses both sides and flags keys/role-grants that drifted apart.
const PERMISSION_CONSTANTS_PATH = "packages/shared/src/constants.ts";
const ROLE_GRANTS_PATH = "server/src/services/company-member-roles.ts";
const PERMISSION_DOC_REL = "administration/roles-and-permissions.md";
const KEY_RE = "[a-z0-9_]+:[a-z0-9_]+";
const HUMAN_ROLES = ["owner", "admin", "operator", "viewer"];
/** Extract the PERMISSION_KEYS string-literal array from constants.ts. */
function parseParentPermissionKeys(content) {
const m = content.match(/PERMISSION_KEYS\s*=\s*\[([\s\S]*?)\]/);
if (!m) return [];
const re = new RegExp(`["'](${KEY_RE})["']`, "g");
return [...m[1].matchAll(re)].map((x) => x[1]);
}
/** Extract role → Set(permissionKey) from grantsForHumanRole()'s switch. */
function parseParentRoleGrants(content) {
const map = new Map();
const startIdx = content.indexOf("grantsForHumanRole");
if (startIdx === -1) return map;
let endIdx = content.indexOf("\nexport ", startIdx + 1);
if (endIdx === -1) endIdx = Math.min(content.length, startIdx + 4000);
const body = content.slice(startIdx, endIdx);
const keyRe = new RegExp(`permissionKey\\s*:\\s*["'](${KEY_RE})["']`, "g");
// split() drops the delimiter, so each chunk holds exactly one case's body.
for (const chunk of body.split(/case\s+["']/).slice(1)) {
const rm = chunk.match(/^([a-z_]+)["']/);
if (!rm) continue;
map.set(rm[1], new Set([...chunk.matchAll(keyRe)].map((x) => x[1])));
}
return map;
}
/** Keys documented in the "permission keys" table (rows whose 1st cell is a key). */
function parseDocPermissionKeys(md) {
const re = new RegExp("^\\|\\s*`(" + KEY_RE + ")`\\s*\\|", "gm");
return new Set([...md.matchAll(re)].map((x) => x[1]));
}
/** Role → Set(key) from the four-roles table (rows whose 1st cell is **Role**). */
function parseDocRoleGrants(md) {
const map = new Map();
const rowRe = /^\|\s*\*\*([A-Za-z]+)\*\*\s*\|(.*)$/gm;
const keyRe = new RegExp("`(" + KEY_RE + ")`", "g");
let m;
while ((m = rowRe.exec(md))) {
const role = m[1].toLowerCase();
if (!HUMAN_ROLES.includes(role)) continue;
map.set(role, new Set([...m[2].matchAll(keyRe)].map((x) => x[1])));
}
return map;
}
function parseArgs(argv) {
const out = { json: false, repo: null, against: null, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--json") out.json = true;
else if (a === "--repo") out.repo = argv[++i];
else if (a === "--against") out.against = argv[++i];
else if (a === "-h" || a === "--help") out.help = true;
}
return out;
}
function readDefaultRepo() {
try {
const anchor = JSON.parse(readFileSync(join(SELF_DIR, "anchor-map.json"), "utf8"));
if (anchor.parent_repo) return anchor.parent_repo;
} catch {
// ignore
}
return "paperclipai/paperclip";
}
function readSyncStateRef() {
// Mode-aware ref from .sync-state.json (the sync flow's own state file):
// - release mode → the immutable release SHA/tag whose surface these docs ship
// - nightly mode → the quarantine-settled parent SHA we last synced against
// This keeps the drift check pinned to the same ref the sync flow operated on,
// instead of the moving default branch. Returns null when the file is
// absent/unreadable or carries no usable ref (caller then falls back to the
// default branch).
try {
const state = JSON.parse(readFileSync(join(ROOT, ".sync-state.json"), "utf8"));
if (state.branch_mode === "release") {
return state.base_release_sha || state.base_release_tag || null;
}
return state.last_seen_parent_sha || null;
} catch {
return null;
}
}
function shortRef(ref) {
return /^[0-9a-f]{7,40}$/i.test(ref) ? ref.slice(0, 7) : ref;
}
function usage() {
return `usage: check-drift.mjs [--json] [--repo OWNER/REPO] [--against REF]\n`;
}
// --- main ------------------------------------------------------------------
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(usage());
process.exit(0);
}
const repo = args.repo || readDefaultRepo();
// Determine the ref to check against, then pin it to an immutable commit SHA
// BEFORE any reads. Precedence:
// 1. --against (explicit override)
// 2. .sync-state.json (mode-aware immutable ref)
// 3. the parent repo's default branch (moving)
// Whatever we land on is resolved to a full SHA and used as BOTH the fetch
// ref and the cache key. Caching by a moving name (e.g. "master") is a
// correctness bug: a stale entry from an earlier commit is reused forever
// while the SHA printed in the header advances, so the report claims to have
// checked today's commit using days-old file contents.
const requestedRef = args.against || readSyncStateRef() || ghDefaultBranch(repo);
const resolvedSha = ghResolveSha(repo, requestedRef);
if (!resolvedSha) {
process.stderr.write(
`error: could not resolve "${requestedRef}" to a commit SHA in ${repo}. ` +
`Refusing to run against an unresolvable ref (would risk false-clean results).\n`
);
process.exit(2);
}
const against = resolvedSha;
const refSlug = pathSlug(resolvedSha);
const checkedAgainst =
requestedRef === resolvedSha ? shortRef(resolvedSha) : `${requestedRef} (${shortRef(resolvedSha)})`;
const docFiles = walk(DOCS);
const drift = [];
const stats = {
parent_paths_checked: 0,
cli_commands_checked: 0,
env_vars_checked: 0,
rest_routes_checked: 0,
permission_keys_checked: 0,
permission_roles_checked: 0,
};
// Class 1: parent paths.
const parentPathRefs = collectParentPaths(docFiles);
stats.parent_paths_checked = parentPathRefs.size;
for (const [path, entry] of parentPathRefs) {
const res = cachedContents(repo, path, against, refSlug);
if (res.status === 404) {
for (const d of entry.docs) {
drift.push({
kind: "parent-path-missing",
doc: `${relative(ROOT, d.file)}${d.line ? `:${d.line}` : ""}`,
documented: path,
parent_searched: `${path}@${against}`,
confidence: "high",
suggest: "Verify the file was removed (not renamed). If renamed, update the doc reference; if removed, delete or rewrite the doc section.",
});
}
}
}
// Class 2: CLI commands.
const cliRefs = collectCliCommands(docFiles);
stats.cli_commands_checked = cliRefs.size;
if (cliRefs.size > 0) {
const cliFiles = fetchCliCommandFiles(repo, against, refSlug);
for (const [name, entry] of cliRefs) {
const matches = cliCommandIsDefined(name, cliFiles);
if (matches === 0) {
for (const d of entry.docs) {
drift.push({
kind: "cli-command-missing",
doc: `${relative(ROOT, d.file)}${d.line ? `:${d.line}` : ""}`,
documented: `paperclipai ${name}`,
parent_searched: `${CLI_CMD_DIRS.join(", ")}@${against}`,
confidence: "high",
suggest: "Search the parent CLI for the command. It may have been renamed or moved to a different group.",
});
}
}
}
}
// Class 3: env vars.
const envRefs = collectEnvVars(docFiles);
stats.env_vars_checked = envRefs.size;
if (envRefs.size > 0) {
const sources = envVarSourcesToCheckFromTree(repo, against, refSlug);
const contentsByPath = {};
for (const s of sources) {
if (s.includes("*")) continue; // skip globs; handled by watcher in live sync
const res = cachedContents(repo, s, against, refSlug);
if (res.status === 200 && typeof res.content === "string") {
contentsByPath[s] = res.content;
}
}
for (const [name, entry] of envRefs) {
if (!envVarPresent(name, contentsByPath)) {
for (const d of entry.docs) {
drift.push({
kind: "env-var-missing",
doc: `${relative(ROOT, d.file)}${d.line ? `:${d.line}` : ""}`,
documented: name,
parent_searched: `${Object.keys(contentsByPath).join(", ")}@${against}`,
confidence: "high",
suggest: "Confirm the env var still exists upstream. If removed, drop the row; if renamed, update the name and surrounding prose.",
});
}
}
}
}
// Class 4: REST routes.
const routesPerDoc = collectRestRoutes(docFiles);
let totalRoutes = 0;
for (const arr of routesPerDoc.values()) totalRoutes += arr.length;
stats.rest_routes_checked = totalRoutes;
const allRouteFiles = totalRoutes > 0 ? fetchRouteFiles(repo, against, refSlug) : [];
for (const [docFile, routes] of routesPerDoc) {
const surface = surfaceFromDocPath(docFile);
if (!surface) continue;
if (allRouteFiles.length > 0) {
for (const r of routes) {
if (!routeIsDefinedInAnyFile(r.method, r.normalized, allRouteFiles, surface)) {
drift.push({
kind: "rest-route-missing",
doc: `${relative(ROOT, docFile)}${r.line ? `:${r.line}` : ""}`,
documented: `${r.method} ${r.path}`,
parent_searched: `server/src/routes/**/*.ts@${against}`,
confidence: "medium",
suggest: "Verify the route was removed (not moved). If removed, delete the doc section.",
});
}
}
continue;
}
const routeFilePath = `server/src/routes/${surface}.ts`;
const res = cachedContents(repo, routeFilePath, against, refSlug);
if (res.status === 404) {
// Surface file is gone — every documented route is drift.
for (const r of routes) {
drift.push({
kind: "rest-route-missing",
doc: `${relative(ROOT, docFile)}${r.line ? `:${r.line}` : ""}`,
documented: `${r.method} ${r.path}`,
parent_searched: `${routeFilePath}@${against}`,
confidence: "medium",
suggest: "Route surface file is gone from parent. Verify the routes moved (then update the doc) or were removed (then delete the doc section).",
});
}
continue;
}
if (res.status !== 200 || typeof res.content !== "string") continue;
for (const r of routes) {
if (!routeIsDefined(r.method, r.normalized, res.content, surface)) {
drift.push({
kind: "rest-route-missing",
doc: `${relative(ROOT, docFile)}${r.line ? `:${r.line}` : ""}`,
documented: `${r.method} ${r.path}`,
parent_searched: `${routeFilePath}@${against}`,
confidence: "medium",
suggest: "Verify the route was removed (not moved). If removed, delete the doc section.",
});
}
}
}
// Class 5: permission catalog & role-default grants.
const permDocPath = join(DOCS, PERMISSION_DOC_REL);
if (existsSync(permDocPath)) {
const md = readFileSync(permDocPath, "utf8");
const docRel = relative(ROOT, permDocPath);
// 5a. Permission-key catalog: PERMISSION_KEYS vs the doc's key table.
const constRes = cachedContents(repo, PERMISSION_CONSTANTS_PATH, against, refSlug);
if (constRes.status === 200 && typeof constRes.content === "string") {
const parentKeys = parseParentPermissionKeys(constRes.content);
if (parentKeys.length > 0) {
stats.permission_keys_checked = parentKeys.length;
const parentSet = new Set(parentKeys);
const docKeys = parseDocPermissionKeys(md);
for (const k of parentKeys) {
if (!docKeys.has(k)) {
drift.push({
kind: "permission-catalog-drift",
doc: docRel,
documented: `permission key \`${k}\` — in parent, undocumented`,
parent_searched: `PERMISSION_KEYS in ${PERMISSION_CONSTANTS_PATH}@${against}`,
confidence: "high",
suggest: "Add the new key to the permission-keys table in roles-and-permissions.md and the grant list in company.md.",
});
}
}
for (const k of docKeys) {
if (!parentSet.has(k)) {
drift.push({
kind: "permission-catalog-drift",
doc: docRel,
documented: `permission key \`${k}\` — documented, absent from parent`,
parent_searched: `PERMISSION_KEYS in ${PERMISSION_CONSTANTS_PATH}@${against}`,
confidence: "high",
suggest: "The key was removed or renamed upstream. Update or drop its rows in roles-and-permissions.md and company.md.",
});
}
}
}
}
// 5b. Role-default grants: grantsForHumanRole() vs the doc's four-roles table.
const rolesRes = cachedContents(repo, ROLE_GRANTS_PATH, against, refSlug);
if (rolesRes.status === 200 && typeof rolesRes.content === "string") {
const parentRoleGrants = parseParentRoleGrants(rolesRes.content);
if (parentRoleGrants.size > 0) {
stats.permission_roles_checked = parentRoleGrants.size;
const docRoleGrants = parseDocRoleGrants(md);
for (const [role, parentGrantSet] of parentRoleGrants) {
const docGrantSet = docRoleGrants.get(role) || new Set();
const added = [...parentGrantSet].filter((k) => !docGrantSet.has(k));
const removed = [...docGrantSet].filter((k) => !parentGrantSet.has(k));
if (added.length === 0 && removed.length === 0) continue;
const bits = [];
if (added.length) bits.push(`missing in docs: ${added.join(", ")}`);
if (removed.length) bits.push(`stale in docs: ${removed.join(", ")}`);
drift.push({
kind: "permission-catalog-drift",
doc: docRel,
documented: `role \`${role}\` default grants (${bits.join("; ")})`,
parent_searched: `grantsForHumanRole() in ${ROLE_GRANTS_PATH}@${against}`,
confidence: "high",
suggest: "Reconcile the role's implicit-grants cell in roles-and-permissions.md (and its description in company.md) with grantsForHumanRole().",
});
}
}
}
}
if (args.json) {
process.stdout.write(
JSON.stringify({ checked_against: checkedAgainst, stats, drift }, null, 2) + "\n"
);
process.exit(0);
}
// Human-readable output.
const lines = [];
lines.push(`Drift check against ${checkedAgainst}`);
lines.push(
`Checked: ${stats.parent_paths_checked} parent paths, ${stats.cli_commands_checked} CLI commands, ${stats.env_vars_checked} env vars, ${stats.rest_routes_checked} REST routes, ${stats.permission_keys_checked} permission keys, ${stats.permission_roles_checked} roles`
);
if (drift.length === 0) {
lines.push("");
lines.push("OK — no drift detected.");
process.stdout.write(lines.join("\n") + "\n");
process.exit(0);
}
lines.push(`Drift candidates: ${drift.length}`);
const byKind = {};
for (const d of drift) {
if (!byKind[d.kind]) byKind[d.kind] = [];
byKind[d.kind].push(d);
}
for (const kind of Object.keys(byKind).sort()) {
lines.push("");
lines.push(`# ${kind} (${byKind[kind].length})`);
for (const d of byKind[kind]) {
const conf = d.confidence === "medium" ? "Verify: " : "";
lines.push(` ${conf}${d.documented}`);
lines.push(` doc: ${d.doc}`);
lines.push(` searched: ${d.parent_searched}`);
lines.push(` suggest: ${d.suggest}`);
}
}
process.stdout.write(lines.join("\n") + "\n");
process.exit(0);
}
main();
]