Files
paperclip-docs/scripts/verify-doc-link-resolution.mjs
39633c2217 docs: connectors catalog, operator-first third revision (#124)
Connector catalog, setup guides, and shared access documentation,
followed by a final code-accuracy repair pass. This PR does not change
application behavior.

## Final accuracy pass

Checked against shipped Paperclip `v2026.916.0`, commit
`dffc2b3ca1b9e88fa21cb17493083e682dffd1ca`, rather than the dirty local
checkout or the earlier canary baseline.

- Corrects connector/method-specific identity choices, human audiences,
and managed GitHub versus PAT scope and verification.
- Explains that **Always allow** covers different arguments for the same
agent/action/connection, with project scope when present; uses actual
button labels and removes the test-panel control from review
instructions.
- Qualifies new-action quarantine, including safe-default read
exceptions. Removes unconditional approval guarantees.
- Separates provider channel/repository reach from Paperclip sender
authorization.
- Completes sandbox sign-in and model credential-selection instructions
from the current UI implementation.
- Repairs Notion's hub summary, removes incomplete optional
Notion/Sentry client-registration procedures, and corrects Linear's
provider-versus-Paperclip attribution.
- Removes unsupported Postman tier claims, contradictory Cloudinary/Mem0
verification assertions, API-equivalence claims, and overbroad
successful-test assurances.
- Retains Supabase's required project reference: both released method
definitions explicitly require it. An earlier review suggested
otherwise; release code is authoritative here.
- Updates the release baseline and removes draft commit chronology from
the public introduction.

## Availability and remaining runtime gates

- **Google:** Verification is pending. Per the product-owner
clarification, authorization can continue where Google offers an
Advanced option; this is not available for every account and does not
override Workspace policy or other Google prerequisites. Notices on the
hub and all Google guides now distinguish an unverified-app warning from
blocked authorization.
- **Slack is available but not yet fully functional; follow-up fixes are
planned.** For agent tools, the released OAuth endpoint/scope defaults
differ from Slack's documented MCP contract. The page now warns against
treating that route as production-ready and removes the misleading
bot-scope walkthrough. Chat-channel bot setup remains separate.
Engineering must reconcile the integration and run OAuth plus one read
before removing this warning. Source:
https://docs.slack.dev/ai/slack-mcp-server/
- No credentialed connector run was performed in this pass. Code
consistency, provider documentation, UI rendering, and successful live
execution are separate evidence levels. No claim of runtime
certification for all connectors.

## Validation

- Root build: 253 crawlable documentation pages.
- Full `docs:test`: all eight suites passed locally, including
root/subpath routes, sitemap history, fingerprints, navigation, skill
source blocks, crawlable links, doc-link resolution, and SEO metadata.
- Internal-link lint: 258 Markdown files; no broken internal links.
- Latest cleanup: full build, all eight docs:test suites, internal-link
lint and diff whitespace checks passed; 1,878 link-resolution checks.
- Fixed the Calendar MCP reference URL; browser-checked the five
inconclusive external destinations (Miro article loads; Asana, Google
Cloud, OpenAI and xAI reach sign-in/console screens). Authenticated
destinations were not tested beyond those screens.
- Replaced the Sandbox providers editorial TODO with a scope note and
removed the CLI overview's unfinished-guide announcement.
- Browser client navigation: 687 checks passed using installed Chrome.
Initial launch lacked the downloaded Playwright browser; rerun used
`DOCS_CHROMIUM_PATH` without changing the tests.
- Nine representative connector/shared pages at 1440px and 390px: HTTP
200, one article H1, no page-level horizontal overflow. Mobile
screenshot inspected; approval page opened in the Codex browser.
- Navigation audit reports three existing non-connector orphan files;
this patch does not alter those files or their navigation.

The previous revision's provider-page expansion, Google shared setup
guide, navigation additions, and accidental node_modules-symlink cleanup
remain in this PR. Internal review ledgers remain outside the
publication branch. No merge or production deployment authorized.

---------

Co-authored-by: DevRel Gemini <[email protected]>
Co-authored-by: Paperclip <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Michael Nguyen <[email protected]>
2026-09-16 22:19:14 -10:00

190 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Pin relative-markdown-link resolution to one meaning.
*
* Two resolvers read the same hrefs. `site/build-release.mjs` rewrites them
* into canonical routes at build time, so crawlers and no-JS visitors get
* correct bytes. `site/app.js` resolves them again in the browser, because the
* client re-renders markdown it fetched at runtime. app.js ships as a
* standalone script and cannot import from the build, so the logic is
* duplicated — and duplication is how the two drifted: the client trimmed the
* source directory with a `/`-anchored pattern, which matches nothing for a
* document at the docs root. `/connectors/` (docs/connectors.md) linking to
* `connectors/access-model.md` therefore requested
* `connectors.mdconnectors/access-model.md` and failed to load, while the
* static route `/connectors/access-model/` served fine and every link linter
* passed (PAP-18407).
*
* This script asserts three things:
* 1. the build resolver matches a shared case table;
* 2. the client resolver, lifted out of site/app.js, matches it too;
* 3. every relative markdown link actually written in docs/ resolves, under
* CLIENT semantics, to a file the nav publishes.
*
* Usage: node scripts/verify-doc-link-resolution.mjs
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
buildDocRouteMap,
docLinkBaseDir,
normalizeDocPath,
resolveDocHref,
resolveDocLinkTarget,
rewriteNav,
} from "../site/build-release.mjs";
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SELF_DIR, "..");
const APP_JS = path.join(ROOT, "site", "app.js");
const CONTENT_JSON = path.join(ROOT, "site", "content.json");
const DOCS_DIR = path.join(ROOT, "docs");
let failures = 0;
let checks = 0;
function check(label, actual, expected) {
checks += 1;
if (actual === expected) return;
failures += 1;
console.error(` FAIL ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
}
/**
* Lift the client resolver out of site/app.js rather than re-implementing it,
* so this test fails when the shipped browser code drifts — not when a copy
* kept beside the test drifts. Mirrors how build-release.mjs already reads
* app.js source for the section icon paths.
*/
async function loadClientResolver() {
const source = await readFile(APP_JS, "utf8");
const begin = source.indexOf("// DOC-LINK-RESOLVER:BEGIN");
const end = source.indexOf("// DOC-LINK-RESOLVER:END");
if (begin === -1 || end === -1 || end < begin) {
throw new Error(
"site/app.js is missing its DOC-LINK-RESOLVER markers. They delimit the "
+ "client copy of the link resolver that this test verifies.",
);
}
const block = source.slice(begin, end);
for (const name of ["normalizeDocPath", "docLinkBaseDir", "resolveDocLinkTarget"]) {
if (!block.includes(`function ${name}(`)) {
throw new Error(`site/app.js resolver block no longer defines ${name}().`);
}
}
const factory = new Function(
`${block}\nreturn { normalizeDocPath, docLinkBaseDir, resolveDocLinkTarget };`,
);
return factory();
}
// sourceFile, href, expected docs-root-relative target.
const CASES = [
// A root document linking into a subdirectory. This is the case that broke.
["connectors.md", "connectors/access-model.md", "connectors/access-model.md"],
["connectors.md", "connectors/first-connector.md", "connectors/first-connector.md"],
["connectors.md", "reference/api/tool-gateway.md", "reference/api/tool-gateway.md"],
// A root document linking to another root document.
["connectors.md", "hosted-beta.md", "hosted-beta.md"],
["connectors.md", "./hosted-beta.md", "hosted-beta.md"],
// Nested relatives: sibling, explicit ./, up one, up two, up-and-over.
["connectors/gmail.md", "access-model.md", "connectors/access-model.md"],
["connectors/gmail.md", "./gmail-setup.md", "connectors/gmail-setup.md"],
["connectors/gmail.md", "../connectors.md", "connectors.md"],
["connectors/gmail.md", "../how-to/connect-agent-to-github.md", "how-to/connect-agent-to-github.md"],
["reference/api/tool-gateway.md", "../../connectors.md", "connectors.md"],
["reference/api/tool-gateway.md", "../cli/overview.md", "reference/cli/overview.md"],
// Anchors must not leak into the resolved file path.
["connectors.md", "connectors/access-model.md#the-four-gates", "connectors/access-model.md"],
["connectors/gmail.md", "action-permissions.md#ask-first", "connectors/action-permissions.md"],
["connectors/gmail.md", "../connectors.md#the-catalog", "connectors.md"],
// Redundant separators and no-op segments collapse the same way.
["connectors.md", "connectors/./access-model.md", "connectors/access-model.md"],
["connectors/sub/deep.md", "../../connectors.md", "connectors.md"],
];
console.log("Doc link resolution");
const client = await loadClientResolver();
console.log(" build and client resolvers agree with the case table");
for (const [sourceFile, href, expected] of CASES) {
const [docHref] = href.split("#");
check(`build ${sourceFile} + ${href}`, resolveDocLinkTarget(docHref, sourceFile), expected);
check(`client ${sourceFile} + ${href}`, client.resolveDocLinkTarget(docHref, sourceFile), expected);
// resolveDocHref is the build's public entry point and keeps the hash.
const resolved = resolveDocHref(href, sourceFile, new Map());
check(`entry ${sourceFile} + ${href}`, resolved?.targetFile, expected);
}
console.log(" base directory of a root document is empty");
for (const [sourceFile, expected] of [
["connectors.md", ""],
["hosted-beta.md", ""],
["connectors/gmail.md", "connectors"],
["reference/api/tool-gateway.md", "reference/api"],
]) {
check(`build dirname(${sourceFile})`, docLinkBaseDir(sourceFile), expected);
check(`client dirname(${sourceFile})`, client.docLinkBaseDir(sourceFile), expected);
}
console.log(" normalizeDocPath implementations are identical");
for (const value of ["a/b/c.md", "a/./b.md", "a/../b.md", "../../x.md", "a//b.md", ""]) {
check(`normalizeDocPath(${JSON.stringify(value)})`, client.normalizeDocPath(value), normalizeDocPath(value));
}
// ─── Every relative link authors actually wrote, under client semantics ────
// content.json stores nav paths relative to site/ (`../docs/foo.md`); the
// release rewrites them to docs-root-relative, which is what both resolvers
// and content.json's shipped copy use.
const content = rewriteNav(JSON.parse(await readFile(CONTENT_JSON, "utf8")));
const routeMap = buildDocRouteMap(content, "/");
const publishedFiles = new Set(routeMap.keys());
async function listMarkdown(dir) {
const { readdir } = await import("node:fs/promises");
const entries = await readdir(dir, { withFileTypes: true });
const found = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) found.push(...(await listMarkdown(full)));
else if (entry.name.endsWith(".md")) found.push(full);
}
return found;
}
const MARKDOWN_LINK = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
console.log(" authored relative links resolve to published pages");
let linkCount = 0;
for (const absolute of (await listMarkdown(DOCS_DIR)).sort()) {
const sourceFile = path.relative(DOCS_DIR, absolute).split(path.sep).join("/");
const markdown = await readFile(absolute, "utf8");
for (const match of markdown.matchAll(MARKDOWN_LINK)) {
const href = match[1];
if (/^(?:[a-z]+:)?\/\//i.test(href) || href.startsWith("#") || href.startsWith("/")) continue;
const [docHref] = href.split("#");
if (!docHref.endsWith(".md")) continue;
linkCount += 1;
const target = client.resolveDocLinkTarget(docHref, sourceFile);
// The client can only soft-navigate to a file content.json publishes; a
// target outside that set is the failure mode this test exists for.
if (!publishedFiles.has(target)) {
failures += 1;
checks += 1;
console.error(` FAIL ${sourceFile} -> ${href}\n client resolved to ${JSON.stringify(target)}, which content.json does not publish`);
continue;
}
check(`${sourceFile} -> ${href}`, resolveDocLinkTarget(docHref, sourceFile), target);
}
}
console.log(` checked ${linkCount} relative markdown links`);
if (failures > 0) {
console.error(`\nDoc link resolution: ${failures} of ${checks} checks failed.`);
process.exit(1);
}
console.log(`\nDoc link resolution: ${checks} checks passed.`);