arckode-framework 1.0.0
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 +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
package/cli/analyze.ts
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// cli/analyze.ts — Analizador estático de arquitectura
|
|
2
|
+
// Lee el proyecto como grafo y detecta violaciones
|
|
3
|
+
// SOLID: cada chequeo es una función con una responsabilidad
|
|
4
|
+
|
|
5
|
+
import { readdir, readFile, access } from 'node:fs/promises'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export interface AnalysisResult {
|
|
9
|
+
valid: boolean
|
|
10
|
+
modules: ModuleReport[]
|
|
11
|
+
connectors: string[]
|
|
12
|
+
violations: Violation[]
|
|
13
|
+
warnings: Warning[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ModuleReport {
|
|
17
|
+
name: string
|
|
18
|
+
hasIndex: boolean
|
|
19
|
+
hasService: boolean
|
|
20
|
+
hasController: boolean
|
|
21
|
+
hasTypes: boolean
|
|
22
|
+
hasSockets: boolean
|
|
23
|
+
hasValidators: boolean
|
|
24
|
+
hasTests: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Violation {
|
|
28
|
+
type:
|
|
29
|
+
| 'DIRECT_MODULE_IMPORT'
|
|
30
|
+
| 'MISSING_INDEX' | 'MISSING_CONTRACT' | 'MISSING_SERVICE' | 'MISSING_TYPES'
|
|
31
|
+
| 'MISSING_SOCKETS' | 'MISSING_VALIDATORS' | 'MISSING_TESTS'
|
|
32
|
+
| 'MISSING_CONNECTORS' | 'CONNECTOR_BUSINESS_LOGIC' | 'INDEX_PENDING_CHANGES'
|
|
33
|
+
// ── Los 6 que las reglas base no cubrían ──
|
|
34
|
+
| 'CONTROLLER_MISSING_VALIDATION' // ruta POST/PUT/PATCH sin validateSchema
|
|
35
|
+
| 'BUSINESS_LOGIC_IN_CONTROLLER' // ORM directo en controller
|
|
36
|
+
| 'EMPTY_MODULE_DESCRIPTION' // descripción vacía en módulo
|
|
37
|
+
| 'TESTS_WITHOUT_CASES' // test file sin test()
|
|
38
|
+
| 'SERVICE_IMPORTS_OTHER_MODULE' // service cruza módulo
|
|
39
|
+
| 'GOD_SERVICE' // service > 200 líneas
|
|
40
|
+
// ── Seguridad y performance ──
|
|
41
|
+
| 'N_PLUS_ONE_RISK' // ORM llamado dentro de loop en service
|
|
42
|
+
| 'IDOR_RISK' // findById sin assertOwnership posterior
|
|
43
|
+
// ── Portabilidad ──
|
|
44
|
+
| 'SERVICE_DEPENDS_ON_ORM' // service inyecta ORM en lugar de RepositoryAdapter
|
|
45
|
+
module: string
|
|
46
|
+
message: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Warning {
|
|
50
|
+
type: 'NO_CONNECTORS' | 'SINGLE_MODULE' | 'NO_COMPOSITION_ROOT' | 'INDEX_PENDING_CHANGES'
|
|
51
|
+
message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function analyzeProject(basePath: string): Promise<AnalysisResult> {
|
|
55
|
+
const violations: Violation[] = []
|
|
56
|
+
const warnings: Warning[] = []
|
|
57
|
+
const modules: ModuleReport[] = []
|
|
58
|
+
|
|
59
|
+
const srcPath = join(basePath, 'src')
|
|
60
|
+
const modulesPath = join(srcPath, 'modules')
|
|
61
|
+
const connectorsPath = join(srcPath, 'connectors')
|
|
62
|
+
const crPath = join(srcPath, 'composition-root.ts')
|
|
63
|
+
|
|
64
|
+
// Verificar composition root
|
|
65
|
+
try {
|
|
66
|
+
await access(crPath)
|
|
67
|
+
} catch {
|
|
68
|
+
warnings.push({ type: 'NO_COMPOSITION_ROOT', message: 'No hay composition-root.ts en src/' })
|
|
69
|
+
return { valid: false, modules: [], connectors: [], violations, warnings }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Leer módulos
|
|
73
|
+
try {
|
|
74
|
+
const moduleDirs = await readdir(modulesPath, { withFileTypes: true })
|
|
75
|
+
const moduleNames = moduleDirs.filter(d => d.isDirectory()).map(d => d.name)
|
|
76
|
+
|
|
77
|
+
if (moduleNames.length === 0) {
|
|
78
|
+
warnings.push({ type: 'SINGLE_MODULE', message: 'No hay módulos en src/modules/' })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const name of moduleNames) {
|
|
82
|
+
const modPath = join(modulesPath, name)
|
|
83
|
+
const report: ModuleReport = {
|
|
84
|
+
name,
|
|
85
|
+
hasIndex: await fileExists(join(modPath, 'index.ts')),
|
|
86
|
+
hasService: await fileExists(join(modPath, 'actions', 'service.ts')),
|
|
87
|
+
hasController: await fileExists(join(modPath, 'actions', 'controller.ts')),
|
|
88
|
+
hasTypes: await fileExists(join(modPath, 'types.ts')),
|
|
89
|
+
hasSockets: await fileExists(join(modPath, 'sockets.ts')),
|
|
90
|
+
hasValidators: await fileExists(join(modPath, 'validators', 'schema.ts')),
|
|
91
|
+
hasTests: await fileExists(join(modPath, 'tests', 'service.test.ts')),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!report.hasIndex) violations.push({ type: 'MISSING_INDEX', module: name, message: `Módulo "${name}" sin index.ts (puerta pública)` })
|
|
95
|
+
if (!report.hasTypes) violations.push({ type: 'MISSING_TYPES', module: name, message: `Módulo "${name}" sin types.ts (DTOs)` })
|
|
96
|
+
if (!report.hasSockets) violations.push({ type: 'MISSING_SOCKETS', module: name, message: `Módulo "${name}" sin sockets.ts (hooks opcionales)` })
|
|
97
|
+
if (!report.hasService) violations.push({ type: 'MISSING_SERVICE', module: name, message: `Módulo "${name}" sin actions/service.ts` })
|
|
98
|
+
if (!report.hasValidators) violations.push({ type: 'MISSING_VALIDATORS', module: name, message: `Módulo "${name}" sin validators/schema.ts` })
|
|
99
|
+
if (!report.hasTests) violations.push({ type: 'MISSING_TESTS', module: name, message: `Módulo "${name}" sin tests/` })
|
|
100
|
+
|
|
101
|
+
modules.push(report)
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
violations.push({ type: 'MISSING_INDEX', module: 'modules', message: 'No existe src/modules/' })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Verificar conectores
|
|
108
|
+
const connectors: string[] = []
|
|
109
|
+
try {
|
|
110
|
+
const connectorFiles = await readdir(connectorsPath)
|
|
111
|
+
for (const file of connectorFiles) {
|
|
112
|
+
if (file.endsWith('.ts')) connectors.push(file)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (connectors.length === 0 && modules.length > 1) {
|
|
116
|
+
warnings.push({ type: 'NO_CONNECTORS', message: `Hay ${modules.length} módulos pero 0 conectores. Los módulos no están conectados.` })
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// No hay carpeta de conectores, puede ser válido si hay 1 módulo
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Verificar imports directos entre módulos (REGLAS CLAUDE #1)
|
|
123
|
+
for (const module of modules) {
|
|
124
|
+
try {
|
|
125
|
+
const allFiles = await getAllTsFiles(join(modulesPath, module.name))
|
|
126
|
+
for (const file of allFiles) {
|
|
127
|
+
const content = await readFile(file, 'utf-8')
|
|
128
|
+
const relPath = file.replace(modulesPath, '')
|
|
129
|
+
for (const other of modules) {
|
|
130
|
+
if (other.name === module.name) continue
|
|
131
|
+
const importPattern = new RegExp(`from ['"][^'"]*\\/modules\\/${other.name}([/'"]|\$)`)
|
|
132
|
+
if (importPattern.test(content)) {
|
|
133
|
+
violations.push({
|
|
134
|
+
type: 'DIRECT_MODULE_IMPORT',
|
|
135
|
+
module: module.name,
|
|
136
|
+
message: `"${relPath}" importa directo de "${other.name}". Usar conector. (CLAUDE #1)`,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch { /* no actions dir */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Verificar lógica de negocio en conectores (REGLA CLAUDE #3)
|
|
145
|
+
for (const connFile of connectors) {
|
|
146
|
+
try {
|
|
147
|
+
const content = await readFile(join(connectorsPath, connFile), 'utf-8')
|
|
148
|
+
// Detectar patrones de lógica de negocio en conectores
|
|
149
|
+
const businessPatterns = [
|
|
150
|
+
/\bif\s*\(/g, /\bswitch\s*\(/g, /\bfor\s*\(/g, /\bwhile\s*\(/g,
|
|
151
|
+
/\btry\s*\{/g, /\bcatch\s*\(/g, /\bnew\s+\w+Action/g,
|
|
152
|
+
/\.create\(/g, /\.update\(/g, /\.delete\(/g,
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
// Contar ocurrencias — 1 o 2 pueden ser normales, más indica lógica
|
|
156
|
+
let businessHits = 0
|
|
157
|
+
for (const pattern of businessPatterns) {
|
|
158
|
+
const matches = content.match(pattern)
|
|
159
|
+
if (matches) businessHits += matches.length
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (businessHits > 3) {
|
|
163
|
+
violations.push({
|
|
164
|
+
type: 'CONNECTOR_BUSINESS_LOGIC',
|
|
165
|
+
module: connFile,
|
|
166
|
+
message: `"${connFile}" tiene ${businessHits} patrones de lógica de negocio. Los conectores solo deben wirear. (CLAUDE #3)`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
} catch { /* ignore */ }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Verificar regla APPEND-ONLY (REGLA CLAUDE #2) — detecta exports removidos
|
|
173
|
+
// Busca comentarios de deprecación o conflicto en index.ts
|
|
174
|
+
for (const module of modules) {
|
|
175
|
+
const indexPath = join(modulesPath, module.name, 'index.ts')
|
|
176
|
+
try {
|
|
177
|
+
const content = await readFile(indexPath, 'utf-8')
|
|
178
|
+
// Si index.ts tiene comentarios TODO o FIXME sobre exports rotos
|
|
179
|
+
if (content.includes('TODO:') || content.includes('FIXME:')) {
|
|
180
|
+
warnings.push({
|
|
181
|
+
type: 'INDEX_PENDING_CHANGES',
|
|
182
|
+
message: `"${module.name}/index.ts" tiene TODO/FIXME. Revisar antes de continuar.`,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
} catch { /* no index */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Verificar que los conectores existen si hay más de 1 módulo (REGLA CLAUDE #5)
|
|
189
|
+
if (modules.length > 1 && connectors.length === 0) {
|
|
190
|
+
violations.push({
|
|
191
|
+
type: 'MISSING_CONNECTORS',
|
|
192
|
+
module: 'system',
|
|
193
|
+
message: `Hay ${modules.length} módulos pero 0 conectores. Los módulos no están conectados. (CLAUDE #5)`,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Los 6 que las reglas base no prevenían ──────────────────────
|
|
198
|
+
|
|
199
|
+
for (const module of modules) {
|
|
200
|
+
const modPath = join(modulesPath, module.name)
|
|
201
|
+
|
|
202
|
+
// 1. DOCUMENTACIÓN FALTANTE — descripción vacía en el módulo
|
|
203
|
+
const indexPath = join(modPath, 'index.ts')
|
|
204
|
+
try {
|
|
205
|
+
const indexContent = await readFile(indexPath, 'utf-8')
|
|
206
|
+
// Busca description: '' o description: " " en el createModule/export
|
|
207
|
+
if (/description:\s*['"`]\s*['"`]/.test(indexContent)) {
|
|
208
|
+
violations.push({
|
|
209
|
+
type: 'EMPTY_MODULE_DESCRIPTION',
|
|
210
|
+
module: module.name,
|
|
211
|
+
message: `"${module.name}" tiene descripción vacía. Todo módulo DEBE documentar su propósito.`,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
} catch { /* no index — ya detectado */ }
|
|
215
|
+
|
|
216
|
+
// 2. TEST COVERAGE — archivo de test existe pero sin casos
|
|
217
|
+
const testPath = join(modPath, 'tests', 'service.test.ts')
|
|
218
|
+
try {
|
|
219
|
+
const testContent = await readFile(testPath, 'utf-8')
|
|
220
|
+
const hasTests = /\btest\s*\(|\bit\s*\(|\bdescribe\s*\(/.test(testContent)
|
|
221
|
+
if (!hasTests) {
|
|
222
|
+
violations.push({
|
|
223
|
+
type: 'TESTS_WITHOUT_CASES',
|
|
224
|
+
module: module.name,
|
|
225
|
+
message: `"${module.name}/tests/service.test.ts" existe pero no tiene ningún test(). Coverage = 0.`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
} catch { /* no test file — ya detectado por MISSING_TESTS */ }
|
|
229
|
+
|
|
230
|
+
// 3. VALIDACIONES INCOMPLETAS — controller con POST/PUT/PATCH sin validateSchema
|
|
231
|
+
const controllerPath = join(modPath, 'actions', 'controller.ts')
|
|
232
|
+
try {
|
|
233
|
+
const ctrlContent = await readFile(controllerPath, 'utf-8')
|
|
234
|
+
const hasMutatingRoute = /router\.(post|put|patch)\s*\(/.test(ctrlContent)
|
|
235
|
+
const hasValidation = /validateSchema\s*\(/.test(ctrlContent)
|
|
236
|
+
if (hasMutatingRoute && !hasValidation) {
|
|
237
|
+
violations.push({
|
|
238
|
+
type: 'CONTROLLER_MISSING_VALIDATION',
|
|
239
|
+
module: module.name,
|
|
240
|
+
message: `"${module.name}/controller.ts" tiene rutas POST/PUT/PATCH sin validateSchema(). Datos no validados llegan al service.`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
} catch { /* no controller */ }
|
|
244
|
+
|
|
245
|
+
// 4. LÓGICA EN CONTROLLER — ORM directo en controller (debería estar en service)
|
|
246
|
+
try {
|
|
247
|
+
const ctrlContent = await readFile(controllerPath, 'utf-8')
|
|
248
|
+
const ormInController = /\borm\.(findMany|findById|create|update|delete|paginate|count)\s*\(/.test(ctrlContent)
|
|
249
|
+
if (ormInController) {
|
|
250
|
+
violations.push({
|
|
251
|
+
type: 'BUSINESS_LOGIC_IN_CONTROLLER',
|
|
252
|
+
module: module.name,
|
|
253
|
+
message: `"${module.name}/controller.ts" llama al ORM directamente. La lógica de datos va en service.ts.`,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
} catch { /* no controller */ }
|
|
257
|
+
|
|
258
|
+
// 5. GOD SERVICE — service con más de 200 líneas indica demasiadas responsabilidades
|
|
259
|
+
const servicePath = join(modPath, 'actions', 'service.ts')
|
|
260
|
+
try {
|
|
261
|
+
const serviceContent = await readFile(servicePath, 'utf-8')
|
|
262
|
+
const lineCount = serviceContent.split('\n').length
|
|
263
|
+
if (lineCount > 200) {
|
|
264
|
+
violations.push({
|
|
265
|
+
type: 'GOD_SERVICE',
|
|
266
|
+
module: module.name,
|
|
267
|
+
message: `"${module.name}/service.ts" tiene ${lineCount} líneas. Un service > 200 líneas es un God Object — dividir en servicios especializados.`,
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
} catch { /* no service */ }
|
|
271
|
+
|
|
272
|
+
// 6. SERVICE CRUZA MÓDULO — service importa de otro módulo directamente
|
|
273
|
+
try {
|
|
274
|
+
const serviceContent = await readFile(servicePath, 'utf-8')
|
|
275
|
+
for (const other of modules) {
|
|
276
|
+
if (other.name === module.name) continue
|
|
277
|
+
const importPattern = new RegExp(`from ['"][^'"]*\\/modules\\/${other.name}([/'"]|\$)`)
|
|
278
|
+
if (importPattern.test(serviceContent)) {
|
|
279
|
+
violations.push({
|
|
280
|
+
type: 'SERVICE_IMPORTS_OTHER_MODULE',
|
|
281
|
+
module: module.name,
|
|
282
|
+
message: `"${module.name}/service.ts" importa de "${other.name}". Los services no pueden cruzar módulos — usar conector. (CLAUDE #1)`,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch { /* no service */ }
|
|
287
|
+
|
|
288
|
+
// 7. N+1 RISK — ORM llamado dentro de un loop en el service
|
|
289
|
+
try {
|
|
290
|
+
const serviceContent = await readFile(servicePath, 'utf-8')
|
|
291
|
+
const lines = serviceContent.split('\n')
|
|
292
|
+
let insideLoop = 0
|
|
293
|
+
for (let i = 0; i < lines.length; i++) {
|
|
294
|
+
const line = lines[i] ?? ''
|
|
295
|
+
// Detectar apertura de loop
|
|
296
|
+
if (/\b(for\s*\(|forEach\s*\(|\.map\s*\(\s*async|\.filter\s*\(\s*async|while\s*\()/.test(line)) insideLoop++
|
|
297
|
+
// Detectar cierre de bloque (aproximación por llaves)
|
|
298
|
+
const opens = (line.match(/\{/g) ?? []).length
|
|
299
|
+
const closes = (line.match(/\}/g) ?? []).length
|
|
300
|
+
if (insideLoop > 0 && closes > opens) insideLoop = Math.max(0, insideLoop - 1)
|
|
301
|
+
// Detectar ORM dentro del loop
|
|
302
|
+
if (insideLoop > 0 && /await\s+\w*orm\w*\.(findMany|findById|create|update|delete|count|paginate)\s*\(/.test(line)) {
|
|
303
|
+
violations.push({
|
|
304
|
+
type: 'N_PLUS_ONE_RISK',
|
|
305
|
+
module: module.name,
|
|
306
|
+
message: `"${module.name}/service.ts" línea ${i + 1}: ORM llamado dentro de un loop — riesgo de N+1. Usar findMany/createMany/updateMany fuera del loop.`,
|
|
307
|
+
})
|
|
308
|
+
break // un warning por archivo es suficiente
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch { /* no service */ }
|
|
312
|
+
|
|
313
|
+
// 8. IDOR RISK — findById sin assertOwnership inmediatamente después
|
|
314
|
+
try {
|
|
315
|
+
const serviceContent = await readFile(servicePath, 'utf-8')
|
|
316
|
+
const lines = serviceContent.split('\n')
|
|
317
|
+
for (let i = 0; i < lines.length; i++) {
|
|
318
|
+
if (/\bfindById\s*\(/.test(lines[i] ?? '')) {
|
|
319
|
+
// Buscar assertOwnership en las siguientes 5 líneas
|
|
320
|
+
const window = lines.slice(i + 1, i + 6).join('\n')
|
|
321
|
+
if (!window.includes('assertOwnership') && !window.includes('userId') && !window.includes('ownerId')) {
|
|
322
|
+
violations.push({
|
|
323
|
+
type: 'IDOR_RISK',
|
|
324
|
+
module: module.name,
|
|
325
|
+
message: `"${module.name}/service.ts" línea ${i + 1}: findById sin verificación de ownership. Usar auth.assertOwnership() para prevenir IDOR.`,
|
|
326
|
+
})
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch { /* no service */ }
|
|
332
|
+
|
|
333
|
+
// 9. SERVICE DEPENDS ON ORM — service inyecta ORM en lugar de RepositoryAdapter (Regla #18)
|
|
334
|
+
try {
|
|
335
|
+
const serviceContent = await readFile(servicePath, 'utf-8')
|
|
336
|
+
// Detectar: private orm: ORM o private readonly orm: ORM en el constructor
|
|
337
|
+
if (/private\s+(readonly\s+)?\w+\s*:\s*ORM\b/.test(serviceContent)) {
|
|
338
|
+
violations.push({
|
|
339
|
+
type: 'SERVICE_DEPENDS_ON_ORM',
|
|
340
|
+
module: module.name,
|
|
341
|
+
message: `"${module.name}/service.ts" inyecta ORM directamente. Usar RepositoryAdapter<T> para poder swapear SQL → MongoDB → Prisma sin tocar el service. (CLAUDE #18)`,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
} catch { /* no service */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
valid: violations.length === 0,
|
|
349
|
+
modules,
|
|
350
|
+
connectors,
|
|
351
|
+
violations,
|
|
352
|
+
warnings,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
357
|
+
try { await access(path); return true } catch { return false }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function getAllTsFiles(dir: string): Promise<string[]> {
|
|
361
|
+
const files: string[] = []
|
|
362
|
+
try {
|
|
363
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
const full = join(dir, entry.name)
|
|
366
|
+
if (entry.isDirectory()) {
|
|
367
|
+
if (entry.name !== 'node_modules' && entry.name !== 'tests' && entry.name !== '__tests__') {
|
|
368
|
+
files.push(...await getAllTsFiles(full))
|
|
369
|
+
}
|
|
370
|
+
} else if (entry.name.endsWith('.ts')) {
|
|
371
|
+
files.push(full)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch { /* ignore */ }
|
|
375
|
+
return files
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function printAnalysis(result: AnalysisResult): void {
|
|
379
|
+
console.log('\n══════════════════════════════════════')
|
|
380
|
+
console.log(' Análisis de Arquitectura')
|
|
381
|
+
console.log('══════════════════════════════════════\n')
|
|
382
|
+
|
|
383
|
+
console.log(`📦 Módulos (${result.modules.length}):`)
|
|
384
|
+
for (const m of result.modules) {
|
|
385
|
+
const icon = m.hasIndex ? '✓' : '✗'
|
|
386
|
+
const parts = [
|
|
387
|
+
m.hasService ? 'service' : null,
|
|
388
|
+
m.hasController ? 'controller' : null,
|
|
389
|
+
m.hasTypes ? 'types' : null,
|
|
390
|
+
m.hasSockets ? 'sockets' : null,
|
|
391
|
+
m.hasValidators ? 'validators' : null,
|
|
392
|
+
m.hasTests ? 'tests' : null,
|
|
393
|
+
].filter(Boolean)
|
|
394
|
+
console.log(` ${icon} ${m.name} [${parts.join(', ')}]`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
console.log(`\n🔌 Conectores (${result.connectors.length}):`)
|
|
398
|
+
for (const c of result.connectors) {
|
|
399
|
+
console.log(` ${c}`)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (result.violations.length > 0) {
|
|
403
|
+
// Agrupar por categoría para que la IA sepa exactamente qué arreglar
|
|
404
|
+
const byCategory: Record<string, Violation[]> = {
|
|
405
|
+
'Estructura': [],
|
|
406
|
+
'Acoplamiento': [],
|
|
407
|
+
'Diseño': [],
|
|
408
|
+
'Calidad': [],
|
|
409
|
+
'Seguridad': [],
|
|
410
|
+
'Performance': [],
|
|
411
|
+
'Portabilidad': [],
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const v of result.violations) {
|
|
415
|
+
if (['MISSING_INDEX', 'MISSING_SERVICE', 'MISSING_TYPES', 'MISSING_SOCKETS', 'MISSING_VALIDATORS', 'MISSING_TESTS', 'MISSING_CONNECTORS', 'EMPTY_MODULE_DESCRIPTION'].includes(v.type)) {
|
|
416
|
+
byCategory['Estructura']!.push(v)
|
|
417
|
+
} else if (['DIRECT_MODULE_IMPORT', 'SERVICE_IMPORTS_OTHER_MODULE', 'CONNECTOR_BUSINESS_LOGIC'].includes(v.type)) {
|
|
418
|
+
byCategory['Acoplamiento']!.push(v)
|
|
419
|
+
} else if (['BUSINESS_LOGIC_IN_CONTROLLER', 'GOD_SERVICE', 'INDEX_PENDING_CHANGES'].includes(v.type)) {
|
|
420
|
+
byCategory['Diseño']!.push(v)
|
|
421
|
+
} else if (['IDOR_RISK'].includes(v.type)) {
|
|
422
|
+
byCategory['Seguridad']!.push(v)
|
|
423
|
+
} else if (['N_PLUS_ONE_RISK'].includes(v.type)) {
|
|
424
|
+
byCategory['Performance']!.push(v)
|
|
425
|
+
} else if (['SERVICE_DEPENDS_ON_ORM'].includes(v.type)) {
|
|
426
|
+
byCategory['Portabilidad']!.push(v)
|
|
427
|
+
} else {
|
|
428
|
+
byCategory['Calidad']!.push(v)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
console.log(`\n❌ Violaciones (${result.violations.length}):`)
|
|
433
|
+
for (const [cat, vs] of Object.entries(byCategory)) {
|
|
434
|
+
if (vs.length === 0) continue
|
|
435
|
+
console.log(`\n [${cat}]`)
|
|
436
|
+
for (const v of vs) console.log(` • ${v.message}`)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (result.warnings.length > 0) {
|
|
441
|
+
console.log(`\n⚠️ Advertencias (${result.warnings.length}):`)
|
|
442
|
+
for (const w of result.warnings) {
|
|
443
|
+
console.log(` ${w.message}`)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
console.log(`\n📊 Resultado: ${result.valid ? '✅ VÁLIDO' : '❌ VIOLACIONES ENCONTRADAS'}`)
|
|
448
|
+
console.log('══════════════════════════════════════\n')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Genera el objeto arckode.json — snapshot del sistema legible por la IA.
|
|
453
|
+
* La IA lee este archivo PRIMERO para conocer el estado del proyecto
|
|
454
|
+
* sin tener que parsear 1800+ líneas del kernel ni todos los módulos.
|
|
455
|
+
*/
|
|
456
|
+
export function buildManifest(result: AnalysisResult): Record<string, unknown> {
|
|
457
|
+
return {
|
|
458
|
+
_arckode: '1.0.0',
|
|
459
|
+
_generated: new Date().toISOString(),
|
|
460
|
+
_instructions: 'Lee este archivo ANTES de escribir código. Contiene el estado completo del sistema: módulos, conectores, violaciones. Si está desactualizado, corré: bun run analyze --json',
|
|
461
|
+
|
|
462
|
+
system: {
|
|
463
|
+
healthy: result.valid,
|
|
464
|
+
violations: result.violations.length,
|
|
465
|
+
warnings: result.warnings.length,
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
modules: result.modules.map(m => ({
|
|
469
|
+
name: m.name,
|
|
470
|
+
structure: {
|
|
471
|
+
index: m.hasIndex,
|
|
472
|
+
types: m.hasTypes,
|
|
473
|
+
sockets: m.hasSockets,
|
|
474
|
+
service: m.hasService,
|
|
475
|
+
controller: m.hasController,
|
|
476
|
+
validators: m.hasValidators,
|
|
477
|
+
tests: m.hasTests,
|
|
478
|
+
},
|
|
479
|
+
complete: m.hasIndex && m.hasTypes && m.hasService && m.hasController && m.hasValidators && m.hasTests,
|
|
480
|
+
})),
|
|
481
|
+
|
|
482
|
+
connectors: result.connectors,
|
|
483
|
+
|
|
484
|
+
violations: result.violations.map(v => ({
|
|
485
|
+
module: v.module,
|
|
486
|
+
type: v.type,
|
|
487
|
+
message: v.message,
|
|
488
|
+
})),
|
|
489
|
+
|
|
490
|
+
warnings: result.warnings.map(w => ({
|
|
491
|
+
type: w.type,
|
|
492
|
+
message: w.message,
|
|
493
|
+
})),
|
|
494
|
+
|
|
495
|
+
rules_summary: [
|
|
496
|
+
'REGLA 1: Módulos NO importan de otros módulos. Usar conectores.',
|
|
497
|
+
'REGLA 2: index.ts es APPEND-ONLY. No eliminar exports.',
|
|
498
|
+
'REGLA 3: Conectores NO tienen lógica de negocio. Solo delegan.',
|
|
499
|
+
'REGLA 4: Cada tabla pertenece a UN módulo.',
|
|
500
|
+
'REGLA 5: Si no está en composition-root.ts, no existe.',
|
|
501
|
+
'REGLA 6: Todo POST/PUT/PATCH requiere validateSchema().',
|
|
502
|
+
'REGLA 7: Controller NO llama al ORM. Llama al service.',
|
|
503
|
+
'REGLA 18: Service recibe RepositoryAdapter<T>, no ORM directamente.',
|
|
504
|
+
],
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// cli/commands/db-migrate.ts — Ejecuta migraciones SQL versionadas
|
|
2
|
+
// Corre los archivos de src/migrations/*.ts que tienen up() / down()
|
|
3
|
+
// Usa _arckode_migrations para rastrear cuáles ya se aplicaron
|
|
4
|
+
|
|
5
|
+
import Database from 'better-sqlite3'
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
|
|
10
|
+
async function readDbPath(basePath: string): Promise<string> {
|
|
11
|
+
const envFile = join(basePath, '.env')
|
|
12
|
+
if (existsSync(envFile)) {
|
|
13
|
+
const content = await readFile(envFile, 'utf-8')
|
|
14
|
+
const match = content.match(/^DB_PATH=(.+)$/m)
|
|
15
|
+
if (match?.[1]) return match[1].trim()
|
|
16
|
+
}
|
|
17
|
+
return './data/db.sqlite'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DbRunner {
|
|
21
|
+
run(sql: string, params?: unknown[]): void
|
|
22
|
+
exec(sql: string): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function dbMigrate(direction: 'up' | 'down' = 'up') {
|
|
26
|
+
const basePath = process.cwd()
|
|
27
|
+
const migrationsDir = join(basePath, 'src', 'migrations')
|
|
28
|
+
|
|
29
|
+
if (!existsSync(migrationsDir)) {
|
|
30
|
+
console.error('❌ No se encuentra src/migrations/')
|
|
31
|
+
console.error(' Creá una con: arckode make:migration <nombre>')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dbRelPath = await readDbPath(basePath)
|
|
36
|
+
const dbAbsPath = resolve(basePath, dbRelPath)
|
|
37
|
+
|
|
38
|
+
const db = new Database(dbAbsPath)
|
|
39
|
+
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS _arckode_migrations (
|
|
42
|
+
name TEXT PRIMARY KEY,
|
|
43
|
+
direction TEXT NOT NULL,
|
|
44
|
+
runAt TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
`)
|
|
47
|
+
|
|
48
|
+
const files = (await readdir(migrationsDir))
|
|
49
|
+
.filter(f => f.endsWith('.ts') || f.endsWith('.js'))
|
|
50
|
+
.sort()
|
|
51
|
+
|
|
52
|
+
if (files.length === 0) {
|
|
53
|
+
console.log('📭 No hay archivos en src/migrations/')
|
|
54
|
+
db.close()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const applied = new Set<string>(
|
|
59
|
+
(db.prepare('SELECT name FROM _arckode_migrations WHERE direction = ?').all('up') as { name: string }[])
|
|
60
|
+
.map(r => r.name)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const runner: DbRunner = {
|
|
64
|
+
run(sql, params = []) { db.prepare(sql).run(...params) },
|
|
65
|
+
exec(sql) { db.exec(sql) },
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (direction === 'up') {
|
|
69
|
+
const pending = files.filter(f => !applied.has(f))
|
|
70
|
+
|
|
71
|
+
if (pending.length === 0) {
|
|
72
|
+
console.log('✅ No hay migraciones pendientes')
|
|
73
|
+
db.close()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of pending) {
|
|
78
|
+
const filePath = join(migrationsDir, file)
|
|
79
|
+
const migration = await import(filePath)
|
|
80
|
+
|
|
81
|
+
if (typeof migration.up !== 'function') {
|
|
82
|
+
console.warn(`⚠️ ${file} no exporta up() — saltando`)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`▶ Aplicando: ${file}`)
|
|
87
|
+
await migration.up(runner)
|
|
88
|
+
db.prepare('INSERT INTO _arckode_migrations (name, direction, runAt) VALUES (?, ?, ?)')
|
|
89
|
+
.run(file, 'up', new Date().toISOString())
|
|
90
|
+
console.log(`✅ ${file}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`\n${pending.length} migración(es) aplicada(s)`)
|
|
94
|
+
} else {
|
|
95
|
+
// down: revertir la última aplicada
|
|
96
|
+
const last = db.prepare('SELECT name FROM _arckode_migrations WHERE direction = ? ORDER BY runAt DESC LIMIT 1')
|
|
97
|
+
.get('up') as { name: string } | undefined
|
|
98
|
+
|
|
99
|
+
if (!last) {
|
|
100
|
+
console.log('📭 No hay migraciones para revertir')
|
|
101
|
+
db.close()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const filePath = join(migrationsDir, last.name)
|
|
106
|
+
const migration = await import(filePath)
|
|
107
|
+
|
|
108
|
+
if (typeof migration.down !== 'function') {
|
|
109
|
+
console.error(`❌ ${last.name} no exporta down()`)
|
|
110
|
+
db.close()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(`▶ Revirtiendo: ${last.name}`)
|
|
115
|
+
await migration.down(runner)
|
|
116
|
+
db.prepare('DELETE FROM _arckode_migrations WHERE name = ?').run(last.name)
|
|
117
|
+
console.log(`✅ Revertida: ${last.name}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
db.close()
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// cli/commands/db-seed.ts — Comando arckode db:seed
|
|
2
|
+
// Ejecuta los seeds definidos en el composition root
|
|
3
|
+
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
export async function dbSeed(seedName?: string) {
|
|
9
|
+
const basePath = process.cwd()
|
|
10
|
+
const crPath = join(basePath, 'src', 'composition-root.ts')
|
|
11
|
+
|
|
12
|
+
if (!existsSync(crPath)) {
|
|
13
|
+
console.error('❌ No se encuentra src/composition-root.ts')
|
|
14
|
+
console.error(' Ejecutá este comando desde la raíz del proyecto.')
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Leer seeds/ y detectar archivos disponibles
|
|
19
|
+
const seedsPath = join(basePath, 'src', 'seeds')
|
|
20
|
+
if (!existsSync(seedsPath)) {
|
|
21
|
+
console.error('❌ No hay directorio src/seeds/')
|
|
22
|
+
console.error(' Creá seeds con: arckode make:seed <Nombre>')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { readdir } = await import('node:fs/promises')
|
|
27
|
+
const seedFiles = (await readdir(seedsPath)).filter(f => f.endsWith('.ts'))
|
|
28
|
+
|
|
29
|
+
if (seedFiles.length === 0) {
|
|
30
|
+
console.log('📭 No hay seeds para ejecutar.')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (seedName) {
|
|
35
|
+
const file = seedFiles.find(f => f.startsWith(seedName))
|
|
36
|
+
if (!file) {
|
|
37
|
+
console.error(`❌ Seed "${seedName}" no encontrado.`)
|
|
38
|
+
console.log(` Seeds disponibles: ${seedFiles.join(', ')}`)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
console.log(`🌱 Ejecutando seed: ${file}`)
|
|
42
|
+
// La IA o el usuario debe agregar la lógica en composition-root según el seed
|
|
43
|
+
console.log(` Para ejecutarlo, corré: RUN_SEEDS=true bun run src/composition-root.ts`)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('🌱 Seeds disponibles:')
|
|
48
|
+
for (const file of seedFiles) {
|
|
49
|
+
console.log(` - ${file.replace('.ts', '')}`)
|
|
50
|
+
}
|
|
51
|
+
console.log('')
|
|
52
|
+
console.log(' Para ejecutar todos: RUN_SEEDS=true bun run src/composition-root.ts')
|
|
53
|
+
console.log(' Para ejecutar uno: bun arckode db:seed <nombre>')
|
|
54
|
+
}
|