@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.
Files changed (62) hide show
  1. package/README.md +1 -1
  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 +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 +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/rollback.mjs +5 -3
  54. package/skills/forge/templates/feature/acl-gateway.ts.md +52 -0
  55. package/skills/forge/templates/feature/acl-repository.ts.md +36 -0
  56. package/skills/forge/templates/feature/acl-translator.ts.md +24 -0
  57. package/skills/forge/templates/feature/cqrs-query.ts.md +40 -0
  58. package/skills/forge/templates/feature/outbox-repository.ts.md +36 -0
  59. package/skills/forge/templates/feature/saga-orchestrator.ts.md +72 -0
  60. package/skills/forge/templates/platform/outbox-relayer.ts.md +80 -0
  61. package/skills/forge/tests/core.test.mjs +288 -4
  62. package/src/cli.js +26 -13
@@ -0,0 +1,36 @@
1
+ ```typescript
2
+ // src/features/<domain>/adapters/out/legacy-<system>/<Domain>ACL.ts
3
+ import { injectable, inject } from "tsyringe";
4
+ import type { I<Domain>Repository } from "../../domain/I<Domain>Repository.js";
5
+ import type { <Domain>Entity } from "../../domain/<Domain>Entity.js";
6
+ import { <Domain>Gateway } from "./<Domain>Gateway.js";
7
+ import { <Domain>Translator } from "./<Domain>Translator.js";
8
+
9
+ @injectable()
10
+ export class <Domain>ACLRepository implements I<Domain>Repository {
11
+ constructor(
12
+ @inject(<Domain>Gateway) private readonly gateway: <Domain>Gateway,
13
+ @inject(<Domain>Translator) private readonly translator: <Domain>Translator,
14
+ ) {}
15
+
16
+ async findById(id: string): Promise<<Domain>Entity | null> {
17
+ try {
18
+ const dto = await this.gateway.fetch(id);
19
+ return dto ? this.translator.toDomain(dto) : null;
20
+ } catch (error) {
21
+ if (error.message.includes("404")) return null;
22
+ throw new Error("<Domain>Repository.fetchFailed");
23
+ }
24
+ }
25
+
26
+ async save(entity: <Domain>Entity): Promise<<Domain>Entity> {
27
+ const dto = this.translator.toExternal(entity);
28
+ const result = await this.gateway.upsert(dto);
29
+ return this.translator.toDomain(result);
30
+ }
31
+
32
+ async delete(id: string): Promise<void> {
33
+ await this.gateway.remove(id);
34
+ }
35
+ }
36
+ ```
@@ -0,0 +1,24 @@
1
+ ```typescript
2
+ // src/features/<domain>/adapters/out/legacy-<system>/<Domain>Translator.ts
3
+ import type { <Domain>Entity } from "../../../domain/<Domain>Entity.js";
4
+ import type { External<Domain>DTO } from "./External<Domain>DTO.js";
5
+
6
+ export class <Domain>Translator {
7
+ toDomain(dto: External<Domain>DTO): <Domain>Entity {
8
+ return {
9
+ id: dto.externalId,
10
+ // mapear campos del DTO externo al modelo de dominio
11
+ // name: dto.fullName,
12
+ // email: dto.emailAddress,
13
+ // status: dto.isActive ? "active" : "inactive",
14
+ } as <Domain>Entity;
15
+ }
16
+
17
+ toExternal(entity: <Domain>Entity): External<Domain>DTO {
18
+ return {
19
+ externalId: entity.id,
20
+ // mapear campos del dominio al DTO externo
21
+ } as External<Domain>DTO;
22
+ }
23
+ }
24
+ ```
@@ -0,0 +1,40 @@
1
+ ```typescript
2
+ // src/features/<domain>/application/queries/Get<Domain>Query.ts
3
+ import { injectable, inject } from "tsyringe";
4
+ import type { I<Domain>ReadRepository } from "../../adapters/out/read/I<Domain>ReadRepository.js";
5
+
6
+ export type <Domain>QueryResult = {
7
+ // DTO plano desnormalizado para lectura
8
+ id: string;
9
+ // campos optimizados para consulta
10
+ };
11
+
12
+ @injectable()
13
+ export class Get<Domain>Query {
14
+ constructor(
15
+ @inject(I<Domain>ReadRepository)
16
+ private readonly readRepo: I<Domain>ReadRepository,
17
+ ) {}
18
+
19
+ async execute(id: string): Promise<<Domain>QueryResult | null> {
20
+ return this.readRepo.findById(id);
21
+ }
22
+ }
23
+
24
+ // Query con filtros
25
+ export class List<Domain>Query {
26
+ constructor(
27
+ @inject(I<Domain>ReadRepository)
28
+ private readonly readRepo: I<Domain>ReadRepository,
29
+ ) {}
30
+
31
+ async execute(filters: {
32
+ page?: number;
33
+ limit?: number;
34
+ sort?: string;
35
+ filter?: Record<string, string>;
36
+ }): Promise<{ data: <Domain>QueryResult[]; total: number }> {
37
+ return this.readRepo.findMany(filters);
38
+ }
39
+ }
40
+ ```
@@ -0,0 +1,36 @@
1
+ ```typescript
2
+ // src/features/<domain>/adapters/out/persistence/<Domain>OutboxRepository.ts
3
+ import { injectable } from "tsyringe";
4
+ import type { DomainEvent } from "../../../domain/events/DomainEvent.js";
5
+
6
+ export type OutboxEntry = {
7
+ id: string;
8
+ eventType: string;
9
+ aggregateId: string;
10
+ aggregateType: string;
11
+ payload: Record<string, unknown>;
12
+ occurredAt: Date;
13
+ processedAt: Date | null;
14
+ retryCount: number;
15
+ lastError: string | null;
16
+ };
17
+
18
+ @injectable()
19
+ export class <Domain>OutboxRepository {
20
+ async add(event: DomainEvent): Promise<void> {
21
+ // Implementar: INSERT en tabla outbox dentro de la transacción actual
22
+ // const entry: OutboxEntry = {
23
+ // id: crypto.randomUUID(),
24
+ // eventType: event.constructor.name,
25
+ // aggregateId: event.aggregateId,
26
+ // aggregateType: "<Domain>",
27
+ // payload: JSON.parse(JSON.stringify(event)),
28
+ // occurredAt: new Date(),
29
+ // processedAt: null,
30
+ // retryCount: 0,
31
+ // lastError: null,
32
+ // };
33
+ // await this.db.outbox.create({ data: entry });
34
+ }
35
+ }
36
+ ```
@@ -0,0 +1,72 @@
1
+ ```typescript
2
+ // src/features/<domain>/adapters/out/saga/SagaOrchestrator.ts
3
+ import { injectable, inject } from "tsyringe";
4
+ import type { SagaInstance, SagaStatus } from "../../domain/SagaInstance.entity.js";
5
+ import type { ISagaRepository } from "../../domain/ISagaRepository.js";
6
+ import type { IEventBus } from "@/platform/events/IEventBus.js";
7
+ import type { ILogger } from "@/platform/logger/ILogger.js";
8
+
9
+ export interface SagaStep {
10
+ name: string;
11
+ execute(ctx: Record<string, unknown>): Promise<void>;
12
+ compensate(ctx: Record<string, unknown>): Promise<void>;
13
+ }
14
+
15
+ @injectable()
16
+ export class SagaOrchestrator {
17
+ constructor(
18
+ @inject(ISagaRepository) private readonly sagaRepo: ISagaRepository,
19
+ @inject(IEventBus) private readonly eventBus: IEventBus,
20
+ @inject(ILogger) private readonly logger: ILogger,
21
+ ) {}
22
+
23
+ async execute(
24
+ sagaType: string,
25
+ steps: SagaStep[],
26
+ initialContext: Record<string, unknown>,
27
+ ): Promise<string> {
28
+ const saga = SagaInstance.start(sagaType, initialContext);
29
+ await this.sagaRepo.save(saga);
30
+
31
+ for (const step of steps) {
32
+ try {
33
+ this.logger.info(`Saga ${saga.id}: executing step ${step.name}`);
34
+ await step.execute(saga.context);
35
+ saga.advance(step.name);
36
+ await this.sagaRepo.save(saga);
37
+ } catch (error) {
38
+ this.logger.error(
39
+ `Saga ${saga.id}: step ${step.name} failed — ${error.message}`,
40
+ );
41
+ await this.compensate(saga, steps);
42
+ return saga.id;
43
+ }
44
+ }
45
+
46
+ saga.complete();
47
+ await this.sagaRepo.save(saga);
48
+ this.eventBus.publish({ type: "saga.completed", sagaId: saga.id });
49
+ return saga.id;
50
+ }
51
+
52
+ private async compensate(
53
+ saga: SagaInstance,
54
+ steps: SagaStep[],
55
+ ): Promise<void> {
56
+ const executed = steps.slice(0, saga.currentStepIndex);
57
+ for (const step of executed.reverse()) {
58
+ try {
59
+ this.logger.info(`Saga ${saga.id}: compensating step ${step.name}`);
60
+ await step.compensate(saga.context);
61
+ } catch (err) {
62
+ this.logger.error(
63
+ `Saga ${saga.id}: compensation failed for ${step.name} — requiere intervención manual`,
64
+ );
65
+ saga.requireManualIntervention(step.name, err.message);
66
+ }
67
+ }
68
+ saga.fail();
69
+ await this.sagaRepo.save(saga);
70
+ }
71
+ }
72
+ ```
@@ -0,0 +1,80 @@
1
+ ```typescript
2
+ // src/platform/scheduler/OutboxRelayer.ts
3
+ import { injectable, inject } from "tsyringe";
4
+ import type { IEventBus } from "@/platform/events/IEventBus.js";
5
+ import type { ILogger } from "@/platform/logger/ILogger.js";
6
+
7
+ type OutboxEntry = {
8
+ id: string;
9
+ eventType: string;
10
+ payload: Record<string, unknown>;
11
+ retryCount: number;
12
+ };
13
+
14
+ @injectable()
15
+ export class OutboxRelayer {
16
+ private readonly POLL_INTERVAL_MS = 1000;
17
+ private readonly MAX_RETRIES = 10;
18
+ private readonly BATCH_SIZE = 50;
19
+ private running = false;
20
+
21
+ constructor(
22
+ @inject(IEventBus) private readonly eventBus: IEventBus,
23
+ @inject(ILogger) private readonly logger: ILogger,
24
+ ) {}
25
+
26
+ async start(): Promise<void> {
27
+ this.running = true;
28
+ this.logger.info("OutboxRelayer started");
29
+ while (this.running) {
30
+ await this.processBatch();
31
+ await this.sleep(this.POLL_INTERVAL_MS);
32
+ }
33
+ }
34
+
35
+ stop(): void {
36
+ this.running = false;
37
+ this.logger.info("OutboxRelayer stopped");
38
+ }
39
+
40
+ private async processBatch(): Promise<void> {
41
+ const batch = await this.fetchUnprocessed();
42
+ for (const entry of batch) {
43
+ try {
44
+ await this.eventBus.publish(entry.payload);
45
+ await this.markProcessed(entry.id);
46
+ } catch (error) {
47
+ this.logger.error(`Outbox ${entry.id}: publish failed — ${error.message}`);
48
+ await this.incrementRetry(entry.id, error.message);
49
+ if (entry.retryCount >= this.MAX_RETRIES) {
50
+ await this.sendToDLQ(entry);
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ private async fetchUnprocessed(): Promise<OutboxEntry[]> {
57
+ // SELECT * FROM outbox WHERE processed_at IS NULL
58
+ // AND retry_count < $1 ORDER BY occurred_at ASC LIMIT $2
59
+ return [];
60
+ }
61
+
62
+ private async markProcessed(id: string): Promise<void> {
63
+ // UPDATE outbox SET processed_at = NOW() WHERE id = $1
64
+ }
65
+
66
+ private async incrementRetry(id: string, error: string): Promise<void> {
67
+ // UPDATE outbox SET retry_count = retry_count + 1, last_error = $1 WHERE id = $2
68
+ }
69
+
70
+ private async sendToDLQ(entry: OutboxEntry): Promise<void> {
71
+ this.logger.error(`Outbox ${entry.id}: moved to DLQ after ${this.MAX_RETRIES} retries`);
72
+ // INSERT INTO outbox_dlq SELECT * FROM outbox WHERE id = $1
73
+ // DELETE FROM outbox WHERE id = $1
74
+ }
75
+
76
+ private sleep(ms: number): Promise<void> {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+ }
80
+ ```
@@ -125,6 +125,9 @@ describe("profile.mjs", () => {
125
125
  });
