@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,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * forge-boot.mjs — Boot orchestrator with conditional depth.
5
+ *
6
+ * Uso:
7
+ * node forge-boot.mjs --depth minimal|standard|full [--command <name>] [--json] [--force]
8
+ *
9
+ * Profundidades:
10
+ * minimal → context + profile (cast, temper, smelt, relocate, reforge, inscribe)
11
+ * standard → minimal + graph + chain (chain, graph)
12
+ * full → standard + ownership + inspect (inspect, quench, default)
13
+ *
14
+ * Cache:
15
+ * Usa .forge/cache/ para reusar datos entre invocaciones.
16
+ * Pasa --force para ignorar caché y regenerar todo.
17
+ */
18
+
19
+ import { join } from "path";
20
+
21
+ const ROOT = process.cwd();
22
+
23
+ async function main() {
24
+ const args = process.argv.slice(2);
25
+ const depth = args.includes("--depth")
26
+ ? args[args.indexOf("--depth") + 1]
27
+ : "full";
28
+ const isJson = args.includes("--json");
29
+ const force = args.includes("--force");
30
+
31
+ const result = { depth, bootTime: Date.now(), context: null, profile: null, graph: null, chain: null, ownership: null, inspect: null };
32
+
33
+ // 1. Context
34
+ const { buildContext } = await import("./context.mjs");
35
+ const ctx = await buildContext(ROOT, null, { force });
36
+ result.context = ctx;
37
+
38
+ // 2. Profile
39
+ const { detectProfile, detectProfileExtended } = await import("./profile.mjs");
40
+ const profileExtended = detectProfileExtended(ctx);
41
+ result.profile = {
42
+ profile: profileExtended.profile,
43
+ hasPlatform: profileExtended.hasPlatform,
44
+ hasShared: profileExtended.hasShared,
45
+ hasInfra: profileExtended.hasInfra,
46
+ layers: profileExtended.layers,
47
+ complementary: profileExtended.complementary,
48
+ };
49
+
50
+ if (depth === "minimal") {
51
+ if (isJson) return console.log(JSON.stringify(result, null, 2));
52
+ console.log(`Boot [${depth}]: ${result.profile.profile} | ${ctx.features.total} features | ${ctx.framework} | ${ctx.database}`);
53
+ return;
54
+ }
55
+
56
+ // 3. Graph
57
+ result.graph = ctx.graph;
58
+
59
+ // 4. Chain
60
+ const { buildDependencyGraph } = await import("./chain.mjs");
61
+ const chain = buildDependencyGraph(ROOT, result.graph);
62
+ result.chain = {
63
+ features: chain.features.length,
64
+ hasCycles: chain.hasCycles,
65
+ globalCycles: chain.globalCycles,
66
+ illegalChains: chain.illegalChains.length,
67
+ isolated: chain.isolated.length,
68
+ topologicalOrder: chain.topologicalOrder,
69
+ };
70
+
71
+ if (depth === "standard") {
72
+ if (isJson) return console.log(JSON.stringify(result, null, 2));
73
+ console.log(`Boot [${depth}]: ${result.profile.profile} | ${ctx.features.total} features | ${result.graph.stats.totalNodes} nodes | chains: ${result.chain.illegalChains}`);
74
+ return;
75
+ }
76
+
77
+ // 5. Ownership (from context)
78
+ result.ownership = {
79
+ health: ctx.ownership?.health || "unknown",
80
+ score: ctx.ownership?.score || 0,
81
+ orphans: ctx.ownership?.orphans?.length || 0,
82
+ duplicates: ctx.ownership?.duplicates?.length || 0,
83
+ misplaced: ctx.ownership?.misplaced?.length || 0,
84
+ hasPlatform: ctx.ownership?.hasPlatform || false,
85
+ hasFeatures: ctx.ownership?.hasFeatures || false,
86
+ };
87
+
88
+ // 6. Inspect (full audit)
89
+ const { allChecks } = await import("./detect.mjs");
90
+ const checks = allChecks(ctx.features.migrated, result.graph, ctx);
91
+ const violations = [];
92
+ for (const [catName, cat] of Object.entries(checks)) {
93
+ for (const check of cat.checks) {
94
+ if (!check.pass) violations.push({ severity: check.severity, label: check.label, category: catName });
95
+ }
96
+ }
97
+ result.inspect = {
98
+ totalChecks: violations.length,
99
+ critical: violations.filter(v => v.severity === "CRITICAL").length,
100
+ errors: violations.filter(v => v.severity === "ERROR").length,
101
+ warnings: violations.filter(v => v.severity === "WARNING").length,
102
+ };
103
+
104
+ if (isJson) return console.log(JSON.stringify(result, null, 2));
105
+ console.log(`Boot [${depth}]: ${result.profile.profile} | features: ${ctx.features.total} | graph: ${result.graph.stats.totalNodes}n/${result.graph.stats.totalEdges}e | violations: ${result.inspect.totalChecks} [CRIT:${result.inspect.critical} ERR:${result.inspect.errors} WARN:${result.inspect.warnings}]`);
106
+ }
107
+
108
+ main().catch(console.error);
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
4
- import { join, dirname } from "path";
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
4
+ import { join, dirname, relative } from "path";
5
+ import { createHash } from "crypto";
5
6
 
