@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.
@@ -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 15 project health checks
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 15 health checks and produces a HealthReport.
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: @ralph/stella Integration
2
+ * Health Check: @soulbatical/stella Integration
3
3
  *
4
- * Checks for @ralph/stella dependency and integration level in MCP server.
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 @ralph/stella in package.json files
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['@ralph/stella']) {
33
- stellaDep = allDeps['@ralph/stella']
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', '@ralph', 'stella', 'package.json'),
54
- join(projectPath, stellaLocation || '', 'node_modules', '@ralph', 'stella', 'package.json'),
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
+ }