arckode-framework 1.3.2 → 1.4.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.
Files changed (64) hide show
  1. package/adapters/jwt.ts +6 -4
  2. package/adapters/mysql.ts +7 -2
  3. package/adapters/postgres.ts +37 -0
  4. package/adapters/sqlite.ts +7 -1
  5. package/adapters/vendor.d.ts +48 -0
  6. package/cli/analyze/checks.ts +333 -0
  7. package/cli/analyze/index.ts +44 -0
  8. package/cli/analyze/report.ts +107 -0
  9. package/cli/analyze/types.ts +46 -0
  10. package/cli/analyze/utils.ts +36 -0
  11. package/cli/analyze.ts +2 -647
  12. package/cli/commands/db-migrate.ts +213 -89
  13. package/cli/commands/db-seed.ts +97 -32
  14. package/cli/commands/db-utils.ts +192 -0
  15. package/cli/commands/new.ts +175 -0
  16. package/cli/commands/routes.ts +94 -0
  17. package/cli/index.ts +57 -404
  18. package/cli/stubs/module/core.ts +162 -0
  19. package/cli/stubs/module/data.ts +171 -0
  20. package/cli/stubs/module/index.ts +5 -0
  21. package/cli/stubs/module/service.ts +198 -0
  22. package/cli/stubs/module/types.ts +12 -0
  23. package/cli/stubs/module-stub.ts +2 -552
  24. package/kernel/auth.ts +114 -0
  25. package/kernel/cache.ts +37 -0
  26. package/kernel/config.ts +129 -0
  27. package/kernel/container.ts +64 -0
  28. package/kernel/db/orm-migrate.ts +136 -0
  29. package/kernel/db/orm-repository.ts +45 -0
  30. package/kernel/db/orm-utils.ts +93 -0
  31. package/kernel/db/orm.ts +254 -0
  32. package/kernel/db/transactor.ts +17 -0
  33. package/kernel/db/types.ts +72 -0
  34. package/kernel/errors.ts +102 -0
  35. package/kernel/framework.default.ts +41 -0
  36. package/kernel/framework.ts +8 -2144
  37. package/kernel/http/router.ts +131 -0
  38. package/kernel/http/server.ts +303 -0
  39. package/kernel/http/types.ts +56 -0
  40. package/kernel/index.ts +25 -0
  41. package/kernel/logger.ts +50 -0
  42. package/kernel/middlewares.ts +19 -7
  43. package/kernel/modules/create-module.ts +5 -0
  44. package/kernel/modules/system.ts +149 -0
  45. package/kernel/modules/types.ts +46 -0
  46. package/kernel/seeds.ts +48 -0
  47. package/kernel/static.ts +11 -2
  48. package/kernel/testing.ts +8 -3
  49. package/kernel/validator.ts +116 -0
  50. package/modules/events/index.ts +19 -3
  51. package/modules/mail/index.ts +14 -2
  52. package/modules/storage/local-adapter.ts +19 -5
  53. package/modules/ws/index.ts +123 -18
  54. package/package.json +8 -11
  55. package/skills/auth/SKILL.md +36 -220
  56. package/skills/cli/SKILL.md +32 -251
  57. package/skills/config/SKILL.md +30 -239
  58. package/skills/connectors/SKILL.md +32 -295
  59. package/skills/helpers/SKILL.md +26 -195
  60. package/skills/middlewares/SKILL.md +30 -280
  61. package/skills/orm/SKILL.md +42 -349
  62. package/skills/realtime/SKILL.md +22 -297
  63. package/skills/services/SKILL.md +40 -183
  64. package/skills/testing/SKILL.md +34 -266
package/adapters/jwt.ts CHANGED
@@ -7,12 +7,14 @@ import type { JwtAdapter } from '../kernel/framework'
7
7
 
