@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
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { join, relative, basename } from "path";
4
- import { readFileSync, existsSync, readdirSync, statSync } from "fs";
4
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from "fs";
5
5
  import { buildGraph } from "./graph.mjs";
6
6
  import { buildOwnershipReport } from "./armorer.mjs";
7
+ import { parseImportsWithLines } from "./parse-imports.mjs";
8
+ import { detectNamingViolations, computeExpectedName } from "./rename.mjs";
9
+ import { formatViolation, formatCheck, GREEN, RED, YELLOW, CYAN, BOLD, RESET, DIM, GRAY } from "./formatter.mjs";
7
10
 
8
11
  const ROOT = process.cwd();
9
12
  const SRC = join(ROOT, "src");
@@ -19,8 +22,6 @@ const LEGACY_DIRS = [
19
22
  ["src/adapters/out/database/mappers", "Mappers legacy"],
20
23
  ["src/setting/dependencies", "DI wiring legacy (.di.ts)"],
21
24
  ];
22
-
23
- const IMPORT_RE = /import\s+(?:type\s+)?(?:\{[^}]*\}|[^;{]+)\s+from\s+['"]([^'"]+)['"]/g;
24
25
  const INJECTABLE_RE = /@injectable\s*\(\)/g;
25
26
  const INJECT_RE = /@inject\s*\([^)]+\)/g;
26
27
  const BD_MODEL_RE = /\b\w+Model\s*\.\s*(find|findOne|findById|create|insertMany|updateOne|updateMany|deleteOne|deleteMany|aggregate|save|lean)\s*\(/g;
@@ -77,7 +78,7 @@ function findFiles(dir, ext, maxDepth = 5) {
77
78
  return results;
78
79
  }
79
80
 
80
- function detectFeaturesOnSrc() {
81
+ export function detectFeaturesOnSrc() {
81
82
  if (!isDir(FEATURES)) return [];
82
83
  return listDir(FEATURES).filter((f) => isDir(join(FEATURES, f)));
83
84
  }
@@ -98,18 +99,7 @@ function detectLegacyFeatures() {
98
99
  }
99
100
 
100
101
  function parseImports(content, filePath) {
101
- const imports = [];
102
- const lines = content.split("\n");
103
- for (let i = 0; i < lines.length; i++) {
104
- const m = lines[i].match(IMPORT_RE);
105
- if (m) {
106
- for (const full of m) {
107
- const src = full.match(/from\s+['"]([^'"]+)['"]/);
108
- if (src) imports.push({ file: filePath, source: src[1], line: i + 1 });
109
- }
110
- }
111
- }
112
- return imports;
102
+ return parseImportsWithLines(content, filePath);
113
103
  }
114
104
 
115
105
  function hasDecorator(content, decoratorRe) {
@@ -129,6 +119,99 @@ function severity(label, sev) {
129
119
  return { severity: sev, label };
130
120
  }
131
121
 
122
+ // ── Inline Ignores ──
123
+
124
+ /**
125
+ * Parse all forge-ignore comments in a file.
126
+ * Returns a Set of rule IDs that should be ignored for each line.
127
+ */
128
+ export function parseInlineIgnores(content) {
129
+ const ignores = {}; // lineNumber → Set<ruleId>
130
+ const lines = content.split("\n");
131
+
132
+ for (let i = 0; i < lines.length; i++) {
133
+ const trimmed = lines[i].trim();
134
+
135
+ // // forge-ignore-next-line
136
+ if (trimmed.includes("// forge-ignore-next-line")) {
137
+ const targetLine = i + 2; // next line is 1-indexed
138
+ if (!ignores[targetLine]) ignores[targetLine] = new Set();
139
+ ignores[targetLine].add("*");
140
+ continue;
141
+ }
142
+
143
+ // // forge-ignore: R1, R3 (applies to current line)
144
+ const match = trimmed.match(/\/\/\s*forge-ignore:\s*(.+)/);
145
+ if (match) {
146
+ if (!ignores[i + 1]) ignores[i + 1] = new Set();
147
+ const rules = match[1].split(",").map(r => r.trim().toUpperCase());
148
+ for (const r of rules) ignores[i + 1].add(r);
149
+ }
150
+ }
151
+
152
+ return ignores;
153
+ }
154
+
155
+ /**
156
+ * Load inline ignores for all .ts/.mjs files in a directory (recursive).
157
+ * Returns Map<filePath, Map<lineNumber, Set<ruleId>>>
158
+ */
159
+ export function loadAllInlineIgnores(dir, maxDepth = 10) {
160
+ const allIgnores = new Map();
161
+
162
+ function walk(d, depth) {
163
+ if (depth > maxDepth) return;
164
+ try {
165
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
166
+ const full = join(d, entry.name);
167
+ if (entry.isDirectory()) {
168
+ walk(full, depth + 1);
169
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".mjs") || entry.name.endsWith(".js")) {
170
+ const content = read(full);
171
+ if (!content) continue;
172
+ const ignores = parseInlineIgnores(content);
173
+ if (Object.keys(ignores).length > 0) {
174
+ allIgnores.set(full, ignores);
175
+ }
176
+ }
177
+ }
178
+ } catch { /* skip */ }
179
+ }
180
+
181
+ if (existsSync(dir)) walk(dir, 0);
182
+ return allIgnores;
183
+ }
184
+
185
+ /**
186
+ * Check if a violation should be ignored based on inline ignores.
187
+ */
188
+ export function isIgnored(violation, allIgnores) {
189
+ if (!violation.detail) return false;
190
+ const filePath = violation.detail.split(":")[0];
191
+ const lineMatch = violation.detail.match(/:(\d+)/);
192
+ if (!lineMatch) return false;
193
+
194
+ const line = parseInt(lineMatch[1], 10);
195
+ const fileIgnores = allIgnores.get(filePath);
196
+ if (!fileIgnores) return false;
197
+
198
+ const lineIgnores = fileIgnores[line];
199
+ if (!lineIgnores) return false;
200
+
201
+ if (lineIgnores.has("*")) return true;
202
+
203
+ const ruleMatch = violation.label.match(/\[([^\]]+)\]/);
204
+ if (ruleMatch) {
205
+ const ruleId = ruleMatch[1].toUpperCase();
206
+ if (lineIgnores.has(ruleId)) return true;
207
+ }
208
+
209
+ // Also check if the violation's rule id is in the ignores
210
+ if (violation.rule && lineIgnores.has(violation.rule.toUpperCase())) return true;
211
+
212
+ return false;
213
+ }
214
+
132
215
  /* ── Checks ── */
