Skip to content

Commit e8b9ad4

Browse files
cameroncookeclaude
andcommitted
ci(docs): Enforce CLI docs command validation
Add a read-only docs command checker that validates xcodebuildmcp command references against the built CLI tool catalog, including CHANGELOG.md and consumer docs. Wire the check into the shared pre-commit hook and CI, and document hook installation through npm run hooks:install so teams get consistent local validation. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9b78f34 commit e8b9ad4

7 files changed

Lines changed: 254 additions & 37 deletions

File tree

.githooks/pre-commit

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
echo "Running pre-commit checks..."
5+
6+
RED='\033[0;31m'
7+
NC='\033[0m'
8+
9+
echo "Checking formatting..."
10+
npm run format:check
11+
if [ $? -ne 0 ]; then
12+
echo "${RED}Formatting check failed. Aborting commit.${NC}"
13+
exit 1
14+
fi
15+
16+
echo "Running linter..."
17+
npm run lint
18+
if [ $? -ne 0 ]; then
19+
echo "${RED}Linting failed. Aborting commit.${NC}"
20+
exit 1
21+
fi
22+
23+
echo "Building project..."
24+
npm run build
25+
if [ $? -ne 0 ]; then
26+
echo "${RED}Build failed. Aborting commit.${NC}"
27+
exit 1
28+
fi
29+
30+
echo "Validating CLI command references in consumer docs..."
31+
npm run docs:check
32+
if [ $? -ne 0 ]; then
33+
echo "${RED}Docs command validation failed. Aborting commit.${NC}"
34+
exit 1
35+
fi
36+
37+
echo "Pre-commit checks passed."

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
- name: Build (Smithery)
3333
run: npm run build
3434

35+
- name: Validate docs CLI command references
36+
run: npm run docs:check
37+
3538
- name: Verify Smithery bundle
3639
run: npm run verify:smithery-bundle
3740

docs/dev/CONTRIBUTING.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,16 @@ brew install axe
5858
```
5959
npm install
6060
```
61-
3. Build the project:
61+
3. Install repository-managed git hooks:
62+
```
63+
npm run hooks:install
64+
```
65+
This configures `core.hooksPath` to `.githooks` so the shared pre-commit hook runs for this repository.
66+
4. Build the project:
6267
```
6368
npm run build
6469
```
65-
4. Start the server:
70+
5. Start the server:
6671
```
6772
node build/cli.js mcp
6873
```
@@ -267,18 +272,27 @@ npm run lint:fix
267272
# 2. Run typechecker (must pass with 0 errors)
268273
npm run typecheck
269274

270-
# 2. Run formatting (must format all files)
275+
# 3. Run formatting (must format all files)
271276
npm run format
272277

273-
# 3. Run build (must compile successfully)
278+
# 4. Run build (must compile successfully)
274279
npm run build
275280

