diff --git a/.gitignore b/.gitignore index be845a0..1125218 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,11 @@ coverage/ # Misc .npmrc +.temp/ +.tmp/ +cursor_logs/ # Scripts scripts/*.bat scripts/*.sh +scripts/fix-core-imports.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed787dc..e0ab3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ## [Unreleased] +### Changed + +- **`docs:link-check`:** validates Markdown heading fragment anchors (GitHub-style slugs) in addition to file/URL existence; unit tests cover slug helpers in `scripts/docs-link-check.mjs`. + ## [0.5.0] - 2026-07-25 ### Added @@ -28,7 +32,7 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ### Removed -- **Breaking (library):** Trimmed internal-only re-exports from the package root and `/alliance` entry to shrink the public surface and blast radius: `trimOptional`, `createUnconfiguredAllianceContext` (core), and the concrete URL generators `generatorMailing`, `generatorSlackCpplang` (alliance). These were internal helpers, not part of the documented API; register built-ins via `registerBuiltinUrlGenerators` and build contexts via `createServer` / `createIsolatedContext`. A snapshot test now guards the runtime export surface so internal symbols cannot leak back in. See [MIGRATION.md](docs/MIGRATION.md#internal-only-re-exports-removed-203). (#203) +- **Breaking (library):** Trimmed internal-only re-exports from the package root and `/alliance` entry to shrink the public surface and blast radius: `trimOptional`, `createUnconfiguredAllianceContext` (core), and the concrete URL generators `generatorMailing`, `generatorSlackCpplang` (alliance). These were internal helpers, not part of the documented API; register built-ins via `registerBuiltinUrlGenerators` and build contexts via `createServer` / `createIsolatedContext`. A snapshot test now guards the runtime export surface so internal symbols cannot leak back in. See [MIGRATION.md](docs/MIGRATION.md#050-internal-only-re-exports-removed-203). (#203) ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b4ced6..f4fc186 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,9 @@ npm run ci | ------ | ------- | | `npm run build` | Clean `dist/` and `tsc` compile | | `npm run typecheck` | `tsc --noEmit` | -| `npm run lint` | ESLint on `src/` | +| `npm run lint` | ESLint on `src/` and `scripts/` | | `npm run lint:fix` | ESLint with `--fix` | -| `npm run format` | Prettier write (`src/**/*.ts`, config JSON) | +| `npm run format` | Prettier write (`src/**/*.ts`, `scripts/**/*.{mjs,ts}`, config JSON) | | `npm run format:check` | Prettier check | | `npm test` | Vitest once | | `npm run test:coverage` | Vitest + coverage thresholds (`vitest.config.ts`) | diff --git a/README.md b/README.md index 5184070..0d103f1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Current version: 0.5.0 (npm `latest` after publish). Pin `@0.5.0` in install and ## Upgrading to 0.5.0 -Version **0.5.0** includes breaking MCP and library changes: `list_sources` returns `sources: { name, description? }[]`, `PineconeClient.listNamespacesWithMetadata()` returns `{ namespaces, warnings }`, internal-only package re-exports were removed (#203), the MCP SDK floor is **^1.29.0**, and hybrid leg failure with zero surviving hits is signaled under `experimental.degraded` / `experimental.hybrid_leg_failed` (#228). Legacy module facades remain deprecated but are **not** removed in this release. See [docs/MIGRATION.md § list_sources](docs/MIGRATION.md#050-list_sources-response-shape), [§ listNamespacesWithMetadata](docs/MIGRATION.md#050-pineconeclientlistnamespaceswithmetadata-return-shape), [§ internal re-exports (#203)](docs/MIGRATION.md#internal-only-re-exports-removed-203), and the [CHANGELOG](CHANGELOG.md#050---2026-07-25). +Version **0.5.0** includes breaking MCP and library changes: `list_sources` returns `sources: { name, description? }[]`, `PineconeClient.listNamespacesWithMetadata()` returns `{ namespaces, warnings }`, internal-only package re-exports were removed (#203), the MCP SDK floor is **^1.29.0**, and hybrid leg failure with zero surviving hits is signaled under `experimental.degraded` / `experimental.hybrid_leg_failed` (#228). Legacy module facades remain deprecated but are **not** removed in this release. See [docs/MIGRATION.md § list_sources](docs/MIGRATION.md#050-list_sources-response-shape), [§ listNamespacesWithMetadata](docs/MIGRATION.md#050-pineconeclientlistnamespaceswithmetadata-return-shape), [§ internal re-exports (#203)](docs/MIGRATION.md#050-internal-only-re-exports-removed-203), and the [CHANGELOG](CHANGELOG.md#050---2026-07-25). ## Upgrading to 0.4.0 diff --git a/docs/CI_CD.md b/docs/CI_CD.md index 1243b18..ad1918b 100644 --- a/docs/CI_CD.md +++ b/docs/CI_CD.md @@ -21,7 +21,7 @@ **Job `quality`** -- Ubuntu + Node 20: `npm audit --audit-level=moderate` (continue-on-error) → `npm pack --dry-run` → **`npm run docs:link-check`** (single `npx markdown-link-check` over `README.md`, `CHANGELOG.md`, and all `docs/**/*.md`). +- Ubuntu + Node 20: `npm audit --audit-level=moderate` (continue-on-error) → `npm pack --dry-run` → **`npm run docs:link-check`** (`markdown-link-check` for file/URL targets plus heading-anchor validation in `scripts/docs-link-check.mjs`). --- diff --git a/docs/README.md b/docs/README.md index f587083..91b8ffb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,5 +14,6 @@ Guides for operators, MCP client authors, and library embedders. | [deprecation-policy.md](./deprecation-policy.md) | Deprecation window, semver, CHANGELOG conventions | | [templates/breaking-change-release-notes.md](./templates/breaking-change-release-notes.md) | GitHub Release body template for breaking versions | | [RELEASING.md](./RELEASING.md) | npm publish via GitHub Releases | +| [release-verification.md](./release-verification.md) | Post-publish verification and production-readiness sign-offs | The main [README.md](../README.md) remains the high-level overview; deep reference lives here. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c893998..0afae2c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,3 +16,7 @@ Packages are published to npm as **`@will-cppa/pinecone-read-only-mcp`**. ## Version source `SERVER_VERSION` is read from `package.json` at runtime so MCP `serverInfo` matches the published package. + +## Post-publish verification + +After a release is on npm, record artifact and CI alignment in [release-verification.md](./release-verification.md) (see [0.5.0 sign-off](./release-verification-0.5.0.md) for `v0.5.0`). diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index 3a39155..633f216 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -32,7 +32,7 @@ Renames may ship the **replacement immediately** alongside the deprecated alias APIs deprecated **before** this policy was published follow the removal target recorded in CHANGELOG and source comments at deprecation time. The `paper_number` field on query result rows (use `document_id` instead) was deprecated in **0.2.0** with removal planned no earlier than the **next major** release after `1.0.0`; it will not be removed in a `0.y` minor without an explicit CHANGELOG entry and MIGRATION update. -### Active deprecations - legacy module facades +### Active deprecations: legacy module facades Module-level singleton facades delegate to `getDefaultServerContext()`. Migrate to **`ServerContext`** instance methods via `createServer(config)` and pass `{ context: ctx }` to `setupCoreServer` / `setupAllianceServer`. Deprecated in **`0.3.0`**; earliest removal **`0.5.0`** (per [Deprecation window](#deprecation-window) above). See [MIGRATION.md § Legacy module-facade deprecations](./MIGRATION.md#030-legacy-module-facade-deprecations). @@ -132,7 +132,7 @@ Each breaking bullet should state: - **What changed** — concrete behavior or schema difference. - **Who is affected** — MCP clients, library embedders, operators, etc. -- **Migration** — link to [MIGRATION.md](./MIGRATION.md#anchor) (or “see MIGRATION.md § …”). +- **Migration** — link to the specific [MIGRATION.md](./MIGRATION.md) heading covering the change (or “see MIGRATION.md § …”). ### Deprecated entries diff --git a/docs/release-verification-0.5.0.md b/docs/release-verification-0.5.0.md new file mode 100644 index 0000000..f4d512b --- /dev/null +++ b/docs/release-verification-0.5.0.md @@ -0,0 +1,147 @@ +# Production readiness sign-off: `@will-cppa/pinecone-read-only-mcp@0.5.0` + +- **Package:** `@will-cppa/pinecone-read-only-mcp` +- **Version:** `0.5.0` +- **Git tag:** `v0.5.0` → `c28e346efafadbfef98891e523d566eebe957dad` (“Updated documents for new release (#236)”) +- **Recorded:** 2026-07-29 (UTC+8 local) +- **Verifier:** Jonathan (@jonathanMLDev), assignee for week 5 verification task +- **Scope:** Verification and sign-off only. No product code changes. `ServerContext` decomposition and legacy facade removal remain deferred past July 2026. + +## Acceptance criteria evidence + +### AC 1 — npm registry version and publish time + +| Field | Value | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | +| `npm view … version` | `0.5.0` | +| `dist-tags.latest` | `0.5.0` | +| `time['0.5.0']` (UTC) | `2026-07-24T18:41:39.687Z` | +| CHANGELOG release date | `2026-07-25` ([CHANGELOG.md](../CHANGELOG.md#050---2026-07-25); calendar date vs UTC publish timestamp) | + +Commands used: `npm view @will-cppa/pinecone-read-only-mcp@0.5.0 version gitHead dist-tags time --json` + +### AC 2 — `package.json` in tarball vs tagged tree + +Repository state for verification: **detached HEAD at `v0.5.0`** (`c28e346`). + +Compared packed `package/package.json` from: + +1. Local `npm pack` after `npm ci` and `npm run build` on `v0.5.0` +2. Registry `npm pack @will-cppa/pinecone-read-only-mcp@0.5.0` + +These fields are **identical** in both tarballs (local pack vs registry pack; no differences in `version`, `files`, `exports`, or `bin`): + +- `version`: `0.5.0` +- `files`: `dist`, `README.md`, `LICENSE`, `CHANGELOG.md` +- `bin`: + +```json +{ + "pinecone-read-only-mcp": "dist/index.js" +} +``` + +- `exports` (same object in both packed `package/package.json` files): + +```json +{ + ".": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./alliance": { + "types": "./dist/alliance/index.d.ts", + "import": "./dist/alliance/index.js" + }, + "./package.json": "./package.json" +} +``` + +`npm pack --dry-run` on the tag reported **208** packaged files; registry pack lists the same paths (no `src/`). + +### AC 3 — `dist/` contents and hygiene + +- Fresh build on tag: `npm run build` (runs `clean` then `tsc -p tsconfig.build.json`) before pack. +- **No** `*.test.*` (or other test sources) under packed `dist/`. +- **File path set** under `package/` (local vs registry): identical. +- **`dist/**` content:** SHA-256 compared for every file under `dist/` in both tarballs → **0 mismatches** (functional parity of compiled output). +- **Tarball bytes:** full `.tgz` SHA-256 differs between local pack and registry. This is expected from tar entry ordering and gzip stream differences across npm/Node versions (local pack used Node v24.11.1; registry was published from CI on Node 20.x) — **not** content drift: `package.json`, `README.md`, `CHANGELOG.md`, and `LICENSE` are byte-identical between the packed tarball and `git show v0.5.0:` for each file (verified independently of the local `npm pack`, so this holds regardless of the packer's Node version). Registry tarball matches npm `dist.shasum` `f912f88fee5a8499eadac70ddcbf4a25660fbe76`. +- Local verification used **Node v24.11.1**; publish workflow uses **Node 20.x** on Ubuntu ([publish.yml](../.github/workflows/publish.yml)). Despite Node major difference, compiled `dist/` artifacts matched registry byte-for-byte. + +### AC 4 — CI, CodeQL, and Publish workflow (release gate) + +**npm `gitHead`:** `c28e346efafadbfef98891e523d566eebe957dad` — matches `git rev-parse v0.5.0^{commit}`. + +Workflow runs confirmed via the public [GitHub Actions](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/actions) UI on 2026-07-29 (listed as successful; no failure marker on these runs): + +| Check | Run | Link | +| ---------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **CI** on merge commit `c28e346` (#236) | CI **#480** — “Updated documents for new release (#236)” on `main` | [Run #480](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/actions/runs/30117302054) — Success | +| **CodeQL** on `c28e346` | CodeQL **#505** — same commit on `main` | [Run #505](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/actions/runs/30117301927) — Success | +| **Publish to npm** for release `v0.5.0` | Publish **#11** — “Release v0.5.0 published” (~3m 25s) | [Run #11](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/actions/runs/30117705749) — Success | + +Direct run permalinks (not `?query=` filters, which GitHub Actions silently ignores for this UI and would otherwise land on the unfiltered run list). + +Release: [v0.5.0](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/releases/tag/v0.5.0) · [Commit checks](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/commit/c28e346efafadbfef98891e523d566eebe957dad/checks) + +Publish path aligns with [RELEASING.md](./RELEASING.md): `workflow_call` to `ci.yml`, then `npm publish --provenance --access public`; `prepublishOnly` runs `npm run ci`. + +### AC 5 — Artifact alignment (npm vs tag `v0.5.0`) + +**Artifact verification is complete.** The published npm package matches repository tag `v0.5.0` (`c28e346`) for the checks in AC 1–4: + +- Registry version and `gitHead` align with tag `c28e346`. +- Packed manifest (`version`, `files`, `exports`, `bin`) matches the tagged `package.json` (see AC 2). +- Published `dist/` matches a clean tag build (no test sources in artifact; `dist/**` hash parity with registry). +- CI (#480), CodeQL (#505), and Publish (#11) for the `v0.5.0` release completed successfully per GitHub Actions (see AC 4). + +No re-publish or version bump is required based on this verification. + +**Registry tarball:** `https://registry.npmjs.org/@will-cppa/pinecone-read-only-mcp/-/pinecone-read-only-mcp-0.5.0.tgz` (`dist.shasum` `f912f88fee5a8499eadac70ddcbf4a25660fbe76`). + +**Process sign-off:** Organizational production-readiness sign-off (AC 6) remains **pending** until this documentation PR is approved and merged. Until then, do not treat the release process as fully signed off in-repo. + +### AC 6 — Pull request and review + +Sign-off is committed under `docs/`. **Requires:** + +- [x] PR opened with this file and doc index updates ([README.md](./README.md), [RELEASING.md](./RELEASING.md)) — [PR #242](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/pull/242) +- [ ] ≥ 1 reviewer approval +- [ ] Approver and approval date recorded here after review +- [ ] Merged PR URL recorded here after merge (same as #242 when merged) + +## Commands reference (replay on tag) + +```powershell +git checkout v0.5.0 +npm ci +npm run build +npm pack --dry-run +npm pack +npm pack @will-cppa/pinecone-read-only-mcp@0.5.0 + +# Extract local and registry tarballs; compare dist/** SHA-256 (expect 0 mismatches) +$localTgz = (Get-ChildItem -Name 'will-cppa-pinecone-read-only-mcp-0.5.0.tgz' | Select-Object -First 1) +$registryTgz = (Get-ChildItem -Name 'will-cppa-pinecone-read-only-mcp-*.tgz' | Where-Object { $_ -ne $localTgz } | Select-Object -First 1) +Remove-Item -Recurse -Force .tmp\verify-local, .tmp\verify-registry -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force .tmp\verify-local, .tmp\verify-registry | Out-Null +tar -xf $localTgz -C .tmp\verify-local +tar -xf $registryTgz -C .tmp\verify-registry +$localDist = '.tmp\verify-local\package\dist' +$registryDist = '.tmp\verify-registry\package\dist' +$localFiles = Get-ChildItem $localDist -Recurse -File | ForEach-Object { $_.FullName.Substring((Resolve-Path $localDist).Path.Length + 1) } +$registryFiles = Get-ChildItem $registryDist -Recurse -File | ForEach-Object { $_.FullName.Substring((Resolve-Path $registryDist).Path.Length + 1) } +Compare-Object $localFiles $registryFiles +$mismatches = @() +foreach ($rel in $localFiles) { + $lHash = (Get-FileHash -Algorithm SHA256 (Join-Path $localDist $rel)).Hash + $rHash = (Get-FileHash -Algorithm SHA256 (Join-Path $registryDist $rel)).Hash + if ($lHash -ne $rHash) { $mismatches += $rel } +} +if ($mismatches.Count -gt 0) { throw "dist hash mismatches: $($mismatches -join ', ')" } +'dist/** SHA-256 parity: 0 mismatches' +``` + +On Ubuntu (publish workflow host), after extracting both tarballs under `.tmp/verify-local/package/dist` and `.tmp/verify-registry/package/dist`, compare with `sha256sum` on each relative path; expect identical digests for every file under `dist/`. + +**Docs link-check:** `npm run docs:link-check` — exit **0** on 2026-07-30 (local, after extending the checker with heading-anchor validation and fixing the anchors it flagged). **Pending:** record the PR #242 `quality` job run URL here once CI finishes on the pushed commit — see the [PR #242 checks tab](https://github.com/cppalliance/pinecone-read-only-mcp-typescript/pull/242/checks). diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 0000000..bd416c5 --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,9 @@ +# Release verification + +Post-publish records that npm artifacts match tagged repository state and release CI gates. + +| Version | Sign-off | +| ------- | -------- | +| 0.5.0 | [release-verification-0.5.0.md](./release-verification-0.5.0.md) | + +Add a new `release-verification-X.Y.Z.md` after each production release and link it from this table. diff --git a/eslint.config.mjs b/eslint.config.mjs index aa6d2af..0039f47 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,10 +3,12 @@ import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; export default tseslint.config( + { ignores: ['scripts/fix-core-imports.mjs'] }, eslint.configs.recommended, ...tseslint.configs.recommended, prettier, { + ignores: ['scripts/**/*.mjs'], rules: { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': [ @@ -17,5 +19,10 @@ export default tseslint.config( }, ], }, + }, + { + files: ['scripts/**/*.mjs'], + ...eslint.configs.recommended, + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, } ); diff --git a/package.json b/package.json index dbf4fb0..de70692 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,10 @@ "benchmark": "tsx benchmarks/latency.ts", "benchmark:smoke": "tsx benchmarks/latency.ts --smoke", "test:search": "tsx scripts/test-search.ts", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write \"src/**/*.ts\" \"*.json\" \".prettierrc\"", - "format:check": "prettier --check \"src/**/*.ts\" \"*.json\" \".prettierrc\"", + "lint": "eslint src/ scripts/", + "lint:fix": "eslint src/ scripts/ --fix", + "format": "prettier --write \"src/**/*.ts\" \"scripts/**/*.{mjs,ts}\" \"*.json\" \".prettierrc\"", + "format:check": "prettier --check \"src/**/*.ts\" \"scripts/**/*.{mjs,ts}\" \"*.json\" \".prettierrc\"", "docs:link-check": "node scripts/docs-link-check.mjs", "typecheck": "tsc --noEmit", "typecheck:benchmarks": "tsc -p benchmarks/tsconfig.json", diff --git a/scripts/docs-link-check.mjs b/scripts/docs-link-check.mjs index 7f93db0..07cb3d5 100644 --- a/scripts/docs-link-check.mjs +++ b/scripts/docs-link-check.mjs @@ -1,11 +1,19 @@ /** * Run markdown-link-check once for README, CHANGELOG, and every *.md under docs/. * Avoids per-file `npx` invocations (slow / flaky under registry hiccups). + * + * markdown-link-check verifies that link *targets* (files/URLs) exist, but does not + * reliably validate heading *fragments* (`file.md#some-heading`), especially across + * files (tcort/markdown-link-check#212, #225). `checkHeadingAnchors` below closes that + * gap with a self-contained GitHub-slug implementation (no extra dependency) so a + * renamed/retitled heading that leaves a dangling `#anchor` fails CI. */ - import { spawnSync } from 'node:child_process'; -import { readdirSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +import { error, log } from 'node:console'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; /** @param {string} dir @returns {string[]} */ function walkMarkdownFiles(dir) { @@ -23,13 +31,145 @@ function walkMarkdownFiles(dir) { return out; } -const paths = ['README.md', 'CHANGELOG.md', ...walkMarkdownFiles('docs')]; +/** @returns {string[]} */ +export function defaultMarkdownPaths() { + return ['README.md', 'CHANGELOG.md', ...walkMarkdownFiles('docs')]; +} + +/** + * ASCII + common Unicode punctuation stripped by GitHub's heading slugger + * (verified against `github-slugger@2` output for every heading in this repo's + * docs). Deliberately excludes `-` and `_`, which GitHub preserves. + */ +const SLUG_STRIP_RE = /[!-,./:-@[-^`{-~\u00A1-\u00BF\u00D7\u00F7\u2000-\u206F\u2190-\u21FF]/g; -const shell = process.platform === 'win32'; -const r = spawnSync( - 'npx', - ['--yes', 'markdown-link-check@3', '-c', '.markdown-link-check.json', ...paths], - { stdio: 'inherit', shell } -); +const CODE_SPAN_PLACEHOLDER = '@@CODESPAN'; + +/** @param {string} text @returns {string} */ +export function stripInlineMarkdown(text) { + const codeSpans = []; + let out = text.replace(/`([^`]*)`/g, (_m, inner) => { + codeSpans.push(inner); + return `${CODE_SPAN_PLACEHOLDER}${codeSpans.length - 1}@@`; + }); + out = out.replace(/\*\*([^*]*)\*\*/g, '$1').replace(/\*([^*]*)\*/g, '$1'); + const placeholderRe = new RegExp(`${CODE_SPAN_PLACEHOLDER}(\\d+)@@`, 'g'); + return out.replace(placeholderRe, (_m, i) => codeSpans[Number(i)]); +} + +/** @param {string} text @returns {string} */ +export function slugify(text) { + return stripInlineMarkdown(text).toLowerCase().replace(SLUG_STRIP_RE, '').replace(/ /g, '-'); +} + +/** @param {string} content @returns {Set} */ +export function headingSlugs(content) { + const slugs = new Set(); + const occurrences = Object.create(null); + let inCodeBlock = false; + for (const line of content.split(/\r?\n/)) { + if (/^```/.test(line.trim())) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + const m = line.match(/^(#{1,6})\s+(.*)$/); + if (!m) continue; + const base = slugify(m[2]); + let slug = base; + if (Object.prototype.hasOwnProperty.call(occurrences, base)) { + occurrences[base]++; + slug = `${base}-${occurrences[base]}`; + } else { + occurrences[base] = 0; + } + slugs.add(slug); + } + return slugs; +} + +/** @param {string} line @param {number} lineNo @param {Array<{line: number, target: string}>} links */ +function extractFragmentLinksFromLine(line, lineNo, links) { + const linkRe = /\[[^\]]*\]\(([^)]+)\)/g; + line.split('`').forEach((segment, i) => { + if (i % 2 === 1) return; + let m; + while ((m = linkRe.exec(segment)) !== null) { + const target = m[1].trim(); + if (/^https?:\/\//.test(target) || target.startsWith('mailto:')) continue; + if (!target.includes('#')) continue; + links.push({ line: lineNo, target }); + } + }); +} -process.exit(r.status === null ? 1 : r.status); +/** @param {string} content @returns {Array<{line: number, target: string}>} */ +export function extractFragmentLinks(content) { + const links = []; + let inCodeBlock = false; + content.split(/\r?\n/).forEach((line, idx) => { + if (/^```/.test(line.trim())) { + inCodeBlock = !inCodeBlock; + return; + } + if (inCodeBlock) return; + extractFragmentLinksFromLine(line, idx + 1, links); + }); + return links; +} + +/** @param {string[]} [paths] @returns {string[]} human-readable failure descriptions */ +export function checkHeadingAnchors(paths = defaultMarkdownPaths()) { + const slugsByFile = new Map(); + for (const p of paths) { + slugsByFile.set(resolve(p), headingSlugs(readFileSync(p, 'utf8'))); + } + + const failures = []; + for (const p of paths) { + const abs = resolve(p); + const content = readFileSync(p, 'utf8'); + for (const { line, target } of extractFragmentLinks(content)) { + const hashIdx = target.indexOf('#'); + const filePart = target.slice(0, hashIdx); + const anchor = target.slice(hashIdx + 1); + const targetAbs = filePart === '' ? abs : resolve(dirname(abs), filePart); + const slugs = slugsByFile.get(targetAbs); + if (!slugs) continue; + if (!slugs.has(anchor)) { + failures.push( + `${p}:${line} -> ${target} (no heading slug "${anchor}" in ${relative('.', targetAbs)})` + ); + } + } + } + return failures; +} + +function main() { + const paths = defaultMarkdownPaths(); + const shell = process.platform === 'win32'; + const linkResult = spawnSync( + 'npx', + ['--yes', 'markdown-link-check@3', '-c', '.markdown-link-check.json', ...paths], + { stdio: 'inherit', shell } + ); + const linkExit = linkResult.status === null ? 1 : linkResult.status; + + const anchorFailures = checkHeadingAnchors(paths); + if (anchorFailures.length > 0) { + error(`\nERROR: ${anchorFailures.length} dead heading anchor(s) found!`); + for (const f of anchorFailures) error(` [✖] ${f}`); + } else { + log('\nAll heading anchors resolve.'); + } + + process.exit(linkExit !== 0 ? linkExit : anchorFailures.length > 0 ? 1 : 0); +} + +const isMain = + process.argv[1] && resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1]); + +if (isMain) { + main(); +} diff --git a/scripts/docs-link-check.test.mjs b/scripts/docs-link-check.test.mjs new file mode 100644 index 0000000..feec830 --- /dev/null +++ b/scripts/docs-link-check.test.mjs @@ -0,0 +1,62 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { + checkHeadingAnchors, + extractFragmentLinks, + headingSlugs, + slugify, +} from './docs-link-check.mjs'; + +describe('slugify', () => { + it('preserves inline code and strips version punctuation', () => { + expect(slugify('0.5.0: `list_sources` response shape')).toBe('050-list_sources-response-shape'); + }); + + it('slugifies em-dash headings', () => { + expect(slugify('AC 1 — npm registry version and publish time')).toBe( + 'ac-1--npm-registry-version-and-publish-time' + ); + }); + + it('slugifies colon-separated deprecation heading', () => { + expect(slugify('Active deprecations: legacy module facades')).toBe( + 'active-deprecations-legacy-module-facades' + ); + }); +}); + +describe('headingSlugs', () => { + it('deduplicates repeated headings with numeric suffixes', () => { + const content = '# Title\n## Section\n## Section\n'; + expect(headingSlugs(content)).toEqual(new Set(['title', 'section', 'section-1'])); + }); +}); + +describe('extractFragmentLinks', () => { + it('collects relative fragment links outside inline code', () => { + const content = 'See [docs](./other.md#anchor) for details.\n'; + expect(extractFragmentLinks(content)).toEqual([{ line: 1, target: './other.md#anchor' }]); + }); + + it('ignores fragment links inside inline code spans', () => { + const content = 'Example `[foo](#bar)` in prose.\n'; + expect(extractFragmentLinks(content)).toEqual([]); + }); +}); + +describe('checkHeadingAnchors', () => { + it('reports missing anchors and accepts valid ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'docs-link-check-')); + const source = join(dir, 'source.md'); + const target = join(dir, 'target.md'); + writeFileSync(source, '[link](./target.md#good)\n[bad](./target.md#missing)\n'); + writeFileSync(target, '## Good\n'); + + const failures = checkHeadingAnchors([source, target]); + expect(failures).toHaveLength(1); + expect(failures[0]).toContain('#missing'); + expect(failures[0]).not.toContain('#good'); + }); +}); diff --git a/scripts/test-search.ts b/scripts/test-search.ts index 2b401d9..53e7eb0 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -172,9 +172,7 @@ async function test() { console.log( `⚠️ Keyword search skipped: sparse index has no namespaces (or index unavailable).` ); - console.log( - ` Ensure the sparse index (PINECONE_INDEX_NAME-sparse) exists and has data.` - ); + console.log(` Ensure the sparse index (PINECONE_INDEX_NAME-sparse) exists and has data.`); } else { const sparseTestNamespace = sparseNamespaces[0].namespace; console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`);