@ronaldjdevfs/forge 1.3.0-beta → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/forge/SKILL.md +57 -128
- package/skills/forge/command/forge.md +59 -14
- package/skills/forge/reference/adr.md +242 -0
- package/skills/forge/reference/anti-corruption-layer.md +340 -0
- package/skills/forge/reference/api-design.md +7 -0
- package/skills/forge/reference/api-versioning.md +354 -0
- package/skills/forge/reference/architectural-depth-checklist.md +311 -0
- package/skills/forge/reference/architecture-template.md +41 -0
- package/skills/forge/reference/assay.md +6 -0
- package/skills/forge/reference/bounded-contexts.md +311 -0
- package/skills/forge/reference/chain.md +6 -0
- package/skills/forge/reference/cohesion-checklist.md +256 -0
- package/skills/forge/reference/cqrs.md +286 -0
- package/skills/forge/reference/data-patterns.md +6 -0
- package/skills/forge/reference/di-strategies.md +6 -0
- package/skills/forge/reference/errors.md +5 -0
- package/skills/forge/reference/events.md +8 -0
- package/skills/forge/reference/evolutionary-architecture.md +300 -0
- package/skills/forge/reference/forge.md +7 -0
- package/skills/forge/reference/hooks.md +6 -0
- package/skills/forge/reference/idempotency.md +283 -0
- package/skills/forge/reference/inscribe.md +5 -0
- package/skills/forge/reference/inspect.md +6 -0
- package/skills/forge/reference/modular-monolith.md +252 -0
- package/skills/forge/reference/observability.md +5 -0
- package/skills/forge/reference/quench.md +5 -0
- package/skills/forge/reference/relocate.md +6 -0
- package/skills/forge/reference/sagas.md +359 -0
- package/skills/forge/reference/security-patterns.md +6 -0
- package/skills/forge/reference/smelt.md +6 -0
- package/skills/forge/reference/temper.md +6 -0
- package/skills/forge/reference/testing-patterns.md +6 -0
- package/skills/forge/reference/transactional-outbox.md +311 -0
- package/skills/forge/scripts/architecture.mjs +10 -5
- package/skills/forge/scripts/assay.mjs +2 -2
- package/skills/forge/scripts/chain.mjs +31 -5
- package/skills/forge/scripts/context.mjs +24 -4
- package/skills/forge/scripts/detect.mjs +39 -30
- package/skills/forge/scripts/forge-boot.mjs +108 -0
- package/skills/forge/scripts/forge-config.mjs +182 -3
- package/skills/forge/scripts/forge-state.mjs +1 -1
- package/skills/forge/scripts/forgeSentinel-lib.mjs +2 -2
- package/skills/forge/scripts/forgeSentinel.mjs +2 -2
- package/skills/forge/scripts/forgeSmith.mjs +2 -2
- package/skills/forge/scripts/graph.mjs +65 -9
- package/skills/forge/scripts/hook.mjs +2 -2
- package/skills/forge/scripts/inspect.mjs +56 -48
- package/skills/forge/scripts/parse-imports.mjs +0 -2
- package/skills/forge/scripts/posttool.mjs +211 -17
- package/skills/forge/scripts/recommendation-engine.mjs +125 -0
- package/skills/forge/scripts/rollback.mjs +5 -3
- package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
- package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
- package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
- package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
- package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
- package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
- package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
- package/skills/forge/tests/core.test.mjs +288 -4
- 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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
3
|
+
import { getGraph } from "./graph.mjs";
|
|
4
4
|
|
|
5
|
-
export function buildDependencyGraph(projectRoot) {
|
|
6
|
-
const graph =
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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" ? "
|
|
732
|
-
: v.rule === "R2" ? "
|
|
733
|
-
: v.rule === "R3" ? "
|
|
734
|
-
: v.rule === "R4" ? "
|
|
735
|
-
: v.rule === "R5" ? "
|
|
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 ||
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
|