@ronaldjdevfs/forge 1.3.1 → 1.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <img src="favicon.svg" alt="Forge Logo" width="100" height="100">
2
2
 
3
- > **v1.3.1** — Bugfixes, New Templates & Interactive Flags
3
+ > **v1.3.2** — Domain Subdirectory Structure & Port Support
4
4
 
5
5
  ## Forge — Backend Architecture Operating System
6
6
 
@@ -58,8 +58,10 @@ Crea un nuevo feature (vertical slice) con estructura hexagonal completa. Antes
58
58
  ```
59
59
  src/features/<name>/
60
60
  ├── domain/
61
- │ ├── <Domain>.entity.ts
62
- └── I<Domain>.repository.ts
61
+ │ ├── entities/ (<Domain>.entity.ts)
62
+ ├── repositories/ (I<Domain>.repository.ts)
63
+ │ ├── errors/ (<Domain>NotFound.error.ts)
64
+ │ └── events/ (<Domain>Created.event.ts)
63
65
  ├── application/
64
66
  │ ├── use-cases/ (<Action>.uc.ts)
65
67
  │ └── mappers/ (<Domain>.mapper.ts)
@@ -262,6 +264,10 @@ src/
262
264
  ├── features/ ← Capacidades de negocio
263
265
  │ └── <name>/
264
266
  │ ├── domain/
267
+ │ │ ├── entities/ (<Domain>.entity.ts)
268
+ │ │ ├── repositories/ (I<Domain>.repository.ts)
269
+ │ │ ├── errors/ (<Domain>NotFound.error.ts)
270
+ │ │ └── events/ (<Domain>Created.event.ts)
265
271
  │ ├── application/
266
272
  │ └── adapters/
267
273
 
@@ -291,6 +297,7 @@ src/
291
297
  | Directorios | `kebab-case/` | `credit-card/`, `event-bus/` |
292
298
  | Archivos | `<PascalCase>.<artefacto>.ts` | `User.entity.ts` |
293
299
  | Interfaces | `I<PascalCase>.<artefacto>.ts` | `IUser.repository.ts` |
300
+ | Puertos (domain) | `<Name>.port.ts` | `PaymentPort.port.ts` |
294
301
  | Use cases | `<Action>.uc.ts` | `CreateUser.uc.ts` |
295
302
  | Clases | `PascalCase` | `UserController` |
296
303
  | Funciones | `camelCase` | `formatDate` |
@@ -378,7 +385,7 @@ Donde vive toda la inteligencia arquitectónica:
378
385
  | `reference/assay.md` | Documentación del comando assay |
379
386
  | `reference/hooks.md` | Documentación del sistema de hooks |
380
387
  | `profiles/` | 10 perfiles tecnológicos detallados |
381
- | `templates/feature/` | 11 templates TypeScript para features |
388
+ | `templates/feature/` | 17 templates TypeScript para features |
382
389
  | `templates/platform/` | 6 templates para componentes de platform |
383
390
  | `templates/shared/` | 4 templates para shared (errors, contracts, types, utils) |
384
391
  | `templates/infra/` | 4 templates para infra (prisma, mongodb, redis, mail) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronaldjdevfs/forge",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Forge — Architecture Operating System for backend systems. Arquitectura Hexagonal, DDD pragmático y vertical slices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -232,17 +232,29 @@ export function checkStructure(features) {
232
232
 
233
233
  if (hasSubdir(fDir, "domain")) {
234
234
  const dDir = join(fDir, "domain");
235
- if (hasFile(dDir, /\.entity\.ts$/)) {
236
- 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 });
237
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;
238
245
  } else {
239
- 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` });
240
247
  }
