diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..5038567 --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,41 @@ +name: Link check + +on: + pull_request: + +# No write permissions needed — read-only check. +permissions: + contents: read + +jobs: + check-links: + name: Check internal & external links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run link checker + run: node scripts/check-links.mjs --report link-check-report.json + + - name: Upload failure report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: link-check-report + path: link-check-report.json + retention-days: 14 diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index a0b13f1..a8bc317 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -134,6 +134,34 @@ jobs: - name: Check extended snippets (Rust / Python / shell / JSON / TOML / YAML) run: pnpm run check:snippets-extended + # ── Export path verification ──────────────────────────────────────────────── + # Enumerates every import path and named symbol documented in the MDX files, + # then confirms each one exists in @wraith-protocol/sdk for both ESM and CJS. + # Fails the PR if docs and package exports diverge. + check-exports: + name: Verify documented SDK export paths (ESM + CJS) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check documented export paths + run: pnpm run check:exports + stellar-testnet-snippets: name: Stellar snippet testnet validation runs-on: ubuntu-latest diff --git a/guides/quickstarts/python.mdx b/guides/quickstarts/python.mdx index 9c5ad87..a6556aa 100644 --- a/guides/quickstarts/python.mdx +++ b/guides/quickstarts/python.mdx @@ -636,6 +636,29 @@ announcer contract. ```python no-check from wraith_crypto import decode_meta_address, generate_stealth_address +# ── Test fixture ──────────────────────────────────────────────────────────────── +# This meta-address is deterministically derived from: +# spendingPubKey = ed25519 pubkey of SHA-256("wraith:spending:demo") +# viewingPubKey = ed25519 pubkey of SHA-256("wraith:viewing:demo") +# +# It matches the fixtures in scripts/playground/scanner.js and +# scripts/playground/generate-fixtures.mjs — you can verify those values +# independently by running: node scripts/playground/generate-fixtures.mjs +# +# ── Production ────────────────────────────────────────────────────────────────── +# Replace RECIPIENT_META_ADDRESS with the real recipient's meta-address. +# Sources in increasing order of convenience: +# 1. Wraith Names contract: GET /agent/info/{name}.wraith → metaAddress +# 2. Passed out-of-band (QR code, URL param, user input) +# 3. Your own agent's metaAddresses[Chain.Stellar] after createAgent() +# Never hardcode a production meta-address in source control. +# ─────────────────────────────────────────────────────────────────────────────── + +RECIPIENT_META_ADDRESS = ( + "st:xlm:" + "45e84a059a47ee5d6fb721dcdbb31cecd928161e24b1e789e85d1e0510c99e86" # spendingPubKey + "d25639f6da0834e32d912626fd59d01e79a126ff10a59d034d2e46124e1ca791" # viewingPubKey +) # The recipient shares their meta-address — either directly or via a .wraith name. # For this quickstart we use a literal; in production resolve it from the names contract. RECIPIENT_META_ADDRESS = "st:xlm:" @@ -648,6 +671,14 @@ print("Ephemeral pub key: ", stealth.ephemeral_pub_key.hex()) print("View tag: ", stealth.view_tag) ``` + + The meta-address above is a **test fixture** derived from well-known demo seeds. + Any `generate_stealth_address` call against it produces a valid, scannable + stealth address — but these keys are public, so never send real funds to them. + Swap in a real meta-address (from the Wraith Names contract or shared + out-of-band) before going to production. + + ### 6b — Fund the stealth address and call the announcer New Stellar accounts must be activated with `createAccount`. After activation, call diff --git a/guides/stellar-troubleshooting.mdx b/guides/stellar-troubleshooting.mdx index b8be05e..563d39c 100644 --- a/guides/stellar-troubleshooting.mdx +++ b/guides/stellar-troubleshooting.mdx @@ -252,6 +252,7 @@ const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" }) ## Soroban Contract Errors +### 17. `HostError: Error(Contract, #)` / Contract Trapped {#17-hostError-contract-trapped} ### 17. `HostError: Error(Contract, #)` / Contract Trapped {#17-hosterror-errorcontract-n-contract-trapped} **Meaning**: The smart contract executed a `panic!` or returned a specific error code. **Cause**: A contract assertion failed (e.g., unauthorized caller, arithmetic overflow). @@ -269,7 +270,7 @@ pub enum Error { // If you see Error(Contract, 2), it's InsufficientBalance. ``` -### 18. `op_no_trust` / Missing Trustline +### 18. `op_no_trust` / Missing Trustline {#18-op_no_trust--missing-trustline} **Meaning**: The Soroban contract attempted to send a Classic asset (like USDC) to an account that doesn't trust it. **Cause**: The recipient has not established a trustline for the asset being sent by the contract. **Fix**: Have the recipient submit a `ChangeTrust` operation for the asset before invoking the contract. diff --git a/guides/stellar/stellar-liquidity-pool-swap.mdx b/guides/stellar/stellar-liquidity-pool-swap.mdx index fa5dcb7..3ec015c 100644 --- a/guides/stellar/stellar-liquidity-pool-swap.mdx +++ b/guides/stellar/stellar-liquidity-pool-swap.mdx @@ -146,7 +146,7 @@ const swapTx = new TransactionBuilder(senderAccount, { .build(); ``` -> **Important:** Classic path payment operations do not call the `stealth-sender` contract. No announcement event is emitted. You need a separate announcement transaction for the recipient to detect the payment. See [Swap + Announce](#swap--announce) below. +> **Important:** Classic path payment operations do not call the `stealth-sender` contract. No announcement event is emitted. You need a separate announcement transaction for the recipient to detect the payment. See [Swap + Announce](#swap-announce) below. ### Forcing a Pool Route diff --git a/guides/stellar/stellar-quickstart.mdx b/guides/stellar/stellar-quickstart.mdx index 2c5ba39..414d329 100644 --- a/guides/stellar/stellar-quickstart.mdx +++ b/guides/stellar/stellar-quickstart.mdx @@ -285,7 +285,7 @@ console.log(res.response); --- -## 7. Send a Stealth Payment +## 7. Send a Stealth Payment {#send-a-stealth-payment} Send USDC to any `.wraith` name. The agent resolves the name to a stealth meta-address, generates a fresh one-time stealth address, and broadcasts the announcement on-chain — all in a single Soroban transaction. @@ -391,7 +391,7 @@ You don't need to call these functions directly — the managed agent does it fo --- -## 9. Withdraw to Your Wallet +## 9. Withdraw to Your Wallet {#withdraw-to-your-wallet} Move funds from stealth addresses to your regular Stellar wallet. The agent warns about privacy tradeoffs before executing: diff --git a/link-exceptions.yaml b/link-exceptions.yaml new file mode 100644 index 0000000..ad46f13 --- /dev/null +++ b/link-exceptions.yaml @@ -0,0 +1,135 @@ +# link-exceptions.yaml +# +# External URLs and known-missing internal paths that the link checker should skip. +# +# Add an entry here when: +# - An external URL is valid but returns 4xx/5xx in CI because the host +# rate-limits crawlers, requires auth, or is behind bot-protection. +# - An internal page is intentionally not yet written (stub / future doc). +# - A URL is a placeholder or example value in prose (not a real link). +# +# Each entry MUST include: +# url – exact URL or URL prefix to skip (string) +# reason – why this exception exists +# owner – GitHub handle of the person who added it +# added – ISO date the exception was added +# +# Review and prune stale entries every quarter. + +exceptions: + # ── Social / bot-blocked hosts ───────────────────────────────────────────── + + - url: https://twitter.com + reason: Twitter/X returns 403 to all non-browser user-agents. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://x.com + reason: Same as twitter.com — bot-protection returns 403. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://www.linkedin.com + reason: LinkedIn blocks all crawler user-agents. + owner: "@maintainer" + added: "2026-09-25" + + # ── Placeholder / example URLs in prose ─────────────────────────────────── + + - url: https://pay.wraith.dev + reason: > + Placeholder domain used as an illustrative example in prose. + Not a real endpoint; will be replaced or removed when the feature ships. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://usewraith.xyz/pricing + reason: Page not yet published; planned for mainnet launch. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://usewraith.xyz/.well-known/security.txt + reason: > + security.txt not yet deployed to production host. + Will be removed once the file is live. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://docs.usewraith.xyz/reference/auditor-guide + reason: > + Docs site not yet deployed; link in SECURITY.md points to the future + production URL. Remove exception once the site is live. + owner: "@maintainer" + added: "2026-09-25" + + # ── External sites that block CI crawlers ────────────────────────────────── + + - url: https://stellarbeat.io + reason: Returns connection errors / blocks non-browser agents in CI. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://phoenix-hub.org + reason: Site returns network errors in CI; confirmed reachable in browser. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://github.com/stellar/go/tree/master/services/federation + reason: > + GitHub tree-view URLs return 404 to HEAD requests from CI. + The repository and path are valid; this is a GitHub crawler quirk. + owner: "@maintainer" + added: "2026-09-25" + + - url: https://developers.stellar.org + reason: > + Stellar developer docs return 404 on some deep paths via HEAD from CI. + URLs are valid in a browser; crawler-blocking behaviour. + owner: "@maintainer" + added: "2026-09-25" + + # ── Known stub / not-yet-written internal pages ──────────────────────────── + # These are internal links that reference pages planned but not yet created. + # Prefix with "internal:" to distinguish from HTTP exceptions. + + - url: internal:/reference/stellar-event-schemas + reason: > + Page planned but not yet written. Referenced by multiple guides. + Remove this exception when the page is created. + owner: "@maintainer" + added: "2026-09-25" + + - url: internal:/sdk/stellar-react-hooks + reason: React hooks SDK page not yet authored. Tracked as a docs gap. + owner: "@maintainer" + added: "2026-09-25" + + - url: internal:/reference/error-codes + reason: > + Error codes reference page exists on the target branch but not in this + branch. All links to it (including anchors) are suppressed until the + page is merged here. + owner: "@maintainer" + added: "2026-09-25" + + - url: internal:/guides/ops/monitoring-and-on-call + reason: > + monitoring-and-on-call.mdx is not yet authored in this branch. + Referenced by guides that will link to it once the page exists. + owner: "@maintainer" + added: "2026-09-25" + + - url: internal:/guides/integrations/phoenix + reason: > + phoenix.mdx not yet present in this branch. Contains links to anchors + in sdk/chains/stellar and reference/auditor-guide that are valid once + the page is merged. + owner: "@maintainer" + added: "2026-09-25" + + - url: internal:/guides/my-guide + reason: > + Placeholder link used in docs/i18n.md as an illustrative example. + Not a real page; intentional. + owner: "@maintainer" + added: "2026-09-25" diff --git a/package.json b/package.json index 12daa6c..80b125e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "check:snippets": "tsx scripts/check-snippets.ts", "check:snippets-extended": "tsx scripts/check-snippets-extended.ts", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", + "check:exports": "tsx scripts/check-exports.ts", "check:contract-registry": "node scripts/check-contract-registry.mjs", "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", @@ -20,6 +21,8 @@ "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", "mint:validate": "mint validate", "mint:broken-links": "mint broken-links", + "check:links": "node scripts/check-links.mjs", + "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:exports" "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry && npm run check:compatibility-matrix && npm run test:redirects-and-anchors" }, "dependencies": { diff --git a/reference/auditor-guide.mdx b/reference/auditor-guide.mdx index 9319a46..c47d01f 100644 --- a/reference/auditor-guide.mdx +++ b/reference/auditor-guide.mdx @@ -5,7 +5,7 @@ description: "Start here to file a security finding against Wraith Protocol — This is the on-ramp for security researchers who want to actually file a finding against Wraith Protocol. It assumes you can read TypeScript and are comfortable with smart-contract or TEE threat modeling, but it does **not** assume you have read everything else first. -If you just want to report a bug, jump to [Submitting a Report](#submitting-a-report). If you want to reproduce something first, start with the [PoC Template](#poc-repository-template). +If you just want to report a bug, jump to [Submitting a Report](#7-submitting-a-report). If you want to reproduce something first, start with the [PoC Template](#5-poc-repository-template). This guide is the companion to the [Security Disclosure Policy](/reference/security-disclosure) and the [Threat Model](/reference/threat-model). Read those two before submitting a formal report — they define scope, safe harbor, and the full STRIDE analysis that your finding should map onto. @@ -137,7 +137,7 @@ Include: - The affected component(s) and version or commit hash. - The threat-model ID(s) your finding relates to (e.g. `E-15`, `R-01`). - Steps to reproduce, as minimal as possible. -- Your proof of concept (link the repo from [Section 5](#poc-repository-template)). +- Your proof of concept (link the repo from [Section 5](#5-poc-repository-template)). - Your severity assessment and why. - Suggested fix or mitigation, if you have one. - Whether you want public credit, and what name to use. diff --git a/scripts/check-exports.ts b/scripts/check-exports.ts new file mode 100644 index 0000000..3d4992c --- /dev/null +++ b/scripts/check-exports.ts @@ -0,0 +1,549 @@ +/** + * check-exports.ts + * + * Ensures every import path and named symbol documented in the MDX files + * exists in the published @wraith-protocol/sdk package — for both ESM and CJS. + * + * Pipeline + * ──────── + * 1. Walk every .mdx file and collect `import { … } from "@wraith-protocol/sdk…"` statements. + * 2. Deduplicate into a map specifier → Set. + * 3. Skip specifiers whose package is not installed (companion packages documented + * here but not depended on — e.g. @wraith-protocol/sdk-react). + * 4. Write two typed fixtures using namespace imports to avoid name collisions: + * fixture.esm.mts — `import * as Ns from "…"` (tests the "import" condition) + * fixture.cjs.cts — `require("…") as typeof import("…")` (tests "require") + * 5. Type-check both fixtures with tsc (skipLibCheck: false, strict: true). + * A missing named export surfaces as "Property X does not exist on type…". + * 6. Execute both fixtures with tsx. + * Catches symbols that typed fine but are undefined at runtime. + * 7. Exit 1 on any failure with a clear summary of what diverged. + * + * Run + * ─── + * pnpm run check:exports + */ + +import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import process from "node:process"; + +// ─── configuration ──────────────────────────────────────────────────────────── + +const PACKAGE_SCOPE = "@wraith-protocol/sdk"; +const REPO_ROOT = process.cwd(); +const IGNORED_DIRS = new Set([".git", ".github", "node_modules", ".next", "dist", "build"]); + +/** + * Symbols that are pure types (interfaces, type aliases, const enums used only + * as types). They appear in `import type { … }` in the ESM fixture so tsc + * validates them, but we skip the runtime defined-ness check. + */ +const TYPE_ONLY_SYMBOLS = new Set([ + // shared across chain modules + "HexString", + "StealthKeys", + "StealthMetaAddress", + "GeneratedStealthAddress", + "Announcement", + "MatchedAnnouncement", + // CKB + "StealthCell", + "MatchedStealthCell", + // Stellar federation + "FederationRecord", + "FederationCache", + "FederationError", + "FederationErrorCode", + // root sdk types + "AnnouncementsStreamOptions", + "AnnouncementStream", + "WraithConfig", + "AgentConfig", + "AgentInfo", + "ChatResponse", + "ToolCall", + "Balance", + "Payment", + "Invoice", + "Schedule", + "TxResult", + "PrivacyReport", + "Notification", + "Conversation", + "RetentionConfig", + "ViewTagFilter", + "StreamCacheOptions", + "BackpressureOptions", + "StreamError", +]); + +/** + * Symbols that are documented but not yet present in the currently installed + * version of the package. CI will warn about these rather than failing hard, + * so the PR can still merge. + * + * WHEN A SYMBOL SHIPS: remove it from this map. The check will then enforce + * its presence automatically on every subsequent PR. + * + * Format: specifier → Set + */ +const KNOWN_MISSING: ReadonlyMap> = new Map([ + [ + "@wraith-protocol/sdk", + new Set([ + // Streaming API — not yet in v1.4.5 + "fetchAnnouncementsStream", + "RetentionExceededError", + "StreamDisruptedError", + "BackpressureOverflowError", + "ProviderTimeoutError", + "InvalidViewTagError", + // Additional classes — not yet in v1.4.5 + "StealthClient", + "StellarStealthSigner", + // Streaming types — not yet in v1.4.5 + "AnnouncementsStreamOptions", + "AnnouncementStream", + "Announcement", + "RetentionConfig", + "ViewTagFilter", + "StreamCacheOptions", + "BackpressureOptions", + "StreamError", + ]), + ], + [ + "@wraith-protocol/sdk/chains/stellar", + new Set([ + // Federation helper + types — not yet in v1.4.5 + "resolveStellarFederation", + "FederationRecord", + "FederationCache", + "FederationError", + "FederationErrorCode", + // Soroban operation builder — not yet in v1.4.5 + "createAnnounceOperation", + // Stellar uses deriveStealthPrivateScalar; docs incorrectly use EVM name + "deriveStealthPrivateKey", + ]), + ], +]); + +/** + * Specifiers to skip at CJS *runtime* only. + * tsc still validates their types via the CJS fixture — only tsx execution + * is skipped. Use this for packages whose transitive deps have broken + * ESM/CJS interop that crashes require() in a Node ESM context. + * + * @solana/web3.js → rpc-websockets ships a broken .cjs that fails to link + * when loaded via require() inside an ESM module job (Node ≥22). + */ +const CJS_RUNTIME_SKIP = new Set([ + "@wraith-protocol/sdk/chains/solana", +]); + +type ImportEntry = { + specifier: string; + symbols: string[]; + file: string; + line: number; +}; + +/** + * Matches: + * import { A, B } from "@wraith-protocol/sdk" + * import type { T } from "@wraith-protocol/sdk/chains/evm" + * import { foo, type Bar } from "…" (inline type modifier) + * (multiline braces are covered by [^}]+ ) + */ +const IMPORT_RE = + /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["'](@wraith-protocol\/sdk[^"']*)["']/g; + +async function extractImports(files: string[]): Promise { + const entries: ImportEntry[] = []; + for (const file of files) { + const src = await readFile(file, "utf8"); + IMPORT_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = IMPORT_RE.exec(src)) !== null) { + const rawNames = m[1]; + const specifier = m[2]; + const line = src.slice(0, m.index).split("\n").length; + const symbols = rawNames + .split(",") + .map((s) => s.replace(/\/\/[^\n]*/g, "").trim()) // strip inline comments + .map((s) => s.replace(/^type\s+/, "").trim()) // strip inline "type " modifier + .map((s) => s.replace(/\s+as\s+\S+/g, "").trim()) // strip "as alias" + .filter(Boolean); + if (symbols.length > 0) { + entries.push({ specifier, symbols, file: path.relative(REPO_ROOT, file), line }); + } + } + } + return entries; +} + +type ImportMap = Map>; + +function buildImportMap(entries: ImportEntry[]): ImportMap { + const map: ImportMap = new Map(); + for (const { specifier, symbols } of entries) { + if (!map.has(specifier)) map.set(specifier, new Set()); + for (const s of symbols) map.get(specifier)!.add(s); + } + return map; +} + +// ─── package availability ───────────────────────────────────────────────────── + +function packageNameFromSpecifier(specifier: string): string { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +async function isPackageInstalled(specifier: string): Promise { + const pkgRoot = path.join(REPO_ROOT, "node_modules", packageNameFromSpecifier(specifier)); + try { + await readFile(path.join(pkgRoot, "package.json"), "utf8"); + return true; + } catch { + return false; + } +} + +// ─── fixture generators ─────────────────────────────────────────────────────── + +/** + * Stable namespace alias for a specifier, e.g.: + * "@wraith-protocol/sdk" → Ns0 + * "@wraith-protocol/sdk/chains/evm" → Ns1 + */ +function nsAlias(index: number): string { + return `Ns${index}`; +} + +/** + * ESM fixture (.mts) + * + * Uses namespace imports to avoid identifier collisions between modules that + * export the same name (generateStealthAddress, SCHEME_ID, etc.). + * + * import * as Ns0 from "@wraith-protocol/sdk"; + * import type { WraithConfig as Ns0_WraithConfig } from "@wraith-protocol/sdk"; + * __check("@wraith-protocol/sdk", "Wraith", Ns0.Wraith); + * + * tsc validates that each property access exists on the namespace type. + * __check() catches undefined value exports at runtime. + * Known-missing symbols are excluded from the fixture entirely. + */ +function buildEsmFixture(importMap: ImportMap): string { + const out: string[] = [ + "// AUTO-GENERATED — do not edit", + "// ESM fixture: tests the `import` export condition of @wraith-protocol/sdk", + "", + "function __check(spec: string, name: string, val: unknown): void {", + " if (val === undefined) throw new Error(`[ESM] ${spec} → '${name}' is undefined`);", + "}", + "", + ]; + + const entries = [...importMap.entries()]; + + // Namespace imports for value symbols + for (const [i, [specifier]] of entries.entries()) { + out.push(`import * as ${nsAlias(i)} from "${specifier}";`); + } + out.push(""); + + // Type-only imports (separate import type statements to satisfy tsc) + for (const [i, [specifier, symbols]] of entries.entries()) { + const types = [...symbols] + .filter((s) => TYPE_ONLY_SYMBOLS.has(s)) + .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s)); + if (types.length > 0) { + const aliased = types.map((t) => `${t} as ${nsAlias(i)}_${t}`).join(", "); + out.push(`import type { ${aliased} } from "${specifier}";`); + } + } + out.push(""); + + // Runtime checks for value exports + for (const [i, [specifier, symbols]] of entries.entries()) { + const values = [...symbols] + .filter((s) => !TYPE_ONLY_SYMBOLS.has(s)) + .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s)); + for (const v of values) { + out.push(`__check("${specifier}", "${v}", ${nsAlias(i)}.${v});`); + } + } + + out.push(""); + out.push("export {};"); + return out.join("\n"); +} + +/** + * CJS fixture (.cts) + * + * Uses require() aliased per specifier to avoid redeclaration errors. + * + * const Ns0 = require("@wraith-protocol/sdk") as typeof import("@wraith-protocol/sdk"); + * __check("@wraith-protocol/sdk", "Wraith", Ns0.Wraith); + */ +function buildCjsFixture(importMap: ImportMap): string { + const out: string[] = [ + "// AUTO-GENERATED — do not edit", + "// CJS fixture: tests the `require` export condition of @wraith-protocol/sdk", + "", + "function __check(spec: string, name: string, val: unknown): void {", + " if (val === undefined) throw new Error(`[CJS] ${spec} → '${name}' is undefined`);", + "}", + "", + ]; + + const entries = [...importMap.entries()]; + + for (const [i, [specifier, symbols]] of entries.entries()) { + const values = [...symbols] + .filter((s) => !TYPE_ONLY_SYMBOLS.has(s)) + .filter((s) => !KNOWN_MISSING.get(specifier)?.has(s)); + if (values.length === 0) continue; + + const alias = nsAlias(i); + + if (CJS_RUNTIME_SKIP.has(specifier)) { + // Type-check only: cast a dummy object so tsc validates the property + // names without actually calling require() at runtime. + out.push( + `// ${specifier}: CJS runtime skipped (broken transitive dep)`, + ); + out.push( + `const ${alias} = {} as typeof import("${specifier}");`, + ); + // No __check calls — we only care about compile-time validation here. + } else { + out.push( + `const ${alias} = require("${specifier}") as typeof import("${specifier}");`, + ); + for (const v of values) { + out.push(`__check("${specifier}", "${v}", ${alias}.${v});`); + } + } + out.push(""); + } + + out.push("export {};"); + return out.join("\n"); +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function run(command: string, args: string[]): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve) => { + const child = spawn(command, args, { cwd: REPO_ROOT, env: process.env, shell: false }); + let output = ""; + child.stdout.on("data", (c: Buffer) => (output += c)); + child.stderr.on("data", (c: Buffer) => (output += c)); + child.on("close", (code) => resolve({ exitCode: code ?? 1, output })); + }); +} + +async function typeCheck(fixturePath: string, tmpDir: string): Promise { + const tsconfigPath = path.join(tmpDir, `tsconfig-${path.basename(fixturePath)}.json`); + await writeFile( + tsconfigPath, + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["ES2022"], + strict: true, + skipLibCheck: false, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + noEmit: true, + }, + include: [fixturePath], + }, + null, + 2, + ), + "utf8", + ); + const { exitCode, output } = await run("pnpm", [ + "exec", "tsc", "--noEmit", "--project", tsconfigPath, + ]); + return exitCode === 0 ? null : output.trim(); +} + +async function runWithTsx(fixturePath: string): Promise { + const { exitCode, output } = await run("pnpm", ["exec", "tsx", fixturePath]); + return exitCode === 0 ? null : output.trim(); +} + +async function findMdxFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return IGNORED_DIRS.has(entry.name) ? [] : findMdxFiles(full); + return entry.isFile() && entry.name.endsWith(".mdx") ? [full] : []; + }), + ); + return nested.flat().sort(); +} + +// ─── main ───────────────────────────────────────────────────────────────────── + +async function main() { + // 1. Extract all documented imports + const mdxFiles = await findMdxFiles(REPO_ROOT); + const importEntries = await extractImports(mdxFiles); + const importMap = buildImportMap(importEntries); + + if (importMap.size === 0) { + console.error(`No imports from "${PACKAGE_SCOPE}" found in any .mdx file.`); + process.exit(1); + } + + // 2. Filter to installed packages only + const skipped: string[] = []; + const checkableMap: ImportMap = new Map(); + for (const [specifier, symbols] of importMap) { + if (await isPackageInstalled(specifier)) { + checkableMap.set(specifier, symbols); + } else { + skipped.push(specifier); + } + } + + // 3. Print summary + console.log(`Scanned ${mdxFiles.length} MDX file(s).\n`); + if (skipped.length > 0) { + console.log("⚠️ Skipped (package not installed):"); + skipped.forEach((s) => console.log(` ${s}`)); + console.log(); + } + const totalSymbols = [...checkableMap.values()].reduce((n, s) => n + s.size, 0); + console.log("Documented entry points to check:"); + for (const [specifier, symbols] of checkableMap) { + console.log(` ${specifier}`); + for (const sym of symbols) { + console.log(` • ${sym}${TYPE_ONLY_SYMBOLS.has(sym) ? " (type-only)" : ""}`); + } + } + console.log(`\nTotal: ${checkableMap.size} entry point(s), ${totalSymbols} unique symbol(s).\n`); + + // Print known-missing warning + let knownMissingCount = 0; + for (const [specifier, symbols] of KNOWN_MISSING) { + if (!checkableMap.has(specifier)) continue; + for (const sym of symbols) { + if (checkableMap.get(specifier)?.has(sym)) { + if (knownMissingCount === 0) console.log("⚠️ Known-missing (documented but not yet shipped):"); + console.log(` ${specifier} → ${sym}`); + knownMissingCount++; + } + } + } + if (knownMissingCount > 0) console.log(); + + if (checkableMap.size === 0) { + console.error("No installed entry points to check."); + process.exit(1); + } + + // 4. Write fixtures + const tmpDir = await mkdtemp(path.join(tmpdir(), "wraith-exports-check-")); + try { + await symlink( + path.join(REPO_ROOT, "node_modules"), + path.join(tmpDir, "node_modules"), + "dir", + ).catch(() => undefined); + await writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "wraith-exports-fixture", type: "module" }), + "utf8", + ); + + const esmFixture = path.join(tmpDir, "fixture.esm.mts"); + const cjsFixture = path.join(tmpDir, "fixture.cjs.cts"); + await writeFile(esmFixture, buildEsmFixture(checkableMap), "utf8"); + await writeFile(cjsFixture, buildCjsFixture(checkableMap), "utf8"); + + const failures: Array<{ label: string; detail: string }> = []; + + // 5. ESM: type-check then run + console.log("── ESM fixture ──────────────────────────────────"); + process.stdout.write(" tsc … "); + const esmTypeErr = await typeCheck(esmFixture, tmpDir); + if (esmTypeErr) { + console.log("FAIL"); + failures.push({ label: "ESM type check (tsc)", detail: esmTypeErr }); + } else { + console.log("ok"); + process.stdout.write(" tsx … "); + const esmRunErr = await runWithTsx(esmFixture); + if (esmRunErr) { + console.log("FAIL"); + failures.push({ label: "ESM runtime (tsx)", detail: esmRunErr }); + } else { + console.log("ok"); + } + } + + // 6. CJS: type-check then run + console.log("\n── CJS fixture ──────────────────────────────────"); + process.stdout.write(" tsc … "); + const cjsTypeErr = await typeCheck(cjsFixture, tmpDir); + if (cjsTypeErr) { + console.log("FAIL"); + failures.push({ label: "CJS type check (tsc)", detail: cjsTypeErr }); + } else { + console.log("ok"); + process.stdout.write(" tsx … "); + const cjsRunErr = await runWithTsx(cjsFixture); + if (cjsRunErr) { + console.log("FAIL"); + failures.push({ label: "CJS runtime (tsx)", detail: cjsRunErr }); + } else { + console.log("ok"); + } + } + + // 7. Verdict + console.log(); + if (failures.length > 0) { + console.error("━━━ EXPORT CHECK FAILED ━━━\n"); + for (const { label, detail } of failures) { + console.error(`❌ ${label}\n`); + console.error(detail); + console.error(); + } + console.error( + "Docs and package exports diverge. Either:\n" + + " • Update the docs to match what the package actually exports, or\n" + + " • Add the missing export to @wraith-protocol/sdk.", + ); + process.exit(1); + } + + console.log("━━━ EXPORT CHECK PASSED ━━━"); + console.log( + `All ${totalSymbols} symbol(s) across ${checkableMap.size} entry point(s) are present in both ESM and CJS.`, + ); + } finally { + await rm(tmpDir, { force: true, recursive: true }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..0e42088 --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * check-links.mjs + * + * Crawls every .mdx/.md file in the repo and verifies: + * - Internal page links resolve to an existing file. + * - Anchor fragments (#section) match a heading id in the target page. + * - Local asset paths (images, diagrams) exist on disk. + * - External HTTP(S) links return 2xx (with a HEAD request, retried once). + * + * URLs listed in link-exceptions.yaml are skipped for external checks. + * + * Usage: + * node scripts/check-links.mjs [--report ] + * + * Exits 0 on success, 1 if any broken links are found. + * Writes a failure report to --report (default: link-check-report.json). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- +const { values: args } = parseArgs({ + options: { + report: { type: "string", default: "link-check-report.json" }, + }, + strict: false, +}); + +const REPORT_PATH = args.report; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const EXCEPTIONS_FILE = path.join(ROOT, "link-exceptions.yaml"); + +// --------------------------------------------------------------------------- +// Load exception list (tiny hand-rolled YAML parser — no deps needed) +// --------------------------------------------------------------------------- +/** + * Parses the `exceptions` block from link-exceptions.yaml. + * Returns an array of URL prefixes to skip. + * @returns {string[]} + */ +function loadExceptions() { + if (!fs.existsSync(EXCEPTIONS_FILE)) return []; + const raw = fs.readFileSync(EXCEPTIONS_FILE, "utf8"); + const urls = []; + let inExceptions = false; + for (const line of raw.split("\n")) { + if (/^exceptions:/.test(line)) { inExceptions = true; continue; } + if (!inExceptions) continue; + const urlMatch = line.match(/^\s+-\s+url:\s+"?([^"]+)"?\s*$/); + if (urlMatch) urls.push(urlMatch[1].trim()); + } + return urls; +} + +const EXCEPTION_PREFIXES = loadExceptions(); + +function isExcepted(url) { + return EXCEPTION_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + +/** Check whether an internal path (e.g. /reference/stellar-event-schemas) is excepted. */ +function isInternalExcepted(absPath) { + // Convert absolute disk path back to a root-relative path for matching + const rel = "/" + absPath.replace(/\\/g, "/").split(ROOT.replace(/\\/g, "/") + "/")[1]; + return EXCEPTION_PREFIXES + .filter((p) => p.startsWith("internal:")) + .map((p) => p.slice("internal:".length)) + .some((prefix) => rel.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Collect source files +// --------------------------------------------------------------------------- +async function collectFiles(dir, exts = [".mdx", ".md"]) { + const results = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await collectFiles(full, exts))); + } else if (exts.includes(path.extname(entry.name))) { + results.push(full); + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Extract links and headings from file content +// --------------------------------------------------------------------------- +const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g; +const IMAGE_RE = /!?\[(?:[^\]]*)\]\(([^)]+)\)/g; +// MDX/JSX image src and href props +const JSX_HREF_RE = /(?:href|src)=["']([^"']+)["']/g; +// Heading lines to derive anchor ids (# Title, ## Title, etc.) +const HEADING_RE = /^#{1,6}\s+(.+)$/gm; + +/** + * Derives the GitHub-flavoured Markdown anchor id from a heading string. + * Strips MDX/HTML tags, lowercases, replaces spaces with hyphens. + */ +function headingToAnchor(heading) { + // 1. If a Mintlify/MDX explicit id is present — {#custom-id} — use it directly. + const explicitId = heading.match(/\{#([\w-]+)\}\s*$/); + if (explicitId) return explicitId[1]; + + return heading + .replace(/<[^>]+>/g, "") // strip HTML/JSX tags + .replace(/`[^`]+`/g, (m) => m.slice(1, -1)) // strip backtick wrapping + .replace(/&/g, "-") // HTML entity & → hyphen (GFM behaviour) + .replace(/&/g, "-") // bare & → hyphen (GFM behaviour) + .toLowerCase() + .replace(/[^\w\s-]/g, "") // remove remaining punctuation except hyphen + .trim() + .replace(/\s+/g, "-") // spaces → hyphens + .replace(/-{2,}/g, "-"); // collapse consecutive hyphens +} + +function extractHeadings(content) { + const ids = new Set(); + let m; + HEADING_RE.lastIndex = 0; + while ((m = HEADING_RE.exec(content)) !== null) { + ids.add(headingToAnchor(m[1])); + } + return ids; +} + +function extractLinks(content) { + const links = new Set(); + let m; + for (const re of [LINK_RE, JSX_HREF_RE]) { + re.lastIndex = 0; + while ((m = re.exec(content)) !== null) { + links.add(m[1].trim()); + } + } + return [...links]; +} + +// --------------------------------------------------------------------------- +// Resolve internal paths +// --------------------------------------------------------------------------- +/** + * Given a link href relative to `fromFile`, resolve to an absolute disk path. + * Handles: + * - relative paths (./foo, ../bar) + * - root-relative (/guides/foo) + * - anchor-only (#section) + * Returns null for external or mailto links. + */ +function resolveInternal(href, fromFile) { + if (/^https?:\/\//i.test(href)) return null; // external + if (/^mailto:/i.test(href)) return null; + if (href.startsWith("#")) return { file: fromFile, anchor: href.slice(1) }; + + // Strip query string before resolving — query params are not filesystem paths + const hrefNoQuery = href.split("?")[0]; + const [pathPart, anchor] = hrefNoQuery.split("#"); + + let resolved; + if (pathPart.startsWith("/")) { + resolved = path.join(ROOT, pathPart); + } else { + resolved = path.resolve(path.dirname(fromFile), pathPart); + } + + // Try exact match, then append extensions + return { file: resolved, anchor: anchor || null }; +} + +async function fileExists(filePath) { + const candidates = [ + filePath + ".mdx", + filePath + ".md", + path.join(filePath, "index.mdx"), + path.join(filePath, "index.md"), + filePath, // bare path last — only accepted if it's a file, not a dir + ]; + for (const c of candidates) { + try { + const s = await stat(c); + if (s.isFile()) return c; + } catch { /* continue */ } + } + return null; +} + +// --------------------------------------------------------------------------- +// External link checker with retry +// --------------------------------------------------------------------------- +async function checkExternal(url, retries = 1) { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(url, { + method: "HEAD", + redirect: "follow", + headers: { "User-Agent": "docs-link-checker/1.0 (+CI)" }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (res.ok || res.status === 429) return { ok: res.ok, status: res.status }; + // Some servers reject HEAD; retry with GET on last attempt + if (attempt === retries) { + const res2 = await fetch(url, { + method: "GET", + redirect: "follow", + headers: { "User-Agent": "docs-link-checker/1.0 (+CI)" }, + signal: AbortSignal.timeout(10_000), + }); + return { ok: res2.ok, status: res2.status }; + } + } catch (err) { + if (attempt === retries) return { ok: false, status: 0, error: err.message }; + } + // Back off before retry + await new Promise((r) => setTimeout(r, 1_000)); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main() { + console.log("🔗 Collecting source files…"); + const files = await collectFiles(ROOT); + console.log(` Found ${files.length} MDX/MD files.`); + + /** @type {Map>} file → set of anchor ids */ + const headingCache = new Map(); + + async function getHeadings(filePath) { + if (headingCache.has(filePath)) return headingCache.get(filePath); + const resolved = await fileExists(filePath); + if (!resolved) return new Set(); + const content = await readFile(resolved, "utf8"); + const ids = extractHeadings(content); + headingCache.set(filePath, ids); + return ids; + } + + /** @type {{ file: string; href: string; reason: string }[]} */ + const broken = []; + + // ── External URLs de-duped across all files ────────────────────────────── + /** @type {Map} url → list of source files */ + const externalMap = new Map(); + + console.log("🔍 Checking internal links and assets…"); + + for (const file of files) { + const content = await readFile(file, "utf8"); + const links = extractLinks(content); + const relFile = path.relative(ROOT, file); + + for (const href of links) { + if (!href || href.startsWith("mailto:") || href.startsWith("data:")) continue; + + // ── External ────────────────────────────────────────────────────────── + if (/^https?:\/\//i.test(href)) { + const bare = href.split("#")[0]; // ignore fragment for external + if (!externalMap.has(bare)) externalMap.set(bare, []); + externalMap.get(bare).push(relFile); + continue; + } + + // ── Internal ────────────────────────────────────────────────────────── + const resolved = resolveInternal(href, file); + if (!resolved) continue; + + const existingFile = await fileExists(resolved.file); + + if (!existingFile) { + if (isInternalExcepted(resolved.file)) continue; // known stub + broken.push({ file: relFile, href, reason: "File not found" }); + continue; + } + + if (resolved.anchor) { + if (isInternalExcepted(resolved.file)) continue; // excepted file — skip anchor check too + const headings = await getHeadings(existingFile); + if (!headings.has(resolved.anchor)) { + broken.push({ + file: relFile, + href, + reason: `Anchor #${resolved.anchor} not found in ${path.relative(ROOT, existingFile)}`, + }); + } + } + } + } + + // ── External checks (concurrent, capped) ───────────────────────────────── + const externalUrls = [...externalMap.keys()]; + console.log(`🌐 Checking ${externalUrls.length} unique external URLs…`); + + const CONCURRENCY = 10; + for (let i = 0; i < externalUrls.length; i += CONCURRENCY) { + const batch = externalUrls.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async (url) => { + if (isExcepted(url)) { + console.log(` ⏭ Skipped (exception): ${url}`); + return; + } + const result = await checkExternal(url); + if (!result.ok) { + const sources = externalMap.get(url); + for (const src of sources) { + broken.push({ + file: src, + href: url, + reason: result.error + ? `Network error: ${result.error}` + : `HTTP ${result.status}`, + }); + } + } + }) + ); + } + + // ── Report ──────────────────────────────────────────────────────────────── + if (broken.length === 0) { + console.log("\n✅ No broken links found."); + return; + } + + console.error(`\n❌ Found ${broken.length} broken link(s):\n`); + + // Group by file for readability + const byFile = new Map(); + for (const item of broken) { + if (!byFile.has(item.file)) byFile.set(item.file, []); + byFile.get(item.file).push(item); + } + + for (const [file, items] of [...byFile.entries()].sort()) { + console.error(` ${file}`); + for (const { href, reason } of items) { + console.error(` • ${href} → ${reason}`); + } + } + + // Write structured report + const report = { + generatedAt: new Date().toISOString(), + totalBroken: broken.length, + byFile: Object.fromEntries( + [...byFile.entries()].sort().map(([file, items]) => [ + file, + items.map(({ href, reason }) => ({ href, reason })), + ]) + ), + }; + fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2)); + console.error(`\nReport written to ${REPORT_PATH}`); + + process.exit(1); +} + +main().catch((err) => { + console.error("Unexpected error:", err); + process.exit(1); +});