@ronaldjdevfs/forge 1.3.0-beta → 1.3.2

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 (69) hide show
  1. package/README.md +11 -4
  2. package/package.json +1 -1
  3. package/skills/forge/SKILL.md +57 -128
  4. package/skills/forge/command/forge.md +59 -14
  5. package/skills/forge/reference/adr.md +242 -0
  6. package/skills/forge/reference/anti-corruption-layer.md +340 -0
  7. package/skills/forge/reference/api-design.md +7 -0
  8. package/skills/forge/reference/api-versioning.md +354 -0
  9. package/skills/forge/reference/architectural-depth-checklist.md +311 -0
  10. package/skills/forge/reference/architecture-template.md +41 -0
  11. package/skills/forge/reference/assay.md +6 -0
  12. package/skills/forge/reference/bounded-contexts.md +311 -0
  13. package/skills/forge/reference/chain.md +6 -0
  14. package/skills/forge/reference/cohesion-checklist.md +256 -0
  15. package/skills/forge/reference/cqrs.md +286 -0
  16. package/skills/forge/reference/data-patterns.md +6 -0
  17. package/skills/forge/reference/di-strategies.md +6 -0
  18. package/skills/forge/reference/errors.md +5 -0
  19. package/skills/forge/reference/events.md +8 -0
  20. package/skills/forge/reference/evolutionary-architecture.md +300 -0
  21. package/skills/forge/reference/forge.md +7 -0
  22. package/skills/forge/reference/hooks.md +6 -0
  23. package/skills/forge/reference/idempotency.md +283 -0
  24. package/skills/forge/reference/inscribe.md +5 -0
  25. package/skills/forge/reference/inspect.md +6 -0
  26. package/skills/forge/reference/modular-monolith.md +252 -0
  27. package/skills/forge/reference/observability.md +5 -0
  28. package/skills/forge/reference/quench.md +5 -0
  29. package/skills/forge/reference/relocate.md +6 -0
  30. package/skills/forge/reference/sagas.md +359 -0
  31. package/skills/forge/reference/security-patterns.md +6 -0
  32. package/skills/forge/reference/smelt.md +6 -0
  33. package/skills/forge/reference/temper.md +6 -0
  34. package/skills/forge/reference/testing-patterns.md +6 -0
  35. package/skills/forge/reference/transactional-outbox.md +311 -0
  36. package/skills/forge/scripts/architecture.mjs +10 -5
  37. package/skills/forge/scripts/assay.mjs +2 -2
  38. package/skills/forge/scripts/chain.mjs +31 -5
  39. package/skills/forge/scripts/context.mjs +24 -4
  40. package/skills/forge/scripts/detect.mjs +57 -36
  41. package/skills/forge/scripts/forge-boot.mjs +108 -0
  42. package/skills/forge/scripts/forge-config.mjs +182 -3
  43. package/skills/forge/scripts/forge-state.mjs +1 -1
  44. package/skills/forge/scripts/forgeSentinel-lib.mjs +2 -2
  45. package/skills/forge/scripts/forgeSentinel.mjs +2 -2
  46. package/skills/forge/scripts/forgeSmith.mjs +2 -2
  47. package/skills/forge/scripts/graph.mjs +65 -9
  48. package/skills/forge/scripts/hook.mjs +2 -2
  49. package/skills/forge/scripts/inspect.mjs +56 -48
  50. package/skills/forge/scripts/parse-imports.mjs +0 -2
  51. package/skills/forge/scripts/posttool.mjs +211 -17
  52. package/skills/forge/scripts/recommendation-engine.mjs +125 -0
  53. package/skills/forge/scripts/rename.mjs +62 -14
  54. package/skills/forge/scripts/rollback.mjs +5 -3
  55. package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
  56. package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
  57. package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
  58. package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
  59. package/skills/forge/templates/feature/entity.ts.md +1 -1
  60. package/skills/forge/templates/feature/mapper.ts.md +1 -1
  61. package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
  62. package/skills/forge/templates/feature/repository-impl.ts.md +2 -2
  63. package/skills/forge/templates/feature/repository-interface.ts.md +2 -2
  64. package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
  65. package/skills/forge/templates/feature/schema.ts.md +1 -1
  66. package/skills/forge/templates/feature/use-case.ts.md +2 -2
  67. package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
  68. package/skills/forge/tests/core.test.mjs +288 -4
  69. package/src/cli.js +26 -13
