arckode-framework 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/jwt.ts +6 -4
- package/adapters/mysql.ts +7 -2
- package/adapters/postgres.ts +37 -0
- package/adapters/sqlite.ts +7 -1
- package/adapters/vendor.d.ts +48 -0
- package/cli/analyze/checks.ts +333 -0
- package/cli/analyze/index.ts +44 -0
- package/cli/analyze/report.ts +107 -0
- package/cli/analyze/types.ts +46 -0
- package/cli/analyze/utils.ts +36 -0
- package/cli/analyze.ts +2 -647
- package/cli/commands/db-migrate.ts +213 -89
- package/cli/commands/db-seed.ts +97 -32
- package/cli/commands/db-utils.ts +192 -0
- package/cli/commands/new.ts +175 -0
- package/cli/commands/routes.ts +94 -0
- package/cli/index.ts +57 -404
- package/cli/stubs/module/core.ts +162 -0
- package/cli/stubs/module/data.ts +171 -0
- package/cli/stubs/module/index.ts +5 -0
- package/cli/stubs/module/service.ts +198 -0
- package/cli/stubs/module/types.ts +12 -0
- package/cli/stubs/module-stub.ts +2 -552
- package/kernel/auth.ts +114 -0
- package/kernel/cache.ts +37 -0
- package/kernel/config.ts +129 -0
- package/kernel/container.ts +64 -0
- package/kernel/db/orm-migrate.ts +136 -0
- package/kernel/db/orm-repository.ts +45 -0
- package/kernel/db/orm-utils.ts +93 -0
- package/kernel/db/orm.ts +254 -0
- package/kernel/db/transactor.ts +17 -0
- package/kernel/db/types.ts +72 -0
- package/kernel/errors.ts +102 -0
- package/kernel/framework.default.ts +41 -0
- package/kernel/framework.ts +8 -2144
- package/kernel/http/router.ts +131 -0
- package/kernel/http/server.ts +303 -0
- package/kernel/http/types.ts +56 -0
- package/kernel/index.ts +25 -0
- package/kernel/logger.ts +50 -0
- package/kernel/middlewares.ts +19 -7
- package/kernel/modules/create-module.ts +5 -0
- package/kernel/modules/system.ts +149 -0
- package/kernel/modules/types.ts +46 -0
- package/kernel/seeds.ts +48 -0
- package/kernel/static.ts +11 -2
- package/kernel/testing.ts +8 -3
- package/kernel/validator.ts +116 -0
- package/modules/events/index.ts +19 -3
- package/modules/mail/index.ts +14 -2
- package/modules/storage/local-adapter.ts +19 -5
- package/modules/ws/index.ts +123 -18
- package/package.json +8 -11
- package/skills/auth/SKILL.md +36 -220
- package/skills/cli/SKILL.md +32 -251
- package/skills/config/SKILL.md +30 -239
- package/skills/connectors/SKILL.md +32 -295
- package/skills/helpers/SKILL.md +26 -195
- package/skills/middlewares/SKILL.md +30 -280
- package/skills/orm/SKILL.md +42 -349
- package/skills/realtime/SKILL.md +22 -297
- package/skills/services/SKILL.md +40 -183
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
if (this.connected) {
|
|
99
|
+
await this.pool.end()
|
|
100
|
+
this.connected = false
|
|
101
|
+
}
|
|
97
102
|
}
|
|
98
103
|
}
|
package/adapters/postgres.ts
CHANGED
|
@@ -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
|
}
|
package/adapters/sqlite.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|