@soulbatical/tetra-dev-toolkit 1.2.0 → 1.3.1
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/README.md +198 -0
- package/bin/cleanup-repos.sh +287 -0
- package/bin/tetra-audit.js +5 -1
- package/bin/tetra-setup.js +35 -1
- package/lib/checks/health/file-organization.js +389 -0
- package/lib/checks/health/index.js +3 -1
- package/lib/checks/health/rpc-param-mismatch.js +92 -0
- package/lib/checks/health/scanner.js +6 -2
- package/lib/checks/health/stella-integration.js +7 -7
- package/lib/checks/health/types.js +2 -2
- package/lib/checks/hygiene/file-organization.js +105 -0
- package/lib/checks/index.js +4 -0
- package/lib/checks/stability/ci-pipeline.js +1 -1
- package/lib/checks/supabase/rpc-param-mismatch.js +453 -0
- package/lib/config.js +2 -1
- package/lib/runner.js +15 -2
- package/package.json +2 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: File Organization (docs, scripts, config & code dir cleanliness)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* 1. Documentation files (.md) live in /docs/ or repo root
|
|
6
|
+
* 2. Scripts (.sh) live in /scripts/ or allowed dirs
|
|
7
|
+
* 3. Config files (.yml/.yaml) live in allowed locations
|
|
8
|
+
* 4. No nested docs/ directories exist
|
|
9
|
+
* 5. Code directories (backend/, frontend/, etc.) contain only code — no clutter
|
|
10
|
+
*
|
|
11
|
+
* Score: 6pt max
|
|
12
|
+
* - 1pt: No stray .md files outside /docs and root
|
|
13
|
+
* - 1pt: No stray .sh scripts outside allowed dirs
|
|
14
|
+
* - 1pt: No stray .yml/.yaml config files outside allowed dirs
|
|
15
|
+
* - 1pt: No nested docs/ directories
|
|
16
|
+
* - 1pt: No clutter in code directories (no .txt, .log, .pdf, .csv, .env, .bak, .tmp, .orig)
|
|
17
|
+
* - 1pt: No clutter in repo root (no .txt, .log, .pdf, .csv, .png, .jpg, tmp/, logs/, data/, venv/)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, existsSync } from 'fs'
|
|
21
|
+
import { join, relative, basename } from 'path'
|
|
22
|
+
import { createCheck } from './types.js'
|
|
23
|
+
|
|
24
|
+
const ALLOWED_ROOT_MD = new Set([
|
|
25
|
+
'readme.md', 'claude.md', 'changelog.md', 'license.md', 'license',
|
|
26
|
+
'prompt.md', 'plan.md', 'contributing.md', 'code_of_conduct.md'
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const IGNORED_DIRS = new Set([
|
|
30
|
+
'node_modules', 'dist', 'build', '.git', '.next', '.cache', '.turbo',
|
|
31
|
+
'coverage', '.nyc_output', '.playwright', 'test-results'
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
const ALLOWED_SCRIPT_DIRS = new Set([
|
|
35
|
+
'scripts', 'shell', 'hooks', '.husky', '.ralph', '.claude'
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/** Directories whose .md content is always allowed (tooling config) */
|
|
39
|
+
const ALLOWED_MD_DIRS = new Set([
|
|
40
|
+
'docs', '.ralph', '.claude', '.agents', 'e2e'
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/** Directories where .yml/.yaml config files are allowed */
|
|
44
|
+
const ALLOWED_CONFIG_DIRS = new Set([
|
|
45
|
+
'.ralph', '.github', '.claude', '.circleci', '.gitlab'
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
/** Code directories that should only contain code */
|
|
49
|
+
const CODE_DIRS = ['backend', 'frontend', 'backend-mcp', 'backend-pdf', 'mcp']
|
|
50
|
+
|
|
51
|
+
/** File extensions that are clutter in code directories */
|
|
52
|
+
const CLUTTER_EXTENSIONS = new Set([
|
|
53
|
+
'.txt', '.log', '.pdf', '.csv', '.bak', '.tmp', '.orig',
|
|
54
|
+
'.patch', '.diff', '.bk', '.old'
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/** File names that are clutter in code directories (regardless of extension) */
|
|
58
|
+
const CLUTTER_NAMES = new Set([
|
|
59
|
+
'.env', '.env.local', '.env.development', '.env.production', '.env.staging'
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** Files allowed in code dirs despite having clutter extensions */
|
|
63
|
+
const ALLOWED_CLUTTER = new Set([
|
|
64
|
+
'robots.txt', 'llms.txt', 'llms-full.txt', 'humans.txt',
|
|
65
|
+
'security.txt', 'ads.txt', 'manifest.txt'
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
/** Subdirectories within code dirs where clutter files are OK */
|
|
69
|
+
const ALLOWED_CLUTTER_SUBDIRS = new Set([
|
|
70
|
+
'public', 'static', 'assets', 'fixtures', 'test-fixtures',
|
|
71
|
+
'supabase', 'migrations', 'prisma'
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
/** File extensions that are clutter in the repo root */
|
|
75
|
+
const ROOT_CLUTTER_EXTENSIONS = new Set([
|
|
76
|
+
'.txt', '.log', '.pdf', '.csv', '.png', '.jpg', '.jpeg', '.gif',
|
|
77
|
+
'.bak', '.tmp', '.orig', '.patch', '.diff', '.bk', '.old', '.py'
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Root files that are allowed despite having clutter extensions.
|
|
82
|
+
* Standard tooling/platform configs that belong in root.
|
|
83
|
+
*/
|
|
84
|
+
const ALLOWED_ROOT_FILES = new Set([
|
|
85
|
+
// Platform configs
|
|
86
|
+
'dockerfile', 'dockerfile.bot', 'dockerfile.dev', 'dockerfile.prod',
|
|
87
|
+
'procfile', 'nixpacks.toml', 'netlify.toml', 'vercel.json', 'railway.json',
|
|
88
|
+
'render.yaml', 'fly.toml',
|
|
89
|
+
// JS/TS tooling
|
|
90
|
+
'components.json', 'ecosystem.config.js', 'ecosystem.config.cjs',
|
|
91
|
+
'.eslintrc.json', '.prettierrc', '.prettierrc.json',
|
|
92
|
+
'vitest.setup.ts', 'vitest.setup.js', 'jest.setup.ts', 'jest.setup.js',
|
|
93
|
+
'commitlint.config.js', 'lint-staged.config.js',
|
|
94
|
+
// Lock files
|
|
95
|
+
'bun.lockb', 'yarn.lock', 'pnpm-lock.yaml', 'deno.lock',
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
/** Root directories that are clutter (should not exist in repo root) */
|
|
99
|
+
const ROOT_CLUTTER_DIRS = new Set([
|
|
100
|
+
'tmp', 'temp', 'logs', 'log', 'data', 'venv', '.venv',
|
|
101
|
+
'reports', 'output', 'out', 'backup', 'backups'
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Recursively find files matching a predicate, respecting ignored dirs
|
|
106
|
+
*/
|
|
107
|
+
function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
|
|
108
|
+
const results = []
|
|
109
|
+
if (currentDepth >= maxDepth) return results
|
|
110
|
+
|
|
111
|
+
let entries
|
|
112
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return results }
|
|
113
|
+
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const name = entry.name
|
|
116
|
+
if (IGNORED_DIRS.has(name)) continue
|
|
117
|
+
|
|
118
|
+
const fullPath = join(dir, name)
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
results.push(...findFiles(fullPath, predicate, maxDepth, currentDepth + 1))
|
|
121
|
+
} else if (entry.isFile() && predicate(name, fullPath)) {
|
|
122
|
+
results.push(fullPath)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return results
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Directories that may contain a docs/ subdirectory (allowed, not flagged) */
|
|
129
|
+
const ALLOWED_NESTED_DOCS_PARENTS = new Set(['.ralph'])
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find directories named "docs" that are not at the repo root,
|
|
133
|
+
* excluding allowed parents like .ralph/docs
|
|
134
|
+
*/
|
|
135
|
+
function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0) {
|
|
136
|
+
const results = []
|
|
137
|
+
if (currentDepth >= maxDepth) return results
|
|
138
|
+
|
|
139
|
+
let entries
|
|
140
|
+
try { entries = readdirSync(currentDir, { withFileTypes: true }) } catch { return results }
|
|
141
|
+
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const name = entry.name
|
|
144
|
+
if (IGNORED_DIRS.has(name)) continue
|
|
145
|
+
if (!entry.isDirectory()) continue
|
|
146
|
+
|
|
147
|
+
const fullPath = join(currentDir, name)
|
|
148
|
+
const relFromRoot = relative(rootPath, fullPath)
|
|
149
|
+
const topDir = relFromRoot.split('/')[0]
|
|
150
|
+
|
|
151
|
+
if (name === 'docs' && currentDepth > 0) {
|
|
152
|
+
// Skip if parent is an allowed directory (e.g., .ralph/docs)
|
|
153
|
+
if (!ALLOWED_NESTED_DOCS_PARENTS.has(topDir)) {
|
|
154
|
+
results.push(fullPath)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return results
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function check(projectPath) {
|
|
164
|
+
const result = createCheck('file-organization', 6, {
|
|
165
|
+
strayMdFiles: [],
|
|
166
|
+
strayScripts: [],
|
|
167
|
+
strayConfigs: [],
|
|
168
|
+
clutterFiles: [],
|
|
169
|
+
rootClutter: [],
|
|
170
|
+
nestedDocsDirs: [],
|
|
171
|
+
totalStrayMd: 0,
|
|
172
|
+
totalStrayScripts: 0,
|
|
173
|
+
totalStrayConfigs: 0,
|
|
174
|
+
totalClutter: 0,
|
|
175
|
+
totalRootClutter: 0,
|
|
176
|
+
totalNestedDocs: 0
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Load .gitignore entries for root clutter check
|
|
180
|
+
const gitignorePath = join(projectPath, '.gitignore')
|
|
181
|
+
const gitignoredDirs = new Set()
|
|
182
|
+
if (existsSync(gitignorePath)) {
|
|
183
|
+
try {
|
|
184
|
+
const lines = readFileSync(gitignorePath, 'utf-8').split('\n')
|
|
185
|
+
.map(l => l.trim()).filter(l => l && !l.startsWith('#'))
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
// Match dir entries like "tmp/" or "tmp"
|
|
188
|
+
const cleaned = line.replace(/\/$/, '')
|
|
189
|
+
if (cleaned) gitignoredDirs.add(cleaned)
|
|
190
|
+
}
|
|
191
|
+
} catch { /* ignore */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Check 1: Stray .md files ---
|
|
195
|
+
const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'))
|
|
196
|
+
const strayMd = []
|
|
197
|
+
|
|
198
|
+
for (const file of allMdFiles) {
|
|
199
|
+
const rel = relative(projectPath, file)
|
|
200
|
+
const parts = rel.split('/')
|
|
201
|
+
|
|
202
|
+
// README.md and CLAUDE.md are always allowed, anywhere
|
|
203
|
+
const nameLc = basename(file).toLowerCase()
|
|
204
|
+
if (nameLc === 'readme.md' || nameLc === 'claude.md') continue
|
|
205
|
+
|
|
206
|
+
// Root .md files: only allowed ones
|
|
207
|
+
if (parts.length === 1) {
|
|
208
|
+
if (ALLOWED_ROOT_MD.has(basename(file).toLowerCase())) continue
|
|
209
|
+
strayMd.push(rel)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Inside allowed top-level directories: always OK
|
|
214
|
+
if (ALLOWED_MD_DIRS.has(parts[0])) continue
|
|
215
|
+
|
|
216
|
+
// Inside shell/templates: OK (Ralph templates)
|
|
217
|
+
if (parts[0] === 'shell' && parts[1] === 'templates') continue
|
|
218
|
+
|
|
219
|
+
// Everything else is stray
|
|
220
|
+
strayMd.push(rel)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
result.details.strayMdFiles = strayMd.slice(0, 20) // Cap at 20 for readability
|
|
224
|
+
result.details.totalStrayMd = strayMd.length
|
|
225
|
+
|
|
226
|
+
// --- Check 2: Stray scripts ---
|
|
227
|
+
const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'))
|
|
228
|
+
const strayScripts = []
|
|
229
|
+
|
|
230
|
+
for (const file of allScripts) {
|
|
231
|
+
const rel = relative(projectPath, file)
|
|
232
|
+
const parts = rel.split('/')
|
|
233
|
+
const topDir = parts[0]
|
|
234
|
+
|
|
235
|
+
// Root-level scripts: OK
|
|
236
|
+
if (parts.length === 1) continue
|
|
237
|
+
|
|
238
|
+
// Inside allowed script directories: OK
|
|
239
|
+
if (ALLOWED_SCRIPT_DIRS.has(topDir)) continue
|
|
240
|
+
|
|
241
|
+
// Everything else is stray
|
|
242
|
+
strayScripts.push(rel)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
result.details.strayScripts = strayScripts.slice(0, 20)
|
|
246
|
+
result.details.totalStrayScripts = strayScripts.length
|
|
247
|
+
|
|
248
|
+
// --- Check 3: Stray config files (.yml/.yaml) ---
|
|
249
|
+
const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'))
|
|
250
|
+
const strayConfigs = []
|
|
251
|
+
|
|
252
|
+
for (const file of allConfigs) {
|
|
253
|
+
const rel = relative(projectPath, file)
|
|
254
|
+
const parts = rel.split('/')
|
|
255
|
+
|
|
256
|
+
// Root-level configs: always OK (doppler.yaml, .coderabbit.yaml, etc.)
|
|
257
|
+
if (parts.length === 1) continue
|
|
258
|
+
|
|
259
|
+
// Inside allowed config directories: OK
|
|
260
|
+
if (ALLOWED_CONFIG_DIRS.has(parts[0])) continue
|
|
261
|
+
|
|
262
|
+
// Everything else is stray
|
|
263
|
+
strayConfigs.push(rel)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
result.details.strayConfigs = strayConfigs.slice(0, 20)
|
|
267
|
+
result.details.totalStrayConfigs = strayConfigs.length
|
|
268
|
+
|
|
269
|
+
// --- Check 4: Clutter in code directories ---
|
|
270
|
+
const clutterFiles = []
|
|
271
|
+
|
|
272
|
+
for (const codeDir of CODE_DIRS) {
|
|
273
|
+
const codePath = join(projectPath, codeDir)
|
|
274
|
+
let dirExists
|
|
275
|
+
try { readdirSync(codePath); dirExists = true } catch { dirExists = false }
|
|
276
|
+
if (!dirExists) continue
|
|
277
|
+
|
|
278
|
+
const allFiles = findFiles(codePath, () => true)
|
|
279
|
+
for (const file of allFiles) {
|
|
280
|
+
const name = basename(file).toLowerCase()
|
|
281
|
+
const rel = relative(projectPath, file)
|
|
282
|
+
const relFromCodeDir = relative(codePath, file)
|
|
283
|
+
const subParts = relFromCodeDir.split('/')
|
|
284
|
+
|
|
285
|
+
// Check if in an allowed subdirectory (public/, supabase/migrations/, etc.)
|
|
286
|
+
if (subParts.length > 1 && ALLOWED_CLUTTER_SUBDIRS.has(subParts[0])) continue
|
|
287
|
+
|
|
288
|
+
// Check for .env files (secrets!)
|
|
289
|
+
if (CLUTTER_NAMES.has(name)) {
|
|
290
|
+
clutterFiles.push(rel)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check for allowed files (robots.txt in public, etc.)
|
|
295
|
+
if (ALLOWED_CLUTTER.has(name)) continue
|
|
296
|
+
|
|
297
|
+
// Check extension
|
|
298
|
+
const ext = name.includes('.') ? '.' + name.split('.').pop() : ''
|
|
299
|
+
if (CLUTTER_EXTENSIONS.has(ext)) {
|
|
300
|
+
// .env.example and .env.test are OK (templates)
|
|
301
|
+
if (name.endsWith('.example') || name.endsWith('.test')) continue
|
|
302
|
+
clutterFiles.push(rel)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
result.details.clutterFiles = clutterFiles.slice(0, 20)
|
|
308
|
+
result.details.totalClutter = clutterFiles.length
|
|
309
|
+
|
|
310
|
+
// --- Check 5: Root clutter (files & directories) ---
|
|
311
|
+
const rootClutter = []
|
|
312
|
+
|
|
313
|
+
let rootEntries
|
|
314
|
+
try { rootEntries = readdirSync(projectPath, { withFileTypes: true }) } catch { rootEntries = [] }
|
|
315
|
+
|
|
316
|
+
for (const entry of rootEntries) {
|
|
317
|
+
const name = entry.name
|
|
318
|
+
const nameLower = name.toLowerCase()
|
|
319
|
+
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
// Check for clutter directories in root (skip if already gitignored)
|
|
322
|
+
if (ROOT_CLUTTER_DIRS.has(nameLower) && !gitignoredDirs.has(name) && !gitignoredDirs.has(nameLower)) {
|
|
323
|
+
rootClutter.push(name + '/')
|
|
324
|
+
}
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!entry.isFile()) continue
|
|
329
|
+
|
|
330
|
+
// Skip dotfiles that are standard config (.gitignore, .eslintrc, .env.example, etc.)
|
|
331
|
+
if (name.startsWith('.')) continue
|
|
332
|
+
|
|
333
|
+
// Skip known allowed root files
|
|
334
|
+
if (ALLOWED_ROOT_FILES.has(nameLower)) continue
|
|
335
|
+
|
|
336
|
+
// Skip package manifests, lockfiles, configs (handled by other checks or standard)
|
|
337
|
+
if (nameLower.endsWith('.json') || nameLower.endsWith('.mjs') || nameLower.endsWith('.cjs')) continue
|
|
338
|
+
if (nameLower.endsWith('.toml') || nameLower.endsWith('.lock')) continue
|
|
339
|
+
if (nameLower.endsWith('.md')) continue // Handled by check 1
|
|
340
|
+
if (nameLower.endsWith('.sh')) continue // Handled by check 2
|
|
341
|
+
if (nameLower.endsWith('.yml') || nameLower.endsWith('.yaml')) continue // Handled by check 3
|
|
342
|
+
if (nameLower.endsWith('.ts') || nameLower.endsWith('.js')) continue // Config files like vitest.setup.ts
|
|
343
|
+
|
|
344
|
+
// Check for clutter extensions
|
|
345
|
+
const ext = nameLower.includes('.') ? '.' + nameLower.split('.').pop() : ''
|
|
346
|
+
if (ROOT_CLUTTER_EXTENSIONS.has(ext)) {
|
|
347
|
+
rootClutter.push(name)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
result.details.rootClutter = rootClutter.slice(0, 20)
|
|
352
|
+
result.details.totalRootClutter = rootClutter.length
|
|
353
|
+
|
|
354
|
+
// --- Check 6: Nested docs/ directories ---
|
|
355
|
+
const nestedDocs = findNestedDocsDirs(projectPath, projectPath)
|
|
356
|
+
const nestedDocsRel = nestedDocs.map(d => relative(projectPath, d))
|
|
357
|
+
|
|
358
|
+
result.details.nestedDocsDirs = nestedDocsRel
|
|
359
|
+
result.details.totalNestedDocs = nestedDocsRel.length
|
|
360
|
+
|
|
361
|
+
// --- Scoring ---
|
|
362
|
+
let score = 6
|
|
363
|
+
|
|
364
|
+
if (strayMd.length > 0) score -= 1
|
|
365
|
+
if (strayScripts.length > 0) score -= 1
|
|
366
|
+
if (strayConfigs.length > 0) score -= 1
|
|
367
|
+
if (clutterFiles.length > 0) score -= 1
|
|
368
|
+
if (rootClutter.length > 0) score -= 1
|
|
369
|
+
if (nestedDocsRel.length > 0) score -= 1
|
|
370
|
+
|
|
371
|
+
result.score = score
|
|
372
|
+
|
|
373
|
+
if (score === 6) {
|
|
374
|
+
result.status = 'ok'
|
|
375
|
+
result.details.message = 'Clean repo: docs in /docs, scripts in /scripts, root & code dirs clean'
|
|
376
|
+
} else {
|
|
377
|
+
result.status = score >= 5 ? 'warning' : 'error'
|
|
378
|
+
const issues = []
|
|
379
|
+
if (strayMd.length > 0) issues.push(`${strayMd.length} stray .md`)
|
|
380
|
+
if (strayScripts.length > 0) issues.push(`${strayScripts.length} stray .sh`)
|
|
381
|
+
if (strayConfigs.length > 0) issues.push(`${strayConfigs.length} stray .yml`)
|
|
382
|
+
if (clutterFiles.length > 0) issues.push(`${clutterFiles.length} clutter in code dirs`)
|
|
383
|
+
if (rootClutter.length > 0) issues.push(`${rootClutter.length} root clutter`)
|
|
384
|
+
if (nestedDocsRel.length > 0) issues.push(`${nestedDocsRel.length} nested docs/`)
|
|
385
|
+
result.details.message = issues.join(', ')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Health Checks — All
|
|
2
|
+
* Health Checks — All 16 project health checks
|
|
3
3
|
*
|
|
4
4
|
* Main entry: scanProjectHealth(projectPath, projectName, options?)
|
|
5
5
|
* Individual checks available via named imports.
|
|
@@ -24,3 +24,5 @@ export { check as checkStellaIntegration } from './stella-integration.js'
|
|
|
24
24
|
export { check as checkClaudeMd } from './claude-md.js'
|
|
25
25
|
export { check as checkDopplerCompliance } from './doppler-compliance.js'
|
|
26
26
|
export { check as checkInfrastructureYml } from './infrastructure-yml.js'
|
|
27
|
+
export { check as checkFileOrganization } from './file-organization.js'
|
|
28
|
+
export { check as checkRpcParamMismatch } from './rpc-param-mismatch.js'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: RPC Parameter Mismatch
|
|
3
|
+
*
|
|
4
|
+
* Compares .rpc() calls in TypeScript with SQL function definitions
|
|
5
|
+
* to detect parameter name mismatches that cause PGRST202 runtime errors.
|
|
6
|
+
*
|
|
7
|
+
* Score: 3 (full) with deductions:
|
|
8
|
+
* - Critical mismatch (extra param in TS): -1.5 per finding (max -3)
|
|
9
|
+
* - Low (missing params): -0.25 per finding (max -1)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createCheck } from './types.js'
|
|
13
|
+
import { run as runAuditCheck } from '../supabase/rpc-param-mismatch.js'
|
|
14
|
+
|
|
15
|
+
export async function check(projectPath) {
|
|
16
|
+
const result = createCheck('rpc-param-mismatch', 3, {
|
|
17
|
+
sqlFunctionsFound: 0,
|
|
18
|
+
rpcCallsFound: 0,
|
|
19
|
+
rpcCallsChecked: 0,
|
|
20
|
+
criticalMismatches: [],
|
|
21
|
+
missingParams: [],
|
|
22
|
+
message: ''
|
|
23
|
+
})
|
|
24
|
+
result.score = 3 // Start full, deduct for issues
|
|
25
|
+
|
|
26
|
+
// Re-use the audit check with default config paths
|
|
27
|
+
const config = {
|
|
28
|
+
paths: {
|
|
29
|
+
backend: ['backend/src', 'src'],
|
|
30
|
+
migrations: ['supabase/migrations', 'backend/supabase/migrations', 'migrations']
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let auditResult
|
|
35
|
+
try {
|
|
36
|
+
auditResult = await runAuditCheck(config, projectPath)
|
|
37
|
+
} catch {
|
|
38
|
+
result.details.message = 'Failed to run RPC param mismatch check'
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (auditResult.skipped) {
|
|
43
|
+
result.score = 3
|
|
44
|
+
result.details.message = auditResult.skipReason || 'Skipped'
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
result.details.sqlFunctionsFound = auditResult.details.sqlFunctionsFound
|
|
49
|
+
result.details.rpcCallsFound = auditResult.details.rpcCallsFound
|
|
50
|
+
result.details.rpcCallsChecked = auditResult.details.rpcCallsChecked
|
|
51
|
+
|
|
52
|
+
// Process findings
|
|
53
|
+
const criticals = auditResult.findings.filter(f => f.severity === 'critical')
|
|
54
|
+
const lows = auditResult.findings.filter(f => f.severity === 'low')
|
|
55
|
+
|
|
56
|
+
result.details.criticalMismatches = criticals.map(f => ({
|
|
57
|
+
file: f.file,
|
|
58
|
+
line: f.line,
|
|
59
|
+
function: f.rpcFunction,
|
|
60
|
+
extraParams: f.extraInTs,
|
|
61
|
+
expectedParams: f.sqlParams
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
result.details.missingParams = lows.map(f => ({
|
|
65
|
+
file: f.file,
|
|
66
|
+
line: f.line,
|
|
67
|
+
function: f.rpcFunction,
|
|
68
|
+
tsParamCount: f.tsParams?.length,
|
|
69
|
+
sqlParamCount: f.sqlParams?.length
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
// Score deductions
|
|
73
|
+
if (criticals.length > 0) {
|
|
74
|
+
const deduction = Math.min(3, criticals.length * 1.5)
|
|
75
|
+
result.score -= deduction
|
|
76
|
+
result.status = 'error'
|
|
77
|
+
result.details.message = `${criticals.length} RPC parameter mismatch(es) — will cause PGRST202 runtime errors`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (lows.length > 0 && result.status === 'ok') {
|
|
81
|
+
const deduction = Math.min(1, lows.length * 0.25)
|
|
82
|
+
result.score -= deduction
|
|
83
|
+
if (result.status === 'ok' && lows.length > 5) result.status = 'warning'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result.status === 'ok') {
|
|
87
|
+
result.details.message = `${auditResult.details.rpcCallsChecked} RPC calls verified against SQL definitions`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
result.score = Math.max(0, result.score)
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Health Scanner
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates all
|
|
4
|
+
* Orchestrates all 16 health checks and produces a HealthReport.
|
|
5
5
|
* This is the main entry point — consumers call scanProjectHealth().
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -20,6 +20,8 @@ import { check as checkStellaIntegration } from './stella-integration.js'
|
|
|
20
20
|
import { check as checkClaudeMd } from './claude-md.js'
|
|
21
21
|
import { check as checkDopplerCompliance } from './doppler-compliance.js'
|
|
22
22
|
import { check as checkInfrastructureYml } from './infrastructure-yml.js'
|
|
23
|
+
import { check as checkFileOrganization } from './file-organization.js'
|
|
24
|
+
import { check as checkRpcParamMismatch } from './rpc-param-mismatch.js'
|
|
23
25
|
import { calculateHealthStatus } from './types.js'
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -48,7 +50,9 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
48
50
|
checkStellaIntegration(projectPath),
|
|
49
51
|
checkClaudeMd(projectPath),
|
|
50
52
|
checkDopplerCompliance(projectPath),
|
|
51
|
-
checkInfrastructureYml(projectPath)
|
|
53
|
+
checkInfrastructureYml(projectPath),
|
|
54
|
+
checkFileOrganization(projectPath),
|
|
55
|
+
checkRpcParamMismatch(projectPath)
|
|
52
56
|
])
|
|
53
57
|
|
|
54
58
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Health Check: @
|
|
2
|
+
* Health Check: @soulbatical/stella Integration
|
|
3
3
|
*
|
|
4
|
-
* Checks for @
|
|
4
|
+
* Checks for @soulbatical/stella dependency and integration level in MCP server.
|
|
5
5
|
* Score: 0 = not installed, 1 = basic, 2 = full integration
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -14,7 +14,7 @@ export async function check(projectPath) {
|
|
|
14
14
|
installed: false, version: null, integrationLevel: 'none', features: [], packageLocation: null
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
// Search for @
|
|
17
|
+
// Search for @soulbatical/stella in package.json files
|
|
18
18
|
const packageLocations = [
|
|
19
19
|
{ path: join(projectPath, 'package.json'), label: 'root' },
|
|
20
20
|
{ path: join(projectPath, 'mcp', 'package.json'), label: 'mcp/' },
|
|
@@ -29,8 +29,8 @@ export async function check(projectPath) {
|
|
|
29
29
|
try {
|
|
30
30
|
const pkg = JSON.parse(readFileSync(loc.path, 'utf-8'))
|
|
31
31
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
32
|
-
if (allDeps['@
|
|
33
|
-
stellaDep = allDeps['@
|
|
32
|
+
if (allDeps['@soulbatical/stella']) {
|
|
33
|
+
stellaDep = allDeps['@soulbatical/stella']
|
|
34
34
|
stellaLocation = loc.label
|
|
35
35
|
break
|
|
36
36
|
}
|
|
@@ -50,8 +50,8 @@ export async function check(projectPath) {
|
|
|
50
50
|
|
|
51
51
|
// Get installed version
|
|
52
52
|
const nmPaths = [
|
|
53
|
-
join(projectPath, 'node_modules', '@
|
|
54
|
-
join(projectPath, stellaLocation || '', 'node_modules', '@
|
|
53
|
+
join(projectPath, 'node_modules', '@soulbatical', 'stella', 'package.json'),
|
|
54
|
+
join(projectPath, stellaLocation || '', 'node_modules', '@soulbatical', 'stella', 'package.json'),
|
|
55
55
|
]
|
|
56
56
|
for (const nmPath of nmPaths) {
|
|
57
57
|
if (!existsSync(nmPath)) continue
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'} HealthCheckType
|
|
8
|
+
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'} HealthCheckType
|
|
9
9
|
*
|
|
10
10
|
* @typedef {'ok'|'warning'|'error'} HealthStatus
|
|
11
11
|
*
|
|
@@ -66,7 +66,7 @@ export function calculateHealthStatus(checks) {
|
|
|
66
66
|
|
|
67
67
|
// Critical checks override percentage
|
|
68
68
|
if (checks.some(c =>
|
|
69
|
-
(c.type === 'secrets' || c.type === 'rls-audit' || c.type === 'repo-visibility') && c.status === 'error'
|
|
69
|
+
(c.type === 'secrets' || c.type === 'rls-audit' || c.type === 'rpc-param-mismatch' || c.type === 'repo-visibility') && c.status === 'error'
|
|
70
70
|
)) {
|
|
71
71
|
return 'unhealthy'
|
|
72
72
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hygiene Check: File Organization
|
|
3
|
+
*
|
|
4
|
+
* Wraps the health check file-organization as a tetra-audit check.
|
|
5
|
+
* Detects stray docs, scripts, clutter in code dirs, and root mess.
|
|
6
|
+
*
|
|
7
|
+
* Severity: high — messy repos ship messy code
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { check as healthCheck } from '../health/file-organization.js'
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
id: 'file-organization',
|
|
14
|
+
name: 'File Organization',
|
|
15
|
+
category: 'hygiene',
|
|
16
|
+
severity: 'high',
|
|
17
|
+
description: 'Checks that docs are in /docs, scripts in /scripts, no clutter in code dirs or repo root'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function run(config, projectRoot) {
|
|
21
|
+
const result = {
|
|
22
|
+
passed: true,
|
|
23
|
+
findings: [],
|
|
24
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
25
|
+
details: {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const health = await healthCheck(projectRoot)
|
|
29
|
+
result.details = health.details
|
|
30
|
+
|
|
31
|
+
// Convert health check results to findings
|
|
32
|
+
if (health.details.totalStrayMd > 0) {
|
|
33
|
+
result.findings.push({
|
|
34
|
+
type: 'Stray documentation files',
|
|
35
|
+
severity: 'high',
|
|
36
|
+
message: `${health.details.totalStrayMd} .md file(s) outside /docs/ — move to docs/ or delete`,
|
|
37
|
+
files: health.details.strayMdFiles
|
|
38
|
+
})
|
|
39
|
+
result.summary.high++
|
|
40
|
+
result.summary.total++
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (health.details.totalStrayScripts > 0) {
|
|
44
|
+
result.findings.push({
|
|
45
|
+
type: 'Stray shell scripts',
|
|
46
|
+
severity: 'medium',
|
|
47
|
+
message: `${health.details.totalStrayScripts} .sh file(s) outside /scripts/ — move to scripts/`,
|
|
48
|
+
files: health.details.strayScripts
|
|
49
|
+
})
|
|
50
|
+
result.summary.medium++
|
|
51
|
+
result.summary.total++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (health.details.totalStrayConfigs > 0) {
|
|
55
|
+
result.findings.push({
|
|
56
|
+
type: 'Stray config files',
|
|
57
|
+
severity: 'low',
|
|
58
|
+
message: `${health.details.totalStrayConfigs} .yml/.yaml file(s) outside allowed dirs`,
|
|
59
|
+
files: health.details.strayConfigs
|
|
60
|
+
})
|
|
61
|
+
result.summary.low++
|
|
62
|
+
result.summary.total++
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (health.details.totalClutter > 0) {
|
|
66
|
+
// Clutter in code dirs is high — often includes .env secrets
|
|
67
|
+
const hasEnv = health.details.clutterFiles.some(f => f.includes('.env'))
|
|
68
|
+
const severity = hasEnv ? 'critical' : 'high'
|
|
69
|
+
result.findings.push({
|
|
70
|
+
type: 'Clutter in code directories',
|
|
71
|
+
severity,
|
|
72
|
+
message: `${health.details.totalClutter} clutter file(s) in code dirs (${hasEnv ? 'includes .env!' : '.txt, .log, .bak, etc.'})`,
|
|
73
|
+
files: health.details.clutterFiles
|
|
74
|
+
})
|
|
75
|
+
result.summary[severity]++
|
|
76
|
+
result.summary.total++
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (health.details.totalRootClutter > 0) {
|
|
80
|
+
result.findings.push({
|
|
81
|
+
type: 'Root clutter',
|
|
82
|
+
severity: 'high',
|
|
83
|
+
message: `${health.details.totalRootClutter} clutter item(s) in repo root — ${health.details.rootClutter.join(', ')}`,
|
|
84
|
+
files: health.details.rootClutter
|
|
85
|
+
})
|
|
86
|
+
result.summary.high++
|
|
87
|
+
result.summary.total++
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (health.details.totalNestedDocs > 0) {
|
|
91
|
+
result.findings.push({
|
|
92
|
+
type: 'Nested docs/ directories',
|
|
93
|
+
severity: 'medium',
|
|
94
|
+
message: `${health.details.totalNestedDocs} nested docs/ dir(s) — consolidate into root /docs/`,
|
|
95
|
+
files: health.details.nestedDocsDirs
|
|
96
|
+
})
|
|
97
|
+
result.summary.medium++
|
|
98
|
+
result.summary.total++
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fail if any high+ findings
|
|
102
|
+
result.passed = result.summary.critical === 0 && result.summary.high === 0
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
}
|