@soulbatical/tetra-dev-toolkit 1.20.9 → 1.20.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/checks/codeQuality/mcp-tool-docs.js +193 -0
- package/lib/checks/health/file-organization.js +1 -1
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/health/test-structure.js +171 -0
- package/lib/checks/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Quality Check: MCP Tool Documentation
|
|
3
|
+
*
|
|
4
|
+
* Ensures all MCP tool definitions have proper descriptions.
|
|
5
|
+
* Scans backend-mcp/src/ for tool definitions and verifies:
|
|
6
|
+
*
|
|
7
|
+
* 1. Every tool has a non-empty `description` field
|
|
8
|
+
* 2. Descriptions are meaningful (>20 chars, not just the tool name)
|
|
9
|
+
* 3. Tool names follow naming convention (snake_case)
|
|
10
|
+
*
|
|
11
|
+
* Severity: high — tools without descriptions are unusable for AI clients
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
15
|
+
import { join, relative, extname } from 'node:path'
|
|
16
|
+
|
|
17
|
+
export const meta = {
|
|
18
|
+
id: 'mcp-tool-docs',
|
|
19
|
+
name: 'MCP Tool Documentation',
|
|
20
|
+
category: 'codeQuality',
|
|
21
|
+
severity: 'high',
|
|
22
|
+
description: 'Ensures all MCP tool definitions have meaningful descriptions and follow naming conventions'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Patterns ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
// Match tool definition objects: { name: "...", description: "..." }
|
|
28
|
+
// Handles both inline and multi-line definitions
|
|
29
|
+
const TOOL_NAME_RE = /name:\s*["'`]([^"'`]+)["'`]/g
|
|
30
|
+
const TOOL_DESC_RE = /description:\s*["'`]([^"'`]*)["'`]/g
|
|
31
|
+
|
|
32
|
+
// snake_case check
|
|
33
|
+
const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
|
34
|
+
|
|
35
|
+
// ── Scanner ───────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function collectFiles(dir) {
|
|
38
|
+
const files = []
|
|
39
|
+
try {
|
|
40
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const fullPath = join(dir, entry.name)
|
|
43
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
|
|
44
|
+
files.push(...await collectFiles(fullPath))
|
|
45
|
+
} else if (['.ts', '.tsx', '.js'].includes(extname(entry.name))) {
|
|
46
|
+
files.push(fullPath)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Directory doesn't exist
|
|
51
|
+
}
|
|
52
|
+
return files
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractTools(content) {
|
|
56
|
+
const tools = []
|
|
57
|
+
|
|
58
|
+
// Strategy 1: Find name/description pairs in tool definition objects
|
|
59
|
+
// Look for patterns like: { name: "tool_name", description: "..." }
|
|
60
|
+
const nameMatches = [...content.matchAll(TOOL_NAME_RE)]
|
|
61
|
+
|
|
62
|
+
for (const nameMatch of nameMatches) {
|
|
63
|
+
const name = nameMatch[1]
|
|
64
|
+
const namePos = nameMatch.index
|
|
65
|
+
|
|
66
|
+
// Look for a description within 500 chars of the name
|
|
67
|
+
const searchWindow = content.slice(Math.max(0, namePos - 200), namePos + 500)
|
|
68
|
+
const descMatch = searchWindow.match(/description:\s*["'`]([^"'`]*)["'`]/)
|
|
69
|
+
|
|
70
|
+
tools.push({
|
|
71
|
+
name,
|
|
72
|
+
description: descMatch ? descMatch[1] : null,
|
|
73
|
+
line: content.slice(0, namePos).split('\n').length,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return tools
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateTool(tool) {
|
|
81
|
+
const violations = []
|
|
82
|
+
|
|
83
|
+
// Check name is snake_case
|
|
84
|
+
if (!SNAKE_CASE_RE.test(tool.name)) {
|
|
85
|
+
violations.push({
|
|
86
|
+
rule: 'tool-naming',
|
|
87
|
+
severity: 'medium',
|
|
88
|
+
message: `Tool "${tool.name}" should use snake_case naming`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check description exists
|
|
93
|
+
if (!tool.description) {
|
|
94
|
+
violations.push({
|
|
95
|
+
rule: 'missing-description',
|
|
96
|
+
severity: 'high',
|
|
97
|
+
message: `Tool "${tool.name}" has no description — AI clients won't know what it does`,
|
|
98
|
+
})
|
|
99
|
+
return violations
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check description is meaningful
|
|
103
|
+
if (tool.description.length < 20) {
|
|
104
|
+
violations.push({
|
|
105
|
+
rule: 'short-description',
|
|
106
|
+
severity: 'high',
|
|
107
|
+
message: `Tool "${tool.name}" description is too short (${tool.description.length} chars, min 20) — "${tool.description}"`,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check description isn't just the tool name
|
|
112
|
+
if (tool.description.toLowerCase().replace(/[_\s-]/g, '') === tool.name.toLowerCase().replace(/[_\s-]/g, '')) {
|
|
113
|
+
violations.push({
|
|
114
|
+
rule: 'lazy-description',
|
|
115
|
+
severity: 'high',
|
|
116
|
+
message: `Tool "${tool.name}" description just repeats the name — write what it does`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return violations
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Main check ────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export async function run(config, projectRoot) {
|
|
126
|
+
const result = {
|
|
127
|
+
passed: true,
|
|
128
|
+
findings: [],
|
|
129
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
130
|
+
details: { files: {}, totalTools: 0, totalViolations: 0, toolsWithoutDesc: 0 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Find MCP tool directories
|
|
134
|
+
const mcpDirs = [
|
|
135
|
+
join(projectRoot, 'backend-mcp', 'src', 'tools'),
|
|
136
|
+
join(projectRoot, 'backend-mcp', 'src'),
|
|
137
|
+
join(projectRoot, 'mcp', 'src', 'tools'),
|
|
138
|
+
join(projectRoot, 'src', 'tools'),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
let allFiles = []
|
|
142
|
+
for (const dir of mcpDirs) {
|
|
143
|
+
try {
|
|
144
|
+
await stat(dir)
|
|
145
|
+
allFiles.push(...await collectFiles(dir))
|
|
146
|
+
} catch {
|
|
147
|
+
// Directory doesn't exist, skip
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (allFiles.length === 0) return result // Not an MCP project
|
|
152
|
+
|
|
153
|
+
for (const filePath of allFiles) {
|
|
154
|
+
const content = await readFile(filePath, 'utf8')
|
|
155
|
+
const tools = extractTools(content)
|
|
156
|
+
|
|
157
|
+
if (tools.length === 0) continue
|
|
158
|
+
|
|
159
|
+
result.details.totalTools += tools.length
|
|
160
|
+
const fileViolations = []
|
|
161
|
+
|
|
162
|
+
for (const tool of tools) {
|
|
163
|
+
const violations = validateTool(tool)
|
|
164
|
+
if (violations.length > 0) {
|
|
165
|
+
fileViolations.push(...violations.map(v => ({ ...v, tool: tool.name, line: tool.line })))
|
|
166
|
+
if (violations.some(v => v.rule === 'missing-description')) {
|
|
167
|
+
result.details.toolsWithoutDesc++
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (fileViolations.length > 0) {
|
|
173
|
+
const rel = relative(projectRoot, filePath)
|
|
174
|
+
result.details.files[rel] = fileViolations
|
|
175
|
+
result.details.totalViolations += fileViolations.length
|
|
176
|
+
|
|
177
|
+
for (const v of fileViolations) {
|
|
178
|
+
result.summary[v.severity] = (result.summary[v.severity] || 0) + 1
|
|
179
|
+
result.summary.total++
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
result.findings.push({
|
|
183
|
+
type: `MCP tool docs in ${rel}`,
|
|
184
|
+
severity: fileViolations.some(v => v.severity === 'high') ? 'high' : 'medium',
|
|
185
|
+
message: `${fileViolations.length} issue(s): ${[...new Set(fileViolations.map(v => v.rule))].join(', ')}`,
|
|
186
|
+
files: fileViolations.map(v => `L${v.line}: [${v.rule}] ${v.message}`)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
result.passed = result.summary.high === 0 && result.summary.critical === 0
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
@@ -37,7 +37,7 @@ const ALLOWED_SCRIPT_DIRS = new Set([
|
|
|
37
37
|
|
|
38
38
|
/** Directories whose .md content is always allowed (tooling config) */
|
|
39
39
|
const ALLOWED_MD_DIRS = new Set([
|
|
40
|
-
'docs', '.ralph', '.claude', '.agents', 'e2e'
|
|
40
|
+
'docs', '.ralph', '.claude', '.agents', 'e2e', 'tests'
|
|
41
41
|
])
|
|
42
42
|
|
|
43
43
|
/** Directories where .yml/.yaml config files are allowed */
|
|
@@ -40,3 +40,4 @@ export { check as checkBundleSize } from './bundle-size.js'
|
|
|
40
40
|
export { check as checkSecurityLayers } from './security-layers.js'
|
|
41
41
|
export { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
42
42
|
export { check as checkReleasePipeline } from './release-pipeline.js'
|
|
43
|
+
export { check as checkTestStructure } from './test-structure.js'
|
|
@@ -36,6 +36,7 @@ import { check as checkBundleSize } from './bundle-size.js'
|
|
|
36
36
|
import { check as checkSecurityLayers } from './security-layers.js'
|
|
37
37
|
import { check as checkSmokeReadiness } from './smoke-readiness.js'
|
|
38
38
|
import { check as checkReleasePipeline } from './release-pipeline.js'
|
|
39
|
+
import { check as checkTestStructure } from './test-structure.js'
|
|
39
40
|
import { calculateHealthStatus } from './types.js'
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -80,7 +81,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
80
81
|
checkBundleSize(projectPath),
|
|
81
82
|
checkSecurityLayers(projectPath),
|
|
82
83
|
checkSmokeReadiness(projectPath),
|
|
83
|
-
checkReleasePipeline(projectPath)
|
|
84
|
+
checkReleasePipeline(projectPath),
|
|
85
|
+
checkTestStructure(projectPath)
|
|
84
86
|
])
|
|
85
87
|
|
|
86
88
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Test Structure
|
|
3
|
+
*
|
|
4
|
+
* Validates that test files follow the canonical directory structure:
|
|
5
|
+
* tests/
|
|
6
|
+
* ├── e2e/ Backend API tests (Vitest)
|
|
7
|
+
* ├── widget/ Widget browser tests (Playwright)
|
|
8
|
+
* ├── unit/ Unit tests
|
|
9
|
+
* └── integration/ Integration tests
|
|
10
|
+
*
|
|
11
|
+
* Detects common anti-patterns:
|
|
12
|
+
* - Spec/test files at repo root or in code dirs
|
|
13
|
+
* - Multiple e2e directories (e.g. /e2e/ AND /tests/e2e/)
|
|
14
|
+
* - Test helpers outside helpers/ subdirs
|
|
15
|
+
* - Playwright specs outside tests/widget/
|
|
16
|
+
* - Missing numbering prefix on e2e/widget specs
|
|
17
|
+
*
|
|
18
|
+
* Score: 5pt max
|
|
19
|
+
* - 1pt: tests/ directory exists
|
|
20
|
+
* - 1pt: No test files in wrong locations (root, code dirs)
|
|
21
|
+
* - 1pt: No duplicate test directories
|
|
22
|
+
* - 1pt: Helpers in helpers/ subdirectories
|
|
23
|
+
* - 1pt: Numbered spec files (01-, 02-, etc.)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
27
|
+
import { join, basename } from 'path'
|
|
28
|
+
import { createCheck } from './types.js'
|
|
29
|
+
|
|
30
|
+
const IGNORED_DIRS = new Set([
|
|
31
|
+
'node_modules', 'dist', 'build', '.git', '.next', '.cache',
|
|
32
|
+
'coverage', '.nyc_output', '.playwright', 'test-results'
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const CODE_DIRS = ['backend', 'frontend', 'backend-mcp', 'backend-pdf', 'mcp', 'widget']
|
|
36
|
+
|
|
37
|
+
const TEST_PATTERNS = /\.(test|spec)\.(ts|tsx|js|jsx)$/
|
|
38
|
+
|
|
39
|
+
export async function check(projectPath) {
|
|
40
|
+
const result = createCheck('test-structure', 5, {
|
|
41
|
+
hasTestsDir: false,
|
|
42
|
+
strayTestFiles: [],
|
|
43
|
+
duplicateTestDirs: [],
|
|
44
|
+
misplacedHelpers: [],
|
|
45
|
+
unnumberedSpecs: [],
|
|
46
|
+
structure: {}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// 1. Check tests/ directory exists
|
|
50
|
+
const testsDir = join(projectPath, 'tests')
|
|
51
|
+
if (existsSync(testsDir) && statSync(testsDir).isDirectory()) {
|
|
52
|
+
result.details.hasTestsDir = true
|
|
53
|
+
result.score += 1
|
|
54
|
+
|
|
55
|
+
// Map structure
|
|
56
|
+
try {
|
|
57
|
+
for (const entry of readdirSync(testsDir, { withFileTypes: true })) {
|
|
58
|
+
if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
|
|
59
|
+
const subPath = join(testsDir, entry.name)
|
|
60
|
+
const files = readdirSync(subPath, { withFileTypes: true })
|
|
61
|
+
.filter(f => f.isFile() && TEST_PATTERNS.test(f.name))
|
|
62
|
+
.map(f => f.name)
|
|
63
|
+
result.details.structure[entry.name] = { files: files.length }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch { /* ignore */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Check for test files in wrong locations
|
|
70
|
+
// Root-level spec/test files
|
|
71
|
+
try {
|
|
72
|
+
for (const entry of readdirSync(projectPath, { withFileTypes: true })) {
|
|
73
|
+
if (entry.isFile() && TEST_PATTERNS.test(entry.name)) {
|
|
74
|
+
result.details.strayTestFiles.push(entry.name)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
|
|
79
|
+
// Test files inside code dirs (except allowed test subdirs)
|
|
80
|
+
for (const codeDir of CODE_DIRS) {
|
|
81
|
+
const codePath = join(projectPath, codeDir)
|
|
82
|
+
if (!existsSync(codePath)) continue
|
|
83
|
+
try {
|
|
84
|
+
const walk = (dir, rel) => {
|
|
85
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const fullPath = join(dir, entry.name)
|
|
87
|
+
const relPath = `${rel}/${entry.name}`
|
|
88
|
+
if (entry.isDirectory()) {
|
|
89
|
+
if (!IGNORED_DIRS.has(entry.name) && entry.name !== 'tests' && entry.name !== '__tests__') {
|
|
90
|
+
walk(fullPath, relPath)
|
|
91
|
+
}
|
|
92
|
+
} else if (entry.isFile() && TEST_PATTERNS.test(entry.name)) {
|
|
93
|
+
result.details.strayTestFiles.push(`${codeDir}${relPath}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(codePath, '')
|
|
98
|
+
} catch { /* ignore */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (result.details.strayTestFiles.length === 0) result.score += 1
|
|
102
|
+
|
|
103
|
+
// 3. Check for duplicate test directories (e.g. both /e2e/ and /tests/e2e/)
|
|
104
|
+
const rootTestDirs = ['e2e', 'test', '__tests__', 'spec', 'cypress']
|
|
105
|
+
for (const dir of rootTestDirs) {
|
|
106
|
+
const rootDir = join(projectPath, dir)
|
|
107
|
+
if (!existsSync(rootDir)) continue
|
|
108
|
+
// Check if it contains spec/test files (not just configs)
|
|
109
|
+
try {
|
|
110
|
+
const hasTests = readdirSync(rootDir).some(f => TEST_PATTERNS.test(f))
|
|
111
|
+
if (hasTests) {
|
|
112
|
+
result.details.duplicateTestDirs.push(`/${dir}/ contains test files — move to /tests/`)
|
|
113
|
+
}
|
|
114
|
+
} catch { /* ignore */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (result.details.duplicateTestDirs.length === 0) result.score += 1
|
|
118
|
+
|
|
119
|
+
// 4. Check that helpers are in helpers/ subdirs
|
|
120
|
+
if (existsSync(testsDir)) {
|
|
121
|
+
try {
|
|
122
|
+
const walk = (dir, rel) => {
|
|
123
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
124
|
+
if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
|
|
125
|
+
walk(join(dir, entry.name), `${rel}/${entry.name}`)
|
|
126
|
+
} else if (entry.isFile()) {
|
|
127
|
+
const name = entry.name.toLowerCase()
|
|
128
|
+
const isHelper = name.includes('helper') || name.includes('util') || name.includes('fixture')
|
|
129
|
+
const inHelperDir = rel.includes('/helpers') || rel.includes('/fixtures') || rel.includes('/utils')
|
|
130
|
+
if (isHelper && !TEST_PATTERNS.test(name) && !inHelperDir) {
|
|
131
|
+
result.details.misplacedHelpers.push(`tests${rel}/${entry.name}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
walk(testsDir, '')
|
|
137
|
+
} catch { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.details.misplacedHelpers.length === 0) result.score += 1
|
|
141
|
+
|
|
142
|
+
// 5. Check numbered spec files in e2e and widget dirs
|
|
143
|
+
const numberedDirs = ['e2e', 'widget']
|
|
144
|
+
for (const subDir of numberedDirs) {
|
|
145
|
+
const dirPath = join(testsDir, subDir)
|
|
146
|
+
if (!existsSync(dirPath)) continue
|
|
147
|
+
try {
|
|
148
|
+
for (const file of readdirSync(dirPath)) {
|
|
149
|
+
if (TEST_PATTERNS.test(file) && !/^\d{2}-/.test(file)) {
|
|
150
|
+
result.details.unnumberedSpecs.push(`tests/${subDir}/${file}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (result.details.unnumberedSpecs.length === 0) result.score += 1
|
|
157
|
+
|
|
158
|
+
// Cap score
|
|
159
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
160
|
+
|
|
161
|
+
// Status
|
|
162
|
+
if (result.score <= 2) {
|
|
163
|
+
result.status = 'error'
|
|
164
|
+
result.details.message = 'Test structure needs significant cleanup'
|
|
165
|
+
} else if (result.score < 5) {
|
|
166
|
+
result.status = 'warning'
|
|
167
|
+
result.details.message = 'Test structure has issues'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
}
|
package/lib/checks/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
|
22
22
|
export * as uiTheming from './codeQuality/ui-theming.js'
|
|
23
23
|
export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
|
|
24
24
|
export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
|
|
25
|
+
export * as mcpToolDocs from './codeQuality/mcp-tool-docs.js'
|
|
25
26
|
|
|
26
27
|
// Health checks (project ecosystem)
|
|
27
28
|
export * as health from './health/index.js'
|