126
126
 
127
127
  describe("graph.mjs", () => {
128
+ before(() => scaffoldProject({}));
129
+ after(() => cleanup());
130
+
128
131
  it("builds an empty graph for an empty project", async () => {
129
132
  const { buildGraph } = await import("../scripts/graph.mjs");
130
133
  const graph = buildGraph(process.cwd());
@@ -138,6 +141,9 @@ describe("graph.mjs", () => {
138
141
  });
139
142
 
140
143
  describe("armorer.mjs", () => {
144
+ before(() => scaffoldProject({}));
145
+ after(() => cleanup());
146
+
141
147
  it("builds a healthy report for an empty project", async () => {
142
148
  const { buildOwnershipReport } = await import("../scripts/armorer.mjs");
143
149
  const report = buildOwnershipReport(process.cwd());
@@ -180,6 +186,9 @@ describe("forge-config.mjs", () => {
180
186
  });
181
187
 
182
188
  describe("chain.mjs", () => {
189
+ before(() => scaffoldProject({}));
190
+ after(() => cleanup());
191
+
183
192
  it("builds an empty dependency graph for empty project", async () => {
184
193
  const { buildDependencyGraph } = await import("../scripts/chain.mjs");
185
194
  const graph = buildDependencyGraph();
@@ -318,10 +327,13 @@ describe("detect.mjs — inline ignores", () => {
318
327
  });
319
328
  });
320
329
 
321
- describe("forgeSentinel.mjs", () => {
322
- it("runSentinelCheck returns empty for no files", async () => {
323
- const { runSentinelCheck } = await import("../scripts/forgeSentinel-lib.mjs");
324
- const result = await runSentinelCheck([], {});
330
+ describe("posttool.mjs", () => {
331
+ before(() => scaffoldProject({}));
332
+ after(() => cleanup());
333
+
334
+ it("postToolCheck returns empty for no files", async () => {
335
+ const { postToolCheck } = await import("../scripts/posttool.mjs");
336
+ const result = await postToolCheck([], {});
325
337
  assert.equal(result.total, 0);
326
338
  assert.ok(result.summary);
327
339
  });
@@ -401,3 +413,275 @@ describe("assay.mjs", () => {
401
413
  assert.ok(opinion.includes("R1") || opinion.includes("R8") || opinion.includes("acoplamiento") || opinion.includes("importan"));
402
414
  });
403
415
  });
416
+
417
+ describe("transactional-outbox pattern", () => {
418
+ it("outbox entry tracks lifecycle states", () => {
419
+ const entry = { id: "1", eventType: "OrderPlaced", processedAt: null, retryCount: 0, lastError: null };
420
+ assert.equal(entry.processedAt, null);
421
+ assert.equal(entry.retryCount, 0);
422
+
423
+ entry.processedAt = new Date();
424
+ entry.retryCount = 2;
425
+ entry.lastError = "timeout";
426
+ assert.ok(entry.processedAt instanceof Date);
427
+ assert.equal(entry.retryCount, 2);
428
+ });
429
+
430
+ it("retry policy respects max retries and backoff", () => {
431
+ const maxRetries = 3;
432
+ const baseDelay = 100;
433
+ let attempts = 0;
434
+
435
+ async function executeWithRetry(fn) {
436
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
437
+ try {
438
+ return await fn();
439
+ } catch (error) {
440
+ if (attempt === maxRetries) throw error;
441
+ // exponential backoff: baseDelay * 2^(attempt-1)
442
+ const delay = baseDelay * Math.pow(2, attempt - 1);
443
+ // not actually waiting in test
444
+ void delay;
445
+ }
446
+ }
447
+ }
448
+
449
+ assert.rejects(async () => {
450
+ await executeWithRetry(() => Promise.reject(new Error("fail")));
451
+ }, /fail/);
452
+ });
453
+
454
+ it("outbox relayer moves event to DLQ after exceeding retries", () => {
455
+ const MAX_RETRIES = 10;
456
+ const entry = { id: "bad-event", retryCount: 9 };
457
+ assert.ok(entry.retryCount < MAX_RETRIES);
458
+
459
+ entry.retryCount++;
460
+ assert.equal(entry.retryCount, 10);
461
+
462
+ const shouldGoToDLQ = entry.retryCount >= MAX_RETRIES;
463
+ assert.ok(shouldGoToDLQ);
464
+ });
465
+
466
+ it("outbox entry requires event_id, aggregate_id, and payload", () => {
467
+ const validEntry = {
468
+ id: crypto.randomUUID(),
469
+ eventType: "OrderPlaced",
470
+ aggregateId: "order-123",
471
+ aggregateType: "Order",
472
+ payload: { total: 100 },
473
+ occurredAt: new Date(),
474
+ processedAt: null,
475
+ retryCount: 0,
476
+ lastError: null,
477
+ };
478
+ assert.ok(validEntry.id);
479
+ assert.ok(validEntry.aggregateId);
480
+ assert.ok(validEntry.payload);
481
+ assert.equal(validEntry.eventType, "OrderPlaced");
482
+ });
483
+
484
+ it("processed outbox entries are no longer picked up by relayer", () => {
485
+ const unprocessed = { processedAt: null };
486
+ const processed = { processedAt: new Date() };
487
+
488
+ const isPending = (entry) => entry.processedAt === null;
489
+ assert.ok(isPending(unprocessed));
490
+ assert.ok(!isPending(processed));
491
+ });
492
+ });
493
+
494
+ describe("idempotency pattern", () => {
495
+ it("idempotency key must be a valid UUID", () => {
496
+ function isValidUUID(key) {
497
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
498
+ }
499
+
500
+ assert.ok(isValidUUID("550e8400-e29b-41d4-a716-446655440000"));
501
+ assert.ok(!isValidUUID("not-a-uuid"));
502
+ assert.ok(!isValidUUID(""));
503
+ });
504
+
505
+ it("same idempotency key returns cached response", async () => {
506
+ const cache = new Map();
507
+
508
+ async function handleRequest(key, handler) {
509
+ if (cache.has(key)) return cache.get(key);
510
+ const result = await handler();
511
+ cache.set(key, result);
512
+ return result;
513
+ }
514
+
515
+ let callCount = 0;
516
+ async function processPayment() {
517
+ callCount++;
518
+ return { status: "success", transactionId: "txn-1" };
519
+ }
520
+
521
+ const key = "550e8400-e29b-41d4-a716-446655440000";
522
+ const result1 = await handleRequest(key, processPayment);
523
+ const result2 = await handleRequest(key, processPayment);
524
+
525
+ assert.equal(result1.status, "success");
526
+ assert.equal(result2.status, "success");
527
+ assert.equal(callCount, 1); // solo se ejecutó una vez
528
+ });
529
+
530
+ it("different idempotency keys create separate resources", async () => {
531
+ const store = new Map();
532
+
533
+ async function createResource(key, data) {
534
+ if (store.has(key)) return store.get(key);
535
+ const resource = { id: crypto.randomUUID(), ...data };
536
+ store.set(key, resource);
537
+ return resource;
538
+ }
539
+
540
+ const r1 = await createResource("key-1", { name: "A" });
541
+ const r2 = await createResource("key-2", { name: "B" });
542
+
543
+ assert.notEqual(r1.id, r2.id);
544
+ });
545
+
546
+ it("idempotency key has TTL and expires", () => {
547
+ const entry = { key: "test", createdAt: new Date() };
548
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24 horas
549
+
550
+ function isExpired(entry) {
551
+ return Date.now() - entry.createdAt.getTime() > TTL_MS;
552
+ }
553
+
554
+ assert.ok(!isExpired(entry)); // creado ahora, no ha expirado
555
+
556
+ const oldEntry = { key: "old", createdAt: new Date(Date.now() - TTL_MS - 1) };
557
+ assert.ok(isExpired(oldEntry));
558
+ });
559
+
560
+ it("only POST, PATCH, PUT need idempotency keys", () => {
561
+ const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
562
+ const needsKey = (method) => ["POST", "PATCH", "PUT"].includes(method);
563
+
564
+ assert.ok(!needsKey("GET"));
565
+ assert.ok(needsKey("POST"));
566
+ assert.ok(needsKey("PUT"));
567
+ assert.ok(needsKey("PATCH"));
568
+ assert.ok(!needsKey("DELETE"));
569
+ });
570
+ });
571
+
572
+ describe("anti-corruption-layer pattern", () => {
573
+ it("translator maps external DTO to domain entity", () => {
574
+ class CustomerTranslator {
575
+ toDomain(dto) {
576
+ return {
577
+ id: dto.customerId,
578
+ name: `${dto.firstName} ${dto.lastName}`,
579
+ email: dto.emailAddress,
580
+ status: dto.active ? "active" : "inactive",
581
+ };
582
+ }
583
+ }
584
+
585
+ const externalDTO = {
586
+ customerId: "ext-123",
587
+ firstName: "John",
588
+ lastName: "Doe",
589
+ emailAddress: "john@example.com",
590
+ active: true,
591
+ };
592
+
593
+ const translator = new CustomerTranslator();
594
+ const entity = translator.toDomain(externalDTO);
595
+
596
+ assert.equal(entity.id, "ext-123");
597
+ assert.equal(entity.name, "John Doe");
598
+ assert.equal(entity.email, "john@example.com");
599
+ assert.equal(entity.status, "active");
600
+ });
601
+
602
+ it("translator maps domain entity back to external DTO", () => {
603
+ class CustomerTranslator {
604
+ toExternal(entity) {
605
+ return {
606
+ customerId: entity.id,
607
+ firstName: entity.name.split(" ")[0],
608
+ lastName: entity.name.split(" ").slice(1).join(" "),
609
+ emailAddress: entity.email,
610
+ active: entity.status === "active",
611
+ };
612
+ }
613
+ }
614
+
615
+ const entity = {
616
+ id: "dom-456",
617
+ name: "Jane Smith",
618
+ email: "jane@example.com",
619
+ status: "active",
620
+ };
621
+
622
+ const translator = new CustomerTranslator();
623
+ const dto = translator.toExternal(entity);
624
+
625
+ assert.equal(dto.customerId, "dom-456");
626
+ assert.equal(dto.firstName, "Jane");
627
+ assert.equal(dto.lastName, "Smith");
628
+ assert.equal(dto.active, true);
629
+ });
630
+
631
+ it("translator handles null/missing values gracefully", () => {
632
+ class CustomerTranslator {
633
+ toDomain(dto) {
634
+ return {
635
+ id: dto.customerId ?? "unknown",
636
+ name: dto.firstName ? `${dto.firstName} ${dto.lastName ?? ""}`.trim() : "Unknown",
637
+ email: dto.emailAddress ?? "",
638
+ status: dto.active === true ? "active" : "inactive",
639
+ };
640
+ }
641
+ }
642
+
643
+ const emptyDTO = { customerId: null, firstName: null, lastName: null, emailAddress: null, active: null };
644
+ const translator = new CustomerTranslator();
645
+ const entity = translator.toDomain(emptyDTO);
646
+
647
+ assert.equal(entity.id, "unknown");
648
+ assert.equal(entity.name, "Unknown");
649
+ assert.equal(entity.status, "inactive");
650
+ });
651
+
652
+ it("gateway enforces timeout and returns null on 404", () => {
653
+ async function fetchCustomer(id) {
654
+ const response = { status: 404, ok: false, statusText: "Not Found" };
655
+ if (response.status === 404) return null;
656
+ if (!response.ok) throw new Error(response.statusText);
657
+ return { id };
658
+ }
659
+
660
+ assert.doesNotReject(async () => {
661
+ const result = await fetchCustomer("nonexistent");
662
+ assert.equal(result, null);
663
+ });
664
+ });
665
+
666
+ it("ACL delegates to translator and gateway in correct order", async () => {
667
+ let order = [];
668
+
669
+ const gateway = {
670
+ async fetch(id) { order.push("gateway"); return { customerId: id, firstName: "A", lastName: "B", emailAddress: "a@b.com", active: true }; },
671
+ };
672
+
673
+ const translator = {
674
+ toDomain(dto) { order.push("translator"); return { id: dto.customerId, name: `${dto.firstName} ${dto.lastName}`, email: dto.emailAddress, status: "active" }; },
675
+ };
676
+
677
+ async function findById(id) {
678
+ const dto = await gateway.fetch(id);
679
+ return dto ? translator.toDomain(dto) : null;
680
+ }
681
+
682
+ const result = await findById("123");
683
+ assert.deepEqual(order, ["gateway", "translator"]);
684
+ assert.equal(result.id, "123");
685
+ assert.equal(result.name, "A B");
686
+ });
687
+ });
package/src/cli.js CHANGED
@@ -86,16 +86,20 @@ function ensureDependencies(configDir) {
86
86
  }
87
87
 
88
88
  const COMMANDS = [
89
- { name: "forge-forge", desc: "Forge — inicializar proyecto arquitectónicamente" },
90
- { name: "forge-cast", desc: "Cast — crear un nuevo feature hexagonal desde cero" },
91
- { name: "forge-inspect", desc: "Inspect — inspeccionar la conformidad arquitectónica" },
92
- { name: "forge-relocate", desc: "Relocatemigrar feature legacy a la nueva estructura" },
93
- { name: "forge-reforge", desc: "Reforgerefactorizar la arquitectura de un feature" },
94
- { name: "forge-quench", desc: "Quenchverificar reglas arquitectónicas del proyecto" },
95
- { name: "forge-temper", desc: "Temperendurecer la arquitectura (DI, seguridad)" },
96
- { name: "forge-chain", desc: "Chainanalizar cadena de dependencias entre features" },
97
- { name: "forge-inscribe", desc: "Inscribegenerar y mantener ARCHITECTURE.md" },
98
- { name: "forge-smelt", desc: "Smeltextraer código reutilizable a shared/" },
89
+ { name: "forge-forge", desc: "Forge — inicializar proyecto arquitectónicamente", flags: "" },
90
+ { name: "forge-cast", desc: "Cast — crear un nuevo feature hexagonal desde cero", flags: "" },
91
+ { name: "forge-inspect", desc: "Inspect — inspeccionar la conformidad arquitectónica", flags: "--json | --diff | --full | --summary | --severity=<nivel> | --force" },
92
+ { name: "forge-assay", desc: "Assayensayo arquitectónico multi-persona", flags: "--persona=<id> | --json | --save | history" },
93
+ { name: "forge-graph", desc: "Graphconstruir el grafo arquitectónico del proyecto", flags: "--json" },
94
+ { name: "forge-armorer", desc: "Armorerreporte de ownership, huérfanos y duplicados", flags: "" },
95
+ { name: "forge-bootstrap", desc: "Bootstrapinicializar platform, shared e infra layers", flags: "" },
96
+ { name: "forge-relocate", desc: "Relocatemigrar feature legacy a la nueva estructura", flags: "" },
97
+ { name: "forge-reforge", desc: "Reforgerefactorizar la arquitectura de un feature", flags: "--cycles" },
98
+ { name: "forge-quench", desc: "Quenchverificar reglas arquitectónicas del proyecto", flags: "--fix | --show-ignores | --severity=<nivel> | --json" },
99
+ { name: "forge-temper", desc: "Temper — endurecer la arquitectura (DI, seguridad)", flags: "" },
100
+ { name: "forge-chain", desc: "Chain — analizar cadena de dependencias entre features", flags: "--json" },
101
+ { name: "forge-inscribe", desc: "Inscribe — generar y mantener ARCHITECTURE.md", flags: "--output=<path>" },
102
+ { name: "forge-smelt", desc: "Smelt — extraer código reutilizable a shared/", flags: "" },
99
103
  ];
100
104
 
101
105
  function generateCommands(configDir) {
@@ -103,7 +107,8 @@ function generateCommands(configDir) {
103
107
  mkdirSync(cmdsDir, { recursive: true });
104
108
  for (const cmd of COMMANDS) {
105
109
  const sub = cmd.name.replace("forge-", "");
106
- const content = [
110
+ const hasFlags = cmd.flags && cmd.flags.length > 0;
111
+ const lines = [
107
112
  "---",
108
113
  `description: ${cmd.desc}`,
109
114
  "agent: build",
@@ -111,8 +116,16 @@ function generateCommands(configDir) {
111
116
  "",
112
117
  `Ejecuta el subcomando ${sub} de Forge con los argumentos: $ARGUMENTS`,
113
118
  "",
114
- ].join("\n");
115
- writeFileSync(join(cmdsDir, `${cmd.name}.md`), content);
119
+ ];
120
+ if (hasFlags) {
121
+ lines.push(
122
+ `Flags disponibles: ${cmd.flags}`,
123
+ "",
124
+ `Si $ARGUMENTS está vacío, pregunta al usuario qué flags quiere usar con la tool question (checkboxes múltiples). Si no selecciona ninguna, ejecuta sin flags.`,
125
+ "",
126
+ );
127
+ }
128
+ writeFileSync(join(cmdsDir, `${cmd.name}.md`), lines.join("\n"));
116
129
  }
117
130
  }
118
131