241
- if (hasFile(dDir, /^I[A-Z]/)) {
242
- 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 });
243
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;
244
256
  } else {
245
- 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` });
246
258
  }
247
259
  } else {
248
260
  checks.push({ ...severity(`${feat}: falta domain/`, SEVERITY.ERROR), pass: false, fix: `Crear ${feat}/domain/` });
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "fs";
4
4
  import { join, relative, dirname, basename, extname, parse } from "path";
5
5
 
6
6
  const ROOT = process.cwd();
@@ -40,6 +40,13 @@ function toPascalCase(str) {
40
40
  .join("");
41
41
  }
42
42
 
43
+ function toPascalCaseI(str) {
44
+ if (/^I[A-Za-z]/.test(str)) {
45
+ return "I" + toPascalCase(str.slice(1));
46
+ }
47
+ return toPascalCase(str);
48
+ }
49
+
43
50
  function toCamelCase(str) {
44
51
  const pascal = toPascalCase(str);
45
52
  return pascal.charAt(0).toLowerCase() + pascal.slice(1);
@@ -66,7 +73,10 @@ const PLATFORM_RULES = {
66
73
  };
67
74
 
68
75
  const FEATURE_SUBDIR_RULES = [
76
+ { subdir: "domain/entities", pattern: "entity", case: "pascal", description: "<Name>.entity.ts" },
77
+ { subdir: "domain/repositories", pattern: "repository", prefix: "I", case: "pascal", description: "I<Name>.repository.ts" },
69
78
  { subdir: "domain", pattern: "entity", case: "pascal", description: "<Name>.entity.ts" },
79
+ { subdir: "domain", pattern: "port", case: "pascal", description: "<Name>.port.ts" },
70
80
  { subdir: "domain", pattern: "repository", prefix: "I", case: "pascal", description: "I<Name>.repository.ts" },
71
81
  { subdir: "application/use-cases", pattern: "uc", case: "pascal", description: "<Action>.uc.ts" },
72
82
  { subdir: "application/mappers", pattern: "mapper", case: "pascal", description: "<Name>.mapper.ts" },
@@ -178,37 +188,73 @@ export function computeExpectedName(filePath) {
178
188
  if (featureSubpath === rule.subdir || featureSubpath.startsWith(rule.subdir + "/")) {
179
189
  const stemLower = stem.toLowerCase();
180
190
 
181
- // For domain/entity: <Entity>.entity.ts
182
- // Entity name derived from feature dir or current filename
183
- if (rule.subdir === "domain" && rule.pattern === "entity") {
191
+ // For domain/entities/: <Name>.entity.ts
192
+ if (rule.subdir === "domain/entities") {
184
193
  let inferredEntity;
185
194
  if (hasSuffix(stem, "entity")) {
186
195
  inferredEntity = stem.replace(/\.entity$/i, "");
187
196
  } else {
188
- const noEntity = stem.replace(/[Ee]ntity$/, "");
189
- inferredEntity = noEntity || featureName;
197
+ inferredEntity = stem;
190
198
  }
191
- const entityStem = toPascalCase(inferredEntity);
199
+ const entityStem = toPascalCaseI(inferredEntity);
192
200
  const expectedName = `${entityStem}.entity${ext}`;
193
201
  if (expectedName !== filename) {
194
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: ${rule.description}` };
202
+ return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain/entities: ${rule.description}` };
195
203
  }
196
204
  }
197
205
 
198
- // For domain/repository interface: I<Entity>.repository.ts
199
- if (rule.subdir === "domain" && rule.pattern === "repository") {
206
+ // For domain/repositories/: I<Name>.repository.ts
207
+ if (rule.subdir === "domain/repositories") {
200
208
  const expectedStem = `I${entityName}.repository`;
201
209
  const expectedName = `${expectedStem}${ext}`;
202
210
  const currentMatches = stem === expectedStem;
203
211
  if (!currentMatches) {
212
+ return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain/repositories: ${rule.description}` };
213
+ }
214
+ }
215
+
216
+ // For flat domain/entity: migrates to domain/entities/<Name>.entity.ts
217
+ if (rule.subdir === "domain" && rule.pattern === "entity" && featureSubpath === "domain") {
218
+ let inferredEntity;
219
+ if (hasSuffix(stem, "entity")) {
220
+ inferredEntity = stem.replace(/\.entity$/i, "");
221
+ } else {
222
+ const noEntity = stem.replace(/[Ee]ntity$/, "");
223
+ inferredEntity = noEntity || featureName;
224
+ }
225
+ const entityStem = toPascalCaseI(inferredEntity);
226
+ const expectedName = `${entityStem}.entity${ext}`;
227
+ const expectedPath = join(dirname(filePath), "entities", expectedName);
228
+ return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "entities", expectedName), rule: `feature/${featureName}/domain → domain/entities: ${rule.description}` };
229
+ }
230
+
231
+ // For domain/port: <Name>.port.ts
232
+ if (rule.subdir === "domain" && rule.pattern === "port") {
233
+ let inferredPort;
234
+ if (hasSuffix(stem, "port")) {
235
+ inferredPort = stem.replace(/\.port$/i, "");
236
+ } else {
237
+ inferredPort = stem;
238
+ }
239
+ const portStem = toPascalCaseI(inferredPort);
240
+ const expectedName = `${portStem}.port${ext}`;
241
+ if (expectedName !== filename) {
204
242
  return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: ${rule.description}` };
205
243
  }
206
- return null;
244
+ }
245
+
246
+ // For flat domain/repository: migrates to domain/repositories/I<Entity>.repository.ts
247
+ if (rule.subdir === "domain" && rule.pattern === "repository" && featureSubpath === "domain") {
248
+ const expectedStem = `I${entityName}.repository`;
249
+ const expectedName = `${expectedStem}${ext}`;
250
+ const expectedPath = join(dirname(filePath), "repositories", expectedName);
251
+ return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "repositories", expectedName), rule: `feature/${featureName}/domain → domain/repositories: ${rule.description}` };
207
252
  }
208
253
 
209
254
  // For use-cases: <Action>.uc.ts
210
255
  if (rule.subdir === "application/use-cases") {
211
- const expectedName = `${toPascalCase(stem)}.uc${ext}`;
256
+ const cleanStem = hasSuffix(stem, "uc") ? stem.replace(/\.uc$/i, "") : stem;
257
+ const expectedName = `${toPascalCase(cleanStem)}.uc${ext}`;
212
258
  if (expectedName !== filename) {
213
259
  return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/application/use-cases: ${rule.description}` };
214
260
  }