6
7
  const ROOT = process.cwd();
7
8
  const CONFIG_DIR = join(ROOT, ".forge");
8
9
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
9
10
  const STATE_PATH = join(CONFIG_DIR, "state.json");
10
11
  const HISTORY_DIR = join(CONFIG_DIR, "history");
12
+ const CACHE_DIR = join(CONFIG_DIR, "cache");
13
+
14
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
11
15
 
12
16
  const DEFAULT_CONFIG = {
13
17
  profile: null,
@@ -23,6 +27,7 @@ const DEFAULT_CONFIG = {
23
27
  const DEFAULT_STATE = {
24
28
  lastAudit: null,
25
29
  lastScore: null,
30
+ lastMax: null,
26
31
  lastGrade: null,
27
32
  violationCount: 0,
28
33
  totalFeatures: 0,
@@ -78,6 +83,7 @@ export function updateStateFromAudit(auditResult) {
78
83
  const state = loadState();
79
84
  state.lastAudit = new Date().toISOString();
80
85
  state.lastScore = auditResult.total || 0;
86
+ state.lastMax = auditResult.max || null;
81
87
  state.lastGrade = auditResult.grade || "F";
82
88
  state.violationCount = (auditResult.violations || []).length;
83
89
  state.health = auditResult.health || "unknown";
@@ -116,7 +122,7 @@ export function displayTrend(entries) {
116
122
  console.log("─".repeat(60));
117
123
  for (const e of entries) {
118
124
  const date = (e.timestamp || "").slice(0, 19).replace("T", " ");
119
- const score = e.score !== undefined ? `${e.score}/110` : "—";
125
+ const score = e.score !== undefined ? `${e.score}${e.max ? "/".concat(e.max) : ""}` : "—";
120
126
  const grade = e.grade || "—";
121
127
  const violations = e.violationCount !== undefined ? String(e.violationCount) : "—";
122
128
  const features = e.totalFeatures !== undefined ? `${e.migratedFeatures || 0}/${e.totalFeatures}` : "—";
@@ -173,6 +179,179 @@ export function configNeedsRefresh(config) {
173
179
  return daysSinceUpdate > 7;
174
180
  }
175
181
 
182
+ /* ── Cache Layer ── */
183
+
184
+ const CACHE_KEYS = ["context", "graph", "profile", "ownership", "chain"];
185
+
186
+ function cachePath(key) {
187
+ mkdirSync(CACHE_DIR, { recursive: true });
188
+ return join(CACHE_DIR, `${key}.json`);
189
+ }
190
+
191
+ function cacheMetaPath() {
192
+ mkdirSync(CACHE_DIR, { recursive: true });
193
+ return join(CACHE_DIR, "meta.json");
194
+ }
195
+
196
+ function loadCacheMeta() {
197
+ try {
198
+ return JSON.parse(readFileSync(cacheMetaPath(), "utf-8"));
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ function saveCacheMeta(meta) {
205
+ const existing = loadCacheMeta();
206
+ writeFileSync(cacheMetaPath(), JSON.stringify({ ...existing, ...meta }, null, 2) + "\n");
207
+ }
208
+
209
+ /**
210
+ * Compute a hash of the src/ directory tree (file paths + mtimes).
211
+ * Used to detect if the project changed since last cache.
212
+ */
213
+ export function hashSrcDir(projectRoot = ROOT) {
214
+ const src = join(projectRoot, "src");
215
+ if (!existsSync(src)) return null;
216
+ const hash = createHash("sha256");
217
+ const entries = [];
218
+ function walk(dir) {
219
+ let dirEntries;
220
+ try { dirEntries = readdirSync(dir); } catch { return; }
221
+ for (const entry of dirEntries.sort()) {
222
+ const full = join(dir, entry);
223
+ let st;
224
+ try { st = statSync(full); } catch { continue; }
225
+ if (st.isDirectory()) {
226
+ walk(full);
227
+ } else if (entry.endsWith(".ts") || entry.endsWith(".js") || entry.endsWith(".tsx") || entry.endsWith(".json")) {
228
+ entries.push(relative(projectRoot, full) + ":" + st.mtimeMs);
229
+ }
230
+ }
231
+ }
232
+ walk(src);
233
+ hash.update(entries.join("\n"));
234
+ return hash.digest("hex").slice(0, 16);
235
+ }
236
+
237
+ /**
238
+ * Save a value to the cache with a src hash for invalidation.
239
+ */
240
+ export function saveCache(key, data, projectRoot = ROOT) {
241
+ if (!CACHE_KEYS.includes(key)) return false;
242
+ const srcHash = hashSrcDir(projectRoot);
243
+ const payload = {
244
+ key,
245
+ srcHash,
246
+ cachedAt: Date.now(),
247
+ data,
248
+ };
249
+ const ok = writeJson(cachePath(key), payload);
250
+ if (ok) {
251
+ saveCacheMeta({ [key]: { srcHash, cachedAt: Date.now() } });
252
+ }
253
+ return ok;
254
+ }
255
+
256
+ /**
257
+ * Load a value from cache if valid (same src hash and not expired).
258
+ * Returns { data, valid } where valid is false if cache is stale.
259
+ */
260
+ export function loadCache(key, projectRoot = ROOT) {
261
+ if (!CACHE_KEYS.includes(key)) return { data: null, valid: false };
262
+ const path = cachePath(key);
263
+ const cached = readJson(path);
264
+ if (!cached) return { data: null, valid: false };
265
+
266
+ // Check TTL
267
+ if (Date.now() - cached.cachedAt > CACHE_TTL_MS) {
268
+ return { data: null, valid: false };
269
+ }
270
+
271
+ // Check src hash match
272
+ const currentHash = hashSrcDir(projectRoot);
273
+ if (currentHash && currentHash !== cached.srcHash) {
274
+ return { data: null, valid: false };
275
+ }
276
+
277
+ return { data: cached.data, valid: true };
278
+ }
279
+
280
+ /**
281
+ * Invalidate a specific cache key or all keys.
282
+ */
283
+ export function invalidateCache(key = null) {
284
+ if (key) {
285
+ const path = cachePath(key);
286
+ try { writeFileSync(path, JSON.stringify({}), "utf-8"); } catch {}
287
+ } else {
288
+ for (const k of CACHE_KEYS) {
289
+ try { writeFileSync(cachePath(k), JSON.stringify({}), "utf-8"); } catch {}
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Determine required boot depth based on command.
296
+ * Returns 'minimal' | 'standard' | 'full'
297
+ * - minimal: context + profile (cast, temper, smelt, relocate, reforge, inscribe)
298
+ * - standard: minimal + graph + chain (chain, graph)
299
+ * - full: standard + armorer + inspect + detect + architecture (inspect, quench, default)
300
+ */
301
+ export function getBootDepth(command) {
302
+ const minimal = ["cast", "temper", "smelt", "relocate", "reforge", "inscribe", "nail", "unnail", "forge-api", "forge state", "forge rollback"];
303
+ const standard = ["chain", "graph", "forge hook"];
304
+ if (minimal.includes(command)) return "minimal";
305
+ if (standard.includes(command)) return "standard";
306
+ return "full";
307
+ }
308
+
309
+ /**
310
+ * Try to load cached boot data up to a given depth.
311
+ * Returns { context, profile, graph, chain, ownership } with valid fields populated.
312
+ */
313
+ export function loadCachedBoot(depth = "full", projectRoot = ROOT) {
314
+ const result = { context: null, profile: null, graph: null, chain: null, ownership: null };
315
+
316
+ // Always try context
317
+ const ctxCache = loadCache("context", projectRoot);
318
+ if (ctxCache.valid) result.context = ctxCache.data;
319
+
320
+ // Always try profile
321
+ const profCache = loadCache("profile", projectRoot);
322
+ if (profCache.valid) result.profile = profCache.data;
323
+
324
+ if (depth === "minimal") return result;
325
+
326
+ // Standard: add graph + chain
327
+ const graphCache = loadCache("graph", projectRoot);
328
+ if (graphCache.valid) result.graph = graphCache.data;
329
+
330
+ const chainCache = loadCache("chain", projectRoot);
331
+ if (chainCache.valid) result.chain = chainCache.data;
332
+
333
+ if (depth === "standard") return result;
334
+
335
+ // Full: add ownership
336
+ const ownCache = loadCache("ownership", projectRoot);
337
+ if (ownCache.valid) result.ownership = ownCache.data;
338
+
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Check if cached boot is valid for a given depth.
344
+ */
345
+ export function hasValidCache(depth = "full", projectRoot = ROOT) {
346
+ const cached = loadCachedBoot(depth, projectRoot);
347
+ if (!cached.context) return false;
348
+ if (depth === "minimal") return true;
349
+ if (!cached.graph) return false;
350
+ if (depth === "standard") return true;
351
+ if (!cached.ownership) return false;
352
+ return true;
353
+ }
354
+
176
355
  async function main() {
177
356
  const args = process.argv.slice(2);
178
357
  const isJson = args.includes("--json");
@@ -85,7 +85,7 @@ async function main() {
85
85
  if (isJson) {
86
86
  console.log(JSON.stringify({ state, recentHistory: history }, null, 2));
87
87
  } else {
88
- const lastScore = state.lastScore !== null ? `${state.lastScore}/110` : "—";
88
+ const lastScore = state.lastScore !== null ? `${state.lastScore}/${state.lastMax || "?"}` : "—";
89
89
  const lastGrade = state.lastGrade || "—";
90
90
  console.log(`Forge State | Score: ${lastScore} | Grade: ${lastGrade} | Violaciones: ${state.violationCount} | Features: ${state.migratedFeatures}/${state.totalFeatures}`);
91
91
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
4
4
  import { join } from "path";
5
- import { buildGraph } from "./graph.mjs";
5
+ import { getGraph } from "./graph.mjs";
6
6
  import { buildContext } from "./context.mjs";
7
7
  import { detectFeaturesOnSrc, allChecks, loadAllInlineIgnores, isIgnored } from "./detect.mjs";
8
8
  import { evaluateRules } from "./registry/rules.mjs";
@@ -18,7 +18,7 @@ export async function runSentinelCheck(files, opts = {}) {
18
18
 
19
19
  const ctx = await buildContext();
20
20
  const features = detectFeaturesOnSrc();
21
- const graph = buildGraph(ROOT);
21
+ const graph = ctx.graph || getGraph();
22
22
  const results = allChecks(features, graph, ctx);
23
23
 
24
24
  const allIgnores = loadAllInlineIgnores(join(ROOT, "src"));
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { execFileSync } from "child_process";
6
- import { buildGraph } from "./graph.mjs";
6
+ import { getGraph } from "./graph.mjs";
7
7
  import { buildContext } from "./context.mjs";
8
8
  import { detectFeaturesOnSrc, allChecks, loadAllInlineIgnores, isIgnored } from "./detect.mjs";
9
9
  import { evaluateRules } from "./registry/rules.mjs";
@@ -76,7 +76,7 @@ function printResult(result, opts = {}) {
76
76
  if (reminder || hook) {
77
77
  if (!hook) {
78
78
  console.log(` ${YELLOW}Recordatorio:${RESET} ${result.total} violación(es) arquitectónica(s).`);
79
- console.log(` Ejecuta ${CYAN}forge quench${RESET} para ver el detalle completo.\n`);
79
+ console.log(` Ejecuta ${CYAN}forge quench${RESET} para ver el detalle completo y pipeline de corrección.\n`);
80
80
  }
81
81
 
82
82
  if (result.hasCritical) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync } from "fs";
4
4
  import { join } from "path";
5
- import { buildGraph } from "./graph.mjs";
5
+ import { getGraph } from "./graph.mjs";
6
6
  import { buildContext } from "./context.mjs";
7
7
  import { detectFeaturesOnSrc, allChecks } from "./detect.mjs";
8
8
  import { evaluateRules } from "./registry/rules.mjs";
@@ -67,7 +67,7 @@ async function checkProposedFile(filePath, content) {
67
67
 
68
68
  const ctx = await buildContext();
69
69
  const features = detectFeaturesOnSrc();
70
- const graph = buildGraph(ROOT);
70
+ const graph = ctx.graph || getGraph();
71
71
  const results = allChecks(features, graph, ctx);
72
72
 
73
73
  const violations = [];
@@ -3,6 +3,7 @@
3
3
  import { join, relative, dirname, basename } from "path";
4
4
  import { readFileSync, existsSync, readdirSync, statSync } from "fs";
5
5
  import { parseImportPaths } from "./parse-imports.mjs";
6
+ import { saveCache, loadCache } from "./forge-config.mjs";
6
7
 
7
8
  const ROOT = process.cwd();
8
9
  const SRC = join(ROOT, "src");
@@ -308,7 +309,7 @@ export function buildGraph(projectRoot = ROOT) {
308
309
  const gImports = parseImportPaths(content, filePath);
309
310
  const seenTargets = new Set();
310
311
 
311
- for (const imp of imports) {
312
+ for (const imp of gImports) {
312
313
  let targetId = resolveNodeId(imp, featureMap);
313
314
 
314
315
  if (!targetId) {
@@ -421,7 +422,7 @@ export function buildGraph(projectRoot = ROOT) {
421
422
  if (directedEdges[e.from]) directedEdges[e.from].push(e.to);
422
423
  }
423
424
 
424
- let hasCycle = false;
425
+ let hasCycles = false;
425
426
  function dfs(node, visited, stack) {
426
427
  if (stack.has(node)) return true;
427
428
  if (visited.has(node)) return false;
@@ -436,20 +437,28 @@ export function buildGraph(projectRoot = ROOT) {
436
437
 
437
438
  const visited = new Set();
438
439
  for (const n of allNodeIds) {
439
- if (hasCycle) break;
440
+ if (hasCycles) break;
440
441
  if (!visited.has(n)) {
441
442
  if (dfs(n, visited, new Set())) {
442
- hasCycle = true;
443
+ hasCycles = true;
443
444
  }
444
445
  }
445
446
  }
446
447
 
447
- if (hasCycle) {
448
+ if (hasCycles) {
448
449
  addViolation("(cycle)", "(cycle)", SEVERITY.ERROR, "R9",
449
450
  "Ciclo de dependencia detectado en el grafo global");
450
451
  }
451
452
 
452
- /* ── 8. Stats ── */
453
+ /* ── 7a. Summary output (compact) ── */
454
+ function buildSummary(graph) {
455
+ return {
456
+ stats: graph.stats,
457
+ violations: graph.violations.map(v => ({ rule: v.rule, severity: v.severity, from: v.from, to: v.to, description: v.description })),
458
+ };
459
+ }
460
+
461
+ /* ── 8. Stats ── */
453
462
  const bySeverity = { CRITICAL: 0, ERROR: 0, WARNING: 0 };
454
463
  for (const v of violations) {
455
464
  bySeverity[v.severity] = (bySeverity[v.severity] || 0) + 1;
@@ -488,6 +497,7 @@ export function buildGraph(projectRoot = ROOT) {
488
497
  criticalViolations: bySeverity.CRITICAL || 0,
489
498
  errorViolations: bySeverity.ERROR || 0,
490
499
  warningViolations: bySeverity.WARNING || 0,
500
+ hasCycles,
491
501
  riskScore,
492
502
  health,
493
503
  dependencyHealth,
@@ -504,6 +514,42 @@ export function validateGraph(graph) {
504
514
  };
505
515
  }
506
516
 
517
+ /**
518
+ * getGraph — Cache-aware graph builder.
519
+ * Uses cached graph if src/ hasn't changed, builds fresh otherwise.
520
+ * Call with { force: true } to always rebuild.
521
+ */
522
+ let _graphCache = null;
523
+
524
+ export function getGraph(projectRoot = ROOT, opts = {}) {
525
+ const { force = false } = opts;
526
+
527
+ // Return memoized in-memory graph (same process)
528
+ if (_graphCache && !force) return _graphCache;
529
+
530
+ // Try disk cache
531
+ if (!force) {
532
+ const cached = loadCache("graph", projectRoot);
533
+ if (cached.valid && cached.data) {
534
+ _graphCache = cached.data;
535
+ return _graphCache;
536
+ }
537
+ }
538
+
539
+ // Build fresh and cache
540
+ const graph = buildGraph(projectRoot);
541
+ saveCache("graph", graph, projectRoot);
542
+ _graphCache = graph;
543
+ return graph;
544
+ }
545
+
546
+ /**
547
+ * Reset in-memory cache (for testing or forced refresh).
548
+ */
549
+ export function resetGraphCache() {
550
+ _graphCache = null;
551
+ }
552
+
507
553
  export function exportGraph(graph) {
508
554
  let out = "## Architecture Graph\n\n";
509
555
  out += `**Nodes:** ${graph.stats.totalNodes} \n`;
@@ -565,11 +611,21 @@ export function exportGraph(graph) {
565
611
  async function main() {
566
612
  const args = process.argv.slice(2);
567
613
  const format = args.includes("--json") ? "json" : "text";
568
- const graph = buildGraph();
614
+ const summary = args.includes("--summary");
615
+ const force = args.includes("--force");
616
+ const graph = getGraph(ROOT, { force });
569
617
  if (format === "json") {
570
- console.log(JSON.stringify(graph, null, 2));
618
+ if (summary) {
619
+ console.log(JSON.stringify(buildSummary(graph), null, 2));
620
+ } else {
621
+ console.log(JSON.stringify(graph, null, 2));
622
+ }
571
623
  } else {
572
- console.log(exportGraph(graph));
624
+ if (summary) {
625
+ console.log(`Graph: ${graph.stats.totalNodes} nodes, ${graph.stats.totalEdges} edges, ${graph.stats.violations} violations, risk ${graph.stats.riskScore}/100, health: ${graph.stats.health}`);
626
+ } else {
627
+ console.log(exportGraph(graph));
628
+ }
573
629
  }
574
630
  }
575
631
 
@@ -150,11 +150,11 @@ async function cmdCheck() {
150
150
 
151
151
  // Run detect only over the staged source files
152
152
  const { allChecks, detectFeaturesOnSrc } = await import("./detect.mjs");
153
- const { buildGraph } = await import("./graph.mjs");
153
+ const { getGraph } = await import("./graph.mjs");
154
154
  const { buildContext } = await import("./context.mjs");
155
155
 
156
156
  const ctx = await buildContext();
157
- const graph = buildGraph(ROOT);
157
+ const graph = ctx.graph || getGraph();
158
158
  const features = detectFeaturesOnSrc();
159
159
  const results = allChecks(features, graph, ctx);
160
160