276-
# 4. Run tests (all tests must pass)
281+
# 5. Validate docs CLI command references (requires built CLI artifact)
282+
npm run docs:check
283+
284+
# 6. Run tests (all tests must pass)
277285
npm test
278286
```
279287

280288
**NO EXCEPTIONS**: Code that fails any of these commands cannot be committed.
281289

290+
The shared pre-commit hook installed via `npm run hooks:install` runs:
291+
- `npm run format:check`
292+
- `npm run lint`
293+
- `npm run build`
294+
- `npm run docs:check`
295+
282296
## Making changes
283297

284298
1. Fork the repository and create a new branch

docs/dev/RELEASE_PROCESS.md

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -171,43 +171,25 @@ Every PR must include these sections in order:
171171
### CI/CD Pipeline
172172
Our GitHub Actions CI pipeline automatically enforces these quality checks:
173173
1. `npm run build` - Compilation check
174-
2. `npm run lint` - ESLint validation
175-
3. `npm run format:check` - Prettier formatting check
176-
4. `npm run typecheck` - **TypeScript error validation**
177-
5. `npm run test` - Test suite execution
174+
2. `npm run docs:check` - Validate CLI command references in consumer docs
175+
3. `npm run lint` - ESLint validation
176+
4. `npm run format:check` - Prettier formatting check
177+
5. `npm run typecheck` - **TypeScript error validation**
178+
6. `npm run test` - Test suite execution
178179

179180
**All checks must pass before PR merge is allowed.**
180181

181182
### Optional: Pre-commit Hook Setup
182-
To catch TypeScript errors before committing locally:
183+
To install the repository-managed pre-commit hook:
183184

184185
```bash
185-
# Create pre-commit hook
186-
cat > .git/hooks/pre-commit << 'EOF'
187-
#!/bin/sh
188-
echo "🔍 Running pre-commit checks..."
189-
190-
# Run TypeScript type checking
191-
echo "📝 Checking TypeScript..."
192-
npm run typecheck
193-
if [ $? -ne 0 ]; then
194-
echo "❌ TypeScript errors found. Please fix before committing."
195-
exit 1
196-
fi
197-
198-
# Run linting
199-
echo "🧹 Running linter..."
200-
npm run lint
201-
if [ $? -ne 0 ]; then
202-
echo "❌ Linting errors found. Please fix before committing."
203-
exit 1
204-
fi
205-
206-
echo "✅ Pre-commit checks passed!"
207-
EOF
208-
209-
# Make it executable
210-
chmod +x .git/hooks/pre-commit
186+
npm run hooks:install
211187
```
212188

213-
This hook will automatically run `typecheck` and `lint` before every commit, preventing TypeScript errors from being committed.
189+
This installs `.githooks/pre-commit` and configures `core.hooksPath` for this repository.
190+
191+
The shared pre-commit hook runs:
192+
- `npm run format:check`
193+
- `npm run lint`
194+
- `npm run build`
195+
- `npm run docs:check`

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"dev": "npm run generate:version && npx smithery dev",
2020
"build:tsup": "npm run generate:version && tsup",
2121
"dev:tsup": "npm run build:tsup && tsup --watch",
22+
"prepare": "node scripts/install-git-hooks.js",
23+
"hooks:install": "node scripts/install-git-hooks.js",
2224
"generate:version": "npx tsx scripts/generate-version.ts",
2325
"bundle:axe": "scripts/bundle-axe.sh",
2426
"lint": "eslint 'src/**/*.{js,ts}'",
@@ -32,6 +34,7 @@
3234
"doctor": "node build/doctor-cli.js",
3335
"docs:update": "npx tsx scripts/update-tools-docs.ts",
3436
"docs:update:dry-run": "npx tsx scripts/update-tools-docs.ts --dry-run --verbose",
37+
"docs:check": "node scripts/check-docs-cli-commands.js",
3538
"test": "vitest run",
3639
"test:smoke": "npm run build && vitest run --config vitest.smoke.config.ts",
3740
"test:watch": "vitest",

scripts/check-docs-cli-commands.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env node
2+
3+
import { spawnSync } from 'node:child_process';
4+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
9+
const repoRoot = path.resolve(scriptDir, '..');
10+
const cliPath = path.join(repoRoot, 'build', 'cli.js');
11+
12+
function fail(message, detail) {
13+
console.error(`\n❌ ${message}`);
14+
if (detail) {
15+
console.error(detail);
16+
}
17+
process.exit(1);
18+
}
19+
20+
function loadToolCatalog() {
21+
if (!existsSync(cliPath)) {
22+
fail(
23+
'Missing build artifact: build/cli.js',
24+
'Run `npm run build:tsup` before `npm run docs:check`.',
25+
);
26+
}
27+
28+
const result = spawnSync(process.execPath, [cliPath, 'tools', '--json'], {
29+
cwd: repoRoot,
30+
encoding: 'utf8',
31+
});
32+
33+
if (result.status !== 0) {
34+
fail('Failed to load CLI tool catalog from build artifact.', result.stderr || result.stdout);
35+
}
36+
37+
try {
38+
return JSON.parse(result.stdout);
39+
} catch (error) {
40+
const message = error instanceof Error ? error.message : 'Unknown JSON parse error';
41+
fail('Could not parse JSON from `node build/cli.js tools --json`.', message);
42+
}
43+
}
44+
45+
function getConsumerDocs() {
46+
const docsDir = path.join(repoRoot, 'docs');
47+
const docsFiles = readdirSync(docsDir, { withFileTypes: true })
48+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
49+
.map((entry) => path.join(docsDir, entry.name))
50+
.sort();
51+
52+
return [path.join(repoRoot, 'README.md'), path.join(repoRoot, 'CHANGELOG.md'), ...docsFiles];
53+
}
54+
55+
function buildValidationSets(catalog) {
56+
const validPairs = new Set();
57+
const validWorkflows = new Set();
58+
59+
if (!Array.isArray(catalog.workflows)) {
60+
fail('Tool catalog does not contain a workflows array.');
61+
}
62+
63+
for (const workflow of catalog.workflows) {
64+
if (!workflow || typeof workflow.workflow !== 'string') {
65+
continue;
66+
}
67+
68+
validWorkflows.add(workflow.workflow);
69+
70+
if (!Array.isArray(workflow.tools)) {
71+
continue;
72+
}
73+
74+
for (const tool of workflow.tools) {
75+
if (tool && typeof tool.name === 'string') {
76+
validPairs.add(`${workflow.workflow} ${tool.name}`);
77+
}
78+
}
79+
}
80+
81+
return { validPairs, validWorkflows };
82+
}
83+
84+
function findInvalidCommands(files, validPairs, validWorkflows) {
85+
const validTopLevel = new Set(['mcp', 'tools', 'daemon']);
86+
const validDaemonActions = new Set(['status', 'start', 'stop', 'restart', 'list']);
87+
const findings = [];
88+
89+
const commandRegex =
90+
/(?:^|[^a-z0-9-])xcodebuildmcp(?!-)\s+([a-z][a-z0-9-]*)(?:\s+([a-z][a-z0-9-]*))?/g;
91+
92+
for (const absoluteFilePath of files) {
93+
const relativePath = path.relative(repoRoot, absoluteFilePath) || absoluteFilePath;
94+
const content = readFileSync(absoluteFilePath, 'utf8');
95+
const lines = content.split(/\r?\n/u);
96+
97+
for (let lineNumber = 1; lineNumber <= lines.length; lineNumber += 1) {
98+
const line = lines[lineNumber - 1];
99+
commandRegex.lastIndex = 0;
100+
let match = commandRegex.exec(line);
101+
102+
while (match) {
103+
const first = match[1];
104+
const second = match[2];
105+
const command = second ? `${first} ${second}` : first;
106+
107+
let valid = false;
108+
109+
if (!second) {
110+
valid = validTopLevel.has(first) || validWorkflows.has(first);
111+
} else if (first === 'daemon') {
112+
valid = validDaemonActions.has(second);
113+
} else {
114+
valid = validPairs.has(command);
115+
}
116+
117+
if (!valid) {
118+
findings.push(`${relativePath}:${lineNumber}: ${command}`);
119+
}
120+
121+
match = commandRegex.exec(line);
122+
}
123+
}
124+
}
125+
126+
return findings;
127+
}
128+
129+
function main() {
130+
const catalog = loadToolCatalog();
131+
const files = getConsumerDocs();
132+
const { validPairs, validWorkflows } = buildValidationSets(catalog);
133+
const findings = findInvalidCommands(files, validPairs, validWorkflows);
134+
135+
if (findings.length > 0) {
136+
fail(
137+
'Found invalid CLI command references in consumer docs.',
138+
`${findings.join('\n')}\n\nRun \`node build/cli.js tools\` to inspect valid commands.`,
139+
);
140+
}
141+
142+
console.log('✅ Docs CLI command check passed (README.md + CHANGELOG.md + docs/*.md).');
143+
}
144+
145+
main();

scripts/install-git-hooks.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env node
2+
3+
import { spawnSync } from 'node:child_process';
4+
import path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8+
const repoRoot = path.resolve(scriptDir, '..');
9+
10+
function runGit(args) {
11+
return spawnSync('git', args, {
12+
cwd: repoRoot,
13+
encoding: 'utf8',
14+
});
15+
}
16+
17+
const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree']);
18+
if (insideWorkTree.status !== 0 || insideWorkTree.stdout.trim() !== 'true') {
19+
console.log('[hooks] Skipping git hook install (not inside a git worktree).');
20+
process.exit(0);
21+
}
22+
23+
const setHookPath = runGit(['config', 'core.hooksPath', '.githooks']);
24+
if (setHookPath.status !== 0) {
25+
const output = (setHookPath.stderr || setHookPath.stdout || '').trim();
26+
console.error('[hooks] Failed to set core.hooksPath to .githooks');
27+
if (output) {
28+
console.error(output);
29+
}
30+
process.exit(1);
31+
}
32+
33+
console.log('[hooks] Installed git hooks path: .githooks');

0 commit comments

Comments
 (0)