mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +00:00
test(docs): enforce documentation freshness
This commit is contained in:
1 parent
306a8276b3
commit
8dc6b817b2
5 files changed
+169
-51
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
name: Documentation improvement
|
name: Documentation improvement
|
||||||
description: Report missing, outdated, or confusing docs.
|
description: Report missing, outdated, or confusing docs.
|
||||||
title: "docs: "
|
title: 'docs: '
|
||||||
labels:
|
labels:
|
||||||
- documentation
|
- documentation
|
||||||
body:
|
body:
|
||||||
@@ -24,7 +24,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: File or page
|
label: File or page
|
||||||
description: Path or URL if you know it.
|
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
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
name: Docs – Quickstart Snippet Parity
|
name: Docs – Parity and Freshness
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- "docs/quickstart-snippet.md"
|
- 'docs/quickstart-snippet.md'
|
||||||
- "README.md"
|
- 'README.md'
|
||||||
- "docker/README.md"
|
- 'docker/README.md'
|
||||||
- "tests/docs/quickstart-parity.sh"
|
- 'macos-bar/README.md'
|
||||||
- ".github/workflows/docs-parity.yml"
|
- '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:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "docs/quickstart-snippet.md"
|
- 'docs/quickstart-snippet.md'
|
||||||
- "README.md"
|
- 'README.md'
|
||||||
- "docker/README.md"
|
- 'docker/README.md'
|
||||||
- "tests/docs/quickstart-parity.sh"
|
- 'macos-bar/README.md'
|
||||||
- ".github/workflows/docs-parity.yml"
|
- '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:
|
jobs:
|
||||||
quickstart-parity:
|
docs-parity:
|
||||||
name: Assert quickstart snippet matches in README.md and docker/README.md
|
name: Assert documentation parity and freshness
|
||||||
if: >-
|
if: >-
|
||||||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|
||||||
|| github.event_name == 'push'
|
|| github.event_name == 'push'
|
||||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -32,3 +47,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Run quickstart parity check
|
- name: Run quickstart parity check
|
||||||
run: bash tests/docs/quickstart-parity.sh
|
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
|
||||||
@@ -57,36 +57,9 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Hardening inventory freshness: the maintainability metrics artifact must be
|
# Age does not prove that generated metrics match the checked-out source tree.
|
||||||
# regenerated within 30 days so the burndown stays current. Runs only after the
|
# Compare both inventory artifacts byte-for-byte before expensive parity checks.
|
||||||
# skip conditions above (CCS_SKIP_PREPUSH_GATE, detached HEAD, behind origin).
|
node scripts/hardening-inventory.js --check
|
||||||
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
|
|
||||||
|
|
||||||
echo "[i] Running CI-parity local checks..."
|
echo "[i] Running CI-parity local checks..."
|
||||||
# `set -euo pipefail` above makes every step fail fast. Keep these commands
|
# `set -euo pipefail` above makes every step fail fast. Keep these commands
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import {
|
|||||||
import { ExitCode } from '../exit-codes';
|
import { ExitCode } from '../exit-codes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P4 behavior-lock: the typed-error -> exit-code mapping is the contract this
|
* The typed-error -> exit-code mapping is a runtime compatibility contract.
|
||||||
* epic relies on. Migrating `throw new Error` to typed subclasses changes the
|
* Migrating `throw new Error` to typed subclasses changes the process exit code
|
||||||
* process exit code (via handleError -> getExitCode); these tests lock the
|
* through handleError; these tests lock the mapping so a future change is
|
||||||
* mapping so a future change is caught. See
|
* caught. See
|
||||||
* docs/reports/typed-error-exit-code-compat-audit.md.
|
* 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', () => {
|
test('each typed class carries its documented ExitCode', () => {
|
||||||
expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR);
|
expect(new ConfigError('m').code).toBe(ExitCode.CONFIG_ERROR);
|
||||||
expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR);
|
expect(new NetworkError('m').code).toBe(ExitCode.NETWORK_ERROR);
|
||||||
|
|||||||
@@ -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.');
|
||||||
Reference in new issue
Block a user