@ronaldjdevfs/forge 1.0.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +338 -0
- package/logo.png +0 -0
- package/package.json +47 -0
- package/skills/forge/SKILL.md +207 -0
- package/skills/forge/command/forge.md +110 -0
- package/skills/forge/profiles/express-mongodb.md +289 -0
- package/skills/forge/profiles/express-postgres.md +164 -0
- package/skills/forge/profiles/express-prisma.md +116 -0
- package/skills/forge/profiles/fastify-postgres.md +110 -0
- package/skills/forge/profiles/nestjs-prisma.md +160 -0
- package/skills/forge/reference/cast.md +77 -0
- package/skills/forge/reference/chain.md +73 -0
- package/skills/forge/reference/forge.md +44 -0
- package/skills/forge/reference/inscribe.md +129 -0
- package/skills/forge/reference/inspect.md +58 -0
- package/skills/forge/reference/patterns.md +108 -0
- package/skills/forge/reference/principles.md +29 -0
- package/skills/forge/reference/quench.md +74 -0
- package/skills/forge/reference/reforge.md +40 -0
- package/skills/forge/reference/relocate.md +38 -0
- package/skills/forge/reference/smelt.md +31 -0
- package/skills/forge/reference/temper.md +49 -0
- package/skills/forge/scripts/architecture.mjs +176 -0
- package/skills/forge/scripts/armorer.mjs +421 -0
- package/skills/forge/scripts/bootstrap.mjs +187 -0
- package/skills/forge/scripts/chain.mjs +258 -0
- package/skills/forge/scripts/context.mjs +237 -0
- package/skills/forge/scripts/detect.mjs +843 -0
- package/skills/forge/scripts/graph.mjs +594 -0
- package/skills/forge/scripts/inspect.mjs +193 -0
- package/skills/forge/scripts/profile.mjs +92 -0
- package/skills/forge/templates/feature/controller.ts.md +66 -0
- package/skills/forge/templates/feature/entity.ts.md +11 -0
- package/skills/forge/templates/feature/mapper.ts.md +18 -0
- package/skills/forge/templates/feature/repository-impl.ts.md +59 -0
- package/skills/forge/templates/feature/repository-interface.ts.md +12 -0
- package/skills/forge/templates/feature/routes.ts.md +17 -0
- package/skills/forge/templates/feature/schema.ts.md +16 -0
- package/skills/forge/templates/feature/use-case.ts.md +19 -0
- package/skills/forge/templates/infra/mail.ts.md +31 -0
- package/skills/forge/templates/infra/mongodb.ts.md +12 -0
- package/skills/forge/templates/infra/prisma.ts.md +21 -0
- package/skills/forge/templates/infra/redis.ts.md +30 -0
- package/skills/forge/templates/platform/config.ts.md +37 -0
- package/skills/forge/templates/platform/database.ts.md +40 -0
- package/skills/forge/templates/platform/di.ts.md +18 -0
- package/skills/forge/templates/platform/http.ts.md +29 -0
- package/skills/forge/templates/platform/logger.ts.md +38 -0
- package/skills/forge/templates/platform/server.ts.md +25 -0
- package/skills/forge/templates/shared/contract.ts.md +20 -0
- package/skills/forge/templates/shared/error.ts.md +27 -0
- package/skills/forge/templates/shared/type.ts.md +18 -0
- package/skills/forge/templates/shared/util.ts.md +23 -0
- package/src/cli.js +179 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join, relative, dirname, basename } from "path";
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
5
|
+
|
|
6
|
+
const ROOT = process.cwd();
|
|
7
|
+
const SRC = join(ROOT, "src");
|
|
8
|
+
|
|
9
|
+
const IMPORT_RE = /import\s+(?:type\s+)?(?:\{[^}]*\}|[^;{]+)\s+from\s+['"]([^'"]+)['"]/g;
|
|
10
|
+
|
|
11
|
+
const INFRA_PACKAGES = {
|
|
12
|
+
"prisma": "infra:prisma",
|
|
13
|
+
"@prisma/client": "infra:prisma",
|
|
14
|
+
"mongoose": "infra:mongoose",
|
|
15
|
+
"pg": "infra:pg",
|
|
16
|
+
"mysql2": "infra:mysql",
|
|
17
|
+
"redis": "infra:redis",
|
|
18
|
+
"amqplib": "infra:rabbitmq",
|
|
19
|
+
"kafka": "infra:kafka",
|
|
20
|
+
"kafkajs": "infra:kafka",
|
|
21
|
+
"@nestjs/core": "infra:nestjs",
|
|
22
|
+
"express": "infra:express",
|
|
23
|
+
"fastify": "infra:fastify",
|
|
24
|
+
"typeorm": "infra:typeorm",
|
|
25
|
+
"drizzle-orm": "infra:drizzle",
|
|
26
|
+
"aws-sdk": "infra:aws",
|
|
27
|
+
"@aws-sdk/client-s3": "infra:aws",
|
|
28
|
+
"nodemailer": "infra:mail",
|
|
29
|
+
"stripe": "infra:stripe",
|
|
30
|
+
"bullmq": "infra:queue",
|
|
31
|
+
"ioredis": "infra:redis",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SEVERITY = { CRITICAL: "CRITICAL", ERROR: "ERROR", WARNING: "WARNING" };
|
|
35
|
+
|
|
36
|
+
function read(path) {
|
|
37
|
+
try { return readFileSync(path, "utf-8"); }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function exists(path) { return existsSync(path); }
|
|
42
|
+
|
|
43
|
+
function isDir(path) { return existsSync(path) && statSync(path).isDirectory(); }
|
|
44
|
+
|
|
45
|
+
function listDir(path) {
|
|
46
|
+
try { return readdirSync(path); }
|
|
47
|
+
catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findFiles(dir, exts, maxDepth = 6) {
|
|
51
|
+
const results = [];
|
|
52
|
+
function walk(d, depth) {
|
|
53
|
+
if (depth > maxDepth) return;
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of readdirSync(d)) {
|
|
56
|
+
const full = join(d, entry);
|
|
57
|
+
if (statSync(full).isDirectory()) {
|
|
58
|
+
walk(full, depth + 1);
|
|
59
|
+
} else if (exts.some(e => entry.endsWith(e))) {
|
|
60
|
+
results.push(full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
if (existsSync(dir)) walk(dir, 0);
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseImports(content) {
|
|
70
|
+
const imports = [];
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
const m = lines[i].match(IMPORT_RE);
|
|
74
|
+
if (m) {
|
|
75
|
+
for (const full of m) {
|
|
76
|
+
const src = full.match(/from\s+['"]([^'"]+)['"]/);
|
|
77
|
+
if (src) imports.push(src[1]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return imports;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function classifyPath(relPath) {
|
|
85
|
+
const parts = relPath.split("/");
|
|
86
|
+
if (parts.includes("platform")) return "platform";
|
|
87
|
+
if (parts.includes("features")) {
|
|
88
|
+
if (parts.includes("domain")) return "domain";
|
|
89
|
+
if (parts.includes("adapters")) return "adapter";
|
|
90
|
+
if (parts.includes("application")) return "application";
|
|
91
|
+
return "feature";
|
|
92
|
+
}
|
|
93
|
+
if (parts.includes("shared") || parts.includes("core")) return "shared";
|
|
94
|
+
if (parts.includes("infra") || parts.includes("infrastructure")) return "infra";
|
|
95
|
+
if (parts.includes("adapters")) return "adapter";
|
|
96
|
+
if (parts.includes("lib")) return "shared";
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveNodeId(importPath, featureMap) {
|
|
101
|
+
const featMatch = importPath.match(/features\/([^/]+)/);
|
|
102
|
+
if (featMatch) {
|
|
103
|
+
const featName = featMatch[1];
|
|
104
|
+
if (featureMap[featName]) {
|
|
105
|
+
if (importPath.includes("/domain/")) return `domain:${featName}`;
|
|
106
|
+
if (importPath.includes("/adapters/")) return `adapter:${featName}`;
|
|
107
|
+
return `feature:${featName}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const platformMatch = importPath.match(/platform\/([^/]+)/);
|
|
112
|
+
if (platformMatch) {
|
|
113
|
+
const comp = platformMatch[1].replace(/\.(ts|js)$/, "");
|
|
114
|
+
return `platform:${comp}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sharedMatch = importPath.match(/shared\/([^/]+)/);
|
|
118
|
+
if (sharedMatch) {
|
|
119
|
+
const comp = sharedMatch[1].replace(/\.(ts|js)$/, "");
|
|
120
|
+
return `shared:${comp}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const infraMatch = importPath.match(/infra(?:structure)?\/([^/]+)/);
|
|
124
|
+
if (infraMatch) {
|
|
125
|
+
const comp = infraMatch[1].replace(/\.(ts|js)$/, "");
|
|
126
|
+
return `infra:${comp}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const [pkg, id] of Object.entries(INFRA_PACKAGES)) {
|
|
130
|
+
if (importPath === pkg || importPath.startsWith(pkg + "/")) return id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (importPath.includes("/infrastructure/")) return "infra:infrastructure";
|
|
134
|
+
if (importPath.includes("/infra/")) return "infra:infrastructure";
|
|
135
|
+
|
|
136
|
+
if (importPath.includes("/core/") || importPath.startsWith("@/core")) return "shared:core";
|
|
137
|
+
if (importPath.includes("/shared/") || importPath.startsWith("@/shared")) return "shared:shared";
|
|
138
|
+
if (importPath.includes("/lib/")) return "shared:lib";
|
|
139
|
+
|
|
140
|
+
if (importPath.includes("/adapters/")) return "adapter:global";
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveRelativeImport(importPath, sourceFileRel, projectRoot) {
|
|
146
|
+
if (!importPath.startsWith(".")) return null;
|
|
147
|
+
const srcDir = dirname(join(projectRoot, sourceFileRel));
|
|
148
|
+
const resolved = join(srcDir, importPath);
|
|
149
|
+
return relative(projectRoot, resolved);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractNodeType(nodeId) {
|
|
153
|
+
if (!nodeId) return null;
|
|
154
|
+
return nodeId.split(":")[0];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function buildGraph(projectRoot = ROOT) {
|
|
158
|
+
const src = join(projectRoot, "src");
|
|
159
|
+
const featuresDir = join(src, "features");
|
|
160
|
+
const platformDir = join(src, "platform");
|
|
161
|
+
const sharedDir = join(src, "shared");
|
|
162
|
+
const infraDirs = [join(src, "infra"), join(src, "infrastructure")];
|
|
163
|
+
|
|
164
|
+
const nodes = [];
|
|
165
|
+
const edges = [];
|
|
166
|
+
const violations = [];
|
|
167
|
+
const featureMap = {};
|
|
168
|
+
const idSet = new Set();
|
|
169
|
+
const edgeSet = new Set();
|
|
170
|
+
|
|
171
|
+
function addNode(id, type, label, path) {
|
|
172
|
+
if (!idSet.has(id)) {
|
|
173
|
+
nodes.push({ id, type, label, path });
|
|
174
|
+
idSet.add(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function addEdge(from, to, type, file, importPath) {
|
|
179
|
+
const key = `${from}|${to}|${type}`;
|
|
180
|
+
if (!edgeSet.has(key)) {
|
|
181
|
+
edges.push({ from, to, type, file: file || null, import: importPath || null });
|
|
182
|
+
edgeSet.add(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ── 1. Platform Layer nodes ── */
|
|
187
|
+
if (isDir(platformDir)) {
|
|
188
|
+
for (const sub of listDir(platformDir)) {
|
|
189
|
+
const full = join(platformDir, sub);
|
|
190
|
+
if (isDir(full)) {
|
|
191
|
+
addNode(`platform:${sub}`, "platform", sub, `src/platform/${sub}/`);
|
|
192
|
+
} else if (sub.endsWith(".ts") || sub.endsWith(".js")) {
|
|
193
|
+
const name = sub.replace(/\.(ts|js)$/, "");
|
|
194
|
+
addNode(`platform:${name}`, "platform", name, `src/platform/${sub}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── 2. Feature Layer nodes ── */
|
|
200
|
+
const features = isDir(featuresDir)
|
|
201
|
+
? listDir(featuresDir).filter(f => isDir(join(featuresDir, f)))
|
|
202
|
+
: [];
|
|
203
|
+
|
|
204
|
+
for (const feat of features) {
|
|
205
|
+
const featId = `feature:${feat}`;
|
|
206
|
+
addNode(featId, "feature", feat, `src/features/${feat}/`);
|
|
207
|
+
featureMap[feat] = featId;
|
|
208
|
+
|
|
209
|
+
const domainPath = join(featuresDir, feat, "domain");
|
|
210
|
+
if (isDir(domainPath)) {
|
|
211
|
+
addNode(`domain:${feat}`, "domain", `${feat}/domain`, `src/features/${feat}/domain/`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const adaptersPath = join(featuresDir, feat, "adapters");
|
|
215
|
+
if (isDir(adaptersPath)) {
|
|
216
|
+
addNode(`adapter:${feat}`, "adapter", `${feat}/adapters`, `src/features/${feat}/adapters/`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── 3. Shared Layer nodes ── */
|
|
221
|
+
if (isDir(sharedDir)) {
|
|
222
|
+
for (const sub of listDir(sharedDir)) {
|
|
223
|
+
const full = join(sharedDir, sub);
|
|
224
|
+
if (isDir(full)) {
|
|
225
|
+
addNode(`shared:${sub}`, "shared", sub, `src/shared/${sub}/`);
|
|
226
|
+
} else if (sub.endsWith(".ts") || sub.endsWith(".js")) {
|
|
227
|
+
const name = sub.replace(/\.(ts|js)$/, "");
|
|
228
|
+
addNode(`shared:${name}`, "shared", name, `src/shared/${sub}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/* Also detect core and lib as shared */
|
|
233
|
+
for (const dir of ["core", "lib"]) {
|
|
234
|
+
const fullPath = join(src, dir);
|
|
235
|
+
if (isDir(fullPath)) {
|
|
236
|
+
addNode(`shared:${dir}`, "shared", dir, `src/${dir}/`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ── 4. Infra Layer nodes ── */
|
|
241
|
+
for (const infraDir of infraDirs) {
|
|
242
|
+
if (isDir(infraDir)) {
|
|
243
|
+
for (const sub of listDir(infraDir)) {
|
|
244
|
+
const full = join(infraDir, sub);
|
|
245
|
+
if (isDir(full)) {
|
|
246
|
+
addNode(`infra:${sub}`, "infra", sub, `src/${basename(infraDir)}/${sub}/`);
|
|
247
|
+
} else if (sub.endsWith(".ts") || sub.endsWith(".js")) {
|
|
248
|
+
const name = sub.replace(/\.(ts|js)$/, "");
|
|
249
|
+
addNode(`infra:${name}`, "infra", name, `src/${basename(infraDir)}/${sub}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ── 5. Global adapters node ── */
|
|
256
|
+
const globalAdapters = join(src, "adapters");
|
|
257
|
+
if (isDir(globalAdapters)) {
|
|
258
|
+
addNode("adapter:global", "adapter", "adapters (global)", "src/adapters/");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ── 6. Scan files and build edges ── */
|
|
262
|
+
const allFiles = findFiles(src, [".ts", ".js", ".tsx", ".jsx"], 8);
|
|
263
|
+
const fileContents = allFiles.map(f => ({ file: f, content: read(f) })).filter(x => x.content);
|
|
264
|
+
|
|
265
|
+
/* 6a. Detect used infra packages first */
|
|
266
|
+
const usedPackages = new Set();
|
|
267
|
+
for (const { content } of fileContents) {
|
|
268
|
+
const imports = parseImports(content);
|
|
269
|
+
for (const imp of imports) {
|
|
270
|
+
for (const [pkg, id] of Object.entries(INFRA_PACKAGES)) {
|
|
271
|
+
if (imp === pkg || imp.startsWith(pkg + "/")) usedPackages.add(id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const pkgId of usedPackages) {
|
|
276
|
+
const label = pkgId.replace("infra:", "");
|
|
277
|
+
addNode(pkgId, "infra", label, `(package) ${label}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* 6b. Parse imports and create edges */
|
|
281
|
+
for (const { file: filePath, content } of fileContents) {
|
|
282
|
+
const relPath = relative(projectRoot, filePath);
|
|
283
|
+
const sourceType = classifyPath(relPath);
|
|
284
|
+
if (!sourceType) continue;
|
|
285
|
+
|
|
286
|
+
let sourceId = null;
|
|
287
|
+
const featMatch = relPath.match(/features\/([^/]+)/);
|
|
288
|
+
|
|
289
|
+
if (featMatch) {
|
|
290
|
+
const featName = featMatch[1];
|
|
291
|
+
if (!featureMap[featName]) continue;
|
|
292
|
+
if (sourceType === "domain") sourceId = `domain:${featName}`;
|
|
293
|
+
else if (sourceType === "adapter") sourceId = `adapter:${featName}`;
|
|
294
|
+
else if (sourceType === "application") sourceId = `feature:${featName}`;
|
|
295
|
+
else sourceId = `feature:${featName}`;
|
|
296
|
+
} else if (sourceType === "platform") {
|
|
297
|
+
const platformComp = relPath.match(/platform\/([^/]+)/);
|
|
298
|
+
if (platformComp) {
|
|
299
|
+
const comp = platformComp[1].replace(/\.(ts|js)$/, "");
|
|
300
|
+
sourceId = `platform:${comp}`;
|
|
301
|
+
}
|
|
302
|
+
} else if (sourceType === "shared") {
|
|
303
|
+
const sharedComp = relPath.match(/(?:shared|core|lib)\/([^/]+)/);
|
|
304
|
+
if (sharedComp) {
|
|
305
|
+
const comp = sharedComp[1].replace(/\.(ts|js)$/, "");
|
|
306
|
+
sourceId = `shared:${comp}`;
|
|
307
|
+
} else {
|
|
308
|
+
sourceId = "shared:shared";
|
|
309
|
+
}
|
|
310
|
+
} else if (sourceType === "infra") {
|
|
311
|
+
const infraComp = relPath.match(/(?:infra|infrastructure)\/([^/]+)/);
|
|
312
|
+
if (infraComp) {
|
|
313
|
+
const comp = infraComp[1].replace(/\.(ts|js)$/, "");
|
|
314
|
+
sourceId = `infra:${comp}`;
|
|
315
|
+
} else {
|
|
316
|
+
sourceId = "infra:infrastructure";
|
|
317
|
+
}
|
|
318
|
+
} else if (sourceType === "adapter") {
|
|
319
|
+
sourceId = "adapter:global";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!sourceId || !idSet.has(sourceId)) continue;
|
|
323
|
+
|
|
324
|
+
const imports = parseImports(content);
|
|
325
|
+
const seenTargets = new Set();
|
|
326
|
+
|
|
327
|
+
for (const imp of imports) {
|
|
328
|
+
let targetId = resolveNodeId(imp, featureMap);
|
|
329
|
+
|
|
330
|
+
if (!targetId) {
|
|
331
|
+
const resolved = resolveRelativeImport(imp, relPath, projectRoot);
|
|
332
|
+
if (resolved) targetId = resolveNodeId(resolved, featureMap);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!targetId || targetId === sourceId) continue;
|
|
336
|
+
if (!idSet.has(targetId)) continue;
|
|
337
|
+
if (seenTargets.has(targetId)) continue;
|
|
338
|
+
seenTargets.add(targetId);
|
|
339
|
+
|
|
340
|
+
const fromType = extractNodeType(sourceId);
|
|
341
|
+
const toType = extractNodeType(targetId);
|
|
342
|
+
|
|
343
|
+
let edgeType = "uses";
|
|
344
|
+
if (fromType === "adapter" && toType === "domain") edgeType = "implements";
|
|
345
|
+
else if (fromType === "adapter" && toType === "infra") edgeType = "adapts_to";
|
|
346
|
+
else if (toType === "feature") edgeType = "depends_on";
|
|
347
|
+
else if (toType === "platform") edgeType = "depends_on";
|
|
348
|
+
else if (fromType === "domain" && toType !== "domain" && toType !== "shared") edgeType = "violates";
|
|
349
|
+
|
|
350
|
+
addEdge(sourceId, targetId, edgeType, relPath, imp);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* ── 7. Validate architecture rules ── */
|
|
355
|
+
|
|
356
|
+
function addViolation(from, to, severity, rule, description, file) {
|
|
357
|
+
violations.push({
|
|
358
|
+
from,
|
|
359
|
+
to,
|
|
360
|
+
type: "violates",
|
|
361
|
+
severity,
|
|
362
|
+
rule,
|
|
363
|
+
description,
|
|
364
|
+
file: file || null,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const edge of edges) {
|
|
369
|
+
const fromType = extractNodeType(edge.from);
|
|
370
|
+
const toType = extractNodeType(edge.to);
|
|
371
|
+
|
|
372
|
+
/* R1: feature → infra = CRITICAL */
|
|
373
|
+
if (fromType === "feature" && toType === "infra") {
|
|
374
|
+
addViolation(edge.from, edge.to, SEVERITY.CRITICAL, "R1",
|
|
375
|
+
"Features no acceden infraestructura directamente (deben usar adapter)", edge.file);
|
|
376
|
+
edge.type = "violates";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* R2: platform → feature = CRITICAL */
|
|
380
|
+
if (fromType === "platform" && toType === "feature") {
|
|
381
|
+
addViolation(edge.from, edge.to, SEVERITY.CRITICAL, "R2",
|
|
382
|
+
"Platform no debe depender de features (el backbone es independiente del negocio)", edge.file);
|
|
383
|
+
edge.type = "violates";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* R3: shared → feature = ERROR */
|
|
387
|
+
if (fromType === "shared" && toType === "feature") {
|
|
388
|
+
addViolation(edge.from, edge.to, SEVERITY.ERROR, "R3",
|
|
389
|
+
"Shared no debe importar de features (shared debe ser puro)", edge.file);
|
|
390
|
+
edge.type = "violates";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* R4: shared → infra = ERROR */
|
|
394
|
+
if (fromType === "shared" && toType === "infra") {
|
|
395
|
+
addViolation(edge.from, edge.to, SEVERITY.ERROR, "R4",
|
|
396
|
+
"Shared no debe importar infraestructura (shared debe ser agnóstico)", edge.file);
|
|
397
|
+
edge.type = "violates";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* R5: domain → infra = CRITICAL */
|
|
401
|
+
if (fromType === "domain" && toType === "infra") {
|
|
402
|
+
addViolation(edge.from, edge.to, SEVERITY.CRITICAL, "R5",
|
|
403
|
+
"Domain no puede importar infraestructura directamente", edge.file);
|
|
404
|
+
edge.type = "violates";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* R6: domain → platform = CRITICAL */
|
|
408
|
+
if (fromType === "domain" && toType === "platform") {
|
|
409
|
+
addViolation(edge.from, edge.to, SEVERITY.CRITICAL, "R6",
|
|
410
|
+
"Domain no puede importar platform directamente (debe ser puro)", edge.file);
|
|
411
|
+
edge.type = "violates";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* R7: infra → feature = WARNING */
|
|
415
|
+
if (fromType === "infra" && toType === "feature") {
|
|
416
|
+
addViolation(edge.from, edge.to, SEVERITY.WARNING, "R7",
|
|
417
|
+
"Infraestructura no debería importar features directamente", edge.file);
|
|
418
|
+
edge.type = "violates";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* R8: Cross-feature direct imports */
|
|
422
|
+
const fromFeatMatch = edge.from.match(/^(?:feature|domain|adapter):(.+)$/);
|
|
423
|
+
const toFeatMatch = edge.to.match(/^(?:feature|domain|adapter):(.+)$/);
|
|
424
|
+
if (fromFeatMatch && toFeatMatch && fromFeatMatch[1] !== toFeatMatch[1]) {
|
|
425
|
+
addViolation(edge.from, edge.to, SEVERITY.ERROR, "R8",
|
|
426
|
+
"Features no deben importar código de otro feature directamente (usar interfaces inyectadas)", edge.file);
|
|
427
|
+
edge.type = "violates";
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* R9: Cycle detection across all layers */
|
|
432
|
+
const allEdges = edges.filter(e => e.type !== "violates");
|
|
433
|
+
const directedEdges = {};
|
|
434
|
+
const allNodeIds = [...new Set(allEdges.flatMap(e => [e.from, e.to]))];
|
|
435
|
+
for (const nid of allNodeIds) directedEdges[nid] = [];
|
|
436
|
+
for (const e of allEdges) {
|
|
437
|
+
if (directedEdges[e.from]) directedEdges[e.from].push(e.to);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let hasCycle = false;
|
|
441
|
+
function dfs(node, visited, stack) {
|
|
442
|
+
if (stack.has(node)) return true;
|
|
443
|
+
if (visited.has(node)) return false;
|
|
444
|
+
visited.add(node);
|
|
445
|
+
stack.add(node);
|
|
446
|
+
for (const nb of directedEdges[node] || []) {
|
|
447
|
+
if (dfs(nb, visited, stack)) return true;
|
|
448
|
+
}
|
|
449
|
+
stack.delete(node);
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const visited = new Set();
|
|
454
|
+
for (const n of allNodeIds) {
|
|
455
|
+
if (hasCycle) break;
|
|
456
|
+
if (!visited.has(n)) {
|
|
457
|
+
if (dfs(n, visited, new Set())) {
|
|
458
|
+
hasCycle = true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (hasCycle) {
|
|
464
|
+
addViolation("(cycle)", "(cycle)", SEVERITY.ERROR, "R9",
|
|
465
|
+
"Ciclo de dependencia detectado en el grafo global");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ── 8. Stats ── */
|
|
469
|
+
const bySeverity = { CRITICAL: 0, ERROR: 0, WARNING: 0 };
|
|
470
|
+
for (const v of violations) {
|
|
471
|
+
bySeverity[v.severity] = (bySeverity[v.severity] || 0) + 1;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const riskScore = Math.min(100,
|
|
475
|
+
(bySeverity.CRITICAL || 0) * 30 +
|
|
476
|
+
(bySeverity.ERROR || 0) * 10 +
|
|
477
|
+
(bySeverity.WARNING || 0) * 3
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const health = riskScore === 0 ? "healthy"
|
|
481
|
+
: riskScore <= 30 ? "degraded"
|
|
482
|
+
: "critical";
|
|
483
|
+
|
|
484
|
+
/* Layer counts */
|
|
485
|
+
const layerStats = { platform: 0, feature: 0, shared: 0, infra: 0, domain: 0, adapter: 0 };
|
|
486
|
+
for (const n of nodes) {
|
|
487
|
+
if (layerStats[n.type] !== undefined) layerStats[n.type]++;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* Dependency health: ratio of valid vs total edges */
|
|
491
|
+
const validEdges = edges.filter(e => e.type !== "violates").length;
|
|
492
|
+
const dependencyHealth = edges.length > 0
|
|
493
|
+
? Math.round((validEdges / edges.length) * 100)
|
|
494
|
+
: 100;
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
nodes,
|
|
498
|
+
edges,
|
|
499
|
+
violations,
|
|
500
|
+
stats: {
|
|
501
|
+
totalNodes: nodes.length,
|
|
502
|
+
totalEdges: edges.length,
|
|
503
|
+
violations: violations.length,
|
|
504
|
+
criticalViolations: bySeverity.CRITICAL || 0,
|
|
505
|
+
errorViolations: bySeverity.ERROR || 0,
|
|
506
|
+
warningViolations: bySeverity.WARNING || 0,
|
|
507
|
+
riskScore,
|
|
508
|
+
health,
|
|
509
|
+
dependencyHealth,
|
|
510
|
+
layers: layerStats,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function validateGraph(graph) {
|
|
516
|
+
return {
|
|
517
|
+
violations: graph.violations,
|
|
518
|
+
stats: graph.stats,
|
|
519
|
+
score: Math.max(0, 100 - graph.stats.riskScore),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function exportGraph(graph) {
|
|
524
|
+
let out = "## Architecture Graph\n\n";
|
|
525
|
+
out += `**Nodes:** ${graph.stats.totalNodes} \n`;
|
|
526
|
+
out += `**Edges:** ${graph.stats.totalEdges} \n`;
|
|
527
|
+
out += `**Risk Score:** ${graph.stats.riskScore}/100 \n`;
|
|
528
|
+
out += `**Health:** ${graph.stats.health} \n`;
|
|
529
|
+
out += `**Dependency Health:** ${graph.stats.dependencyHealth}% \n\n`;
|
|
530
|
+
|
|
531
|
+
const typeLabels = {
|
|
532
|
+
platform: "Platform Layer",
|
|
533
|
+
feature: "Feature Layer",
|
|
534
|
+
shared: "Shared Layer",
|
|
535
|
+
infra: "Infrastructure Layer",
|
|
536
|
+
domain: "Domain Layer",
|
|
537
|
+
adapter: "Adapter Layer",
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const typeOrder = ["platform", "feature", "shared", "infra", "domain", "adapter"];
|
|
541
|
+
|
|
542
|
+
for (const type of typeOrder) {
|
|
543
|
+
const typeNodes = graph.nodes.filter(n => n.type === type);
|
|
544
|
+
if (typeNodes.length === 0) continue;
|
|
545
|
+
out += `### ${typeLabels[type]}\n`;
|
|
546
|
+
for (const n of typeNodes) {
|
|
547
|
+
out += `- \`${n.id}\` — ${n.label}\n`;
|
|
548
|
+
}
|
|
549
|
+
out += "\n";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (graph.violations.length > 0) {
|
|
553
|
+
out += "### Violations\n";
|
|
554
|
+
out += "| Rule | From | To | Severity | Description |\n";
|
|
555
|
+
out += "|------|------|----|----------|-------------|\n";
|
|
556
|
+
for (const v of graph.violations) {
|
|
557
|
+
out += `| ${v.rule} | \`${v.from}\` | \`${v.to}\` | ${v.severity} | ${v.description} |\n`;
|
|
558
|
+
}
|
|
559
|
+
out += "\n";
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
out += "### Dependency Graph\n";
|
|
563
|
+
const layerEdges = graph.edges.filter(e =>
|
|
564
|
+
["platform", "feature", "shared", "infra"].includes(extractNodeType(e.from)) ||
|
|
565
|
+
["platform", "feature", "shared", "infra"].includes(extractNodeType(e.to))
|
|
566
|
+
);
|
|
567
|
+
const edgeGroup = {};
|
|
568
|
+
for (const e of layerEdges) {
|
|
569
|
+
if (!edgeGroup[e.from]) edgeGroup[e.from] = [];
|
|
570
|
+
const suffix = e.type === "violates" ? " (VIOLATION)" : "";
|
|
571
|
+
edgeGroup[e.from].push(`${e.to}${suffix}`);
|
|
572
|
+
}
|
|
573
|
+
for (const [from, targets] of Object.entries(edgeGroup)) {
|
|
574
|
+
out += `- \`${from}\` → [${targets.join(", ")}]\n`;
|
|
575
|
+
}
|
|
576
|
+
out += "\n";
|
|
577
|
+
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function main() {
|
|
582
|
+
const args = process.argv.slice(2);
|
|
583
|
+
const format = args.includes("--json") ? "json" : "text";
|
|
584
|
+
const graph = buildGraph();
|
|
585
|
+
if (format === "json") {
|
|
586
|
+
console.log(JSON.stringify(graph, null, 2));
|
|
587
|
+
} else {
|
|
588
|
+
console.log(exportGraph(graph));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (process.argv[1] && (process.argv[1].endsWith("graph.mjs") || process.argv[1].endsWith("graph.js"))) {
|
|
593
|
+
main().catch(console.error);
|
|
594
|
+
}
|