@ronaldjdevfs/forge 1.2.0 → 1.3.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.
Files changed (74) hide show
  1. package/README.md +36 -21
  2. package/package.json +7 -2
  3. package/skills/forge/SKILL.md +56 -122
  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 +39 -30
  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 +86 -0
  45. package/skills/forge/scripts/forgeSentinel.mjs +184 -0
  46. package/skills/forge/scripts/forgeSmith-admin.mjs +104 -0
  47. package/skills/forge/scripts/forgeSmith.mjs +164 -0
  48. package/skills/forge/scripts/graph.mjs +65 -9
  49. package/skills/forge/scripts/hook.mjs +2 -2
  50. package/skills/forge/scripts/inspect.mjs +56 -48
  51. package/skills/forge/scripts/parse-imports.mjs +0 -2
  52. package/skills/forge/scripts/pin.mjs +10 -3
  53. package/skills/forge/scripts/posttool.mjs +2 -2
  54. package/skills/forge/scripts/recommendation-engine.mjs +125 -0
  55. package/skills/forge/scripts/rollback.mjs +5 -3
  56. package/skills/forge/templates/agents/SKILL.md.template +283 -0
  57. package/skills/forge/templates/agents/agents/hooks.json +18 -0
  58. package/skills/forge/templates/agents/claude/CLAUDE.md +17 -16
  59. package/skills/forge/templates/agents/claude/settings.local.json +18 -0
  60. package/skills/forge/templates/agents/codex/hooks.json +18 -0
  61. package/skills/forge/templates/agents/cursor/.cursorrules +22 -5
  62. package/skills/forge/templates/agents/cursor/hooks.json +11 -0
  63. package/skills/forge/templates/agents/gemini/SKILL.md +13 -0
  64. package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
  65. package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
  66. package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
  67. package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
  68. package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
  69. package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
  70. package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
  71. package/skills/forge/tests/core.test.mjs +284 -0
  72. package/src/agents.mjs +35 -2
  73. package/src/cli.js +112 -39
  74. package/src/wizard.mjs +142 -90
@@ -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");
@@ -728,11 +729,11 @@ export function checkGraph(graph) {
728
729
  label: `[${v.rule}] ${v.description}`,
729
730
  pass: false,
730
731
  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/."
732
+ fix: v.rule === "R1" ? "Feature no debe importar infraestructura directamente. Extraer interfaz al dominio y usar adapter en infra."
733
+ : v.rule === "R2" ? "Platform no debe depender de features. Extraer la interfaz a shared/ o mover la lógica."
734
+ : v.rule === "R3" ? "Shared no debe importar de features. Shared debe ser puro, sin dependencias de negocio."
735
+ : v.rule === "R4" ? "Shared no debe importar infraestructura. Shared debe ser puro, sin dependencias técnicas."
736
+ : v.rule === "R5" ? "Domain no puede importar infraestructura. El dominio debe estar aislado de la infra."
736
737
  : "Revisar la dirección de la dependencia",
737
738
  });
738
739
  score += penalty;
@@ -838,7 +839,7 @@ export function checkPlatform(ctx) {
838
839
  export function checkDependencies(ctx) {
839
840
  const checks = [];
840
841
  let score = 0;
841
- const graph = ctx.graph || buildGraph();
842
+ const graph = ctx.graph || getGraph();
842
843
 
843
844
  if (!graph || graph.nodes.length === 0) {
844
845
  checks.push({ ...severity("No hay nodos en el grafo para analizar dependencias", SEVERITY.WARNING), pass: false });
@@ -917,26 +918,29 @@ export function checkCustomRules(features) {
917
918
  continue;
918
919
  }
919
920
 
921
+ const featureFiles = [];
920
922
  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
- }
923
+ const featDir = join(FEATURES, feat);
924
+ if (isDir(featDir)) featureFiles.push(...findFiles(featDir));
925
+ }
926
+ const targetFiles = featureFiles.length > 0 ? featureFiles : findFiles(SRC);
927
+ for (const f of targetFiles) {
928
+ if (fileRe && !fileRe.test(f)) continue;
929
+ const content = read(f);
930
+ if (!content) continue;
931
+
932
+ contentRe.lastIndex = 0;
933
+ let match;
934
+ while ((match = contentRe.exec(content)) !== null) {
935
+ violations++;
936
+ const line = content.slice(0, match.index).split("\n").length;
937
+ checks.push({
938
+ severity: rule.severity || SEVERITY.ERROR,
939
+ label: `[${rule.id}] ${rule.message || "Violación de regla custom"} en ${relative(ROOT, f)}:${line}`,
940
+ pass: false,
941
+ detail: `Match: "${match[0].slice(0, 80)}"`,
942
+ fix: rule.fix || null,
943
+ });
940
944
  }
941
945
  }
942
946
  }
@@ -982,7 +986,7 @@ export function checkNaming(projectRoot = ROOT) {
982
986
  }
983
987
 
984
988
  export function allChecks(features, graph, ctx) {
985
- const g = graph || buildGraph(process.cwd());
989
+ const g = graph || getGraph();
986
990
  const c = ctx || {};
987
991
  return {
988
992
  structure: checkStructure(features),
@@ -1116,7 +1120,7 @@ async function main() {
1116
1120
  const { buildContext } = await import("./context.mjs");
1117
1121
  const ctx = await buildContext();
1118
1122
  const features = detectFeaturesOnSrc();
1119
- const graph = buildGraph(ROOT);
1123
+ const graph = ctx.graph || getGraph();
1120
1124
  const results = allChecks(features, graph, ctx);
1121
1125
 
1122
1126
  // Load inline ignores
@@ -1128,13 +1132,13 @@ async function main() {
1128
1132
  }
1129
1133
 
1130
1134
  // Filter out ignored violations
1131
- const filtered = all.filter(c => {
1135
+ let filtered = all.filter(c => {
1132
1136
  if (c.pass) return true;
1133
1137
  if (isIgnored(c, allIgnores)) return false;
1134
1138
  return true;
1135
1139
  });
1136
1140
 
1137
- if (filterSeverity) all = all.filter((c) => c.severity === filterSeverity);
1141
+ if (filterSeverity) filtered = filtered.filter((c) => c.severity === filterSeverity);
1138
1142
 
1139
1143
  if (doFix) {
1140
1144
  const result = applyFixes(filtered);
@@ -1184,6 +1188,11 @@ async function main() {
1184
1188
  console.log(formatCheck(c));
1185
1189
  }
1186
1190
  console.log(`\nTotal: ${filtered.length} checks${ignoredCount > 0 ? ` (${ignoredCount} ignorados inline)` : ""}`);
1191
+
1192
+ if (filtered.some(c => !c.pass)) {
1193
+ const pipeline = buildPipeline(filtered, ctx.graph, ctx.ownership, null, ctx);
1194
+ printPipeline(pipeline);
1195
+ }
1187
1196
  }
1188
1197
  }
1189
1198