8
8
  export const jwtTokenAdapter: JwtAdapter = {
9
9
  sign(payload, secret, expiresIn) {
10
- // jsonwebtoken's expiresIn accepts ms-compatible strings (e.g. "24h", "30d")
11
- // We cast via unknown to satisfy its StringValue/number union
12
- return jwt.sign(payload, secret, { expiresIn: expiresIn as unknown as number })
10
+ return jwt.sign(payload, secret, {
11
+ algorithm: 'HS256',
12
+ expiresIn: expiresIn as unknown as number,
13
+ })
13
14
  },
14
15
 
15
16
  verify(token, secret) {
16
- return jwt.verify(token, secret) as Record<string, unknown>
17
+ // algorithms whitelist previene ataques de algorithm confusion (none, RS256→HS256)
18
+ return jwt.verify(token, secret, { algorithms: ['HS256'] }) as Record<string, unknown>
17
19
  },
18
20
  }
package/adapters/mysql.ts CHANGED
@@ -43,10 +43,12 @@ class MySQLTransactionAdapter implements DbAdapter {
43
43
 
44
44
  export class MySQLAdapter implements DbAdapter {
45
45
  private pool!: mysql.Pool
46
+ private connected = false
46
47
 
47
48
  constructor(private config: MySQLConfig) {}
48
49
 
49
50
  async connect(): Promise<void> {
51
+ if (this.connected) return
50
52
  this.pool = mysql.createPool({
51
53
  host: this.config.host,
52
54
  port: this.config.port ?? 3306,
@@ -57,9 +59,9 @@ export class MySQLAdapter implements DbAdapter {
57
59
  waitForConnections: true,
58
60
  queueLimit: 0,
59
61
  })
60
- // Verificar conexión al arrancar (fail fast)
61
62
  const conn = await this.pool.getConnection()
62
63
  conn.release()
64
+ this.connected = true
63
65
  }
64
66
 
65
67
  async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
@@ -93,6 +95,9 @@ export class MySQLAdapter implements DbAdapter {
93
95
  }
94
96
 
95
97
  async close(): Promise<void> {
96
- await this.pool.end()
98
+ if (this.connected) {
99
+ await this.pool.end()
100
+ this.connected = false
101
+ }
97
102
  }
98
103
  }
@@ -4,6 +4,13 @@
4
4
  import pg, { type Pool as PgPool } from 'pg'
5
5
  import type { DbAdapter } from '../kernel/framework'
6
6
 
7
+ // @types/pg 8.20+ con moduleResolution:bundler resuelve index.d.mts donde
8
+ // PoolClient pierde los métodos heredados de ClientBase — interfaz mínima local
9
+ interface TxClient {
10
+ query(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount: number | null }>
11
+ release(err?: Error | boolean): void
12
+ }
13
+
7
14
  export interface PostgresConfig {
8
15
  connectionString: string
9
16
  poolMin?: number
@@ -12,10 +19,12 @@ export interface PostgresConfig {
12
19
 
13
20
  export class PostgresAdapter implements DbAdapter {
14
21
  private pool!: PgPool
22
+ private connected = false
15
23
 
16
24
  constructor(private config: PostgresConfig) {}
17
25
 
18
26
  async connect(): Promise<void> {
27
+ if (this.connected) return
19
28
  this.pool = new pg.Pool({
20
29
  connectionString: this.config.connectionString,
21
30
  min: this.config.poolMin ?? 2,
@@ -23,6 +32,7 @@ export class PostgresAdapter implements DbAdapter {
23
32
  })
24
33
  const client = await this.pool.connect()
25
34
  client.release()
35
+ this.connected = true
26
36
  }
27
37
 
28
38
  // Convierte ? a $1, $2, $3... para compatibilidad con PostgreSQL
@@ -46,6 +56,33 @@ export class PostgresAdapter implements DbAdapter {
46
56
  }
47
57
  }
48
58
 
59
+ async transaction<T>(fn: (adapter: DbAdapter) => Promise<T>): Promise<T> {
60
+ const client = (await this.pool.connect()) as unknown as TxClient
61
+ try {
62
+ await client.query('BEGIN')
63
+ const txAdapter: DbAdapter = {
64
+ query: async (sql, params = []) => {
65
+ const r = await client.query(this.convertPlaceholders(sql), params)
66
+ return r.rows
67
+ },
68
+ run: async (sql, params = []) => {
69
+ const r = await client.query(this.convertPlaceholders(sql), params)
70
+ const row = r.rows[0]
71
+ return { changes: r.rowCount ?? 0, lastId: row?.id != null ? String(row.id) : undefined }
72
+ },
73
+ close: async () => {},
74
+ }
75
+ const result = await fn(txAdapter)
76
+ await client.query('COMMIT')
77
+ return result
78
+ } catch (err) {
79
+ await client.query('ROLLBACK')
80
+ throw err
81
+ } finally {
82
+ client.release()
83
+ }
84
+ }
85
+
49
86
  async close(): Promise<void> {
50
87
  await this.pool.end()
51
88
  }
@@ -20,16 +20,19 @@ const DEFAULT_CONFIG: SqliteConfig = {
20
20
  export class SqliteAdapter implements DbAdapter {
21
21
  private db!: Database
22
22
  private config: SqliteConfig
23
+ private connected = false
23
24
 
24
25
  constructor(config?: Partial<SqliteConfig>) {
25
26
  this.config = { ...DEFAULT_CONFIG, ...config }
26
27
  }
27
28
 
28
29
  async connect(): Promise<void> {
30
+ if (this.connected) return
29
31
  this.db = new Database(this.config.path)
30
32
 
31
33
  if (this.config.wal) this.db.exec('PRAGMA journal_mode = WAL')
32
34
  if (this.config.foreignKeys) this.db.exec('PRAGMA foreign_keys = ON')
35
+ this.connected = true
33
36
  }
34
37
 
35
38
  query(sql: string, params: unknown[] = []): Promise<unknown[]> {
@@ -62,7 +65,10 @@ export class SqliteAdapter implements DbAdapter {
62
65
  }
63
66
 
64
67
  close(): Promise<void> {
65
- this.db.close()
68
+ if (this.connected) {
69
+ this.db.close()
70
+ this.connected = false
71
+ }
66
72
  return Promise.resolve()
67
73
  }
68
74
  }
@@ -0,0 +1,48 @@
1
+ // adapters/vendor.d.ts — Minimal type stubs for optional peer dependencies.
2
+ // pg and redis are optional — users install them only when using those adapters.
3
+ // These stubs let TypeScript compile without the packages installed.
4
+
5
+ declare module 'pg' {
6
+ export interface PoolConfig {
7
+ connectionString?: string
8
+ min?: number
9
+ max?: number
10
+ [key: string]: unknown
11
+ }
12
+
13
+ export interface QueryResult<R = Record<string, unknown>> {
14
+ rows: R[]
15
+ rowCount: number | null
16
+ }
17
+
18
+ export interface PoolClient {
19
+ release(): void
20
+ }
21
+
22
+ export class Pool {
23
+ constructor(config?: PoolConfig)
24
+ connect(): Promise<PoolClient>
25
+ query(sql: string, params?: unknown[]): Promise<QueryResult>
26
+ end(): Promise<void>
27
+ }
28
+
29
+ const pg: {
30
+ Pool: typeof Pool
31
+ } & typeof import('pg')
32
+ export default pg
33
+ }
34
+
35
+ declare module 'redis' {
36
+ export type RedisClientType = {
37
+ connect(): Promise<void>
38
+ get(key: string): Promise<string | null>
39
+ set(key: string, value: string): Promise<unknown>
40
+ setEx(key: string, seconds: number, value: string): Promise<unknown>
41
+ del(keys: string | string[]): Promise<unknown>
42
+ keys(pattern: string): Promise<string[]>
43
+ flushAll(): Promise<unknown>
44
+ quit(): Promise<unknown>
45
+ }
46
+
47
+ export function createClient(options?: { url?: string; [key: string]: unknown }): RedisClientType
48
+ }
@@ -0,0 +1,333 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+ import type { ModuleReport, Violation, Warning } from './types'
4
+ import { fileExists, resolvePath, canonicalizeConnectorName, getAllTsFiles } from './utils'
5
+
6
+ export type InternalPaths = Map<string, { service: string | null; controller: string | null }>
7
+
8
+ export async function checkModuleStructure(
9
+ modulesPath: string,
10
+ violations: Violation[],
11
+ warnings: Warning[],
12
+ ): Promise<{ modules: ModuleReport[]; internalPaths: InternalPaths }> {
13
+ const modules: ModuleReport[] = []
14
+ const internalPaths: InternalPaths = new Map()
15
+
16
+ try {
17
+ const moduleDirs = await readdir(modulesPath, { withFileTypes: true })
18
+ const moduleNames = moduleDirs.filter(d => d.isDirectory()).map(d => d.name)
19
+
20
+ if (moduleNames.length === 0) {
21
+ warnings.push({ type: 'SINGLE_MODULE', message: 'No hay módulos en src/modules/' })
22
+ }
23
+
24
+ for (const name of moduleNames) {
25
+ const modPath = join(modulesPath, name)
26
+ const servicePath = await resolvePath(modPath, ['service.ts', 'actions/service.ts'])
27
+ const controllerPath = await resolvePath(modPath, ['controller.ts', 'actions/controller.ts'])
28
+
29
+ const report: ModuleReport = {
30
+ name,
31
+ hasIndex: await fileExists(join(modPath, 'index.ts')),
32
+ hasService: servicePath !== null,
33
+ hasController: controllerPath !== null,
34
+ hasTypes: await fileExists(join(modPath, 'types.ts')),
35
+ hasSockets: await fileExists(join(modPath, 'sockets.ts')),
36
+ hasValidators: await fileExists(join(modPath, 'validators', 'schema.ts')),
37
+ hasTests: await fileExists(join(modPath, 'tests', 'service.test.ts')),
38
+ }
39
+
40
+ internalPaths.set(name, { service: servicePath, controller: controllerPath })
41
+
42
+ if (!report.hasIndex) violations.push({ type: 'MISSING_INDEX', module: name, message: `Módulo "${name}" sin index.ts (puerta pública)` })
43
+ if (!report.hasTypes) violations.push({ type: 'MISSING_TYPES', module: name, message: `Módulo "${name}" sin types.ts (DTOs)` })
44
+ if (!report.hasSockets) violations.push({ type: 'MISSING_SOCKETS', module: name, message: `Módulo "${name}" sin sockets.ts (hooks opcionales)` })
45
+ if (!report.hasService) violations.push({ type: 'MISSING_SERVICE', module: name, message: `Módulo "${name}" sin service.ts (al root del módulo)` })
46
+ if (!report.hasValidators) violations.push({ type: 'MISSING_VALIDATORS', module: name, message: `Módulo "${name}" sin validators/schema.ts` })
47
+ if (!report.hasTests) violations.push({ type: 'MISSING_TESTS', module: name, message: `Módulo "${name}" sin tests/` })
48
+
49
+ modules.push(report)
50
+ }
51
+ } catch {
52
+ violations.push({ type: 'MISSING_INDEX', module: 'modules', message: 'No existe src/modules/' })
53
+ }
54
+
55
+ return { modules, internalPaths }
56
+ }
57
+
58
+ export async function checkConnectors(
59
+ connectorsPath: string,
60
+ crPath: string,
61
+ modules: ModuleReport[],
62
+ violations: Violation[],
63
+ warnings: Warning[],
64
+ ): Promise<string[]> {
65
+ const connectors: string[] = []
66
+
67
+ try {
68
+ const connectorFiles = await readdir(connectorsPath)
69
+ for (const file of connectorFiles) {
70
+ if (file.endsWith('.ts')) connectors.push(file)
71
+ }
72
+
73
+ if (connectors.length === 0 && modules.length > 1) {
74
+ warnings.push({ type: 'NO_CONNECTORS', message: `Hay ${modules.length} módulos pero 0 conectores. Los módulos no están conectados.` })
75
+ }
76
+
77
+ const canonicalMap = new Map<string, string[]>()
78
+ for (const file of connectors) {
79
+ const canonical = canonicalizeConnectorName(file)
80
+ const list = canonicalMap.get(canonical) ?? []
81
+ list.push(file)
82
+ canonicalMap.set(canonical, list)
83
+ }
84
+ for (const [canonical, files] of canonicalMap) {
85
+ if (files.length > 1) {
86
+ violations.push({
87
+ type: 'DUPLICATE_CONNECTOR',
88
+ module: 'connectors',
89
+ message: `Conectores duplicados con mismo nombre lógico "${canonical}": ${files.join(', ')}. Eliminar los stubs muertos del CLI.`,
90
+ })
91
+ }
92
+ }
93
+
94
+ try {
95
+ const crContent = await readFile(crPath, 'utf-8')
96
+ for (const file of connectors) {
97
+ const basename = file.replace(/\.ts$/, '')
98
+ const importPattern = new RegExp(`from\\s+['"][^'"]*connectors\\/${basename}['"]`)
99
+ if (!importPattern.test(crContent)) {
100
+ violations.push({
101
+ type: 'UNREGISTERED_CONNECTOR',
102
+ module: 'connectors',
103
+ message: `Conector "${file}" existe pero no se importa en composition-root.ts. Dead code o falta registrar con system.addConnector().`,
104
+ })
105
+ }
106
+ }
107
+ } catch { /* sin composition-root, ya warned arriba */ }
108
+ } catch { /* no connectors folder */ }
109
+
110
+ if (modules.length > 1 && connectors.length === 0) {
111
+ violations.push({
112
+ type: 'MISSING_CONNECTORS',
113
+ module: 'system',
114
+ message: `Hay ${modules.length} módulos pero 0 conectores. Los módulos no están conectados. (CLAUDE #5)`,
115
+ })
116
+ }
117
+
118
+ return connectors
119
+ }
120
+
121
+ export async function checkCrossImports(
122
+ modulesPath: string,
123
+ modules: ModuleReport[],
124
+ violations: Violation[],
125
+ ): Promise<void> {
126
+ for (const module of modules) {
127
+ try {
128
+ const allFiles = await getAllTsFiles(join(modulesPath, module.name))
129
+ for (const file of allFiles) {
130
+ const content = await readFile(file, 'utf-8')
131
+ const relPath = file.replace(modulesPath, '')
132
+ for (const other of modules) {
133
+ if (other.name === module.name) continue
134
+ const importPattern = new RegExp(`from ['"][^'"]*\\/modules\\/${other.name}([/'"]|\$)`)
135
+ if (importPattern.test(content)) {
136
+ violations.push({
137
+ type: 'DIRECT_MODULE_IMPORT',
138
+ module: module.name,
139
+ message: `"${relPath}" importa directo de "${other.name}". Usar conector. (CLAUDE #1)`,
140
+ })
141
+ }
142
+ }
143
+ }
144
+ } catch { /* no actions dir */ }
145
+ }
146
+ }
147
+
148
+ export async function checkConnectorLogic(
149
+ connectorsPath: string,
150
+ connectors: string[],
151
+ violations: Violation[],
152
+ ): Promise<void> {
153
+ const businessPatterns = [
154
+ /\bif\s*\(/g, /\bswitch\s*\(/g, /\bfor\s*\(/g, /\bwhile\s*\(/g,
155
+ /\btry\s*\{/g, /\bcatch\s*\(/g, /\bnew\s+\w+Action/g,
156
+ /\.create\(/g, /\.update\(/g, /\.delete\(/g,
157
+ ]
158
+
159
+ for (const connFile of connectors) {
160
+ try {
161
+ const content = await readFile(join(connectorsPath, connFile), 'utf-8')
162
+ let businessHits = 0
163
+ for (const pattern of businessPatterns) {
164
+ const matches = content.match(pattern)
165
+ if (matches) businessHits += matches.length
166
+ }
167
+ if (businessHits > 3) {
168
+ violations.push({
169
+ type: 'CONNECTOR_BUSINESS_LOGIC',
170
+ module: connFile,
171
+ message: `"${connFile}" tiene ${businessHits} patrones de lógica de negocio. Los conectores solo deben wirear. (CLAUDE #3)`,
172
+ })
173
+ }
174
+ } catch { /* ignore */ }
175
+ }
176
+ }
177
+
178
+ export async function checkIndexFiles(
179
+ modulesPath: string,
180
+ modules: ModuleReport[],
181
+ warnings: Warning[],
182
+ ): Promise<void> {
183
+ for (const module of modules) {
184
+ const indexPath = join(modulesPath, module.name, 'index.ts')
185
+ try {
186
+ const content = await readFile(indexPath, 'utf-8')
187
+ if (content.includes('TODO:') || content.includes('FIXME:')) {
188
+ warnings.push({
189
+ type: 'INDEX_PENDING_CHANGES',
190
+ message: `"${module.name}/index.ts" tiene TODO/FIXME. Revisar antes de continuar.`,
191
+ })
192
+ }
193
+ } catch { /* no index */ }
194
+ }
195
+ }
196
+
197
+ export async function checkModuleQuality(
198
+ modulesPath: string,
199
+ modules: ModuleReport[],
200
+ internalPaths: InternalPaths,
201
+ violations: Violation[],
202
+ warnings: Warning[],
203
+ ): Promise<void> {
204
+ for (const module of modules) {
205
+ const modPath = join(modulesPath, module.name)
206
+ const paths = internalPaths.get(module.name) ?? { service: null, controller: null }
207
+ const { service: servicePath, controller: controllerPath } = paths
208
+
209
+ // Description vacía
210
+ try {
211
+ const indexContent = await readFile(join(modPath, 'index.ts'), 'utf-8')
212
+ if (/description:\s*['"`]\s*['"`]/.test(indexContent)) {
213
+ violations.push({ type: 'EMPTY_MODULE_DESCRIPTION', module: module.name, message: `"${module.name}" tiene descripción vacía. Todo módulo DEBE documentar su propósito.` })
214
+ }
215
+ } catch { /* no index */ }
216
+
217
+ // Tests sin casos
218
+ try {
219
+ const testContent = await readFile(join(modPath, 'tests', 'service.test.ts'), 'utf-8')
220
+ if (!/\btest\s*\(|\bit\s*\(|\bdescribe\s*\(/.test(testContent)) {
221
+ violations.push({ type: 'TESTS_WITHOUT_CASES', module: module.name, message: `"${module.name}/tests/service.test.ts" existe pero no tiene ningún test(). Coverage = 0.` })
222
+ }
223
+ } catch { /* no test file */ }
224
+
225
+ // Legacy layout warning
226
+ if ((servicePath?.includes('/actions/')) || (controllerPath?.includes('/actions/'))) {
227
+ warnings.push({ type: 'LEGACY_ACTIONS_FOLDER', message: `"${module.name}" usa la estructura legacy actions/. Mover service.ts y controller.ts al root del módulo.` })
228
+ }
229
+
230
+ if (controllerPath) {
231
+ const ctrlContent = await readFile(controllerPath, 'utf-8').catch(() => '')
232
+
233
+ // Controller sin validateSchema en rutas mutantes
234
+ if (/router\.(post|put|patch)\s*\(/.test(ctrlContent) && !/validateSchema\s*\(/.test(ctrlContent)) {
235
+ violations.push({ type: 'CONTROLLER_MISSING_VALIDATION', module: module.name, message: `"${module.name}/controller.ts" tiene rutas POST/PUT/PATCH sin validateSchema(). Datos no validados llegan al service.` })
236
+ }
237
+
238
+ // ORM directo en controller
239
+ if (/\borm\.(findMany|findById|create|update|delete|paginate|count)\s*\(/.test(ctrlContent)) {
240
+ violations.push({ type: 'BUSINESS_LOGIC_IN_CONTROLLER', module: module.name, message: `"${module.name}/controller.ts" llama al ORM directamente. La lógica de datos va en service.ts.` })
241
+ }
242
+ }
243
+
244
+ if (!servicePath) continue
245
+
246
+ const serviceContent = await readFile(servicePath, 'utf-8').catch(() => '')
247
+
248
+ // God service
249
+ const lineCount = serviceContent.split('\n').length
250
+ if (lineCount > 200) {
251
+ violations.push({ type: 'GOD_SERVICE', module: module.name, message: `"${module.name}/service.ts" tiene ${lineCount} líneas. Un service > 200 líneas es un God Object — extraer a usecases/.` })
252
+ }
253
+
254
+ // Service cruza módulo
255
+ for (const other of modules) {
256
+ if (other.name === module.name) continue
257
+ if (new RegExp(`from ['"][^'"]*\\/modules\\/${other.name}([/'"]|\$)`).test(serviceContent)) {
258
+ violations.push({ type: 'SERVICE_IMPORTS_OTHER_MODULE', module: module.name, message: `"${module.name}/service.ts" importa de "${other.name}". Los services no pueden cruzar módulos — usar conector. (CLAUDE #1)` })
259
+ }
260
+ }
261
+
262
+ // N+1 risk
263
+ const lines = serviceContent.split('\n')
264
+ let insideLoop = 0
265
+ for (let i = 0; i < lines.length; i++) {
266
+ const line = lines[i] ?? ''
267
+ if (/\b(for\s*\(|forEach\s*\(|\.map\s*\(\s*async|\.filter\s*\(\s*async|while\s*\()/.test(line)) insideLoop++
268
+ const opens = (line.match(/\{/g) ?? []).length
269
+ const closes = (line.match(/\}/g) ?? []).length
270
+ if (insideLoop > 0 && closes > opens) insideLoop = Math.max(0, insideLoop - 1)
271
+ if (insideLoop > 0 && /await\s+\w*orm\w*\.(findMany|findById|create|update|delete|count|paginate)\s*\(/.test(line)) {
272
+ violations.push({ type: 'N_PLUS_ONE_RISK', module: module.name, 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.` })
273
+ break
274
+ }
275
+ }
276
+
277
+ // IDOR risk — findById sin assertOwnership
278
+ const filesToScan: string[] = [servicePath]
279
+ for (const subdir of ['usecases', 'actions']) {
280
+ try {
281
+ const subEntries = await readdir(join(modPath, subdir), { withFileTypes: true })
282
+ for (const e of subEntries) {
283
+ if (e.isFile() && e.name.endsWith('.ts') && e.name !== 'service.ts' && e.name !== 'controller.ts') {
284
+ filesToScan.push(join(modPath, subdir, e.name))
285
+ }
286
+ }
287
+ } catch { /* no subdir */ }
288
+ }
289
+
290
+ for (const filePath of filesToScan) {
291
+ if (/-orm\.ts$|-repo\.ts$|-repository\.ts$|repository\.ts$/.test(filePath)) continue
292
+ try {
293
+ const content = await readFile(filePath, 'utf-8')
294
+ const fileLines = content.split('\n')
295
+ let insideInterface = 0
296
+ for (let i = 0; i < fileLines.length; i++) {
297
+ const line = fileLines[i] ?? ''
298
+ if (/^\s*(export\s+)?interface\s+\w+/.test(line)) insideInterface++
299
+ const opens = (line.match(/\{/g) ?? []).length
300
+ const closes = (line.match(/\}/g) ?? []).length
301
+ if (insideInterface > 0) insideInterface = Math.max(0, insideInterface + opens - closes)
302
+ if (insideInterface > 0) continue
303
+ if (/^\s*findById\s*\([^)]*\)\s*:/.test(line)) continue
304
+ if (/\bfindById\s*\(/.test(line)) {
305
+ const window = fileLines.slice(i + 1, i + 6).join('\n')
306
+ if (!window.includes('assertOwnership') && !window.includes('userId') && !window.includes('ownerId')) {
307
+ violations.push({ type: 'IDOR_RISK', module: module.name, message: `"${relative(modulesPath, filePath)}" línea ${i + 1}: findById sin verificación de ownership. Usar auth.assertOwnership() para prevenir IDOR.` })
308
+ break
309
+ }
310
+ }
311
+ }
312
+ } catch { /* file unreadable */ }
313
+ }
314
+
315
+ // Service depends on ORM
316
+ if (/private\s+(readonly\s+)?\w+\s*:\s*ORM\b/.test(serviceContent)) {
317
+ violations.push({ type: 'SERVICE_DEPENDS_ON_ORM', module: module.name, message: `"${module.name}/service.ts" inyecta ORM directamente. Usar RepositoryAdapter<T> para poder swapear SQL → MongoDB → Prisma sin tocar el service. (CLAUDE #18)` })
318
+ }
319
+
320
+ // Model in types file
321
+ try {
322
+ const typesContent = await readFile(join(modPath, 'types.ts'), 'utf-8')
323
+ if (/\bModelDefinition\b/.test(typesContent)) {
324
+ violations.push({ type: 'MODEL_IN_TYPES_FILE', module: module.name, message: `"${module.name}/types.ts" contiene ModelDefinition o schema de DB. Moverlo a model.ts — son conceptos distintos. (CLAUDE #22)` })
325
+ }
326
+ } catch { /* no types.ts */ }
327
+
328
+ // Missing model.ts
329
+ if (!await fileExists(join(modPath, 'model.ts'))) {
330
+ violations.push({ type: 'MISSING_MODEL_TS', module: module.name, message: `"${module.name}" no tiene model.ts. El schema de DB debe vivir separado de types.ts. (CLAUDE #22)` })
331
+ }
332
+ }
333
+ }
@@ -0,0 +1,44 @@
1
+ import { access } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { AnalysisResult } from './types'
4
+ import {
5
+ checkModuleStructure,
6
+ checkConnectors,
7
+ checkCrossImports,
8
+ checkConnectorLogic,
9
+ checkIndexFiles,
10
+ checkModuleQuality,
11
+ } from './checks'
12
+
13
+ export type { AnalysisResult, ModuleReport, Violation, Warning } from './types'
14
+ export { printAnalysis, buildManifest } from './report'
15
+
16
+ export async function analyzeProject(basePath: string): Promise<AnalysisResult> {
17
+ const violations: AnalysisResult['violations'] = []
18
+ const warnings: AnalysisResult['warnings'] = []
19
+
20
+ const srcPath = join(basePath, 'src')
21
+ const modulesPath = join(srcPath, 'modules')
22
+ const connectorsPath = join(srcPath, 'connectors')
23
+ const crPath = join(srcPath, 'composition-root.ts')
24
+
25
+ try {
26
+ await access(crPath)
27
+ } catch {
28
+ warnings.push({ type: 'NO_COMPOSITION_ROOT', message: 'No hay composition-root.ts en src/' })
29
+ return { valid: false, modules: [], connectors: [], violations, warnings }
30
+ }
31
+
32
+ const { modules, internalPaths } = await checkModuleStructure(modulesPath, violations, warnings)
33
+ const connectors = await checkConnectors(connectorsPath, crPath, modules, violations, warnings)
34
+
35
+ await Promise.all([
36
+ checkCrossImports(modulesPath, modules, violations),
37
+ checkConnectorLogic(connectorsPath, connectors, violations),
38
+ checkIndexFiles(modulesPath, modules, warnings),
39
+ ])
40
+
41
+ await checkModuleQuality(modulesPath, modules, internalPaths, violations, warnings)
42
+
43
+ return { valid: violations.length === 0, modules, connectors, violations, warnings }
44
+ }