mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 18:37:25 -10:00
Release docs sync for **paperclip v2026.824.1** (cumulative window
`v2026.824.0 → v2026.824.1`, 4 commits / 12 files). This patch release
repairs the onboarding background-service flow end to end.
## Change manifest
| Watcher | Parent files | Kind | Docs target |
|---|---|---|---|
| cli-commands | `cli/src/commands/onboard.ts`,
`cli/src/onboard-service.ts` | modified |
`docs/reference/cli/setup-commands.md` |
**`docs/reference/cli/setup-commands.md`** (`onboard` section) — once
the background service is installed, onboarding now hands you off to the
running instance: it waits for the port the service actually bound
(fallback port if the configured one is busy), prints the dashboard URL,
and opens it in your browser on interactive terminals. Headless runs
print the URL; the new **`PAPERCLIP_NO_BROWSER=1`** opts out of the
browser launch. If the service doesn't come up, onboarding points at
`paperclipai service logs` instead of claiming success.
`paperclip_version` bumped to `v2026.824.1`.
**`docs/reference/changelog.md`** — new `v2026.824.1` docs-changelog
entry.
Not doc-worthy this window: `run.ts` runtime-info plumbing, the
`paperclipai doctor` diagnostic-message refinements, and the test files
— no new or now-wrong surface in our docs.
## Verification
- `docs:build` ✓ · `sync:check` ✓ · full `docs:test` (6/6) ✓
- **verify-edit** against `v2026.824.1`: **47 claims verified, 0
unverified, 0 suspicious**. `PAPERCLIP_NO_BROWSER` and `paperclipai
service logs` both confirmed in parent code at the tag.
- **Tag-accuracy (Phase 5.7):** self-contained release — `nightly`
carries no doc drafts (only `.sync-state.json` differs from `main`), so
there are no post-tag leaks to quarantine.
- **Screenshots:** window touched only `cli/**`, no `ui/src/**` —
nothing stale.
## ⚠ Drift
Investigated against `v2026.824.1`. The two previously high-confidence
CLI findings (`paperclipai binary`, `paperclipai into`) were **scanner
false positives** — the drift scanner was reading `paperclipai <word>`
out of `seo_description` frontmatter prose. Fixed in this PR
(`scripts/sync/check-drift.mjs` now skips the YAML frontmatter block);
both disappear.
Remaining (5, medium, `Verify:` — **confirmed correct, no action
needed**):
- `POST /api/companies/import/transfers` ·
`docs/reference/api/companies.md:433`
- `PUT /api/companies/import/transfers/{transferId}/parts/{partIndex}` ·
:434
- `GET /api/companies/import/transfers/{transferId}` · :435
- `POST /api/companies/import/transfers/{transferId}/preview` · :436
- `POST /api/companies/import/transfers/{transferId}/apply` · :437
These routes **exist** at `v2026.824.1`
(`server/src/routes/companies.ts` lines 696/762/825/944/958), registered
via the `COMPANY_IMPORT_TRANSFERS_ROUTE_PATH` constant from
`@paperclipai/shared`. The scanner only matches string-literal paths, so
constant/template-literal registration reads as a false negative — a
known medium-tier limitation. The docs are accurate; nothing to change.
(A follow-up could teach the resolver to follow cross-package path
constants.)
## Checklist
- [x] Cumulative diff from last release tag
- [x] Edits verified against the release tag
- [x] Build + link + SEO + crawlable gates pass
- [x] Drift investigated; false positives fixed at the scanner, false
negatives confirmed correct
- [x] Docs-changelog entry added
- [ ] After merge: tag `docs/v2026.824.1`, realign `nightly`, verify
`docs.paperclip.ing` serves the release
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
1076 lines
41 KiB
JavaScript
1076 lines
41 KiB
JavaScript
#!/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: <dir>/contents-<slug>-<ref>.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: <dir>/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?: <utf8 decoded string> }.
|
|
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: "<base64>", 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-<ref>.json → { "sha": "<full 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<refPath, { docs: Array<{ file, line }> }>
|
|
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 <sub>`).
|
|
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("<name>"...)` 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/<name>.ts (or
|
|
// commands/<name>-<...>.ts where the leading token equals <name>) 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<docFile, Array<{ method, path, normalized, line }>>
|
|
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/<plural> 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/<word>/..." prefix the route
|
|
// itself starts with, also add the variant with that <word> 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 `:<word>` or `{<word>}`.
|
|
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();
|