@@ -237,15 +283,21 @@ export function computeExpectedName(filePath) {
237
283
  }
238
284
  }
239
285
 
240
- // Check domain/ for files that look like entity/repository but don't match (loose files)
286
+ // Check domain/ for loose files without suffix migrate to subdirectory
241
287
  if (featureSubpath === "domain" && !stem.includes(".")) {
242
288
  const lower = stem.toLowerCase();
243
289
  if (lower.includes("repository") || stem.startsWith("I")) {
244
290
  const expectedName = `I${entityName}.repository${ext}`;
245
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: I<Name>.repository.ts` };
291
+ const expectedPath = join(dirname(filePath), "repositories", expectedName);
292
+ return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "repositories", expectedName), rule: `feature/${featureName}/domain → domain/repositories: I<Name>.repository.ts` };
293
+ }
294
+ if (lower.includes("port")) {
295
+ const expectedName = `${entityName}.port${ext}`;
296
+ return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: <Name>.port.ts` };
246
297
  }
247
298
  const expectedName = `${entityName}.entity${ext}`;
248
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `feature/${featureName}/domain: <Name>.entity.ts` };
299
+ const expectedPath = join(dirname(filePath), "entities", expectedName);
300
+ return { current: filePath, expected: expectedPath, relPath: join(dirname(relPath), "entities", expectedName), rule: `feature/${featureName}/domain → domain/entities: <Name>.entity.ts` };
249
301
  }
250
302
 
251
303
  return null;
@@ -291,13 +343,13 @@ export function computeExpectedName(filePath) {
291
343
  if (subdir === "contracts") {
292
344
  const hasI = stem.startsWith("I");
293
345
  if (hasI) {
294
- const fixedStem = toPascalCase(stem);
295
- if (fixedStem !== stem) {
296
- const expectedName = `${fixedStem}${ext}`;
297
- return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `shared/contracts: ${rule.description}` };
298
- }
299
- } else {
300
- const expectedName = `I${toPascalCase(stem)}${ext}`;
346
+ const fixedStem = toPascalCaseI(stem);
347
+ if (fixedStem !== stem) {
348
+ const expectedName = `${fixedStem}${ext}`;
349
+ return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `shared/contracts: ${rule.description}` };
350
+ }
351
+ } else {
352
+ const expectedName = `I${toPascalCaseI(stem)}${ext}`;
301
353
  return { current: filePath, expected: join(dirname(filePath), expectedName), relPath: join(dirname(relPath), expectedName), rule: `shared/contracts: ${rule.description}` };
302
354
  }
303
355
  }
@@ -536,6 +588,9 @@ export function renameFile(oldPath, newPath, projectRoot = ROOT) {
536
588
  return { success: true, updatedImports: [], note: "El archivo ya tiene el nombre correcto" };
537
589
  }
538
590
 
591
+ // Ensure target directory exists
592
+ mkdirSync(dirname(newPath), { recursive: true });
593
+
539
594
  // Physical rename
