Files
d627632874 Release docs for paperclip v2026.1106.0 (#172)
Docs for parent release `v2026.1106.0` (`d69631b0`), up from
`v2026.1023.0` (`1b4c8038`): 515 parent commits.

## How this branch was built

The tag is 914 commits behind what `nightly` has drafted, so this branch
isn't cut from `nightly` HEAD. It starts at nightly commit `1952052`
(parent `043f2514`), 19 commits before the tag, then adds:

- the `main` hotfix (#160)
- the sync tooling from `nightly`
- docs for those 19 commits

Later nightly drafts stay on `nightly`, so no pages had to be removed.
No `nightly-draft/*` PRs are open.

## What changed

- **Runner by default:** new Claude, Codex, OpenCode and Pi agents start
on Paperclip Runner on supported hosts, with **Legacy runner** and
**Upgrade runner**.
- **Workspaces:** cleanup of finished workspaces, optional Git
expiration, and restore points for unsaved work. Restore points are off
by default.
- **New pages:** Prompt injection prevention, Google Workspace, GitHub
Copilot, Speko, Braintrust, Daytona, Ditto and Unikraft.
- **Breaking and upgrade notes:** the run history limit, Sentry run
failures off by default and the DSN variables, the Docker start command
for custom images, `--yes` on `instructions-file:delete`, and slower
migrations on large instances.
- **Also covered:** project repositories as skill sources, roster bulk
edit, Artifacts V1 (experimental, off by default), composer dismissal,
`expectedStatusVersion`, `unexecuted_review_stage`, and plugin workspace
providers.
- **Changelog:** a v2026.1106.0 entry in `reference/changelog.md`.

## Older text corrected

These were wrong at the tag, and most are also wrong on the live site:

- which permissions let an agent read a peer's configuration or change
instructions (`api/agents.md`)
- `decideNowCount`, which doesn't exist; the field is `deskBadgeCount`
- `file-resources` routes, which are board-only
- the **Create project** dialog fields, a CEO rule the code doesn't
enforce, and where a terminated agent's reports appear
- `instructions-file:put` and `:delete` examples that always failed
(`cli/agent.md`)
- a curl example that returned `422` (`api/cases.md`)
- Google Workspace **Reconnect** instructions, which claimed it reopens
the service picker
- the `permissionKey` table, which listed 8 of 21 keys
- drafting text at the end of `api/agents.md`

## ⚠ Please check these

- **Product behaviour that may be a parent bug.** The docs now describe
the tag's code, which you may prefer to fix upstream:
- The project **Environment** dropdown saves a choice, but runs ignore
it (`experimental/environments.md`).
- `environments:manage` is granted by roles but no environment route
checks it.
- `suggest-changes` is documented as agent-only, because only agents
have a consent path. Company skill policy decides skill edits.
- **Reader-visible mismatches in the parent UI.** The docs follow the
code. Upstream copy still mentions a "Built-in agents tab", says Copilot
"awaits qualification", and tells people to "Reconnect Google Workspace
to change access".

## ⚠ Post-tag leaks removed

Nothing to remove from an already-drafted page: post-tag surfaces such
as 1Password, Vercel, Supabase deploy, custom gateway onboarding and
Artifacts V1 as default never came onto this branch. Authors left out
post-tag hunks of the next nightly draft, and the reasons are in the run
notes.

## ⚠ Drift

None. Zero findings at the tag, from a full parent snapshot.

## ⚠ Verification Failures

The tag-accuracy gate reports one leak and nine review entries. None is
a real failure:

- **Leak:** `reference/cli/service.md`, `paperclipai payload and`. This
is a false positive; it's prose from a real message at
`cli/src/commands/service.ts:184`.
- **Review, 8 new connector pages:** the gate can't confirm new pages.
Reviewers confirmed each surface exists at the tag.
- **Review, `local_tools`:** an example value from the tag's Runner
README.

Not provable from code, and kept deliberately:

- `sandbox-providers.md` uses `sandbox.yaml`. The tag's README says
`install.yaml`, which returned 404 when checked. The upstream fix is
post-tag.
- The Google Workspace row about older OAuth brokers rests on release
notes and a design doc. The broker isn't in this repo.
- External claims about provider behaviour (for example Braintrust,
Daytona, Unikraft and Google scopes).

## ↻ Renames detected

None. The rename helper wasn't run: the 19-commit delta adds no new
adapter, skill, or plugin directories that it would check.

## Verification

All checks read a checkout pinned to the tag.

- **First round:** authors, a release-notes audit, an audit of
corrections stranded on `nightly`, an audit of 321 lines the last
realign dropped from `main` (about 130 checked directly in code), and
six fresh verifiers.
- **Second round:** seven new verifiers re-checked every changed area,
tracing behaviour rather than matching labels, and fixed about 50 more
defects. That volume means more may remain.
- **Limits of that second round:**
- Each verifier reported what it only string-matched. That includes
parts of Artifacts V1, the **Upgrade runner** notice, Copilot run
behaviour, and some Plugin SDK contract text.
  - The second-round fixes themselves weren't reviewed by a third round.
- **Gates:** `docs:build`, `docs:test` and `sync:check` pass.
- **Nothing was run against a live instance.** All of this comes from
reading code.

## Screenshots

**196 of 278 screenshots are stale and none were recaptured.** The list
is in `SCREENSHOTS_PENDING.md`. Some visibly differ from the text, such
as the removed **Run with provider trace** button and the old roster
tabs.

## After merge

1. Keep the PR title as is: `tag-docs-release.yml` reads the version
from it.
2. Confirm the `docs/v2026.1106.0` tag exists.
3. Run `node scripts/sync/realign-nightly.mjs release/v2026.1106.0
--push`, then audit for dropped lines and duplicated paragraphs, and set
`nightly` state back to `branch_mode: nightly`.
4. Check that `docs.paperclip.ing` serves the release: a new page, a new
claim, and the changelog's top entry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DevRel Gemini <[email protected]>
Co-authored-by: Paperclip <[email protected]>
Co-authored-by: Dotta <[email protected]>
Co-authored-by: ClaudeCoder <[email protected]>
2026-11-07 13:02:42 +01:00

1465 lines
49 KiB
JavaScript

#!/usr/bin/env node
// Post-edit verification for paperclip-docs.
//
// Drift detection (check-drift.mjs) catches the coarse case: a doc references
// a parent surface that no longer exists at all. THIS script is finer-grained:
// for a single doc file, it extracts every specific claim (CLI flag names,
// env var names, route paths, adapter config fields/values, file paths) and
// verifies each one against the current parent code.
//
// Designed to be invoked by the /sync-docs orchestrator immediately after a
// subagent has written a doc edit, to catch hallucinated or over-inferred
// assertions that the subagent's input alone could not falsify.
//
// Usage:
// node scripts/sync/verify-edit.mjs <doc-file> [--against REF] [--repo OWNER/REPO] [--json]
//
// Exit code is always 0 (verification is advisory).
//
// Testing hook: PAPERCLIP_SYNC_FIXTURE_DIR redirects gh api calls to JSON
// fixtures (same shape as check-drift.mjs).
//
// PAPERCLIP_SYNC_LOCAL_SRC=<dir> reads parent files from a local checkout at
// the --against ref instead of the GitHub API (no rate limit).
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";
import {
collectStringConstants,
ensureParentSnapshot,
inlineRouteConstants,
isTestPath,
normalizeRoute,
readSnapshotEnvSources,
routeIsDefined as routeIsDefinedNormalized,
snapshotContents,
snapshotFiles,
} from "./lib/parent-source.mjs";
const SELF_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(SELF_DIR, "../..");
const CACHE_ROOT = "/tmp/paperclip-sync";
const FIXTURE_DIR = process.env.PAPERCLIP_SYNC_FIXTURE_DIR || null;
// Optional local checkout of the parent repo at the --against ref. Reading
// from disk avoids the GitHub API entirely (no rate limit, much faster).
const LOCAL_SRC = process.env.PAPERCLIP_SYNC_LOCAL_SRC || null;
// Full parent snapshot at the pinned SHA (lib/parent-source.mjs). Set in
// main(): PAPERCLIP_SYNC_LOCAL_SRC when given, otherwise a one-time tarball
// download per commit. null means "use the contents API" (fixture mode,
// PAPERCLIP_SYNC_NO_SNAPSHOT, or a failed download).
let SNAPSHOT = null;
const API_ENV_SOURCE_CAP = 700;
const warnings = [];
function warn(msg) {
warnings.push(msg);
process.stderr.write(`${msg}\n`);
}
// --- gh wrapper (mirrors check-drift.mjs) ---------------------------------
function pathSlug(p) {
return p.replace(/[/]/g, "__");
}
function ghContents(repo, path, ref) {
if (SNAPSHOT) return snapshotContents(SNAPSHOT, path);
if (FIXTURE_DIR) {
const file = join(FIXTURE_DIR, `contents-${pathSlug(path)}-${ref}.json`);
if (!existsSync(file)) 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 };
}
if (Array.isArray(body.list)) {
return { status: 200, list: body.list };
}
return { status: 200 };
}
return { status: 200 };
}
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);
}
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) {
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") {
return {
status: 200,
content: Buffer.from(parsed.content, "base64").toString("utf8"),
};
}
return { status: 200, list: parsed };
} catch {
return { status: 200 };
}
}
if (status && status >= 400) {
if (status === 401 || status === 403) {
process.stderr.write(
`error: gh api ${apiPath} returned ${status}. Check auth / rate limit.\n`
);
process.exit(2);
}
return { status };
}
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 ghTreeFiles(repo, ref) {
// Walks the directory, so a `git archive` extract (no .git) works too. The
// old `git ls-files` path returned nothing for extracts, which silently
// dropped the run back to a handful of env source files.
if (SNAPSHOT) return snapshotFiles(SNAPSHOT);
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;
}
}
function ghResolveSha(repo, ref) {
if (LOCAL_SRC) {
const r = spawnSync("git", ["-C", LOCAL_SRC, "rev-parse", "HEAD"], { encoding: "utf8" });
const head = (r.stdout || "").trim();
if (head && !head.startsWith(ref) && !ref.startsWith(head.slice(0, 7))) {
process.stderr.write(`warning: PAPERCLIP_SYNC_LOCAL_SRC is at ${head.slice(0, 7)}, not ${ref}\n`);
}
return head.slice(0, 7) || null;
}
if (FIXTURE_DIR) return null;
const r = spawnSync(
"gh",
["api", `repos/${repo}/commits/${ref}`, "-q", ".sha"],
{ encoding: "utf8" }
);
if (r.status !== 0) return null;
return r.stdout.trim().slice(0, 7) || null;
}
// Full 40-char SHA for pinning the snapshot (ghResolveSha returns a short one
// for display). null in fixture mode or when the ref can't be resolved.
function resolveFullSha(repo, ref) {
if (/^[0-9a-f]{40}$/i.test(ref)) return ref.toLowerCase();
if (FIXTURE_DIR) return null;
const r = spawnSync("gh", ["api", `repos/${repo}/commits/${ref}`, "-q", ".sha"], { encoding: "utf8" });
if (r.status !== 0) return null;
return r.stdout.trim() || null;
}
// --- caching ---------------------------------------------------------------
function cacheGet(refSlug, key) {
if (FIXTURE_DIR || SNAPSHOT) return null;
const dir = join(CACHE_ROOT, `verify-${refSlug}`);
const file = join(dir, `${pathSlug(key)}.json`);
if (!existsSync(file)) return null;
try {
const st = statSync(file);
// 1 day TTL.
if (Date.now() - st.mtimeMs > 24 * 60 * 60 * 1000) return null;
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return null;
}
}
function cacheSet(refSlug, key, value) {
if (FIXTURE_DIR || SNAPSHOT) return;
const dir = join(CACHE_ROOT, `verify-${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;
}
// --- doc structural analysis ----------------------------------------------
// Return an array of "regions" — { kind, startLine, endLine, content, sectionPath }
// kind: "code" | "table" | "list" | "prose"
// sectionPath: array of heading titles from H1 → current.
function parseRegions(content) {
const lines = content.split("\n");
const regions = [];
let i = 0;
let sectionPath = [];
let inFence = false;
let fenceMarker = "";
let fenceStart = 0;
let fenceLang = "";
// Build heading map for each line index → sectionPath after that line.
const headingAt = new Array(lines.length).fill(null);
let path = [];
let fenceTracker = false;
for (let j = 0; j < lines.length; j++) {
const ln = lines[j];
const fm = ln.match(/^(```+|~~~+)/);
if (fm) {
fenceTracker = !fenceTracker;
}
if (!fenceTracker) {
const hm = ln.match(/^(#{1,6})\s+(.+?)\s*$/);
if (hm) {
const depth = hm[1].length;
const title = hm[2].trim();
path = path.slice(0, depth - 1);
path[depth - 1] = title;
path = path.slice(0, depth);
}
}
headingAt[j] = path.slice();
}
// Now walk for regions.
while (i < lines.length) {
const ln = lines[i];
const fm = ln.match(/^(```+|~~~+)(.*)$/);
if (fm && !inFence) {
inFence = true;
fenceMarker = fm[1];
fenceLang = (fm[2] || "").trim().toLowerCase();
fenceStart = i;
i++;
continue;
}
if (inFence) {
if (ln.startsWith(fenceMarker)) {
regions.push({
kind: "code",
lang: fenceLang,
startLine: fenceStart + 1,
endLine: i + 1,
content: lines.slice(fenceStart + 1, i).join("\n"),
sectionPath: headingAt[fenceStart] || [],
});
inFence = false;
fenceMarker = "";
fenceLang = "";
}
i++;
continue;
}
// Table block: rows starting with `|` separated by a header sep.
if (/^\s*\|/.test(ln)) {
const start = i;
while (i < lines.length && /^\s*\|/.test(lines[i])) i++;
regions.push({
kind: "table",
startLine: start + 1,
endLine: i,
content: lines.slice(start, i).join("\n"),
sectionPath: headingAt[start] || [],
});
continue;
}
// List block.
if (/^\s*[-*+]\s+/.test(ln) || /^\s*\d+\.\s+/.test(ln)) {
const start = i;
while (
i < lines.length &&
(/^\s*[-*+]\s+/.test(lines[i]) ||
/^\s*\d+\.\s+/.test(lines[i]) ||
/^\s{2,}\S/.test(lines[i]) ||
lines[i] === "")
) {
// stop at blank-blank
if (lines[i] === "" && lines[i + 1] === "") break;
i++;
}
regions.push({
kind: "list",
startLine: start + 1,
endLine: i,
content: lines.slice(start, i).join("\n"),
sectionPath: headingAt[start] || [],
});
continue;
}
// Prose block.
const start = i;
while (
i < lines.length &&
lines[i] !== "" &&
!/^(```+|~~~+)/.test(lines[i]) &&
!/^\s*\|/.test(lines[i]) &&
!/^(#{1,6})\s+/.test(lines[i]) &&
!/^\s*[-*+]\s+/.test(lines[i]) &&
!/^\s*\d+\.\s+/.test(lines[i])
) {
i++;
}
if (i > start) {
regions.push({
kind: "prose",
startLine: start + 1,
endLine: i,
content: lines.slice(start, i).join("\n"),
sectionPath: headingAt[start] || [],
});
} else {
i++;
}
}
return regions;
}
function lineOfOffset(content, offset) {
// 1-based line of byte offset.
let line = 1;
for (let i = 0; i < offset && i < content.length; i++) {
if (content[i] === "\n") line++;
}
return line;
}
function locateFirst(content, needle) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(needle)) return i + 1;
}
return null;
}
// --- Levenshtein for "closest match" ---------------------------------------
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
const m = a.length;
const n = b.length;
let prev = new Array(n + 1);
let cur = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
cur[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
cur[j] = Math.min(
prev[j] + 1,
cur[j - 1] + 1,
prev[j - 1] + cost
);
}
[prev, cur] = [cur, prev];
}
return prev[n];
}
function closestMatch(needle, haystack, maxDist = 3) {
let best = null;
let bestDist = maxDist + 1;
for (const cand of haystack) {
const d = levenshtein(needle, cand);
if (d < bestDist) {
bestDist = d;
best = cand;
}
}
return best && bestDist <= maxDist ? { match: best, distance: bestDist } : null;
}
// --- claim extraction ------------------------------------------------------
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Doc-kind detection. We prefer the repo-relative path layout
// (docs/reference/<kind>/<name>.md), but also tolerate a doc passed by an
// out-of-tree path (e.g. /tmp/cursor-cloud.md) by falling back to the optional
// hint set by the caller (DOC_KIND_HINT) or to content sniffing.
let DOC_KIND_HINT = null;
function isCliDoc(docPath) {
if (/docs\/reference\/cli\//.test(docPath)) return true;
return DOC_KIND_HINT === "cli";
}
function isApiDoc(docPath) {
if (/docs\/reference\/api\/[^/]+\.md$/.test(docPath)) return true;
return DOC_KIND_HINT === "api";
}
function isAdapterDoc(docPath) {
if (/docs\/reference\/adapters\/[^/]+\.md$/.test(docPath)) return true;
return DOC_KIND_HINT === "adapter";
}
function adapterNameFromDoc(docPath) {
const m = docPath.match(/docs\/reference\/adapters\/([^/]+)\.md$/);
if (m) return m[1];
// Fallback: basename without extension. Works when caller passes /tmp/foo.md.
if (DOC_KIND_HINT === "adapter") {
const base = docPath.split(/[\\/]/).pop().replace(/\.md$/, "");
return base || null;
}
return null;
}
// Sniff the doc kind from filename + content when path doesn't match the
// canonical docs/ layout. Heuristics are deliberately permissive — false
// positives here cost us extra verification work, not bad output.
function sniffDocKind(docPath, content) {
if (/docs\/reference\/cli\//.test(docPath)) return "cli";
if (/docs\/reference\/api\//.test(docPath)) return "api";
if (/docs\/reference\/adapters\//.test(docPath)) return "adapter";
if (/docs\/reference\/deploy\/environment-variables\.md$/.test(docPath)) return "env";
// Out-of-tree fallback: look at content.
if (/^##\s+(GET|POST|PUT|PATCH|DELETE)\s+\/api\//m.test(content)) return "api";
if (/\bpaperclipai\s+[a-z]/.test(content) && /##\s*(Usage|Options|Flags)/i.test(content)) return "cli";
// adapter docs typically reference adapterType / Cursor SDK / adapter / etc.
if (/^#\s+.*\b(Adapter|Cursor|OpenClaw|Claude|Codex|HTTP|Local)\b/i.test(content) &&
/(adapterType|adapterConfig|## (Common Fields|Config|Configuration))/i.test(content)) {
return "adapter";
}
return null;
}
function isConfigSection(sectionPath) {
// Heading "trail" that suggests a config-field listing.
const re = /(^|\s)(config(uration)?|common fields|fields|options|properties|schema|settings)(\s|$)/i;
return sectionPath.some((h) => re.test(h));
}
// Class: cli-command — from any region.
function extractCliCommands(regions, content, docPath) {
if (!isCliDoc(docPath)) return [];
const out = [];
const re = /(?:^|[\s`(])(?:pnpm\s+)?paperclipai\s+([a-z][a-z-]*)(?:\s+([a-z][a-z-]*))?/g;
const seen = new Set();
let m;
while ((m = re.exec(content))) {
const head = m[1];
const sub = m[2];
const key = sub ? `${head} ${sub}` : head;
if (["help", "version"].includes(head)) continue;
if (seen.has(key)) continue;
seen.add(key);
const line = lineOfOffset(content, m.index);
out.push({
kind: "cli-command",
value: `paperclipai ${key}`,
head,
sub: sub || null,
location: `${docPath}:${line}`,
});
}
return out;
}
// Class: cli-flag — only from code blocks inside cli docs.
function extractCliFlags(regions, docPath) {
if (!isCliDoc(docPath)) return [];
const out = [];
const seen = new Set();
for (const r of regions) {
if (r.kind !== "code") continue;
const re = /--([a-z][a-z0-9-]*)/g;
let m;
while ((m = re.exec(r.content))) {
const flag = m[1];
if (seen.has(flag)) continue;
seen.add(flag);
const offsetInRegion = m.index;
const lineWithin = r.content.slice(0, offsetInRegion).split("\n").length - 1;
const line = r.startLine + lineWithin;
out.push({
kind: "cli-flag",
value: `--${flag}`,
location: `${docPath}:${line}`,
});
}
}
return out;
}
// Class: env-var — backticked uppercase identifier in env-y context.
function extractEnvVars(regions, docPath) {
if (isApiDoc(docPath)) return [];
const out = [];
const seen = new Set();
const envCtxRe = /(env(ironment)?|deploy|secret|config|variable)/i;
for (const r of regions) {
if (r.kind === "prose" && !envCtxRe.test(r.content) && !r.sectionPath.some((s) => envCtxRe.test(s))) {
continue;
}
if (r.kind === "list" && !r.sectionPath.some((s) => envCtxRe.test(s)) && !envCtxRe.test(r.content)) {
continue;
}
const re = /`([A-Z_][A-Z0-9_]{2,})`/g;
let m;
while ((m = re.exec(r.content))) {
const name = m[1];
// Skip common english all-caps words.
if (["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "URL", "URI", "JSON", "YAML", "HTML", "API", "REST", "CLI", "UUID", "ID", "OK"].includes(name)) continue;
if (seen.has(name)) continue;
seen.add(name);
const offsetInRegion = m.index;
const lineWithin = r.content.slice(0, offsetInRegion).split("\n").length - 1;
const line = r.startLine + lineWithin;
out.push({
kind: "env-var",
value: name,
location: `${docPath}:${line}`,
});
}
}
return out;
}
// Class: rest-route — headings, fenced blocks, or table cells containing METHOD /api/...
function extractRestRoutes(content, docPath) {
if (!isApiDoc(docPath)) return [];
const out = [];
const seen = new Set();
const re = /(?:^|[\s|`])((GET|POST|PUT|PATCH|DELETE)\s+(\/api\/[^\s`|)\]]+))/gm;
let m;
while ((m = re.exec(content))) {
const method = m[2];
const rawPath = m[3].replace(/[.,;:`)\]]+$/g, "");
const key = `${method} ${rawPath}`;
if (seen.has(key)) continue;
seen.add(key);
const line = lineOfOffset(content, m.index + m[0].indexOf(m[1]));
out.push({
kind: "rest-route",
value: key,
method,
path: rawPath,
location: `${docPath}:${line}`,
});
}
return out;
}
// Class: adapter-config-field — backticked identifier inside a config-y region.
function extractAdapterConfigFields(regions, docPath) {
if (!isAdapterDoc(docPath)) return [];
const out = [];
const seen = new Set();
for (const r of regions) {
// Only structurally "config-y": code blocks, tables, or lists under a
// config-ish heading. Prose paragraphs are excluded even if backticked.
const inConfigSection = isConfigSection(r.sectionPath);
const structural = r.kind === "code" || r.kind === "table" || r.kind === "list";
if (!structural) continue;
if (!inConfigSection && r.kind !== "code") continue;
// Backticked identifiers that look like field names.
const re = /`([a-z][a-zA-Z0-9_]*)`/g;
let m;
while ((m = re.exec(r.content))) {
const field = m[1];
if (field.length < 3) continue;
// Skip very common english words that show up as code formatting.
if (["true", "false", "null", "undefined", "the", "and", "for", "with", "use", "yes", "this", "that", "any", "all", "off"].includes(field)) continue;
if (seen.has(field)) continue;
seen.add(field);
const offsetInRegion = m.index;
const lineWithin = r.content.slice(0, offsetInRegion).split("\n").length - 1;
const line = r.startLine + lineWithin;
out.push({
kind: "adapter-config-field",
value: field,
location: `${docPath}:${line}`,
});
}
// YAML/JSON-style key extraction in code blocks: `key:` or `"key":`
if (r.kind === "code") {
const reKey = /^[ \t-]*("?)([a-z][a-zA-Z0-9_]*)\1\s*:/gm;
let km;
while ((km = reKey.exec(r.content))) {
const field = km[2];
if (field.length < 3) continue;
if (seen.has(field)) continue;
seen.add(field);
const offsetInRegion = km.index;
const lineWithin = r.content.slice(0, offsetInRegion).split("\n").length - 1;
const line = r.startLine + lineWithin;
out.push({
kind: "adapter-config-field",
value: field,
location: `${docPath}:${line}`,
});
}
}
}
return out;
}
// Class: adapter-config-value — string-literal values inside adapter-doc code blocks.
function extractAdapterConfigValues(regions, docPath) {
if (!isAdapterDoc(docPath)) return [];
const out = [];
const seen = new Set();
for (const r of regions) {
if (r.kind !== "code") continue;
// key: "value" or key: value (unquoted scalar). We capture quoted first.
const reQuoted = /[:=]\s*["']([^"'\n]{2,80})["']/g;
let m;
while ((m = reQuoted.exec(r.content))) {
const v = m[1];
// Only "interesting" values: contain a dash, dot, or are kebab-case identifiers.
if (!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i.test(v)) continue;
if (seen.has(v)) continue;
seen.add(v);
const offsetInRegion = m.index;
const lineWithin = r.content.slice(0, offsetInRegion).split("\n").length - 1;
const line = r.startLine + lineWithin;
out.push({
kind: "adapter-config-value",
value: v,
location: `${docPath}:${line}`,
});
}
}
return out;
}
// Class: file-path — backticked or linked parent paths.
function extractFilePaths(content, docPath) {
const out = [];
const seen = new Set();
const re = /`((?:cli|packages|server|skills)\/[A-Za-z0-9_./-]+\.(?:ts|md|json))`/g;
let m;
while ((m = re.exec(content))) {
const p = m[1];
if (seen.has(p)) continue;
seen.add(p);
const line = lineOfOffset(content, m.index);
out.push({
kind: "file-path",
value: p,
location: `${docPath}:${line}`,
});
}
// Markdown link form.
const re2 = /\]\(((?:cli|packages|server|skills)\/[A-Za-z0-9_./-]+\.(?:ts|md|json))\)/g;
while ((m = re2.exec(content))) {
const p = m[1];
if (seen.has(p)) continue;
seen.add(p);
const line = lineOfOffset(content, m.index);
out.push({
kind: "file-path",
value: p,
location: `${docPath}:${line}`,
});
}
return out;
}
// --- verification ----------------------------------------------------------
function listDir(repo, path, ref, refSlug) {
const res = cachedContents(repo, path, ref, refSlug);
if (res.status !== 200 || !Array.isArray(res.list)) return [];
return res.list;
}
function fetchCliFiles(repo, ref, refSlug) {
// Same shape as check-drift.fetchCliCommandFiles, but exported state is
// used both for command verification and (per-command) flag verification.
const acc = [];
const seen = new Set();
function visit(dir) {
if (seen.has(dir)) return;
seen.add(dir);
const list = listDir(repo, dir, ref, refSlug);
for (const item of 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/src/commands", "cli/src/commands/client"]) visit(dir);
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 findCliCommandFile(name, cliFiles) {
// Return the file most likely to define <name>.
const cmdRe = new RegExp(`\\.command\\(["']${escapeRegex(name)}\\b`);
for (const f of cliFiles) {
if (cmdRe.test(f.content)) return f;
}
for (const f of cliFiles) {
const base = f.path.split("/").pop().replace(/\.ts$/, "");
if (base === name) return f;
}
return null;
}
function readAdapterDocSources() {
try {
const anchor = JSON.parse(
readFileSync(join(SELF_DIR, "anchor-map.json"), "utf8")
);
return anchor.adapter_doc_sources || null;
} catch {
return null;
}
}
function fetchAdapterFiles(repo, adapter, ref, refSlug, adapterDocSources) {
const acc = [];
const seen = new Set();
function visit(dir) {
if (seen.has(dir)) return;
seen.add(dir);
const list = listDir(repo, dir, ref, refSlug);
for (const item of list) {
if (item.type === "file" && /\.(ts|tsx|json)$/.test(item.name)) {
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);
}
}
}
// Use anchor-map.json adapter_doc_sources override when the doc name does not
// match the adapter package directory (e.g. hermes-local/hermes-gateway → hermes).
const overrideBases = adapterDocSources && adapterDocSources[adapter];
const bases = overrideBases || [
`packages/adapters/${adapter}`,
`packages/adapters/${adapter}/src`,
`packages/plugins/sandbox-providers/${adapter}`,
];
for (const base of bases) {
visit(base);
}
return acc;
}
function fetchEnvSources(repo, ref, refSlug) {
// Snapshot mode: every non-test source file, no cap.
if (SNAPSHOT) return readSnapshotEnvSources(SNAPSHOT);
const out = {};
const sources = [".env.example", "server/src/config.ts"];
try {
const anchor = JSON.parse(
readFileSync(join(SELF_DIR, "anchor-map.json"), "utf8")
);
const w = (anchor.watchers || []).find((x) => x.name === "env-vars");
if (w) {
for (const p of w.parent_paths || []) {
if (!p.includes("*") && !sources.includes(p)) sources.push(p);
}
}
} catch {
// ignore
}
const tree = ghTreeFiles(repo, ref);
if (tree) {
for (const p of tree) {
if (shouldFetchEnvSource(p) && !sources.includes(p)) sources.push(p);
}
}
if (sources.length > API_ENV_SOURCE_CAP) {
warn(
`warning: env-var scan was capped at ${API_ENV_SOURCE_CAP} of ${sources.length} parent files (GitHub API mode). ` +
`Unverified env-var claims may be false positives. Unset PAPERCLIP_SYNC_NO_SNAPSHOT or set ` +
`PAPERCLIP_SYNC_LOCAL_SRC to scan everything.`
);
}
for (const s of sources.slice(0, API_ENV_SOURCE_CAP)) {
const res = cachedContents(repo, s, ref, refSlug);
if (res.status === 200 && typeof res.content === "string") {
out[s] = res.content;
}
}
return out;
}
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
// unverified env-var claims. 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 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 withInlinedRouteConstants(acc, tree);
}
function visit(dir) {
if (seen.has(dir)) return;
seen.add(dir);
const list = listDir(repo, dir, ref, refSlug);
for (const item of 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 withInlinedRouteConstants(acc, null);
}
// Inline string-constant route paths (`router.post(IMPORT_PATH, …)`,
// `${IMPORT_PATH}/:id`) so the matcher sees literal paths. Constants come from
// the route files and, in snapshot mode, packages/shared.
function withInlinedRouteConstants(routeFiles, tree) {
const sources = [...routeFiles];
if (tree && SNAPSHOT) {
for (const p of tree) {
if (!/^packages\/shared\/src\/.+\.ts$/.test(p) || isTestPath(p)) continue;
const res = snapshotContents(SNAPSHOT, p);
if (res.status === 200 && typeof res.content === "string") sources.push({ path: p, content: res.content });
}
}
const constants = collectStringConstants(sources);
return routeFiles.map((f) => ({ ...f, content: inlineRouteConstants(f.content, constants) }));
}
// Shared matcher (lib/parent-source.mjs) — handles array registration,
// router[method] loops, and `{param}.ext` doc paths.
function routeIsDefined(method, rawPath, routeContent, surface) {
return routeIsDefinedNormalized(method, normalizeRoute(rawPath), routeContent, surface);
}
function envVarPresent(name, envSources, tokens = null) {
if (isExternallySuppliedEnvVar(name) || isRuntimeInjectedEnvVar(name)) return true;
const envExample = envSources[".env.example"];
if (envExample && new RegExp(`^${name}\\b`, "m").test(envExample)) return true;
return (tokens || new Set(extractEnvIdentifiers(envSources))).has(name);
}
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);
}
// Extract all candidate field names from adapter source — used to suggest
// closest matches on misses.
function extractAdapterIdentifiers(adapterFiles) {
const ids = new Set();
for (const f of adapterFiles) {
const re = /\b([a-z][a-zA-Z0-9_-]{2,})\b/g;
let m;
while ((m = re.exec(f.content))) {
ids.add(m[1]);
}
// Also kebab-case quoted values.
const reK = /["']([a-z][a-z0-9-]{2,})["']/g;
while ((m = reK.exec(f.content))) {
ids.add(m[1]);
}
}
return [...ids];
}
function extractEnvIdentifiers(envSources) {
const ids = new Set();
for (const content of Object.values(envSources)) {
const re = /\b([A-Z_][A-Z0-9_]{2,})\b/g;
let m;
while ((m = re.exec(content))) ids.add(m[1]);
}
return [...ids];
}
// --- main ------------------------------------------------------------------
function parseArgs(argv) {
const out = {
doc: null,
json: false,
repo: null,
against: null,
help: false,
};
const positional = [];
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;
else positional.push(a);
}
if (positional.length) out.doc = positional[0];
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 usage() {
return (
"usage: verify-edit.mjs <doc-file> [--against REF] [--repo OWNER/REPO] [--json]\n"
);
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.doc) {
process.stdout.write(usage());
process.exit(args.help ? 0 : 2);
}
const docAbsPath = resolve(process.cwd(), args.doc);
if (!existsSync(docAbsPath)) {
process.stderr.write(`error: doc file not found: ${args.doc}\n`);
process.exit(2);
}
const docContent = readFileSync(docAbsPath, "utf8");
// Prefer ROOT-relative path for location strings; fall back to the as-given path.
let docRel = relative(ROOT, docAbsPath);
if (docRel.startsWith("..")) docRel = args.doc;
const repo = args.repo || readDefaultRepo();
const against = args.against || ghDefaultBranch(repo);
const refSlug = pathSlug(against);
SNAPSHOT = ensureParentSnapshot({
repo,
sha: LOCAL_SRC || FIXTURE_DIR ? null : resolveFullSha(repo, against),
cacheRoot: CACHE_ROOT,
fixtureMode: !!FIXTURE_DIR,
log: (msg) => process.stderr.write(`${msg}\n`),
});
// Determine doc kind (drives which extractors are valid).
DOC_KIND_HINT = sniffDocKind(docRel, docContent);
// 1. Extract claims.
const regions = parseRegions(docContent);
const claims = [];
claims.push(...extractCliCommands(regions, docContent, docRel));
claims.push(...extractCliFlags(regions, docRel));
claims.push(...extractEnvVars(regions, docRel));
claims.push(...extractRestRoutes(docContent, docRel));
claims.push(...extractAdapterConfigFields(regions, docRel));
claims.push(...extractAdapterConfigValues(regions, docRel));
claims.push(...extractFilePaths(docContent, docRel));
// 2. Verify each — bucket into verified, unverified, suspicious.
const verified = [];
const unverified = [];
const suspicious = [];
// Pre-fetch parent surfaces lazily by need.
let cliFiles = null;
const getCliFiles = () => {
if (cliFiles === null) cliFiles = fetchCliFiles(repo, against, refSlug);
return cliFiles;
};
const adapterFilesCache = {};
const adapterDocSources = readAdapterDocSources();
const getAdapterFiles = (adapter) => {
if (!adapterFilesCache[adapter]) {
adapterFilesCache[adapter] = fetchAdapterFiles(repo, adapter, against, refSlug, adapterDocSources);
}
return adapterFilesCache[adapter];
};
let envSources = null;
let envTokens = null;
const getEnvSources = () => {
if (envSources === null) {
envSources = fetchEnvSources(repo, against, refSlug);
envTokens = new Set(extractEnvIdentifiers(envSources));
}
return envSources;
};
let routeFiles = null;
const getRouteFiles = () => {
if (routeFiles === null) routeFiles = fetchRouteFiles(repo, against, refSlug);
return routeFiles;
};
for (const claim of claims) {
if (claim.kind === "cli-command") {
const files = getCliFiles();
const cmd = claim.head;
const cmdRe = new RegExp(`\\.command\\(["']${escapeRegex(cmd)}\\b`);
let hit = false;
for (const f of files) {
if (cmdRe.test(f.content)) { hit = true; break; }
}
if (!hit) {
for (const f of files) {
const base = f.path.split("/").pop().replace(/\.ts$/, "");
if (base === cmd) { hit = true; break; }
}
}
if (hit) {
verified.push(claim);
} else {
// Suggest closest known command.
const names = new Set();
for (const f of files) {
const reC = /\.command\(["']([a-z][a-z-]*)\b/g;
let m;
while ((m = reC.exec(f.content))) names.add(m[1]);
const base = f.path.split("/").pop().replace(/\.ts$/, "");
if (/^[a-z][a-z-]*$/.test(base)) names.add(base);
}
const sug = closestMatch(cmd, [...names]);
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: ["cli/src/commands/**", "cli/src/index.ts"],
evidence: `no .command("${cmd}") registration or commands/${cmd}.ts found (searched ${files.length} files)`,
suggest: sug
? `Closest match in parent: '${sug.match}'.`
: "Verify the CLI command name. It may have been renamed or removed.",
});
}
continue;
}
if (claim.kind === "cli-flag") {
// Determine which command this flag should belong to. We don't fully
// track per-claim command context, so we search across all cli files.
const files = getCliFiles();
const flag = claim.value.slice(2); // strip --
// Commander declares flags with .option(), .requiredOption() and
// new Option(); all three are real flags.
const re = new RegExp(`(?:\\.(?:required)?[oO]ption|new\\s+Option)\\(\\s*["'][^"']*--${escapeRegex(flag)}\\b`);
let hit = false;
for (const f of files) {
if (re.test(f.content)) { hit = true; break; }
}
if (hit) {
verified.push(claim);
} else {
// Collect all known long flags for fuzzy.
const known = new Set();
for (const f of files) {
const fre = /(?:\.(?:required)?[oO]ption|new\s+Option)\(\s*["'][^"']*--([a-z][a-z0-9-]*)\b/g;
let m;
while ((m = fre.exec(f.content))) known.add(m[1]);
}
const sug = closestMatch(flag, [...known]);
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: ["cli/src/commands/**"],
evidence: `no .option("--${flag}") found in ${files.length} CLI source files`,
suggest: sug
? `Closest match in parent: '--${sug.match}'.`
: "Verify the flag name. It may have been renamed or removed.",
});
}
continue;
}
if (claim.kind === "env-var") {
const sources = getEnvSources();
if (envVarPresent(claim.value, sources, envTokens)) {
verified.push(claim);
} else {
const known = [...envTokens];
const sug = closestMatch(claim.value, known);
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [SNAPSHOT ? "all non-test source files (snapshot)" : "selected env source files (GitHub API)"],
evidence: `no literal match in ${Object.keys(sources).length} env source files`,
suggest: sug
? `Closest match in parent: '${sug.match}'.`
: "Verify the env var still exists.",
});
}
continue;
}
if (claim.kind === "rest-route") {
// surface from doc path.
const m = claim.location.match(/docs\/reference\/api\/([^/:]+)\.md/);
const surface = m ? m[1] : null;
if (!surface) {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [],
evidence: "could not infer surface from doc path",
suggest: "Move the route into a per-surface doc under docs/reference/api/<surface>.md.",
});
continue;
}
const allRouteFiles = getRouteFiles();
if (allRouteFiles.length > 0) {
const hit = allRouteFiles.find((file) =>
routeIsDefined(claim.method, claim.path, file.content, surface)
);
if (hit) {
verified.push(claim);
} else {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: ["server/src/routes/**/*.ts"],
evidence: `no ${claim.method.toLowerCase()}() registration matching ${claim.path} in ${allRouteFiles.length} route files`,
suggest: "Verify the route was removed (not moved). If moved, update the doc's location; if removed, delete the section.",
});
}
continue;
}
const routeFilePath = `server/src/routes/${surface}.ts`;
const res = cachedContents(repo, routeFilePath, against, refSlug);
if (res.status !== 200 || typeof res.content !== "string") {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [routeFilePath],
evidence: `route surface file not found (status ${res.status})`,
suggest: "Confirm the route file exists; the route may have moved to another surface.",
});
continue;
}
if (routeIsDefined(claim.method, claim.path, res.content, surface)) {
verified.push(claim);
} else {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [routeFilePath],
evidence: `no ${claim.method.toLowerCase()}() registration matching ${claim.path} in ${routeFilePath}`,
suggest: "Verify the route was removed (not moved). If moved, update the doc's location; if removed, delete the section.",
});
}
continue;
}
if (claim.kind === "adapter-config-field") {
const adapter = adapterNameFromDoc(claim.location.replace(/:\d+$/, ""));
if (!adapter) {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [],
evidence: "could not infer adapter name from doc path",
suggest: "Verify the doc lives under docs/reference/adapters/<name>.md.",
});
continue;
}
const files = getAdapterFiles(adapter);
// Look for the literal identifier as a word in source.
const re = new RegExp(`\\b${escapeRegex(claim.value)}\\b`);
const reQ = new RegExp(`["']${escapeRegex(claim.value)}["']`);
let hit = false;
for (const f of files) {
if (re.test(f.content) || reQ.test(f.content)) {
hit = true;
break;
}
}
if (hit) {
verified.push(claim);
} else {
const known = extractAdapterIdentifiers(files);
const sug = closestMatch(claim.value, known);
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [`packages/adapters/${adapter}/**`],
evidence: `no match in ${files.length} files`,
suggest: sug
? `Verify field name. Closest match in parent: '${sug.match}'${sug.match.includes("-") ? " (kebab-case)" : ""}.`
: "Verify the field exists in the adapter source.",
});
}
continue;
}
if (claim.kind === "adapter-config-value") {
const adapter = adapterNameFromDoc(claim.location.replace(/:\d+$/, ""));
if (!adapter) {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [],
evidence: "could not infer adapter name",
suggest: "Verify the doc path.",
});
continue;
}
const files = getAdapterFiles(adapter);
let hit = false;
for (const f of files) {
if (f.content.includes(claim.value)) { hit = true; break; }
}
if (hit) {
verified.push(claim);
} else {
const known = extractAdapterIdentifiers(files);
const sug = closestMatch(claim.value, known);
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [`packages/adapters/${adapter}/**`],
evidence: `value not found in ${files.length} files`,
suggest: sug
? `Closest match in parent: '${sug.match}'.`
: "Verify the value (enum option, model name, mode) still exists upstream.",
});
}
continue;
}
if (claim.kind === "file-path") {
const res = cachedContents(repo, claim.value, against, refSlug);
if (res.status === 200) {
verified.push(claim);
} else {
unverified.push({
kind: claim.kind,
value: claim.value,
location: claim.location,
searched: [claim.value],
evidence: `parent file returns status ${res.status}`,
suggest: "The file may have been renamed or removed. Cross-check with detect-renames output.",
});
}
continue;
}
}
// Resolve SHA for header.
const sha = ghResolveSha(repo, against);
const checkedAgainst = sha ? `${against} (${sha})` : against;
const result = {
doc: docRel,
checked_against: checkedAgainst,
source: SNAPSHOT ? "snapshot" : "github-api",
warnings,
claims_extracted: claims.length,
verified: verified.length,
unverified,
suspicious,
};
if (args.json) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
process.exit(0);
}
// Human-readable.
const lines = [];
lines.push(`Verify edit: ${docRel}`);
lines.push(`Checked against: ${checkedAgainst}`);
lines.push(
`Claims: ${claims.length} extracted, ${verified.length} verified, ${unverified.length} unverified, ${suspicious.length} suspicious`
);
if (unverified.length === 0 && suspicious.length === 0) {
lines.push("");
lines.push("OK — all claims verified.");
process.stdout.write(lines.join("\n") + "\n");
process.exit(0);
}
if (unverified.length) {
lines.push("");
lines.push("# unverified");
for (const u of unverified) {
lines.push(` ${u.kind}: ${u.value}`);
lines.push(` doc: ${u.location}`);
lines.push(` searched: ${u.searched.join(", ")}`);
lines.push(` evidence: ${u.evidence}`);
lines.push(` suggest: ${u.suggest}`);
}
}
if (suspicious.length) {
lines.push("");
lines.push("# suspicious");
for (const s of suspicious) {
lines.push(` ${s.kind}: ${s.value}`);
lines.push(` doc: ${s.location}`);
lines.push(` reason: ${s.reason}`);
}
}
process.stdout.write(lines.join("\n") + "\n");
process.exit(0);
}
main();