133
216
 
134
217
  export function checkStructure(features) {
@@ -793,25 +876,242 @@ export function checkDependencies(ctx) {
793
876
  return { score: Math.min(score, 15), checks };
794
877
  }
795
878
 
879
+ // ── Custom rules (B2) ──
880
+
881
+ const CUSTOM_RULES_PATH = join(ROOT, ".forge", "rules.json");
882
+
883
+ export function loadCustomRules() {
884
+ try {
885
+ const raw = readFileSync(CUSTOM_RULES_PATH, "utf-8");
886
+ return JSON.parse(raw);
887
+ } catch {
888
+ return [];
889
+ }
890
+ }
891
+
892
+ export function checkCustomRules(features) {
893
+ const rules = loadCustomRules();
894
+ const checks = [];
895
+ let score = 0;
896
+
897
+ if (rules.length === 0) {
898
+ checks.push({ severity: SEVERITY.INFO, label: "Reglas custom: vacío (usa .forge/rules.json)", pass: true });
899
+ score += 5;
900
+ return { score, checks };
901
+ }
902
+
903
+ let totalPatterns = 0;
904
+ let violations = 0;
905
+
906
+ for (const rule of rules) {
907
+ if (!rule.id || !rule.pattern) continue;
908
+ totalPatterns++;
909
+
910
+ let fileRe = null;
911
+ let contentRe = null;
912
+ try {
913
+ if (rule.files) fileRe = new RegExp(rule.files);
914
+ contentRe = new RegExp(rule.pattern);
915
+ } catch {
916
+ checks.push({ severity: SEVERITY.ERROR, label: `Regla custom ${rule.id}: patrón inválido`, pass: false });
917
+ continue;
918
+ }
919
+
920
+ for (const feat of features) {
921
+ if (!feat.files) continue;
922
+ for (const f of feat.files) {
923
+ if (fileRe && !fileRe.test(f)) continue;
924
+ const content = read(f);
925
+ if (!content) continue;
926
+
927
+ contentRe.lastIndex = 0;
928
+ let match;
929
+ while ((match = contentRe.exec(content)) !== null) {
930
+ violations++;
931
+ const line = content.slice(0, match.index).split("\n").length;
932
+ checks.push({
933
+ severity: rule.severity || SEVERITY.ERROR,
934
+ label: `[${rule.id}] ${rule.message || "Violación de regla custom"} en ${relative(ROOT, f)}:${line}`,
935
+ pass: false,
936
+ detail: `Match: "${match[0].slice(0, 80)}"`,
937
+ fix: rule.fix || null,
938
+ });
939
+ }
940
+ }
941
+ }
942
+ }
943
+
944
+ if (totalPatterns > 0 && violations === 0) {
945
+ checks.push({ severity: SEVERITY.INFO, label: `Reglas custom: ${totalPatterns} patrón(es) activo(s), 0 violaciones`, pass: true });
946
+ score += 5;
947
+ } else if (totalPatterns > 0) {
948
+ checks.push({ severity: SEVERITY.WARNING, label: `Reglas custom: ${totalPatterns} patrón(es), ${violations} violación(es)`, pass: violations === 0 });
949
+ }
950
+
951
+ return { score, checks };
952
+ }
953
+
954
+ export function checkNaming(projectRoot = ROOT) {
955
+ const checks = [];
956
+ let score = 10;
957
+
958
+ const violations = detectNamingViolations(projectRoot);
959
+
960
+ if (violations.length === 0) {
961
+ checks.push({ ...severity("Naming conventions: sin violaciones", SEVERITY.INFO), pass: true });
962
+ return { score, checks };
963
+ }
964
+
965
+ checks.push({
966
+ ...severity(`${violations.length} violacion(es) de naming conventions`, SEVERITY.WARNING),
967
+ pass: false,
968
+ fix: "Ejecutar `node .opencode/skills/forge/scripts/rename.mjs --all` o `forge reforge <filename>` para corregir",
969
+ });
970
+
971
+ for (const v of violations) {
972
+ checks.push({
973
+ ...severity(`Naming: ${relative(ROOT, v.current)}`, SEVERITY.SUGGESTION),
974
+ pass: false,
975
+ detail: `→ ${relative(ROOT, v.expected)}`,
976
+ fix: v.rule,
977
+ });
978
+ score -= 1;
979
+ }
980
+
981
+ return { score: Math.max(score, 0), checks };
982
+ }
983
+
796
984
  export function allChecks(features, graph, ctx) {
797
985
  const g = graph || buildGraph(process.cwd());
798
986
  const c = ctx || {};
799
987
  return {
800
988
  structure: checkStructure(features),
801
989
  layers: checkLayers(features),
990
+ decorators: checkDecorators(features),
802
991
  ownership: checkOwnership(c),
803
992
  platform: checkPlatform(c),
804
993
  dependencies: checkDependencies(c),
805
994
  graph: checkGraph(g),
995
+ customRules: checkCustomRules(features),
996
+ naming: checkNaming(),
806
997
  };
807
998
  }
808
999
 
1000
+ // ── Auto-Fix ──
1001
+
1002
+ /**
1003
+ * Apply auto-fixes for non-CRITICAL violations.
1004
+ * Returns { fixed: number, skipped: number, details: string[] }
1005
+ */
1006
+ export function applyFixes(checks, projectRoot = ROOT) {
1007
+ let fixed = 0;
1008
+ let skipped = 0;
1009
+ const details = [];
1010
+ const SRC = join(projectRoot, "src");
1011
+
1012
+ for (const check of checks) {
1013
+ if (check.pass) continue;
1014
+ if (check.severity === "CRITICAL") {
1015
+ skipped++;
1016
+ continue;
1017
+ }
1018
+
1019
+ // Fix missing @injectable()
1020
+ if (check.fix?.startsWith("Agregar @injectable()") && check.detail) {
1021
+ const filePath = check.detail.includes(":")
1022
+ ? join(projectRoot, check.detail.split(":")[0])
1023
+ : null;
1024
+ if (filePath && existsSync(filePath)) {
1025
+ const content = readFileSync(filePath, "utf-8");
1026
+ const className = check.detail.replace(/.*\//, "").replace(/\.[jt]s/, "");
1027
+ const newContent = content.replace(
1028
+ /(export\s+class\s+\w+)/,
1029
+ "@injectable()\n$1"
1030
+ );
1031
+ if (newContent !== content) {
1032
+ writeFileSync(filePath, newContent);
1033
+ fixed++;
1034
+ details.push(` ${GREEN}✔${RESET} ${relative(projectRoot, filePath)}: agregado @injectable()`);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ // Fix missing experimentalDecorators
1040
+ if (check.fix?.includes('"experimentalDecorators"')) {
1041
+ const tsconfigPath = join(projectRoot, "tsconfig.json");
1042
+ if (existsSync(tsconfigPath)) {
1043
+ try {
1044
+ const content = readFileSync(tsconfigPath, "utf-8");
1045
+ const json = JSON.parse(content);
1046
+ json.compilerOptions = json.compilerOptions || {};
1047
+ json.compilerOptions.experimentalDecorators = true;
1048
+ json.compilerOptions.emitDecoratorMetadata = true;
1049
+ writeFileSync(tsconfigPath, JSON.stringify(json, null, 2) + "\n");
1050
+ fixed++;
1051
+ details.push(` ${GREEN}✔${RESET} tsconfig.json: agregado experimentalDecorators / emitDecoratorMetadata`);
1052
+ } catch {}
1053
+ }
1054
+ }
1055
+
1056
+ // Fix naming violations
1057
+ if (check.label.includes("Naming:") && check.detail?.includes("→")) {
1058
+ const current = join(projectRoot, check.detail.split(" →")[0].trim());
1059
+ const expected = join(projectRoot, check.detail.split("→ ")[1].trim());
1060
+ if (existsSync(current) && !existsSync(expected)) {
1061
+ try {
1062
+ const dir = join(expected, "..");
1063
+ mkdirSync(dir, { recursive: true });
1064
+ renameSync(current, expected);
1065
+ fixed++;
1066
+ details.push(` ${GREEN}✔${RESET} Renombrado: ${relative(projectRoot, current)} → ${relative(projectRoot, expected)}`);
1067
+ } catch (e) {
1068
+ skipped++;
1069
+ details.push(` ${YELLOW}⚠${RESET} No se pudo renombrar: ${e.message}`);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ // Fix container.resolve()
1075
+ if (check.fix?.includes("container.resolve") && check.detail) {
1076
+ const filePath = join(projectRoot, check.detail);
1077
+ if (existsSync(filePath)) {
1078
+ const content = readFileSync(filePath, "utf-8");
1079
+ const newContent = content
1080
+ .replace(/container\.resolve\([^)]+\)/g, "/* DI: injected via constructor */ null")
1081
+ .replace(/import.*container.*from.*tsyringe.*/g, "// container resolve removed");
1082
+ if (newContent !== content) {
1083
+ writeFileSync(filePath, newContent);
1084
+ fixed++;
1085
+ details.push(` ${GREEN}✔${RESET} ${relative(projectRoot, filePath)}: container.resolve() reemplazado`);
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ // Fix missing reflect-metadata import
1091
+ if (check.fix?.includes('import "reflect-metadata"') && check.detail) {
1092
+ const filePath = join(projectRoot, check.detail);
1093
+ if (existsSync(filePath)) {
1094
+ const content = readFileSync(filePath, "utf-8");
1095
+ if (!content.includes('import "reflect-metadata"')) {
1096
+ writeFileSync(filePath, 'import "reflect-metadata";\n' + content);
1097
+ fixed++;
1098
+ details.push(` ${GREEN}✔${RESET} ${relative(projectRoot, filePath)}: agregado import reflect-metadata`);
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ return { fixed, skipped, details };
1105
+ }
1106
+
809
1107
  /* ── CLI ── */
810
1108
  async function main() {
811
1109
  const args = process.argv.slice(2);
812
1110
  const filterType = args.includes("--type") ? args[args.indexOf("--type") + 1] : null;
813
1111
  const filterSeverity = args.includes("--severity") ? args[args.indexOf("--severity") + 1] : null;
814
1112
  const format = args.includes("--json") ? "json" : "text";
1113
+ const doFix = args.includes("--fix");
1114
+ const showIgnores = args.includes("--show-ignores");
815
1115
 
816
1116
  const { buildContext } = await import("./context.mjs");
817
1117
  const ctx = await buildContext();
@@ -819,22 +1119,71 @@ async function main() {
819
1119
  const graph = buildGraph(ROOT);
820
1120
  const results = allChecks(features, graph, ctx);
821
1121
 
1122
+ // Load inline ignores
1123
+ const allIgnores = loadAllInlineIgnores(join(ROOT, "src"));
1124
+
822
1125
  let all = [];
823
1126
  for (const [, cat] of Object.entries(results)) {
824
1127
  all = all.concat(cat.checks);
825
1128
  }
826
1129
 
1130
+ // Filter out ignored violations
1131
+ const filtered = all.filter(c => {
1132
+ if (c.pass) return true;
1133
+ if (isIgnored(c, allIgnores)) return false;
1134
+ return true;
1135
+ });
1136
+
827
1137
  if (filterSeverity) all = all.filter((c) => c.severity === filterSeverity);
828
1138
 
1139
+ if (doFix) {
1140
+ const result = applyFixes(filtered);
1141
+ console.log(`\n${BOLD}Auto-Fix${RESET}`);
1142
+ if (result.fixed > 0) {
1143
+ result.details.forEach(d => console.log(d));
1144
+ console.log(`\n${GREEN}✔${RESET} ${result.fixed} violación(es) corregidas automáticamente`);
1145
+ } else {
1146
+ console.log(` ${GRAY}No se encontraron violaciones auto-corregibles${RESET}`);
1147
+ }
1148
+ if (result.skipped > 0) {
1149
+ console.log(` ${YELLOW}⚠${RESET} ${result.skipped} violación(es) CRITICAL no se auto-corrigieron (requieren intervención manual)`);
1150
+ }
1151
+ console.log();
1152
+ return;
1153
+ }
1154
+
1155
+ // Show inline ignores summary
1156
+ let totalIgnores = 0;
1157
+ for (const [, fileIgnores] of allIgnores) {
1158
+ for (const [, rules] of Object.entries(fileIgnores)) {
1159
+ totalIgnores += rules.size;
1160
+ }
1161
+ }
1162
+ const ignoredCount = all.length - filtered.length;
1163
+
829
1164
  if (format === "json") {
830
- console.log(JSON.stringify({ checks: all, total: all.length }, null, 2));
1165
+ console.log(JSON.stringify({
1166
+ checks: filtered,
1167
+ total: filtered.length,
1168
+ ignoredCount,
1169
+ inlineIgnores: totalIgnores,
1170
+ }, null, 2));
831
1171
  } else {
832
- for (const c of all) {
833
- const icon = c.pass ? "✔" : "✘";
834
- console.log(`[${c.severity}] ${icon} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
835
- if (!c.pass && c.fix) console.log(` → Fix: ${c.fix}`);
1172
+ if (showIgnores && totalIgnores > 0) {
1173
+ console.log(`\n${BOLD}Inline Ignores${RESET}`);
1174
+ console.log(` ${totalIgnores} ignore(s) en ${allIgnores.size} archivo(s)`);
1175
+ for (const [file, lines] of allIgnores) {
1176
+ for (const [line, rules] of Object.entries(lines)) {
1177
+ console.log(` ${GRAY}${relative(ROOT, file)}:${line}${RESET} → ${[...rules].join(", ")}`);
1178
+ }
1179
+ }
1180
+ console.log();
1181
+ }
1182
+
1183
+ for (const c of filtered) {
1184
+ console.log(formatCheck(c));
836
1185
  }
837
- console.log(`\nTotal: ${all.length} checks`);
1186
+ console.log(`\nTotal: ${filtered.length} checks${ignoredCount > 0 ? ` (${ignoredCount} ignorados inline)` : ""}`);
838
1187
  }
839
1188
  }
840
1189