diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 1b4b97aa..2fb84552 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -1,6 +1,6 @@ name: Documentation improvement description: Report missing, outdated, or confusing docs. -title: "docs: " +title: 'docs: ' labels: - documentation body: @@ -24,7 +24,7 @@ body: attributes: label: File or page description: Path or URL if you know it. - placeholder: README.md or docs/cursor-integration.md + placeholder: README.md or https://docs.ccs.kaitran.ca/providers/oauth/cursor - type: textarea id: problem attributes: diff --git a/.github/workflows/docs-parity.yml b/.github/workflows/docs-parity.yml index b7c7c20e..669c1504 100644 --- a/.github/workflows/docs-parity.yml +++ b/.github/workflows/docs-parity.yml @@ -1,29 +1,44 @@ -name: Docs – Quickstart Snippet Parity +name: Docs – Parity and Freshness on: push: paths: - - "docs/quickstart-snippet.md" - - "README.md" - - "docker/README.md" - - "tests/docs/quickstart-parity.sh" - - ".github/workflows/docs-parity.yml" + - 'docs/quickstart-snippet.md' + - 'README.md' + - 'docker/README.md' + - 'macos-bar/README.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - 'src/**' + - 'scripts/hardening-inventory.js' + - 'scripts/maintainability-metrics.js' + - 'scripts/runtime-source-classifier.js' + - 'tests/docs/quickstart-parity.sh' + - 'tests/docs/documentation-freshness.js' + - '.github/workflows/docs-parity.yml' pull_request: paths: - - "docs/quickstart-snippet.md" - - "README.md" - - "docker/README.md" - - "tests/docs/quickstart-parity.sh" - - ".github/workflows/docs-parity.yml" + - 'docs/quickstart-snippet.md' + - 'README.md' + - 'docker/README.md' + - 'macos-bar/README.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - 'src/**' + - 'scripts/hardening-inventory.js' + - 'scripts/maintainability-metrics.js' + - 'scripts/runtime-source-classifier.js' + - 'tests/docs/quickstart-parity.sh' + - 'tests/docs/documentation-freshness.js' + - '.github/workflows/docs-parity.yml' jobs: - quickstart-parity: - name: Assert quickstart snippet matches in README.md and docker/README.md + docs-parity: + name: Assert documentation parity and freshness if: >- contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association) || github.event_name == 'push' runs-on: [self-hosted, linux, x64, cliproxy] - steps: - name: Checkout uses: actions/checkout@v4 @@ -32,3 +47,9 @@ jobs: - name: Run quickstart parity check run: bash tests/docs/quickstart-parity.sh + + - name: Validate documentation pointers and relative links + run: node tests/docs/documentation-freshness.js + + - name: Validate generated hardening inventory + run: node scripts/hardening-inventory.js --check diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 73b98615..796abd29 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -57,36 +57,9 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then fi fi -# Hardening inventory freshness: the maintainability metrics artifact must be -# regenerated within 30 days so the burndown stays current. Runs only after the -# skip conditions above (CCS_SKIP_PREPUSH_GATE, detached HEAD, behind origin). -HARDENING_JSON="docs/reports/hardening-inventory.json" -if [[ ! -f "$HARDENING_JSON" ]]; then - echo "[X] Missing $HARDENING_JSON." - echo " Regenerate with: bun run report:hardening" - exit 1 -fi -HARDENING_TS="" -# If the working-tree copy differs from HEAD (contributor regenerated but not -# yet committed), use filesystem mtime; otherwise use the last commit time, -# which is stable across CI clones (checkout resets mtimes) and so correctly -# flags a stale committed artifact. -if git diff --quiet -- "$HARDENING_JSON" 2>/dev/null && git diff --cached --quiet -- "$HARDENING_JSON" 2>/dev/null; then - HARDENING_TS=$(git log -1 --format=%ct -- "$HARDENING_JSON" 2>/dev/null) -else - HARDENING_TS=$(stat -f %m "$HARDENING_JSON" 2>/dev/null || stat -c %Y "$HARDENING_JSON" 2>/dev/null) -fi -if [[ -n "$HARDENING_TS" ]]; then - NOW_TS=$(date +%s) - AGE_DAYS=$(( (NOW_TS - HARDENING_TS) / 86400 )) - if (( AGE_DAYS > 30 )); then - echo "[X] Hardening inventory is stale (${AGE_DAYS}d old; max 30d)." - echo " Regenerate with: bun run report:hardening" - echo " Then commit docs/reports/hardening-inventory.{json,md}." - exit 1 - fi - echo "[i] Hardening inventory fresh (${AGE_DAYS}d old; max 30d)." -fi +# Age does not prove that generated metrics match the checked-out source tree. +# Compare both inventory artifacts byte-for-byte before expensive parity checks. +node scripts/hardening-inventory.js --check echo "[i] Running CI-parity local checks..." # `set -euo pipefail` above makes every step fail fast. Keep these commands diff --git a/src/errors/__tests__/typed-error-migration-exit-codes.test.ts b/src/errors/__tests__/typed-error-migration-exit-codes.test.ts index cd7f8646..6eeb2636 100644 --- a/src/errors/__tests__/typed-error-migration-exit-codes.test.ts +++ b/src/errors/__tests__/typed-error-migration-exit-codes.test.ts @@ -17,13 +17,13 @@ import { import { ExitCode } from '../exit-codes'; /** - * P4 behavior-lock: the typed-error -> exit-code mapping is the contract this - * epic relies on. Migrating `throw new Error` to typed subclasses changes the - * process exit code (via handleError -> getExitCode); these tests lock the - * mapping so a future change is caught. See + * The typed-error -> exit-code mapping is a runtime compatibility contract. + * Migrating `throw new Error` to typed subclasses changes the process exit code + * through handleError; these tests lock the mapping so a future change is + * caught. See * docs/reports/typed-error-exit-code-compat-audit.md. */ -describe('typed-error taxonomy -> exit-code mapping (P4 contract)', () => { +describe('typed-error taxonomy -> exit-code compatibility contract', () => { test('each typed class carries its documented ExitCode', () => { expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR); expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR); diff --git a/tests/docs/documentation-freshness.js b/tests/docs/documentation-freshness.js new file mode 100644 index 00000000..ee5c66bd --- /dev/null +++ b/tests/docs/documentation-freshness.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '../..'); +const failures = []; + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function requireText(relativePath, expected) { + if (!read(relativePath).includes(expected)) { + failures.push(`${relativePath} is missing: ${expected}`); + } +} + +function collectFiles(directory, filePattern) { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(absolutePath, filePattern)); + } else if (entry.isFile() && filePattern.test(entry.name)) { + files.push(absolutePath); + } + } + return files; +} + +const removedGuides = [ + 'docs/ccs-bar.md', + 'docs/cursor-integration.md', + 'docs/dashboard-auth-cli.md', + 'docs/session-sharing-technical-analysis.md', +]; + +for (const relativePath of removedGuides) { + if (fs.existsSync(path.join(root, relativePath))) { + failures.push(`${relativePath} should use its canonical public-doc replacement`); + } +} + +requireText('README.md', 'https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers'); +requireText('README.md', 'https://docs.ccs.kaitran.ca/features/workflow/browser-automation'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_UPLOAD_ROOTS'); +requireText('docs/browser-automation.md', 'CCS_BROWSER_DOWNLOAD_ROOTS'); +requireText('docs/browser-automation.md', 'browser_wait_for_event'); +requireText('docs/browser-automation.md', 'path-scoped bearer values'); +requireText('docs/codex-auth.md', 'src/codex-auth/codex-auth-help.ts'); +requireText('docs/codex-auth.md', 'CCSXP_CODEX_HOME'); +requireText('docs/image-analysis.md', 'https://docs.ccs.kaitran.ca/features/ai/image-analysis'); +requireText('docs/openai-compatible-providers.md', 'CCS_OPENAI_PROXY_INSECURE'); +requireText('docs/openai-compatible-providers.md', 'CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS'); +requireText('macos-bar/README.md', 'macos-bar/VERSION'); +requireText('macos-bar/README.md', '.github/workflows/bar-release.yml'); +requireText( + '.github/ISSUE_TEMPLATE/documentation.yml', + 'https://docs.ccs.kaitran.ca/providers/oauth/cursor' +); + +const practicalGuidanceFiles = [ + path.join(root, 'README.md'), + path.join(root, 'CLAUDE.md'), + path.join(root, 'CONTRIBUTING.md'), + path.join(root, 'SECURITY.md'), + path.join(root, 'docker', 'README.md'), + path.join(root, 'macos-bar', 'README.md'), + ...collectFiles(path.join(root, 'docs'), /\.mdx?$/), + ...collectFiles(path.join(root, '.github', 'ISSUE_TEMPLATE'), /\.(md|ya?ml)$/), +]; + +for (const staleGuidePath of removedGuides) { + for (const guidancePath of practicalGuidanceFiles) { + if (read(path.relative(root, guidancePath)).includes(staleGuidePath)) { + failures.push( + `${path.relative(root, guidancePath)} references deleted guide: ${staleGuidePath}` + ); + } + } +} + +const markdownFiles = [ + path.join(root, 'README.md'), + path.join(root, 'docker', 'README.md'), + path.join(root, 'macos-bar', 'README.md'), + ...collectFiles(path.join(root, 'docs'), /\.mdx?$/), +]; +const linkPattern = /!?\[[^\]]*]\(([^)]+)\)/g; + +for (const markdownPath of markdownFiles) { + const source = fs.readFileSync(markdownPath, 'utf8'); + for (const match of source.matchAll(linkPattern)) { + let target = match[1].trim().replace(/^<|>$/g, ''); + if (!target || target.startsWith('#') || /^(https?:|mailto:|tel:)/i.test(target)) { + continue; + } + + target = target.split('#', 1)[0].split('?', 1)[0]; + try { + target = decodeURIComponent(target); + } catch { + failures.push(`${path.relative(root, markdownPath)} has invalid link encoding: ${match[1]}`); + continue; + } + + const resolved = path.resolve(path.dirname(markdownPath), target); + if (!fs.existsSync(resolved)) { + failures.push(`${path.relative(root, markdownPath)} has missing relative link: ${match[1]}`); + } + } +} + +if (failures.length > 0) { + console.error('[X] Documentation freshness checks failed:'); + for (const failure of failures) { + console.error(` ${failure}`); + } + process.exit(1); +} + +console.log('[OK] Documentation pointers, retained contracts, and relative links are current.');