@@ -0,0 +1,311 @@
1
+ # Transactional Outbox — Entrega Confiable de Eventos
2
+
3
+ Publicar un evento y escribir en la BD en la misma operación atómica es un problema conocido como **dual-write**. Sin un mecanismo explícito, el sistema puede terminar con datos escritos sin evento publicado, o evento publicado sin datos escritos. El outbox pattern resuelve esto.
4
+
5
+ ---
6
+
7
+ ## El Problema del Dual-Write
8
+
9
+ ```ts
10
+ // ❌ Sin outbox: race condition
11
+ async function placeOrder(command: PlaceOrderCommand): Promise<void> {
12
+ await this.orderRepo.save(order); // 1. Escribe en BD
13
+ await this.eventBus.publish(event); // 2. Publica evento
14
+ // Si el paso 2 falla, el pedido existe pero nadie lo sabe
15
+ // Si el paso 1 falla después del paso 2, hay un evento huérfano
16
+ }
17
+ ```
18
+
19
+ **Escenarios de fallo:**
20
+ - BD commit exitoso, broker caído → evento perdido
21
+ - BD commit lento, timeout del cliente → retry → pedido duplicado
22
+ - Broker recibe evento, BD falla → evento fantasma
23
+
24
+ ---
25
+
26
+ ## El Patrón Outbox
27
+
28
+ ### Cómo funciona
29
+
30
+ 1. **Escribir en la BD**: la operación de negocio + el evento se guardan en la misma transacción
31
+ 2. **Relayer**: un proceso independiente lee la tabla outbox y publica los eventos al broker
32
+ 3. **Eliminar**: una vez publicado exitosamente, el evento se elimina o marca como enviado
33
+
34
+ ```mermaid
35
+ flowchart LR
36
+ subgraph "Feature (Orders)"
37
+ UseCase[Caso de uso]
38
+ Repo[(BD principal)]
39
+ OutboxTabla[(Tabla outbox)]
40
+ end
41
+ subgraph "Outbox Processor"
42
+ Relayer[Relayer]
43
+ end
44
+ subgraph "Infraestructura"
45
+ Broker[Message Broker]
46
+ end
47
+
48
+ UseCase --> Repo
49
+ UseCase --> OutboxTabla
50
+ Relayer --> OutboxTabla
51
+ Relayer --> Broker
52
+ Broker --> Consumers[Consumidores]
53
+ ```
54
+
55
+ ### Tabla outbox
56
+
57
+ ```sql
58
+ CREATE TABLE IF NOT EXISTS public.outbox (
59
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
60
+ event_type VARCHAR(255) NOT NULL,
61
+ aggregate_id VARCHAR(255) NOT NULL, -- ID de la entidad que originó el evento
62
+ aggregate_type VARCHAR(255) NOT NULL, -- tipo de entidad
63
+ payload JSONB NOT NULL, -- datos del evento
64
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
65
+ processed_at TIMESTAMPTZ, -- NULL = no procesado
66
+ retry_count INT NOT NULL DEFAULT 0,
67
+ last_error TEXT
68
+ );
69
+
70
+ CREATE INDEX idx_outbox_unprocessed ON public.outbox (processed_at ASC NULLS FIRST, occurred_at ASC);
71
+ ```
72
+
73
+ ### Uso en el caso de uso
74
+
75
+ ```ts
76
+ // application/use-cases/PlaceOrderUseCase.ts
77
+ export class PlaceOrderUseCase {
78
+ constructor(
79
+ private readonly unitOfWork: IUnitOfWork,
80
+ ) {}
81
+
82
+ async execute(command: PlaceOrderCommand): Promise<string> {
83
+ return this.unitOfWork.withTransaction(async (uow) => {
84
+ const order = OrderEntity.create(command.customerId, command.items);
85
+ await uow.orderRepo.save(order);
86
+
87
+ // El evento se persiste en la misma transacción que el pedido
88
+ uow.outbox.add(new OrderPlacedEvent({
89
+ orderId: order.id,
90
+ customerId: order.customerId,
91
+ total: order.total,
92
+ items: order.items.map((i) => ({ productId: i.productId, quantity: i.quantity })),
93
+ }));
94
+
95
+ return order.id;
96
+ });
97
+ }
98
+ }
99
+ ```
100
+
101
+ ### Outbox como parte del UnitOfWork
102
+
103
+ ```ts
104
+ // domain/IUnitOfWork.ts
105
+ import { OutboxEvent } from "./OutboxEvent";
106
+
107
+ export interface IUnitOfWork {
108
+ withTransaction<T>(
109
+ fn: (uow: {
110
+ orderRepo: IOrderRepository;
111
+ outbox: {
112
+ add(event: DomainEvent): void;
113
+ getPending(): OutboxEvent[];
114
+ };
115
+ }) => Promise<T>,
116
+ ): Promise<T>;
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Outbox Processor (Relayer)
123
+
124
+ El relayer es un proceso que corre en platform/scheduler o como worker independiente.
125
+
126
+ ### Poll-based (más simple)
127
+
128
+ ```ts
129
+ // platform/scheduler/OutboxRelayer.ts
130
+ export class OutboxRelayer {
131
+ private readonly pollInterval = 1000; // 1 segundo
132
+ private running = false;
133
+
134
+ constructor(
135
+ private readonly outboxRepo: IOutboxRepository,
136
+ private readonly eventBus: IEventBus,
137
+ private readonly logger: ILogger,
138
+ ) {}
139
+
140
+ async start(): Promise<void> {
141
+ this.running = true;
142
+ while (this.running) {
143
+ await this.processBatch();
144
+ await this.sleep(this.pollInterval);
145
+ }
146
+ }
147
+
148
+ private async processBatch(): Promise<void> {
149
+ const batch = await this.outboxRepo.getUnprocessed(50);
150
+ for (const event of batch) {
151
+ try {
152
+ await this.eventBus.publish(event.toDomainEvent());
153
+ await this.outboxRepo.markProcessed(event.id);
154
+ } catch (error) {
155
+ await this.outboxRepo.incrementRetry(event.id, error.message);
156
+ if (event.retryCount >= 10) {
157
+ await this.outboxRepo.markAsDeadLetter(event.id);
158
+ this.logger.error(`Event ${event.id} moved to DLQ after ${event.retryCount} retries`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ stop(): void {
165
+ this.running = false;
166
+ }
167
+ }
168
+ ```
169
+
170
+ ### Log-based (CDC) — más escalable
171
+
172
+ Usa Change Data Capture (Debezium, PostgreSQL logical replication) para leer la tabla outbox desde el WAL de Postgres:
173
+
174
+ ```
175
+ [Postgres WAL] → [Debezium] → [Kafka] → [Consumidores]
176
+ ```
177
+
178
+ **Ventajas:**
179
+ - Sin polling, sin carga en la BD
180
+ - Latencia de milisegundos
181
+ - Escala horizontalmente
182
+
183
+ **Desventajas:**
184
+ - Infraestructura adicional (Debezium, Kafka connect)
185
+ - Configuración más compleja
186
+ - Monitoreo adicional
187
+
188
+ ---
189
+
190
+ ## Dead Letter Queue (DLQ)
191
+
192
+ Eventos que no pudieron publicarse después de N reintentos:
193
+
194
+ ```sql
195
+ -- Tabla outbox_dlq para eventos fallidos
196
+ CREATE TABLE IF NOT EXISTS public.outbox_dlq (
197
+ id UUID PRIMARY KEY,
198
+ event_type VARCHAR(255) NOT NULL,
199
+ aggregate_id VARCHAR(255) NOT NULL,
200
+ payload JSONB NOT NULL,
201
+ occurred_at TIMESTAMPTZ NOT NULL,
202
+ failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
203
+ last_error TEXT NOT NULL,
204
+ retry_count INT NOT NULL
205
+ );
206
+ ```
207
+
208
+ **Manejo de DLQ:**
209
+ - Alerta a operaciones cuando un evento entra a DLQ
210
+ - Dashboard para revisar y reprocesar eventos fallidos
211
+ - Reprocesamiento manual con límite de reintentos
212
+ - Eventos en DLQ por más de 7 días: escalar a equipo
213
+
214
+ ---
215
+
216
+ ## Idempotencia en Consumidores
217
+
218
+ El outbox garantiza *at-least-once delivery*. Los consumidores deben ser idempotentes para garantizar *exactly-once processing*.
219
+
220
+ ```ts
221
+ // adapters/in/events/OrderPlacedConsumer.ts
222
+ export class OrderPlacedConsumer {
223
+ constructor(
224
+ private readonly processedEvents: IProcessedEventRepository,
225
+ private readonly inventoryService: IInventoryService,
226
+ ) {}
227
+
228
+ async handle(event: OrderPlacedEvent): Promise<void> {
229
+ // Deduplicación: si ya procesamos este event ID, ignoramos
230
+ const alreadyProcessed = await this.processedEvents.exists(event.eventId);
231
+ if (alreadyProcessed) return;
232
+
233
+ await this.inventoryService.reserveStock(event.orderId, event.items);
234
+
235
+ // Registrar event ID como procesado (en la misma transacción si es posible)
236
+ await this.processedEvents.markProcessed(event.eventId);
237
+ }
238
+ }
239
+ ```
240
+
241
+ Ver `reference/idempotency.md` para más detalles sobre deduplicación.
242
+
243
+ ---
244
+
245
+ ## Implementación en el Modelo de Forge
246
+
247
+ ### Estructura
248
+
249
+ ```
250
+ src/features/orders/
251
+ domain/
252
+ events/
253
+ OrderPlacedEvent.ts
254
+ IUnitOfWork.ts ← incluye outbox.add()
255
+ OutboxEvent.ts ← wrapper del evento para persistir
256
+ adapters/
257
+ out/
258
+ persistence/
259
+ PostgresUnitOfWork.ts ← transacción + outbox en misma conexión
260
+ OutboxRepository.ts ← CRUD de la tabla outbox
261
+
262
+ src/platform/
263
+ scheduler/
264
+ OutboxRelayer.ts ← proceso que publica eventos
265
+ ```
266
+
267
+ ### Template de feature con outbox
268
+
269
+ Para features que publican eventos, el template debe incluir:
270
+
271
+ ```
272
+ src/features/<name>/
273
+ domain/
274
+ IOutbox.ts ← interfaz outbox en UnitOfWork
275
+ adapters/
276
+ out/
277
+ persistence/
278
+ <Name>UnitOfWork.ts ← UnitOfWork con outbox integrado
279
+ <Name>OutboxRepository.ts ← repositorio de outbox
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Anti-patrones
285
+
286
+ | Anti-patrón | Problema | Solución |
287
+ |---|---|---|
288
+ | **Outbox sin cleanup** | La tabla outbox crece sin límite. Los índices se degradan. | Archivar eventos procesados > 7 días a tabla de histórico. `DELETE FROM outbox WHERE processed_at < NOW() - INTERVAL '7 days'`. |
289
+ | **Relayer sin límite de reintentos** | Un evento falla infinitamente, consumiendo recursos. | Máximo 10 reintentos, luego DLQ. |
290
+ | **Eventos sin idempotencia** | El mismo evento se procesa dos veces si el relayer falla tras publicar pero antes de marcar. | Siempre deduplicar por event ID en el consumidor. |
291
+ | **Outbox sin índice** | `getUnprocessed()` escanea toda la tabla. | Índice compuesto en `(processed_at NULLS FIRST, occurred_at ASC)`. |
292
+ | **Relayer como parte del caso de uso** | Publicar eventos en el mismo proceso que escribe. Si el relayer falla, el caso de uso falla. | El relayer es un proceso separado (otro hilo, otro worker, otro deploy). |
293
+ | **Transactional outbox sin transacción** | La escritura del evento y la operación de negocio no están en la misma transacción. | El outbox SOLO funciona dentro de una transacción. Si tu ORM/Bd no soporta transacciones, no uses outbox (usa CDC). |
294
+
295
+ ---
296
+
297
+ ## Conexión con Forge
298
+
299
+ | Comando | Acción |
300
+ |---|---|
301
+ | `forge cast` | Incluir outbox en features que publican eventos |
302
+ | `forge inspect` | Verifica que features con eventos tienen outbox implementado |
303
+ | `forge quench` | Regla detect: feature publica eventos pero no tiene tabla outbox |
304
+ | `forge graph` | Visualiza el flujo: Feature → Outbox → Relayer → Broker → Consumers |
305
+
306
+ ## Ver también
307
+
308
+ - `reference/events.md` — eventos de dominio que el outbox publica
309
+ - `reference/idempotency.md` — deduplicación en el relayer
310
+ - `reference/sagas.md` — outbox como mecanismo de entrega en sagas
311
+ - `reference/data-patterns.md` — repository pattern para outbox
@@ -4,7 +4,7 @@ import { join, basename } from "path";
4
4
  import { writeFileSync, existsSync } from "fs";
5
5
  import { buildContext } from "./context.mjs";
6
6
  import { detectProfile, detectProfileExtended } from "./profile.mjs";
7
- import { buildGraph, exportGraph } from "./graph.mjs";
7
+ import { getGraph, exportGraph } from "./graph.mjs";
8
8
  import { buildOwnershipReport } from "./armorer.mjs";
9
9
  import { allChecks } from "./detect.mjs";
10
10
 
@@ -154,15 +154,18 @@ async function main() {
154
154
 
155
155
  const ctx = await buildContext();
156
156
  const profile = detectProfile(ctx);
157
- const graph = buildGraph();
157
+ const graph = ctx.graph || getGraph();
158
158
  const ownership = buildOwnershipReport();
159
159
  const features = ctx.features.migrated;
160
160
  const result = allChecks(features, graph, ctx);
161
161
 
162
+ const CAT_MAX = { structure: 30, layers: 25, decorators: 20, ownership: 20, platform: 15, dependencies: 15, graph: 20, customRules: 5, naming: 10 };
162
163
  const totalScore = (result.structure?.score || 0) + (result.layers?.score || 0)
163
164
  + (result.ownership?.score || 0) + (result.platform?.score || 0)
164
- + (result.dependencies?.score || 0) + (result.graph?.score || 0);
165
- const maxScore = 20 + 20 + 20 + 15 + 15 + 20;
165
+ + (result.dependencies?.score || 0) + (result.graph?.score || 0)
166
+ + (result.decorators?.score || 0) + (result.customRules?.score || 0)
167
+ + (result.naming?.score || 0);
168
+ const maxScore = Object.values(CAT_MAX).reduce((a, b) => a + b, 0);
166
169
  const auditScore = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
167
170
 
168
171
  const md = generateMd(ctx, profile, graph, ownership, auditScore);
@@ -173,4 +176,6 @@ async function main() {
173
176
  console.log(` Platform: ${ctx.platform.components.length} | Features: ${ctx.features.migrated.length} | Shared: ${ctx.shared.components.length} | Infra: ${ctx.infra.components.length}`);
174
177
  }
175
178
 
176
- main().catch(console.error);
179
+ if (process.argv[1] && (process.argv[1].endsWith("architecture.mjs") || process.argv[1].endsWith("architecture.js"))) {
180
+ main().catch(console.error);
181
+ }
@@ -19,7 +19,7 @@
19
19
  import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "fs";
20
20
  import { join, basename } from "path";
21
21
  import { loadState, loadHistory } from "./forge-config.mjs";
22
- import { buildGraph } from "./graph.mjs";
22
+ import { getGraph } from "./graph.mjs";
23
23
  import {
24
24
  CYAN, GREEN, RED, YELLOW, BOLD, RESET, DIM, GRAY,
25
25
  formatJson, formatViolation,
@@ -327,7 +327,7 @@ function getLastInspectReport() {
327
327
 
328
328
  function rebuildGraph() {
329
329
  try {
330
- return buildGraph(ROOT);
330
+ return getGraph(ROOT);
331
331
  } catch {
332
332
  return { nodes: [], edges: [], stats: { hasCycles: false, dependencyHealth: 100, totalNodes: 0, totalEdges: 0, violations: 0, riskScore: 0, health: "unknown" } };
333
333
  }
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { buildGraph } from "./graph.mjs";
3
+ import { getGraph } from "./graph.mjs";
4
4
 
5
- export function buildDependencyGraph(projectRoot) {
6
- const graph = buildGraph(projectRoot);
5
+ export function buildDependencyGraph(projectRoot, existingGraph = null) {
6
+ const graph = existingGraph || getGraph(projectRoot);
7
7
 
8
8
  const layers = {
9
9
  platform: [],
@@ -194,9 +194,35 @@ export function buildDependencyGraph(projectRoot) {
194
194
  }
195
195
 
196
196
  async function main() {
197
- const depGraph = buildDependencyGraph();
198
197
  const args = process.argv.slice(2);
199
- if (args.includes("--json")) {
198
+ const isJson = args.includes("--json");
199
+ const summary = args.includes("--summary");
200
+
201
+ const depGraph = buildDependencyGraph();
202
+
203
+ if (summary) {
204
+ const out = {
205
+ features: depGraph.features.length,
206
+ hasCycles: depGraph.hasCycles,
207
+ globalCycles: depGraph.globalCycles,
208
+ illegalChains: depGraph.illegalChains.length,
209
+ isolated: depGraph.isolated.length,
210
+ layers: {
211
+ platform: depGraph.layers.platform.nodes.length,
212
+ features: depGraph.layers.features.nodes.length,
213
+ shared: depGraph.layers.shared.nodes.length,
214
+ infra: depGraph.layers.infra.nodes.length,
215
+ },
216
+ };
217
+ if (isJson) {
218
+ console.log(JSON.stringify(out, null, 2));
219
+ } else {
220
+ console.log(`Chain: ${out.features} features, cycles: ${out.hasCycles}, illegal chains: ${out.illegalChains}, isolated: ${out.isolated}`);
221
+ }
222
+ return;
223
+ }
224
+
225
+ if (isJson) {
200
226
  console.log(JSON.stringify(depGraph, null, 2));
201
227
  } else {
202
228
  console.log("## Chain — Multi-layer Dependency Analysis\n");
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { readFileSync, existsSync, readdirSync, statSync } from "fs";
4
4
  import { join, basename, resolve } from "path";
5
- import { buildGraph } from "./graph.mjs";
5
+ import { getGraph } from "./graph.mjs";
6
6
  import { buildOwnershipReport } from "./armorer.mjs";
7
+ import { saveCache, loadCache } from "./forge-config.mjs";
7
8
 
8
9
  const ROOT = process.cwd();
9
10
  const SRC = join(ROOT, "src");
@@ -256,10 +257,24 @@ export function detectWorkspaces(projectRoot = ROOT) {
256
257
  }));
257
258
  }
258
259
 
259
- export async function buildContext(projectRoot = ROOT, workspaceScope = null) {
260
+ export async function buildContext(projectRoot = ROOT, workspaceScope = null, opts = {}) {
261
+ const { force = false } = opts;
260
262
  const root = workspaceScope || projectRoot;
261
263
  const src = join(root, "src");
262
264
 
265
+ // Try cache first
266
+ if (!force) {
267
+ const cached = loadCache("context", root);
268
+ if (cached.valid && cached.data) {
269
+ // Ensure graph is loaded (it may be separate cache entry)
270
+ if (!cached.data.graph) {
271
+ const graphCached = getGraph(root, { force: false });
272
+ cached.data.graph = graphCached;
273
+ }
274
+ return cached.data;
275
+ }
276
+ }
277
+
263
278
  const pkg = readJson(join(root, "package.json")) || {};
264
279
  const tsconfig = readJson(join(root, "tsconfig.json"));
265
280
 
@@ -280,7 +295,7 @@ export async function buildContext(projectRoot = ROOT, workspaceScope = null) {
280
295
  const shared = classifyComponents(ownership.ownership.shared, SHARED_KNOWN);
281
296
  const infra = classifyComponents(ownership.ownership.infra, INFRA_KNOWN);
282
297
  const orphans = ownership.orphans;
283
- const graph = buildGraph(root);
298
+ const graph = getGraph(root, { force });
284
299
 
285
300
  const hasFeatures = migratedFeatures.length > 0;
286
301
  const hasLegacy = legacyFeatures.length > 0;
@@ -290,7 +305,7 @@ export async function buildContext(projectRoot = ROOT, workspaceScope = null) {
290
305
  const monorepo = isRoot ? detectMonorepo(projectRoot) : null;
291
306
  const workspaces = isRoot ? detectWorkspaces(projectRoot) : [];
292
307
 
293
- return {
308
+ const ctx = {
294
309
  projectName: basename(root),
295
310
  isWorkspace: !isRoot,
296
311
  workspaceScope: isRoot ? null : basename(root),
@@ -329,6 +344,11 @@ export async function buildContext(projectRoot = ROOT, workspaceScope = null) {
329
344
  graph,
330
345
  dependencies: {},
331
346
  };
347
+
348
+ // Save to cache
349
+ saveCache("context", ctx, root);
350
+
351
+ return ctx;
332
352
  }
333
353
 
334
354
  async function main() {
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { join, relative, basename } from "path";
4
4
  import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from "fs";
5
- import { buildGraph } from "./graph.mjs";
5
+ import { getGraph } from "./graph.mjs";
6
6
  import { buildOwnershipReport } from "./armorer.mjs";
7
7
  import { parseImportsWithLines } from "./parse-imports.mjs";
8
8
  import { detectNamingViolations, computeExpectedName } from "./rename.mjs";
9
9
  import { formatViolation, formatCheck, GREEN, RED, YELLOW, CYAN, BOLD, RESET, DIM, GRAY } from "./formatter.mjs";
10
+ import { buildPipeline, printPipeline } from "./recommendation-engine.mjs";
10
11
 
11
12
  const ROOT = process.cwd();
12
13
  const SRC = join(ROOT, "src");
@@ -231,17 +232,29 @@ export function checkStructure(features) {
231
232
 
232
233
  if (hasSubdir(fDir, "domain")) {
233
234
  const dDir = join(fDir, "domain");
234
- if (hasFile(dDir, /\.entity\.ts$/)) {
235
- checks.push({ ...severity(`${feat}: domain/<Name>.entity.ts`, SEVERITY.INFO), pass: true });
235
+ const entitiesDir = join(dDir, "entities");
236
+ const reposDir = join(dDir, "repositories");
237
+
238
+ // Check domain/entities/ subdirectory (preferred)
239
+ if (hasSubdir(dDir, "entities") && hasFile(entitiesDir, /\.entity\.ts$/)) {
240
+ checks.push({ ...severity(`${feat}: domain/entities/<Name>.entity.ts`, SEVERITY.INFO), pass: true });
236
241
  featScore += 3;
242
+ } else if (hasFile(dDir, /\.entity\.ts$/)) {
243
+ checks.push({ ...severity(`${feat}: domain/<Name>.entity.ts (plano — migrar a domain/entities/)`, SEVERITY.WARNING), pass: false, fix: `Mover a ${feat}/domain/entities/` });
244
+ featScore += 1;
237
245
  } else {
238
- checks.push({ ...severity(`${feat}: falta entity en domain/`, SEVERITY.ERROR), pass: false, fix: `Crear ${feat}.entity.ts en ${feat}/domain/` });
246
+ checks.push({ ...severity(`${feat}: falta entity en domain/`, SEVERITY.ERROR), pass: false, fix: `Crear ${feat}/domain/entities/<Name>.entity.ts` });
239
247
  }
240
- if (hasFile(dDir, /^I[A-Z]/)) {
241
- checks.push({ ...severity(`${feat}: domain/I<Name>Repository.ts`, SEVERITY.INFO), pass: true });
248
+
249
+ // Check domain/repositories/ subdirectory (preferred)
250
+ if (hasSubdir(dDir, "repositories") && hasFile(reposDir, /\.repository\.ts$/)) {
251
+ checks.push({ ...severity(`${feat}: domain/repositories/I<Name>.repository.ts`, SEVERITY.INFO), pass: true });
242
252
  featScore += 2;
253
+ } else if (hasFile(dDir, /^I[A-Z]/)) {
254
+ checks.push({ ...severity(`${feat}: domain/I<Name>Repository.ts (plano — migrar a domain/repositories/)`, SEVERITY.WARNING), pass: false, fix: `Mover a ${feat}/domain/repositories/I<Name>.repository.ts` });
255
+ featScore += 1;
243
256
  } else {
244
- checks.push({ ...severity(`${feat}: falta repository interface en domain/`, SEVERITY.WARNING), pass: false, fix: `Crear I${feat.charAt(0).toUpperCase() + feat.slice(1)}Repository.ts` });
257
+ checks.push({ ...severity(`${feat}: falta repository interface en domain/`, SEVERITY.WARNING), pass: false, fix: `Crear ${feat}/domain/repositories/I${feat.charAt(0).toUpperCase() + feat.slice(1)}.repository.ts` });
245
258
  }
246
259
  } else {
247
260
  checks.push({ ...severity(`${feat}: falta domain/`, SEVERITY.ERROR), pass: false, fix: `Crear ${feat}/domain/` });
@@ -728,11 +741,11 @@ export function checkGraph(graph) {
728
741
  label: `[${v.rule}] ${v.description}`,
729
742
  pass: false,
730
743
  detail: `${v.from} → ${v.to}${v.file ? ` (${v.file})` : ""}`,
731
- fix: v.rule === "R1" ? "Core no debe importar de features. Mover la lógica a shared."
732
- : v.rule === "R2" ? "Domain no puede importar infraestructura. Extraer interfaz y usar adapter."
733
- : v.rule === "R3" ? "Feature no accede infraestructura directamente. Crear adapter en el feature."
734
- : v.rule === "R4" ? "Feature no importa otro feature directamente. Inyectar interfaz."
735
- : v.rule === "R5" ? "Romper el ciclo de dependencias extrayendo interfaz común a shared/."
744
+ fix: v.rule === "R1" ? "Feature no debe importar infraestructura directamente. Extraer interfaz al dominio y usar adapter en infra."
745
+ : v.rule === "R2" ? "Platform no debe depender de features. Extraer la interfaz a shared/ o mover la lógica."
746
+ : v.rule === "R3" ? "Shared no debe importar de features. Shared debe ser puro, sin dependencias de negocio."
747
+ : v.rule === "R4" ? "Shared no debe importar infraestructura. Shared debe ser puro, sin dependencias técnicas."
748
+ : v.rule === "R5" ? "Domain no puede importar infraestructura. El dominio debe estar aislado de la infra."
736
749
  : "Revisar la dirección de la dependencia",
737
750
  });
738
751
  score += penalty;
@@ -838,7 +851,7 @@ export function checkPlatform(ctx) {
838
851
  export function checkDependencies(ctx) {
839
852
  const checks = [];
840
853
  let score = 0;
841
- const graph = ctx.graph || buildGraph();
854
+ const graph = ctx.graph || getGraph();
842
855
 
843
856
  if (!graph || graph.nodes.length === 0) {
844
857
  checks.push({ ...severity("No hay nodos en el grafo para analizar dependencias", SEVERITY.WARNING), pass: false });
@@ -917,26 +930,29 @@ export function checkCustomRules(features) {
917
930
  continue;
918
931
  }
919
932
 
933
+ const featureFiles = [];
920
934
  for (const feat of features) {
921
- if (!feat.files) continue;
922
- for (const f of feat.files) {
923
- if (fileRe && !fileRe.test(f)) continue;
924
- const content = read(f);
925
- if (!content) continue;
926
-
927
- contentRe.lastIndex = 0;
928
- let match;
929
- while ((match = contentRe.exec(content)) !== null) {
930
- violations++;
931
- const line = content.slice(0, match.index).split("\n").length;
932
- checks.push({
933
- severity: rule.severity || SEVERITY.ERROR,
934
- label: `[${rule.id}] ${rule.message || "Violación de regla custom"} en ${relative(ROOT, f)}:${line}`,
935
- pass: false,
936
- detail: `Match: "${match[0].slice(0, 80)}"`,
937
- fix: rule.fix || null,
938
- });
939
- }
935
+ const featDir = join(FEATURES, feat);
936
+ if (isDir(featDir)) featureFiles.push(...findFiles(featDir));
937
+ }
938
+ const targetFiles = featureFiles.length > 0 ? featureFiles : findFiles(SRC);
939
+ for (const f of targetFiles) {
940
+ if (fileRe && !fileRe.test(f)) continue;
941
+ const content = read(f);
942
+ if (!content) continue;
943
+
944
+ contentRe.lastIndex = 0;
945
+ let match;
946
+ while ((match = contentRe.exec(content)) !== null) {
947
+ violations++;
948
+ const line = content.slice(0, match.index).split("\n").length;
949
+ checks.push({
950
+ severity: rule.severity || SEVERITY.ERROR,
951
+ label: `[${rule.id}] ${rule.message || "Violación de regla custom"} en ${relative(ROOT, f)}:${line}`,
952
+ pass: false,
953
+ detail: `Match: "${match[0].slice(0, 80)}"`,
954
+ fix: rule.fix || null,
955
+ });
940
956
  }
941
957
  }
942
958
  }
@@ -982,7 +998,7 @@ export function checkNaming(projectRoot = ROOT) {
982
998
  }
983
999
 
984
1000
  export function allChecks(features, graph, ctx) {
985
- const g = graph || buildGraph(process.cwd());
1001
+ const g = graph || getGraph();
986
1002
  const c = ctx || {};
987
1003
  return {
988
1004
  structure: checkStructure(features),
@@ -1116,7 +1132,7 @@ async function main() {
1116
1132
  const { buildContext } = await import("./context.mjs");
1117
1133
  const ctx = await buildContext();
1118
1134
  const features = detectFeaturesOnSrc();
1119
- const graph = buildGraph(ROOT);
1135
+ const graph = ctx.graph || getGraph();
1120
1136
  const results = allChecks(features, graph, ctx);
1121
1137
 
1122
1138
  // Load inline ignores
@@ -1128,13 +1144,13 @@ async function main() {
1128
1144
  }
1129
1145
 
1130
1146
  // Filter out ignored violations
1131
- const filtered = all.filter(c => {
1147
+ let filtered = all.filter(c => {
1132
1148
  if (c.pass) return true;
1133
1149
  if (isIgnored(c, allIgnores)) return false;
1134
1150
  return true;
1135
1151
  });
1136
1152
 
1137
- if (filterSeverity) all = all.filter((c) => c.severity === filterSeverity);
1153
+ if (filterSeverity) filtered = filtered.filter((c) => c.severity === filterSeverity);
1138
1154
 
1139
1155
  if (doFix) {
1140
1156
  const result = applyFixes(filtered);
@@ -1184,6 +1200,11 @@ async function main() {
1184
1200
  console.log(formatCheck(c));
1185
1201
  }
1186
1202
  console.log(`\nTotal: ${filtered.length} checks${ignoredCount > 0 ? ` (${ignoredCount} ignorados inline)` : ""}`);
1203
+
1204
+ if (filtered.some(c => !c.pass)) {
1205
+ const pipeline = buildPipeline(filtered, ctx.graph, ctx.ownership, null, ctx);
1206
+ printPipeline(pipeline);
1207
+ }
1187
1208
  }
1188
1209
  }
1189
1210