@ronaldjdevfs/forge 1.0.2 → 1.1.0

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 (48) hide show
  1. package/README.md +130 -8
  2. package/package.json +2 -2
  3. package/skills/forge/SKILL.md +86 -15
  4. package/skills/forge/profiles/express-drizzle.md +107 -0
  5. package/skills/forge/profiles/fastify-mongodb.md +103 -0
  6. package/skills/forge/profiles/fastify-prisma.md +81 -0
  7. package/skills/forge/profiles/nestjs-mongodb.md +92 -0
  8. package/skills/forge/profiles/nestjs-postgres.md +98 -0
  9. package/skills/forge/reference/api-design.md +62 -0
  10. package/skills/forge/reference/assay.md +82 -0
  11. package/skills/forge/reference/cast.md +81 -7
  12. package/skills/forge/reference/data-patterns.md +86 -0
  13. package/skills/forge/reference/di-strategies.md +50 -0
  14. package/skills/forge/reference/errors.md +65 -0
  15. package/skills/forge/reference/events.md +95 -0
  16. package/skills/forge/reference/help.md +40 -0
  17. package/skills/forge/reference/hooks.md +62 -0
  18. package/skills/forge/reference/observability.md +66 -0
  19. package/skills/forge/reference/patterns.md +52 -0
  20. package/skills/forge/reference/principles.md +6 -0
  21. package/skills/forge/reference/reforge.md +69 -5
  22. package/skills/forge/reference/relocate.md +15 -2
  23. package/skills/forge/reference/security-patterns.md +87 -0
  24. package/skills/forge/reference/testing-patterns.md +69 -0
  25. package/skills/forge/scripts/assay.mjs +481 -0
  26. package/skills/forge/scripts/context.mjs +147 -43
  27. package/skills/forge/scripts/detect.mjs +371 -22
  28. package/skills/forge/scripts/forge-api.mjs +373 -0
  29. package/skills/forge/scripts/forge-config.mjs +268 -0
  30. package/skills/forge/scripts/forge-signals.mjs +131 -0
  31. package/skills/forge/scripts/forge-state.mjs +97 -0
  32. package/skills/forge/scripts/formatter.mjs +133 -0
  33. package/skills/forge/scripts/graph.mjs +5 -21
  34. package/skills/forge/scripts/hook.mjs +250 -0
  35. package/skills/forge/scripts/inspect.mjs +171 -22
  36. package/skills/forge/scripts/parse-imports.mjs +249 -0
  37. package/skills/forge/scripts/pin.mjs +151 -0
  38. package/skills/forge/scripts/posttool.mjs +224 -0
  39. package/skills/forge/scripts/profile.mjs +124 -20
  40. package/skills/forge/scripts/registry/rules.mjs +344 -0
  41. package/skills/forge/scripts/rename.mjs +669 -0
  42. package/skills/forge/scripts/rollback.mjs +213 -0
  43. package/skills/forge/scripts/update.mjs +114 -0
  44. package/skills/forge/templates/feature/domain-error.ts.md +9 -0
  45. package/skills/forge/templates/feature/domain-event.ts.md +9 -0
  46. package/skills/forge/templates/feature/event-handler.ts.md +10 -0
  47. package/skills/forge/templates/feature/use-case.ts.md +10 -2
  48. package/skills/forge/tests/core.test.mjs +403 -0
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Forge core tests — node:test, sin dependencias externas.
3
+ *
4
+ * Ejecutar: node --test .opencode/skills/forge/tests/core.test.mjs
5
+ */
6
+
7
+ import { describe, it, before, after } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+
13
+ let tmpDir;
14
+ let origCwd;
15
+
16
+ // ── Helpers ──
17
+
18
+ function scaffoldProject(files) {
19
+ tmpDir = mkdtempSync(join(tmpdir(), "forge-test-"));
20
+ for (const [path, content] of Object.entries(files)) {
21
+ const full = join(tmpDir, path);
22
+ mkdirSync(join(full, ".."), { recursive: true });
23
+ writeFileSync(full, content);
24
+ }
25
+ origCwd = process.cwd;
26
+ process.cwd = () => tmpDir;
27
+ return tmpDir;
28
+ }
29
+
30
+ function cleanup() {
31
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
32
+ if (origCwd) process.cwd = origCwd;
33
+ }
34
+
35
+ // ── Tests ──
36
+
37
+ describe("profile.mjs", () => {
38
+ it("detects express-prisma from context", async () => {
39
+ const { detectProfile } = await import("../scripts/profile.mjs");
40
+ const ctx = {
41
+ framework: "Express",
42
+ database: "PostgreSQL",
43
+ orm: "Prisma",
44
+ diStrategy: "tsyringe",
45
+ };
46
+ assert.equal(detectProfile(ctx), "express-prisma");
47
+ });
48
+
49
+ it("detects fastify-postgres from context", async () => {
50
+ const { detectProfile } = await import("../scripts/profile.mjs");
51
+ const ctx = {
52
+ framework: "Fastify",
53
+ database: "PostgreSQL",
54
+ orm: "Prisma",
55
+ diStrategy: "manual",
56
+ };
57
+ assert.equal(detectProfile(ctx), "fastify-postgres");
58
+ });
59
+
60
+ it("detects nestjs-prisma from context", async () => {
61
+ const { detectProfile } = await import("../scripts/profile.mjs");
62
+ const ctx = {
63
+ framework: "NestJS",
64
+ database: "PostgreSQL",
65
+ orm: "Prisma",
66
+ diStrategy: "framework",
67
+ };
68
+ assert.equal(detectProfile(ctx), "nestjs-prisma");
69
+ });
70
+
71
+ it("returns unknown for empty context", async () => {
72
+ const { detectProfile } = await import("../scripts/profile.mjs");
73
+ const ctx = {
74
+ framework: "unknown",
75
+ database: "unknown",
76
+ orm: "none",
77
+ diStrategy: "manual",
78
+ };
79
+ assert.equal(detectProfile(ctx), "unknown");
80
+ });
81
+
82
+ it("detects express-drizzle from context", async () => {
83
+ const { detectProfile } = await import("../scripts/profile.mjs");
84
+ const ctx = {
85
+ framework: "Express",
86
+ database: "PostgreSQL",
87
+ orm: "Drizzle",
88
+ diStrategy: "manual",
89
+ };
90
+ assert.equal(detectProfile(ctx), "express-drizzle");
91
+ });
92
+
93
+ it("detects fastify-mongodb from context", async () => {
94
+ const { detectProfile } = await import("../scripts/profile.mjs");
95
+ const ctx = {
96
+ framework: "Fastify",
97
+ database: "MongoDB",
98
+ orm: "Mongoose",
99
+ diStrategy: "manual",
100
+ };
101
+ assert.equal(detectProfile(ctx), "fastify-mongodb");
102
+ });
103
+
104
+ it("detects nestjs-mongodb from context", async () => {
105
+ const { detectProfile } = await import("../scripts/profile.mjs");
106
+ const ctx = {
107
+ framework: "NestJS",
108
+ database: "MongoDB",
109
+ orm: "Mongoose",
110
+ diStrategy: "framework",
111
+ };
112
+ assert.equal(detectProfile(ctx), "nestjs-mongodb");
113
+ });
114
+
115
+ it("detects nestjs-postgres from context", async () => {
116
+ const { detectProfile } = await import("../scripts/profile.mjs");
117
+ const ctx = {
118
+ framework: "NestJS",
119
+ database: "PostgreSQL",
120
+ orm: "TypeORM",
121
+ diStrategy: "framework",
122
+ };
123
+ assert.equal(detectProfile(ctx), "nestjs-postgres");
124
+ });
125
+ });
126
+
127
+ describe("graph.mjs", () => {
128
+ it("builds an empty graph for an empty project", async () => {
129
+ const { buildGraph } = await import("../scripts/graph.mjs");
130
+ const graph = buildGraph(process.cwd());
131
+ assert.ok(Array.isArray(graph.nodes));
132
+ assert.ok(Array.isArray(graph.edges));
133
+ assert.equal(graph.stats.totalNodes, 0);
134
+ assert.equal(graph.stats.totalEdges, 0);
135
+ assert.equal(graph.stats.violations, 0);
136
+ assert.equal(graph.stats.health, "healthy");
137
+ });
138
+ });
139
+
140
+ describe("armorer.mjs", () => {
141
+ it("builds a healthy report for an empty project", async () => {
142
+ const { buildOwnershipReport } = await import("../scripts/armorer.mjs");
143
+ const report = buildOwnershipReport(process.cwd());
144
+ assert.equal(report.health, "healthy");
145
+ assert.ok(Array.isArray(report.orphans));
146
+ assert.ok(Array.isArray(report.duplicates));
147
+ assert.ok(Array.isArray(report.misplaced));
148
+ assert.equal(typeof report.score, "number");
149
+ assert.equal(report.hasPlatform, false);
150
+ assert.equal(report.hasFeatures, false);
151
+ assert.equal(report.hasShared, false);
152
+ assert.equal(report.hasInfra, false);
153
+ });
154
+ });
155
+
156
+ describe("forge-config.mjs", () => {
157
+ it("returns default state when no config file exists", async () => {
158
+ const { loadState } = await import("../scripts/forge-config.mjs");
159
+ const state = await loadState();
160
+ // When no config exists, loadState returns a default
161
+ assert.ok(state !== null);
162
+ assert.ok(typeof state === "object");
163
+ });
164
+
165
+ it("loads and saves state", async () => {
166
+ const { loadState, saveState, STATE_FILE } = await import("../scripts/forge-config.mjs");
167
+
168
+ // Remove any existing state for clean test
169
+ const initial = await loadState();
170
+ assert.ok(initial !== null);
171
+
172
+ // Save a custom value
173
+ const testValue = { test: true, timestamp: Date.now() };
174
+ await saveState(testValue);
175
+
176
+ // Load it back
177
+ // Clean up: restore original state
178
+ await saveState(initial);
179
+ });
180
+ });
181
+
182
+ describe("chain.mjs", () => {
183
+ it("builds an empty dependency graph for empty project", async () => {
184
+ const { buildDependencyGraph } = await import("../scripts/chain.mjs");
185
+ const graph = buildDependencyGraph();
186
+ assert.ok(Array.isArray(graph.nodes));
187
+ assert.ok(graph.hasCycles === false || graph.hasCycles === true);
188
+ assert.ok(typeof graph.cycleFree === "boolean");
189
+ });
190
+ });
191
+
192
+ describe("formatter.mjs", () => {
193
+ it("formatViolation produces colored output", async () => {
194
+ const { formatViolation, formatCheck } = await import("../scripts/formatter.mjs");
195
+ const out = formatViolation({ severity: "ERROR", label: "Test", detail: "src/test.ts", fix: "Fix it" });
196
+ assert.ok(out.includes("ERROR"));
197
+ assert.ok(out.includes("Test"));
198
+ assert.ok(out.includes("Fix"));
199
+ });
200
+
201
+ it("formatCheck shows pass/fail icons", async () => {
202
+ const { formatCheck } = await import("../scripts/formatter.mjs");
203
+ const pass = formatCheck({ severity: "INFO", label: "OK", pass: true });
204
+ const fail = formatCheck({ severity: "ERROR", label: "FAIL", pass: false, fix: "Fix it" });
205
+ assert.ok(pass.includes("✔"));
206
+ assert.ok(fail.includes("✘"));
207
+ assert.ok(fail.includes("Fix"));
208
+ });
209
+
210
+ it("scoreBar returns a bar string", async () => {
211
+ const { scoreBar } = await import("../scripts/formatter.mjs");
212
+ const bar = scoreBar(15, 20);
213
+ assert.ok(bar.includes("█"));
214
+ assert.ok(bar.includes("░"));
215
+ });
216
+
217
+ it("formatJson produces valid JSON", async () => {
218
+ const { formatJson } = await import("../scripts/formatter.mjs");
219
+ const json = formatJson({ a: 1, b: [2, 3] });
220
+ const parsed = JSON.parse(json);
221
+ assert.equal(parsed.a, 1);
222
+ assert.deepEqual(parsed.b, [2, 3]);
223
+ });
224
+ });
225
+
226
+ describe("registry/rules.mjs", () => {
227
+ it("has 9 built-in rules (R1-R9)", async () => {
228
+ const { RULES, RULES_BY_ID } = await import("../scripts/registry/rules.mjs");
229
+ assert.equal(RULES.length, 9);
230
+ assert.ok(RULES_BY_ID.R1);
231
+ assert.ok(RULES_BY_ID.R9);
232
+ for (const r of RULES) {
233
+ assert.ok(r.id);
234
+ assert.ok(r.name);
235
+ assert.ok(r.severity);
236
+ assert.ok(r.check);
237
+ assert.ok(typeof r.check === "function");
238
+ }
239
+ });
240
+
241
+ it("R1 detects feature→infra violations", async () => {
242
+ const { RULES_BY_ID } = await import("../scripts/registry/rules.mjs");
243
+ const mockGraph = {
244
+ edges: [
245
+ { from: "features/auth", fromLayer: "feature", to: "infra/prisma", toLayer: "infra", file: "src/features/auth/repo.ts" },
246
+ { from: "features/users", fromLayer: "feature", to: "features/products", toLayer: "feature", file: "src/features/users/cross.ts" }, // not R1
247
+ ],
248
+ };
249
+ const violations = RULES_BY_ID.R1.check(mockGraph);
250
+ assert.equal(violations.length, 1);
251
+ assert.equal(violations[0].rule, "R1");
252
+ assert.equal(violations[0].severity, "CRITICAL");
253
+ });
254
+
255
+ it("loadCustomRules returns empty array when no config", async () => {
256
+ const { loadCustomRules } = await import("../scripts/registry/rules.mjs");
257
+ const rules = loadCustomRules();
258
+ assert.ok(Array.isArray(rules));
259
+ });
260
+
261
+ it("evaluateRules runs all rules against a graph", async () => {
262
+ const { evaluateRules } = await import("../scripts/registry/rules.mjs");
263
+ const mockGraph = {
264
+ nodes: [{ id: "features/auth", layer: "feature" }, { id: "infra/prisma", layer: "infra" }],
265
+ edges: [{ from: "features/auth", fromLayer: "feature", to: "infra/prisma", toLayer: "infra", file: "src/features/auth/repo.ts" }],
266
+ stats: { hasCycles: false },
267
+ };
268
+ const violations = evaluateRules(mockGraph);
269
+ assert.ok(violations.length > 0);
270
+ const r1Violations = violations.filter(v => v.rule === "R1");
271
+ assert.equal(r1Violations.length, 1);
272
+ });
273
+ });
274
+
275
+ describe("detect.mjs — inline ignores", () => {
276
+ it("parseInlineIgnores detects forge-ignore-next-line", async () => {
277
+ const { parseInlineIgnores } = await import("../scripts/detect.mjs");
278
+ const content = "// forge-ignore-next-line\nimport { x } from '../infra/prisma';\nconst a = 1;\n";
279
+ const ignores = parseInlineIgnores(content);
280
+ assert.ok(ignores[2]); // line 2 should be ignored
281
+ assert.ok(ignores[2].has("*")); // wildcard
282
+ assert.equal(Object.keys(ignores).length, 1);
283
+ });
284
+
285
+ it("parseInlineIgnores detects forge-ignore: R1", async () => {
286
+ const { parseInlineIgnores } = await import("../scripts/detect.mjs");
287
+ const content = "import { x } from '../infra/prisma'; // forge-ignore: R1\nconst a = 1;\n";
288
+ const ignores = parseInlineIgnores(content);
289
+ assert.ok(ignores[1]); // line 1 should be ignored
290
+ assert.ok(ignores[1].has("R1"));
291
+ assert.ok(!ignores[1].has("R2"));
292
+ });
293
+
294
+ it("parseInlineIgnores handles multiple rules", async () => {
295
+ const { parseInlineIgnores } = await import("../scripts/detect.mjs");
296
+ const content = "import { x } from '../other'; // forge-ignore: R1, R8\n";
297
+ const ignores = parseInlineIgnores(content);
298
+ assert.ok(ignores[1]);
299
+ assert.ok(ignores[1].has("R1"));
300
+ assert.ok(ignores[1].has("R8"));
301
+ });
302
+
303
+ it("isIgnored returns true for wildcard ignore", async () => {
304
+ const { parseInlineIgnores, isIgnored } = await import("../scripts/detect.mjs");
305
+ const content = "// forge-ignore-next-line\nimport { x } from '../infra/prisma';\n";
306
+ const ignores = new Map();
307
+ ignores.set("src/test.ts", parseInlineIgnores(content));
308
+
309
+ const violation = { severity: "CRITICAL", label: "[R1] Feature imports infra", detail: "src/test.ts:2", fix: "Fix" };
310
+ assert.ok(isIgnored(violation, ignores));
311
+ });
312
+
313
+ it("isIgnored returns false for non-ignored violation", async () => {
314
+ const { isIgnored } = await import("../scripts/detect.mjs");
315
+ const ignores = new Map();
316
+ const violation = { severity: "ERROR", label: "[R8] Cross-feature import", detail: "src/test.ts:5", fix: "Fix" };
317
+ assert.ok(!isIgnored(violation, ignores));
318
+ });
319
+ });
320
+
321
+ describe("posttool.mjs", () => {
322
+ it("postToolCheck returns empty for no files", async () => {
323
+ const { postToolCheck } = await import("../scripts/posttool.mjs");
324
+ const result = await postToolCheck([], {});
325
+ assert.equal(result.total, 0);
326
+ assert.ok(result.summary);
327
+ });
328
+ });
329
+
330
+ describe("assay.mjs", () => {
331
+ it("has 5 predefined personas", async () => {
332
+ const { PERSONAS } = await import("../scripts/assay.mjs");
333
+ assert.equal(PERSONAS.length, 5);
334
+ const ids = PERSONAS.map(p => p.id);
335
+ assert.ok(ids.includes("bezos"));
336
+ assert.ok(ids.includes("fowler"));
337
+ assert.ok(ids.includes("hacker"));
338
+ assert.ok(ids.includes("pm"));
339
+ assert.ok(ids.includes("senior"));
340
+ for (const p of PERSONAS) {
341
+ assert.ok(p.name);
342
+ assert.ok(p.role);
343
+ assert.ok(p.focus);
344
+ assert.ok(Array.isArray(p.focus));
345
+ assert.ok(p.focus.length > 0);
346
+ assert.ok(typeof p.getOpinion === "function");
347
+ }
348
+ });
349
+
350
+ it("generateAssay produces opinions for all personas", async () => {
351
+ const { generateAssay } = await import("../scripts/assay.mjs");
352
+ const mockReport = {
353
+ total: 72,
354
+ max: 140,
355
+ grade: "F",
356
+ violations: [
357
+ { severity: "CRITICAL", label: "[R1] Feature imports infra", pass: false, rule: "R1", detail: "src/test.ts:1" },
358
+ { severity: "ERROR", label: "Cross-feature import", pass: false, rule: "R8", detail: "src/test.ts:2" },
359
+ ],
360
+ severityCounts: { CRITICAL: 1, ERROR: 1 },
361
+ };
362
+ const mockGraph = {
363
+ nodes: [{ id: "a", layer: "feature" }, { id: "b", layer: "infra" }],
364
+ edges: [{ from: "a", fromLayer: "feature", to: "b", toLayer: "infra" }],
365
+ stats: { hasCycles: false, dependencyHealth: 50, totalNodes: 2, totalEdges: 1, violations: 1, riskScore: 30, health: "degraded" },
366
+ };
367
+ const opinions = generateAssay(mockReport, mockGraph);
368
+ assert.equal(opinions.length, 5);
369
+ for (const op of opinions) {
370
+ assert.ok(op.persona);
371
+ assert.ok(op.persona.id);
372
+ assert.ok(op.persona.name);
373
+ assert.ok(typeof op.opinion === "string");
374
+ assert.ok(op.opinion.length > 0);
375
+ }
376
+ });
377
+
378
+ it("each persona gives unique perspective", async () => {
379
+ const { generateAssay } = await import("../scripts/assay.mjs");
380
+ const opinions = generateAssay({ total: 0, max: 140, grade: "F", violations: [], severityCounts: {} }, { nodes: [], edges: [], stats: {} });
381
+ const texts = opinions.map(o => o.opinion);
382
+ // All should be different
383
+ const unique = new Set(texts);
384
+ assert.ok(unique.size >= 3, "At least 3 personas give different opinions");
385
+ });
386
+
387
+ it("bezos focuses on coupling violations", async () => {
388
+ const { PERSONAS } = await import("../scripts/assay.mjs");
389
+ const bezos = PERSONAS.find(p => p.id === "bezos");
390
+ const report = {
391
+ total: 50,
392
+ max: 140,
393
+ grade: "F",
394
+ violations: [
395
+ { severity: "CRITICAL", label: "[R1] Feature imports infra", pass: false, rule: "R1", detail: "src/a.ts" },
396
+ { severity: "CRITICAL", label: "[R8] Cross-feature import", pass: false, rule: "R8", detail: "src/b.ts" },
397
+ ],
398
+ severityCounts: { CRITICAL: 2 },
399
+ };
400
+ const opinion = bezos.getOpinion(report, { stats: { hasCycles: false } });
401
+ assert.ok(opinion.includes("R1") || opinion.includes("R8") || opinion.includes("acoplamiento") || opinion.includes("importan"));
402
+ });
403
+ });