@soulbatical/tetra-dev-toolkit 1.20.21 → 1.20.23
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/bin/tetra-init.js +30 -4
- package/bin/tetra-style-audit.js +86 -0
- package/lib/audits/style-compliance-audit.js +528 -0
- package/lib/checks/codeQuality/appshell-compliance.js +573 -0
- package/lib/checks/health/deploy-readiness.js +222 -0
- package/lib/checks/health/file-organization.js +21 -9
- package/lib/checks/health/gitignore.js +2 -1
- package/lib/checks/health/index.js +1 -0
- package/lib/checks/health/scanner.js +3 -1
- package/lib/checks/index.js +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Deploy Readiness for Private Packages
|
|
3
|
+
*
|
|
4
|
+
* Verifies that a project is correctly configured to deploy with
|
|
5
|
+
* @soulbatical private npm packages on Railway and Netlify.
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* 1. .npmrc exists with @soulbatical scope + ${NPM_TOKEN} auth
|
|
9
|
+
* 2. No file: references to @soulbatical packages (breaks CI/CD)
|
|
10
|
+
* 3. Railway projects have a Dockerfile with npm auth step
|
|
11
|
+
* 4. Dockerfile ARG NPM_TOKEN + .npmrc creation happens BEFORE npm install
|
|
12
|
+
*
|
|
13
|
+
* Score: 0-4 (1 per aspect)
|
|
14
|
+
* Skipped if project has no @soulbatical dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { createCheck } from './types.js'
|
|
20
|
+
|
|
21
|
+
const SOULBATICAL_PACKAGES = [
|
|
22
|
+
'@soulbatical/tetra-core',
|
|
23
|
+
'@soulbatical/tetra-ui',
|
|
24
|
+
'@soulbatical/tetra-dev-toolkit',
|
|
25
|
+
'@soulbatical/stella'
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collect all @soulbatical dependency references from a project's package.json files
|
|
30
|
+
*/
|
|
31
|
+
function findSoulbaticalDeps(projectPath) {
|
|
32
|
+
const pkgPaths = [
|
|
33
|
+
{ path: join(projectPath, 'package.json'), label: 'root' },
|
|
34
|
+
{ path: join(projectPath, 'backend', 'package.json'), label: 'backend' },
|
|
35
|
+
{ path: join(projectPath, 'frontend', 'package.json'), label: 'frontend' },
|
|
36
|
+
{ path: join(projectPath, 'backend-mcp', 'package.json'), label: 'backend-mcp' },
|
|
37
|
+
{ path: join(projectPath, 'bot', 'package.json'), label: 'bot' }
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const deps = []
|
|
41
|
+
const fileRefs = []
|
|
42
|
+
|
|
43
|
+
for (const { path: pkgPath, label } of pkgPaths) {
|
|
44
|
+
if (!existsSync(pkgPath)) continue
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
47
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
48
|
+
|
|
49
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
50
|
+
if (!name.startsWith('@soulbatical/')) continue
|
|
51
|
+
deps.push({ name, version, location: label })
|
|
52
|
+
|
|
53
|
+
if (version.startsWith('file:') || version.startsWith('link:')) {
|
|
54
|
+
fileRefs.push({ name, version, location: label })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { deps, fileRefs }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if .npmrc has proper config for private packages
|
|
65
|
+
*/
|
|
66
|
+
function checkNpmrc(projectPath) {
|
|
67
|
+
const npmrcPath = join(projectPath, '.npmrc')
|
|
68
|
+
if (!existsSync(npmrcPath)) return { exists: false, hasAuth: false, hasScope: false, content: '' }
|
|
69
|
+
|
|
70
|
+
const content = readFileSync(npmrcPath, 'utf-8')
|
|
71
|
+
return {
|
|
72
|
+
exists: true,
|
|
73
|
+
hasAuth: content.includes('_authToken=${NPM_TOKEN}') || content.includes('_authToken=$NPM_TOKEN'),
|
|
74
|
+
hasScope: content.includes('@soulbatical:registry='),
|
|
75
|
+
content
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if Dockerfile has proper npm auth for private packages
|
|
81
|
+
*/
|
|
82
|
+
function checkDockerfile(projectPath) {
|
|
83
|
+
// Check common Dockerfile locations
|
|
84
|
+
const dockerfiles = [
|
|
85
|
+
join(projectPath, 'Dockerfile'),
|
|
86
|
+
join(projectPath, 'Dockerfile.prod'),
|
|
87
|
+
join(projectPath, 'backend', 'Dockerfile')
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
for (const dfPath of dockerfiles) {
|
|
91
|
+
if (!existsSync(dfPath)) continue
|
|
92
|
+
const content = readFileSync(dfPath, 'utf-8')
|
|
93
|
+
|
|
94
|
+
const hasArgNpmToken = /ARG\s+NPM_TOKEN/i.test(content)
|
|
95
|
+
const hasNpmrcCreation = content.includes('.npmrc') && content.includes('authToken')
|
|
96
|
+
const hasNpmInstall = /npm\s+(ci|install)/i.test(content)
|
|
97
|
+
|
|
98
|
+
// Check ordering: .npmrc creation should be BEFORE npm install
|
|
99
|
+
let orderCorrect = false
|
|
100
|
+
if (hasNpmrcCreation && hasNpmInstall) {
|
|
101
|
+
const npmrcPos = content.indexOf('.npmrc')
|
|
102
|
+
const installPos = content.search(/npm\s+(ci|install)/i)
|
|
103
|
+
orderCorrect = npmrcPos < installPos
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
exists: true,
|
|
108
|
+
path: dfPath,
|
|
109
|
+
hasArgNpmToken,
|
|
110
|
+
hasNpmrcCreation,
|
|
111
|
+
hasNpmInstall,
|
|
112
|
+
orderCorrect
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { exists: false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect if project deploys to Railway (has railway config or infrastructure hints)
|
|
121
|
+
*/
|
|
122
|
+
function isRailwayProject(projectPath) {
|
|
123
|
+
if (existsSync(join(projectPath, 'railway.json'))) return true
|
|
124
|
+
if (existsSync(join(projectPath, 'railway.toml'))) return true
|
|
125
|
+
|
|
126
|
+
// Check INFRASTRUCTURE.yml for Railway hosting
|
|
127
|
+
const infraPath = join(projectPath, '.ralph', 'INFRASTRUCTURE.yml')
|
|
128
|
+
if (existsSync(infraPath)) {
|
|
129
|
+
try {
|
|
130
|
+
const content = readFileSync(infraPath, 'utf-8')
|
|
131
|
+
if (content.toLowerCase().includes('railway')) return true
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function check(projectPath) {
|
|
139
|
+
const result = createCheck('deploy-readiness', 4, {
|
|
140
|
+
hasSoulbaticalDeps: false,
|
|
141
|
+
soulbaticalDeps: [],
|
|
142
|
+
fileRefs: [],
|
|
143
|
+
npmrc: {},
|
|
144
|
+
dockerfile: {},
|
|
145
|
+
isRailway: false,
|
|
146
|
+
skipped: false
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Find @soulbatical dependencies
|
|
150
|
+
const { deps, fileRefs } = findSoulbaticalDeps(projectPath)
|
|
151
|
+
result.details.soulbaticalDeps = deps
|
|
152
|
+
result.details.fileRefs = fileRefs
|
|
153
|
+
|
|
154
|
+
// Skip if no @soulbatical dependencies
|
|
155
|
+
if (deps.length === 0) {
|
|
156
|
+
result.details.skipped = true
|
|
157
|
+
result.details.message = 'No @soulbatical dependencies — check skipped'
|
|
158
|
+
result.score = result.maxScore // Full score if not applicable
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result.details.hasSoulbaticalDeps = true
|
|
163
|
+
const isRailway = isRailwayProject(projectPath)
|
|
164
|
+
result.details.isRailway = isRailway
|
|
165
|
+
|
|
166
|
+
// --- Check 1: .npmrc exists with auth + scope (+1 point) ---
|
|
167
|
+
const npmrc = checkNpmrc(projectPath)
|
|
168
|
+
result.details.npmrc = npmrc
|
|
169
|
+
|
|
170
|
+
if (npmrc.exists && npmrc.hasAuth && npmrc.hasScope) {
|
|
171
|
+
result.score += 1
|
|
172
|
+
} else if (npmrc.exists && npmrc.hasAuth) {
|
|
173
|
+
result.score += 0.5 // Auth OK but missing scope
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Check 2: No file: references (+1 point) ---
|
|
177
|
+
if (fileRefs.length === 0) {
|
|
178
|
+
result.score += 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Check 3: Railway projects need Dockerfile (+1 point) ---
|
|
182
|
+
if (isRailway) {
|
|
183
|
+
const df = checkDockerfile(projectPath)
|
|
184
|
+
result.details.dockerfile = df
|
|
185
|
+
|
|
186
|
+
if (df.exists && df.hasArgNpmToken && df.hasNpmrcCreation && df.orderCorrect) {
|
|
187
|
+
result.score += 1
|
|
188
|
+
} else if (df.exists && df.hasNpmInstall) {
|
|
189
|
+
result.score += 0.5 // Dockerfile exists but missing npm auth
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
result.score += 1 // Not Railway — full score for this check
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Check 4: Overall deploy confidence (+1 point) ---
|
|
196
|
+
// All non-dev @soulbatical deps use semver (not file:, not link:)
|
|
197
|
+
const nonDevFileRefs = fileRefs.filter(r => r.location !== 'root') // root devDeps are OK locally
|
|
198
|
+
const allSemver = nonDevFileRefs.length === 0
|
|
199
|
+
const npmrcReady = npmrc.exists && npmrc.hasAuth
|
|
200
|
+
const dockerReady = !isRailway || (result.details.dockerfile.exists && result.details.dockerfile.hasArgNpmToken)
|
|
201
|
+
|
|
202
|
+
if (allSemver && npmrcReady && dockerReady) {
|
|
203
|
+
result.score += 1
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
207
|
+
|
|
208
|
+
// Summary message
|
|
209
|
+
if (result.score < result.maxScore) {
|
|
210
|
+
result.status = 'warning'
|
|
211
|
+
const issues = []
|
|
212
|
+
if (!npmrc.exists) issues.push('missing .npmrc')
|
|
213
|
+
else if (!npmrc.hasAuth) issues.push('.npmrc missing ${NPM_TOKEN} auth')
|
|
214
|
+
else if (!npmrc.hasScope) issues.push('.npmrc missing @soulbatical:registry scope')
|
|
215
|
+
if (fileRefs.length > 0) issues.push(`${fileRefs.length} file: ref(s) to @soulbatical packages — will break CI/CD`)
|
|
216
|
+
if (isRailway && !result.details.dockerfile.exists) issues.push('Railway project without Dockerfile �� private packages will fail')
|
|
217
|
+
else if (isRailway && !result.details.dockerfile.hasArgNpmToken) issues.push('Dockerfile missing ARG NPM_TOKEN')
|
|
218
|
+
result.details.message = issues.join(', ')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
@@ -102,9 +102,15 @@ const ROOT_CLUTTER_DIRS = new Set([
|
|
|
102
102
|
])
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Recursively find files matching a predicate, respecting ignored dirs
|
|
105
|
+
* Recursively find files matching a predicate, respecting ignored dirs.
|
|
106
|
+
* @param {string} dir - Directory to scan
|
|
107
|
+
* @param {Function} predicate - (name, fullPath) => boolean
|
|
108
|
+
* @param {number} maxDepth - Max recursion depth
|
|
109
|
+
* @param {number} currentDepth - Current depth
|
|
110
|
+
* @param {string} rootPath - Project root (for relative path matching against gitignore)
|
|
111
|
+
* @param {Set<string>} gitignored - Gitignored path patterns from .gitignore
|
|
106
112
|
*/
|
|
107
|
-
function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
|
|
113
|
+
function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0, rootPath = dir, gitignored = new Set()) {
|
|
108
114
|
const results = []
|
|
109
115
|
if (currentDepth >= maxDepth) return results
|
|
110
116
|
|
|
@@ -117,7 +123,10 @@ function findFiles(dir, predicate, maxDepth = 5, currentDepth = 0) {
|
|
|
117
123
|
|
|
118
124
|
const fullPath = join(dir, name)
|
|
119
125
|
if (entry.isDirectory()) {
|
|
120
|
-
|
|
126
|
+
// Skip directories matched by .gitignore entries
|
|
127
|
+
const relPath = relative(rootPath, fullPath)
|
|
128
|
+
if (gitignored.has(name) || gitignored.has(relPath)) continue
|
|
129
|
+
results.push(...findFiles(fullPath, predicate, maxDepth, currentDepth + 1, rootPath, gitignored))
|
|
121
130
|
} else if (entry.isFile() && predicate(name, fullPath)) {
|
|
122
131
|
results.push(fullPath)
|
|
123
132
|
}
|
|
@@ -132,7 +141,7 @@ const ALLOWED_NESTED_DOCS_PARENTS = new Set(['.ralph'])
|
|
|
132
141
|
* Find directories named "docs" that are not at the repo root,
|
|
133
142
|
* excluding allowed parents like .ralph/docs
|
|
134
143
|
*/
|
|
135
|
-
function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0) {
|
|
144
|
+
function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0, gitignored = new Set()) {
|
|
136
145
|
const results = []
|
|
137
146
|
if (currentDepth >= maxDepth) return results
|
|
138
147
|
|
|
@@ -146,6 +155,9 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
|
|
|
146
155
|
|
|
147
156
|
const fullPath = join(currentDir, name)
|
|
148
157
|
const relFromRoot = relative(rootPath, fullPath)
|
|
158
|
+
|
|
159
|
+
// Skip gitignored directories
|
|
160
|
+
if (gitignored.has(name) || gitignored.has(relFromRoot)) continue
|
|
149
161
|
const topDir = relFromRoot.split('/')[0]
|
|
150
162
|
|
|
151
163
|
if (name === 'docs' && currentDepth > 0) {
|
|
@@ -154,7 +166,7 @@ function findNestedDocsDirs(rootPath, currentDir, maxDepth = 4, currentDepth = 0
|
|
|
154
166
|
results.push(fullPath)
|
|
155
167
|
}
|
|
156
168
|
} else {
|
|
157
|
-
results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1))
|
|
169
|
+
results.push(...findNestedDocsDirs(rootPath, fullPath, maxDepth, currentDepth + 1, gitignored))
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
return results
|
|
@@ -192,7 +204,7 @@ export async function check(projectPath) {
|
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
// --- Check 1: Stray .md files ---
|
|
195
|
-
const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'))
|
|
207
|
+
const allMdFiles = findFiles(projectPath, (name) => name.endsWith('.md'), 5, 0, projectPath, gitignoredDirs)
|
|
196
208
|
const strayMd = []
|
|
197
209
|
|
|
198
210
|
for (const file of allMdFiles) {
|
|
@@ -224,7 +236,7 @@ export async function check(projectPath) {
|
|
|
224
236
|
result.details.totalStrayMd = strayMd.length
|
|
225
237
|
|
|
226
238
|
// --- Check 2: Stray scripts ---
|
|
227
|
-
const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'))
|
|
239
|
+
const allScripts = findFiles(projectPath, (name) => name.endsWith('.sh'), 5, 0, projectPath, gitignoredDirs)
|
|
228
240
|
const strayScripts = []
|
|
229
241
|
|
|
230
242
|
for (const file of allScripts) {
|
|
@@ -246,7 +258,7 @@ export async function check(projectPath) {
|
|
|
246
258
|
result.details.totalStrayScripts = strayScripts.length
|
|
247
259
|
|
|
248
260
|
// --- Check 3: Stray config files (.yml/.yaml) ---
|
|
249
|
-
const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'))
|
|
261
|
+
const allConfigs = findFiles(projectPath, (name) => name.endsWith('.yml') || name.endsWith('.yaml'), 5, 0, projectPath, gitignoredDirs)
|
|
250
262
|
const strayConfigs = []
|
|
251
263
|
|
|
252
264
|
for (const file of allConfigs) {
|
|
@@ -352,7 +364,7 @@ export async function check(projectPath) {
|
|
|
352
364
|
result.details.totalRootClutter = rootClutter.length
|
|
353
365
|
|
|
354
366
|
// --- Check 6: Nested docs/ directories ---
|
|
355
|
-
const nestedDocs = findNestedDocsDirs(projectPath, projectPath)
|
|
367
|
+
const nestedDocs = findNestedDocsDirs(projectPath, projectPath, 4, 0, gitignoredDirs)
|
|
356
368
|
const nestedDocsRel = nestedDocs.map(d => relative(projectPath, d))
|
|
357
369
|
|
|
358
370
|
result.details.nestedDocsDirs = nestedDocsRel
|
|
@@ -18,7 +18,8 @@ const CRITICAL = [
|
|
|
18
18
|
const RECOMMENDED = [
|
|
19
19
|
{ name: 'credential files (*.pem, *.key)', patterns: ['*.pem', '*.key'] },
|
|
20
20
|
{ name: 'Supabase temp', patterns: ['.supabase', 'supabase/.temp'] },
|
|
21
|
-
{ name: '.DS_Store', patterns: ['.DS_Store'] }
|
|
21
|
+
{ name: '.DS_Store', patterns: ['.DS_Store'] },
|
|
22
|
+
{ name: 'Ralph runtime state (.ralph/)', patterns: ['.ralph'] }
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
function isCovered(lines, pattern) {
|
|
@@ -43,3 +43,4 @@ export { check as checkReleasePipeline } from './release-pipeline.js'
|
|
|
43
43
|
export { check as checkTestStructure } from './test-structure.js'
|
|
44
44
|
export { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
45
45
|
export { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
46
|
+
export { check as checkDeployReadiness } from './deploy-readiness.js'
|
|
@@ -39,6 +39,7 @@ import { check as checkReleasePipeline } from './release-pipeline.js'
|
|
|
39
39
|
import { check as checkTestStructure } from './test-structure.js'
|
|
40
40
|
import { check as checkSentryMonitoring } from './sentry-monitoring.js'
|
|
41
41
|
import { check as checkShadcnUiTokens } from './shadcn-ui-tokens.js'
|
|
42
|
+
import { check as checkDeployReadiness } from './deploy-readiness.js'
|
|
42
43
|
import { calculateHealthStatus } from './types.js'
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -86,7 +87,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
86
87
|
checkReleasePipeline(projectPath),
|
|
87
88
|
checkTestStructure(projectPath),
|
|
88
89
|
checkSentryMonitoring(projectPath),
|
|
89
|
-
checkShadcnUiTokens(projectPath)
|
|
90
|
+
checkShadcnUiTokens(projectPath),
|
|
91
|
+
checkDeployReadiness(projectPath)
|
|
90
92
|
])
|
|
91
93
|
|
|
92
94
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
package/lib/checks/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export * as rpcParamMismatch from './supabase/rpc-param-mismatch.js'
|
|
|
19
19
|
export * as rpcGeneratorOrigin from './supabase/rpc-generator-origin.js'
|
|
20
20
|
|
|
21
21
|
// Code quality checks
|
|
22
|
+
export * as appshellCompliance from './codeQuality/appshell-compliance.js'
|
|
22
23
|
export * as uiTheming from './codeQuality/ui-theming.js'
|
|
23
24
|
export * as barrelImportDetector from './codeQuality/barrel-import-detector.js'
|
|
24
25
|
export * as typescriptStrictness from './codeQuality/typescript-strictness.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.23",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"tetra-test-audit": "./bin/tetra-test-audit.js",
|
|
44
44
|
"tetra-check-pages": "./bin/tetra-check-pages.js",
|
|
45
45
|
"tetra-check-views": "./bin/tetra-check-views.js",
|
|
46
|
-
"tetra-doctor": "./bin/tetra-doctor.js"
|
|
46
|
+
"tetra-doctor": "./bin/tetra-doctor.js",
|
|
47
|
+
"tetra-style-audit": "./bin/tetra-style-audit.js"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
50
|
"bin/",
|