540
595
  try {
541
596
  renameSync(oldPath, newPath);
@@ -1,8 +1,8 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/adapters/out/legacy-<system>/<Domain>ACL.ts
3
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";
4
+ import type { I<Domain>Repository } from "../../domain/repositories/I<Domain>.repository.js";
5
+ import type { <Domain>Entity } from "../../domain/entities/<Domain>.entity.js";
6
6
  import { <Domain>Gateway } from "./<Domain>Gateway.js";
7
7
  import { <Domain>Translator } from "./<Domain>Translator.js";
8
8
 
@@ -1,6 +1,6 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/adapters/out/legacy-<system>/<Domain>Translator.ts
3
- import type { <Domain>Entity } from "../../../domain/<Domain>Entity.js";
3
+ import type { <Domain>Entity } from "../../../domain/entities/<Domain>.entity.js";
4
4
  import type { External<Domain>DTO } from "./External<Domain>DTO.js";
5
5
 
6
6
  export class <Domain>Translator {
@@ -1,5 +1,5 @@
1
1
  ```typescript
2
- // src/features/<domain>/domain/<Domain>.entity.ts
2
+ // src/features/<domain>/domain/entities/<Domain>.entity.ts
3
3
  import type { SomeGlobalType } from "@/shared/types/types.js";
4
4
 
5
5
  export interface <Domain> {
@@ -1,6 +1,6 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/application/mappers/<Domain>.mapper.ts
3
- import type { <Domain> } from "../domain/<Domain>.entity.js";
3
+ import type { <Domain> } from "../domain/entities/<Domain>.entity.js";
4
4
 
5
5
  export class <Domain>Mapper {
6
6
  static toDomain(doc: Record<string, any>): <Domain> {
@@ -1,8 +1,8 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/adapters/out/persistence/<Domain>.repository.ts
3
3
  import { injectable } from "tsyringe";
4
- import type { <Domain> } from "../../../domain/<Domain>.entity.js";
5
- import type { I<Domain>Repository } from "../../../domain/I<Domain>.repository.js";
4
+ import type { <Domain> } from "../../../domain/entities/<Domain>.entity.js";
5
+ import type { I<Domain>Repository } from "../../../domain/repositories/I<Domain>.repository.js";
6
6
  import { <Domain>Mapper } from "../../../application/mappers/<Domain>.mapper.js";
7
7
  import <Domain>Model from "./<Domain>.schema.js";
8
8
  import { RepositoryError } from "@/shared/errors/RepositoryError.js";
@@ -1,6 +1,6 @@
1
1
  ```typescript
2
- // src/features/<domain>/domain/I<Domain>.repository.ts
3
- import type { <Domain> } from "./<Domain>.entity.js";
2
+ // src/features/<domain>/domain/repositories/I<Domain>.repository.ts
3
+ import type { <Domain> } from "../entities/<Domain>.entity.js";
4
4
 
5
5
  export interface I<Domain>Repository {
6
6
  create(data: Partial<<Domain>>, session?: unknown): Promise<<Domain>>;
@@ -1,8 +1,8 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/adapters/out/saga/SagaOrchestrator.ts
3
3
  import { injectable, inject } from "tsyringe";
4
- import type { SagaInstance, SagaStatus } from "../../domain/SagaInstance.entity.js";
5
- import type { ISagaRepository } from "../../domain/ISagaRepository.js";
4
+ import type { SagaInstance, SagaStatus } from "../../domain/entities/SagaInstance.entity.js";
5
+ import type { ISagaRepository } from "../../domain/repositories/ISagaRepository.repository.js";
6
6
  import type { IEventBus } from "@/platform/events/IEventBus.js";
7
7
  import type { ILogger } from "@/platform/logger/ILogger.js";
8
8
 
@@ -1,7 +1,7 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/adapters/out/persistence/<Domain>.schema.ts
3
3
  import { model, Schema } from "mongoose";
4
- import type { <Domain> } from "../../../domain/<Domain>.entity.js";
4
+ import type { <Domain> } from "../../../domain/entities/<Domain>.entity.js";
5
5
 
6
6
  const <Domain>Schema = new Schema<<Domain>>(
7
7
  {
@@ -1,8 +1,8 @@
1
1
  ```typescript
2
2
  // src/features/<domain>/application/use-cases/Create<Domain>.uc.ts
3
3
  import { injectable, inject } from "tsyringe";
4
- import type { <Domain> } from "../../domain/<Domain>.entity.js";
5
- import type { I<Domain>Repository } from "../../domain/I<Domain>.repository.js";
4
+ import type { <Domain> } from "../../domain/entities/<Domain>.entity.js";
5
+ import type { I<Domain>Repository } from "../../domain/repositories/I<Domain>.repository.js";
6
6
  import { UseCaseError } from "@/shared/errors/UseCaseError.js";
7
7
  // Opcional: errores de dominio y eventos
8
8
  // import { <Domain>NotFoundError } from "../../domain/errors/<Domain>NotFound.error.js";