@ronaldjdevfs/forge 1.0.1 → 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 +3 -3
  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,131 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "child_process";
4
+ import { existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { loadConfig, loadState } from "./forge-config.mjs";
7
+
8
+ const ROOT = process.cwd();
9
+
10
+ function gitSignals(cwd) {
11
+ const run = (args, { trim = true } = {}) => {
12
+ try {
13
+ const out = execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
14
+ return trim ? out.trim() : out;
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+
20
+ const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
21
+ const defaultBranch = run(["symbolic-ref", "refs/remotes/origin/HEAD"])?.replace("refs/remotes/origin/", "") || "main";
22
+ const topLevel = run(["rev-parse", "--show-toplevel"]);
23
+
24
+ let changedFiles = [];
25
+ try {
26
+ const raw = run(["diff", "--name-only", "--diff-filter=ACMR", `${defaultBranch}...HEAD`], { trim: false });
27
+ if (raw) changedFiles = raw.split("\n").filter(Boolean);
28
+ } catch {
29
+ // fallback to working tree
30
+ try {
31
+ const raw = run(["status", "--porcelain"]);
32
+ if (raw) changedFiles = raw.split("\n").filter(Boolean).map(l => l.slice(3));
33
+ } catch {}
34
+ }
35
+
36
+ return {
37
+ branch: branch ?? "unknown",
38
+ defaultBranch,
39
+ changedFiles,
40
+ hasChanges: changedFiles.length > 0,
41
+ isRepo: branch !== null,
42
+ topLevel,
43
+ };
44
+ }
45
+
46
+ function contextSignals(cwd) {
47
+ const config = loadConfig();
48
+ const state = loadState();
49
+
50
+ const hasFeatures = existsSync(join(cwd, "src", "features"));
51
+ const hasPlatform = existsSync(join(cwd, "src", "platform"));
52
+ const hasShared = existsSync(join(cwd, "src", "shared"));
53
+ const hasInfra = existsSync(join(cwd, "src", "infra"));
54
+ const hasLegacy = existsSync(join(cwd, "src", "application")) || existsSync(join(cwd, "src", "adapters", "in", "http", "controllers"));
55
+
56
+ return {
57
+ setup: {
58
+ hasFeatures,
59
+ hasPlatform,
60
+ hasShared,
61
+ hasInfra,
62
+ hasLegacy,
63
+ hasSrc: existsSync(join(cwd, "src")),
64
+ hasPackageJson: existsSync(join(cwd, "package.json")),
65
+ },
66
+ config: {
67
+ exists: !!config.profile,
68
+ profile: config.profile,
69
+ framework: config.framework,
70
+ needsRefresh: config.lastContextUpdate ? (Date.now() - new Date(config.lastContextUpdate).getTime()) / 86400000 > 7 : true,
71
+ },
72
+ audit: {
73
+ lastScore: state.lastScore,
74
+ lastGrade: state.lastGrade,
75
+ lastAudit: state.lastAudit,
76
+ violationCount: state.violationCount,
77
+ health: state.health,
78
+ totalFeatures: state.totalFeatures,
79
+ migratedFeatures: state.migratedFeatures,
80
+ legacyFeatures: state.legacyFeatures,
81
+ hasAudit: !!state.lastAudit,
82
+ },
83
+ };
84
+ }
85
+
86
+ function buildSignals(cwd) {
87
+ const signals = {};
88
+
89
+ // Check tsconfig exists
90
+ signals.hasTsConfig = existsSync(join(cwd, "tsconfig.json"));
91
+
92
+ // Check for common build artifacts
93
+ signals.hasDist = existsSync(join(cwd, "dist"));
94
+ signals.hasNext = existsSync(join(cwd, ".next"));
95
+
96
+ return signals;
97
+ }
98
+
99
+ async function main() {
100
+ const args = process.argv.slice(2);
101
+ const isJson = args.includes("--json");
102
+ const isMinimal = args.includes("--minimal");
103
+
104
+ const git = gitSignals(ROOT);
105
+ const ctx = contextSignals(ROOT);
106
+ const build = buildSignals(ROOT);
107
+
108
+ const signals = { git, context: ctx, build };
109
+
110
+ if (isJson) {
111
+ console.log(JSON.stringify(signals, null, 2));
112
+ } else if (isMinimal) {
113
+ const lines = [];
114
+ if (git.branch) lines.push(`Branch: ${git.branch}`);
115
+ if (git.hasChanges) lines.push(`Cambios: ${git.changedFiles.length} archivos`);
116
+ if (ctx.audit.hasAudit) lines.push(`Score: ${ctx.audit.lastGrade} (${ctx.audit.lastScore})`);
117
+ if (!ctx.setup.hasFeatures) lines.push("⚠ Sin src/features/");
118
+ if (ctx.config.needsRefresh) lines.push("⚠ Config necesita refresco");
119
+ lines.push(`Features: ${ctx.audit.migratedFeatures} migrados + ${ctx.audit.legacyFeatures} legacy = ${ctx.audit.totalFeatures} total`);
120
+ console.log(lines.join(" | "));
121
+ } else {
122
+ console.log("── Forge Signals ──");
123
+ console.log(`Git: ${git.branch} (${git.changedFiles.length} cambios)`);
124
+ console.log(`Audit: ${ctx.audit.lastGrade ?? "—"} (${ctx.audit.lastScore ?? "—"}) — ${ctx.audit.health}`);
125
+ console.log(`Features: ${ctx.audit.totalFeatures} total (${ctx.audit.migratedFeatures} migrados, ${ctx.audit.legacyFeatures} legacy)`);
126
+ console.log(`Layers: platform=${ctx.setup.hasPlatform} shared=${ctx.setup.hasShared} infra=${ctx.setup.hasInfra}`);
127
+ console.log(`Config: ${ctx.config.profile ?? "pendiente"} | Refresh: ${ctx.config.needsRefresh ? "necesario" : "ok"}`);
128
+ }
129
+ }
130
+
131
+ main().catch(console.error);
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * forge-state.mjs — Wrapper de estado post-auditoría.
5
+ *
6
+ * Delega toda la lógica de persistencia en forge-config.mjs.
7
+ * Existe como CLI independiente para compatibilidad con SKILL.md
8
+ * y comandos ad-hoc.
9
+ *
10
+ * Uso:
11
+ * node forge-state.mjs --show
12
+ * node forge-state.mjs --save <score> <grade> <violations> <features> [migrated]
13
+ * node forge-state.mjs --history
14
+ * node forge-state.mjs --json
15
+ */
16
+
17
+ import { loadState, saveState, saveHistory, loadHistory, displayTrend } from "./forge-config.mjs";
18
+ import { existsSync } from "fs";
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 isJson = args.includes("--json");
26
+
27
+ if (args.includes("--show")) {
28
+ const state = loadState();
29
+ if (isJson) {
30
+ console.log(JSON.stringify(state, null, 2));
31
+ } else {
32
+ console.log("── Forge State ──");
33
+ for (const [k, v] of Object.entries(state)) {
34
+ console.log(` ${k}: ${v ?? "(sin datos)"}`);
35
+ }
36
+ }
37
+ process.exit(0);
38
+ }
39
+
40
+ if (args.includes("--save")) {
41
+ const scoreIdx = args.indexOf("--save") + 1;
42
+ const score = parseInt(args[scoreIdx], 10);
43
+ const grade = args[scoreIdx + 1] || "—";
44
+ const violations = parseInt(args[scoreIdx + 2] || "0", 10);
45
+ const features = parseInt(args[scoreIdx + 3] || "0", 10);
46
+ const migrated = parseInt(args[scoreIdx + 4] || "0", 10);
47
+ const platform = args.includes("--platform");
48
+
49
+ const state = {
50
+ lastAudit: new Date().toISOString(),
51
+ lastScore: isNaN(score) ? null : score,
52
+ lastGrade: grade,
53
+ violationCount: isNaN(violations) ? 0 : violations,
54
+ totalFeatures: isNaN(features) ? 0 : features,
55
+ migratedFeatures: isNaN(migrated) ? 0 : migrated,
56
+ legacyFeatures: isNaN(features) ? 0 : features - (isNaN(migrated) ? 0 : migrated),
57
+ platformExists: platform || existsSync(join(ROOT, "src", "platform")),
58
+ health: score >= 80 ? "healthy" : score >= 50 ? "fair" : "poor",
59
+ };
60
+
61
+ saveState(state);
62
+ saveHistory({ score, grade, violationCount: state.violationCount, totalFeatures: features, migratedFeatures: migrated });
63
+
64
+ if (isJson) {
65
+ console.log(JSON.stringify({ saved: true, state }, null, 2));
66
+ } else {
67
+ console.log(`forge-state: estado guardado (score: ${score}, grade: ${grade})`);
68
+ }
69
+ process.exit(0);
70
+ }
71
+
72
+ if (args.includes("--history")) {
73
+ const entries = loadHistory(isJson ? 999 : 20);
74
+ if (isJson) {
75
+ console.log(JSON.stringify(entries, null, 2));
76
+ } else {
77
+ displayTrend(entries);
78
+ }
79
+ process.exit(0);
80
+ }
81
+
82
+ // Default: show
83
+ const state = loadState();
84
+ const history = loadHistory(1);
85
+ if (isJson) {
86
+ console.log(JSON.stringify({ state, recentHistory: history }, null, 2));
87
+ } else {
88
+ const lastScore = state.lastScore !== null ? `${state.lastScore}/110` : "—";
89
+ const lastGrade = state.lastGrade || "—";
90
+ console.log(`Forge State | Score: ${lastScore} | Grade: ${lastGrade} | Violaciones: ${state.violationCount} | Features: ${state.migratedFeatures}/${state.totalFeatures}`);
91
+ }
92
+ process.exit(0);
93
+ }
94
+
95
+ if (process.argv[1] && (process.argv[1].endsWith("forge-state.mjs") || process.argv[1].endsWith("forge-state.js"))) {
96
+ main().catch(console.error);
97
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ export const CYAN = "\x1b[36m";
4
+ export const GREEN = "\x1b[32m";
5
+ export const RED = "\x1b[31m";
6
+ export const YELLOW = "\x1b[33m";
7
+ export const BOLD = "\x1b[1m";
8
+ export const RESET = "\x1b[0m";
9
+ export const DIM = "\x1b[2m";
10
+ export const GRAY = "\x1b[90m";
11
+
12
+ export const SEVERITY_COLORS = {
13
+ CRITICAL: RED,
14
+ ERROR: RED,
15
+ WARNING: YELLOW,
16
+ INFO: CYAN,
17
+ SUGGESTION: GRAY,
18
+ };
19
+
20
+ export function col(sev) {
21
+ return SEVERITY_COLORS[sev] || RESET;
22
+ }
23
+
24
+ export function formatJson(data) {
25
+ return JSON.stringify(data, null, 2);
26
+ }
27
+
28
+ export function formatTable(rows, columns) {
29
+ const colWidths = columns.map((col, i) => {
30
+ const header = col.label || col;
31
+ const maxVal = Math.max(header.length, ...rows.map(r => String(r[col.key || col] || "").length));
32
+ return maxVal;
33
+ });
34
+
35
+ const line = (parts) => {
36
+ const inner = parts.map((p, i) => String(p).padEnd(colWidths[i])).join(" │ ");
37
+ return ` ${inner} `;
38
+ };
39
+
40
+ const sep = () => "─" + colWidths.map(w => "─".repeat(w)).join("─┼─") + "─";
41
+
42
+ const out = [];
43
+ out.push(line(columns.map(c => c.label || c)));
44
+ out.push(sep());
45
+ for (const row of rows) {
46
+ out.push(line(columns.map(c => String(row[c.key || c] ?? ""))));
47
+ }
48
+ return out.join("\n");
49
+ }
50
+
51
+ export function formatViolation({ severity, label, detail, fix }, _opts = {}) {
52
+ const color = col(severity);
53
+ const parts = [`${color}[${severity}]${RESET} ${label}`];
54
+ if (detail) parts.push(` ${GRAY}— ${detail}${RESET}`);
55
+ if (fix) parts.push(`\n ${DIM}→ Fix: ${fix}${RESET}`);
56
+ return parts.join("");
57
+ }
58
+
59
+ export function formatCheck(check, opts = {}) {
60
+ const icon = check.pass ? `${GREEN}✔${RESET}` : `${RED}✘${RESET}`;
61
+ const sev = check.pass ? "" : ` ${col(check.severity)}[${check.severity}]${RESET}`;
62
+ const detail = check.detail ? ` ${GRAY}— ${check.detail}${RESET}` : "";
63
+ let out = ` ${icon}${sev} ${check.label}${detail}`;
64
+ if (!check.pass && check.fix && opts.showFixes !== false) {
65
+ out += `\n ${DIM}→ Fix: ${check.fix}${RESET}`;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function scoreBar(score, max, barLen = 40) {
71
+ const p = max > 0 ? Math.round((score / max) * barLen) : 0;
72
+ const filled = "█".repeat(p);
73
+ const empty = "░".repeat(barLen - p);
74
+ const color = score >= max * 0.8 ? GREEN : score >= max * 0.5 ? YELLOW : RED;
75
+ return `${color}${filled}${RESET}${DIM}${empty}${RESET}`;
76
+ }
77
+
78
+ export function formatReport(report, opts = {}) {
79
+ const { total, max, categories, violations, recommendations, severityCounts } = report;
80
+ const barLen = opts.barLen || 40;
81
+ const pct = max > 0 ? Math.round((total / max) * 100) : 0;
82
+ const lines = [];
83
+
84
+ const grade = pct >= 90 ? "A" : pct >= 80 ? "B" : pct >= 65 ? "C" : pct >= 50 ? "D" : "F";
85
+ const gradeColor = pct >= 80 ? GREEN : pct >= 50 ? YELLOW : RED;
86
+
87
+ lines.push(`\n${BOLD}Puntaje total: ${gradeColor}${total}/${max} (${pct}%) — ${grade}${RESET}\n`);
88
+
89
+ if (Object.keys(severityCounts || {}).length > 0) {
90
+ lines.push(` ${BOLD}Resumen por severidad${RESET}`);
91
+ for (const [sev, count] of Object.entries(severityCounts)) {
92
+ lines.push(` ${col(sev)}${sev}${RESET}: ${count}`);
93
+ }
94
+ lines.push("");
95
+ }
96
+
97
+ for (const [key, cat] of Object.entries(categories || {})) {
98
+ const cmax = opts.catMax?.[key] || cat.max || 10;
99
+ const name = opts.catNames?.[key] || key;
100
+ lines.push(` ${BOLD}${name} (${cat.score}/${cmax})${RESET}`);
101
+ lines.push(` ${scoreBar(cat.score, cmax, barLen)}`);
102
+ for (const check of cat.checks) {
103
+ lines.push(formatCheck(check, opts));
104
+ }
105
+ lines.push("");
106
+ }
107
+
108
+ if (recommendations && recommendations.length > 0) {
109
+ lines.push(` ${BOLD}${YELLOW}Recomendaciones${RESET}`);
110
+ const unique = [...new Set(recommendations)];
111
+ unique.forEach((r, i) => lines.push(` ${i + 1}. ${r}`));
112
+ lines.push("");
113
+ }
114
+
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function printViolations(violations) {
119
+ for (const v of violations) {
120
+ console.log(formatViolation(v));
121
+ }
122
+ }
123
+
124
+ if (process.argv[1]?.endsWith("formatter.mjs")) {
125
+ const args = process.argv.slice(2);
126
+ if (args.includes("--test")) {
127
+ console.log(formatViolation({ severity: "ERROR", label: "Test violation", detail: "src/test.ts:42", fix: "Do something" }));
128
+ console.log(formatCheck({ severity: "WARNING", label: "Test check", pass: false, detail: "detail", fix: "fix it" }));
129
+ console.log(formatCheck({ severity: "INFO", label: "Passing check", pass: true }));
130
+ console.log(scoreBar(15, 20));
131
+ console.log(formatJson({ test: true, items: [1, 2, 3] }));
132
+ }
133
+ }
@@ -2,12 +2,11 @@
2
2
 
3
3
  import { join, relative, dirname, basename } from "path";
4
4
  import { readFileSync, existsSync, readdirSync, statSync } from "fs";
5
+ import { parseImportPaths } from "./parse-imports.mjs";
5
6
 
6
7
  const ROOT = process.cwd();
7
8
  const SRC = join(ROOT, "src");
8
9
 
9
- const IMPORT_RE = /import\s+(?:type\s+)?(?:\{[^}]*\}|[^;{]+)\s+from\s+['"]([^'"]+)['"]/g;
10
-
11
10
  const INFRA_PACKAGES = {
12
11
  "prisma": "infra:prisma",
13
12
  "@prisma/client": "infra:prisma",
@@ -66,21 +65,6 @@ function findFiles(dir, exts, maxDepth = 6) {
66
65
  return results;
67
66
  }
68
67
 
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
68
  function classifyPath(relPath) {
85
69
  const parts = relPath.split("/");
86
70
  if (parts.includes("platform")) return "platform";
@@ -264,9 +248,9 @@ export function buildGraph(projectRoot = ROOT) {
264
248
 
265
249
  /* 6a. Detect used infra packages first */
266
250
  const usedPackages = new Set();
267
- for (const { content } of fileContents) {
268
- const imports = parseImports(content);
269
- for (const imp of imports) {
251
+ for (const { file: filePath, content } of fileContents) {
252
+ const gImports = parseImportPaths(content, filePath);
253
+ for (const imp of gImports) {
270
254
  for (const [pkg, id] of Object.entries(INFRA_PACKAGES)) {
271
255
  if (imp === pkg || imp.startsWith(pkg + "/")) usedPackages.add(id);
272
256
  }
@@ -321,7 +305,7 @@ export function buildGraph(projectRoot = ROOT) {
321
305
 
322
306
  if (!sourceId || !idSet.has(sourceId)) continue;
323
307
 
324
- const imports = parseImports(content);
308
+ const gImports = parseImportPaths(content, filePath);
325
309
  const seenTargets = new Set();
326
310
 
327
311
  for (const imp of imports) {
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * hook.mjs — Git pre-commit hook para validación arquitectónica.
5
+ *
6
+ * Instala/uninstall un hook de git pre-commit que ejecuta forge detect
7
+ * sobre los archivos staged y bloquea el commit si hay violaciones CRITICAL
8
+ * o ERROR en los archivos tocados.
9
+ *
10
+ * Uso:
11
+ * node hook.mjs install → Instalar hook pre-commit
12
+ * node hook.mjs uninstall → Eliminar hook pre-commit
13
+ * node hook.mjs status → Mostrar estado del hook
14
+ * node hook.mjs check → Ejecutar validación sobre staged files
15
+ * node hook.mjs ignore <rule-id> → Ignorar regla específica
16
+ * node hook.mjs unignore <rule-id> → Dejar de ignorar regla
17
+ * node hook.mjs list-ignored → Listar reglas ignoradas
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "fs";
21
+ import { join, relative, resolve } from "path";
22
+ import { execFileSync } from "child_process";
23
+
24
+ const ROOT = process.cwd();
25
+ const HOOKS_DIR = join(ROOT, ".git", "hooks");
26
+ const HOOK_PATH = join(HOOKS_DIR, "pre-commit");
27
+ const IGNORE_PATH = join(ROOT, ".forge", "hooks-ignore.json");
28
+ const SCRIPT_DIR = new URL(".", import.meta.url).pathname;
29
+
30
+ const HOOK_TEMPLATE = `#!/bin/sh
31
+ # forge pre-commit hook — valida arquitectura antes de cada commit
32
+ # Instalado por: node .opencode/skills/forge/scripts/hook.mjs install
33
+
34
+ FORGE_SCRIPTS="${SCRIPT_DIR}"
35
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR -- "*.ts" "*.js" "*.mjs" "*.tsx" "*.jsx" 2>/dev/null)
36
+
37
+ if [ -z "$STAGED_FILES" ]; then
38
+ exit 0
39
+ fi
40
+
41
+ # Ejecutar detect sobre los archivos staged
42
+ node "$FORGE_SCRIPTS/hook.mjs" check
43
+ EXIT_CODE=$?
44
+
45
+ if [ $EXIT_CODE -ne 0 ]; then
46
+ echo ""
47
+ echo "✘ [forge] Commit bloqueado por violaciones arquitectónicas."
48
+ echo " Para ignorar: node .opencode/skills/forge/scripts/hook.mjs ignore <rule-id>"
49
+ echo " Para saltar: git commit --no-verify"
50
+ exit 1
51
+ fi
52
+
53
+ exit 0
54
+ `;
55
+
56
+ function readJson(path) {
57
+ try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; }
58
+ }
59
+
60
+ function writeJson(path, data) {
61
+ try {
62
+ mkdirSync(join(path, ".."), { recursive: true });
63
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
64
+ return true;
65
+ } catch { return false; }
66
+ }
67
+
68
+ function shell(cmd, args, opts = {}) {
69
+ try {
70
+ return execFileSync(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
71
+ } catch { return ""; }
72
+ }
73
+
74
+ function getStagedFiles() {
75
+ const raw = shell("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "--", "*.ts", "*.js", "*.mjs", "*.tsx", "*.jsx"]);
76
+ return raw ? raw.split("\n").filter(Boolean) : [];
77
+ }
78
+
79
+ function isGitRepo() {
80
+ return shell("git", ["rev-parse", "--git-dir"]) !== "";
81
+ }
82
+
83
+ function loadIgnored() {
84
+ const data = readJson(IGNORE_PATH);
85
+ return Array.isArray(data?.ignored) ? data.ignored : [];
86
+ }
87
+
88
+ function saveIgnored(ignored) {
89
+ return writeJson(IGNORE_PATH, { ignored });
90
+ }
91
+
92
+ function hookInstalled() {
93
+ if (!existsSync(HOOK_PATH)) return false;
94
+ const content = readFileSync(HOOK_PATH, "utf-8");
95
+ return content.includes("# forge pre-commit hook");
96
+ }
97
+
98
+ // ── Actions ──
99
+
100
+ function cmdInstall() {
101
+ if (!isGitRepo()) {
102
+ console.error("forge-hooks: no es un repositorio git");
103
+ process.exit(1);
104
+ }
105
+
106
+ if (hookInstalled()) {
107
+ console.log("forge-hooks: pre-commit hook ya instalado en .git/hooks/pre-commit");
108
+ process.exit(0);
109
+ }
110
+
111
+ mkdirSync(HOOKS_DIR, { recursive: true });
112
+ writeFileSync(HOOK_PATH, HOOK_TEMPLATE, { mode: 0o755 });
113
+ console.log("forge-hooks: pre-commit hook instalado en .git/hooks/pre-commit");
114
+ }
115
+
116
+ function cmdUninstall() {
117
+ if (!existsSync(HOOK_PATH)) {
118
+ console.log("forge-hooks: no hay hook pre-commit instalado");
119
+ process.exit(0);
120
+ }
121
+
122
+ rmSync(HOOK_PATH, { force: true });
123
+ console.log("forge-hooks: pre-commit hook eliminado");
124
+ }
125
+
126
+ function cmdStatus() {
127
+ const installed = hookInstalled();
128
+ const ignored = loadIgnored();
129
+ console.log("── Forge Hooks ──");
130
+ console.log(` Pre-commit hook: ${installed ? "✓ instalado" : "✘ no instalado"}`);
131
+ console.log(` Reglas ignoradas: ${ignored.length > 0 ? ignored.join(", ") : "(ninguna)"}`);
132
+ console.log(` Hook path: ${HOOK_PATH}`);
133
+ console.log(` Ignore file: ${IGNORE_PATH}`);
134
+ }
135
+
136
+ async function cmdCheck() {
137
+ if (!isGitRepo()) {
138
+ console.error("forge-hooks: no es un repositorio git");
139
+ process.exit(1);
140
+ }
141
+
142
+ const stagedFiles = getStagedFiles();
143
+ if (stagedFiles.length === 0) {
144
+ process.exit(0);
145
+ }
146
+
147
+ const ignored = loadIgnored();
148
+ const srcFiles = stagedFiles.filter(f => f.startsWith("src/"));
149
+ if (srcFiles.length === 0) process.exit(0);
150
+
151
+ // Run detect only over the staged source files
152
+ const { allChecks, detectFeaturesOnSrc } = await import("./detect.mjs");
153
+ const { buildGraph } = await import("./graph.mjs");
154
+ const { buildContext } = await import("./context.mjs");
155
+
156
+ const ctx = await buildContext();
157
+ const graph = buildGraph(ROOT);
158
+ const features = detectFeaturesOnSrc();
159
+ const results = allChecks(features, graph, ctx);
160
+
161
+ let violations = [];
162
+ for (const [, cat] of Object.entries(results)) {
163
+ for (const check of cat.checks) {
164
+ if (!check.pass && (check.severity === "CRITICAL" || check.severity === "ERROR")) {
165
+ // Check if this violation touches a staged file
166
+ const touchesStaged = !check.detail || srcFiles.some(sf => check.detail.includes(sf) || check.label.includes(sf));
167
+ if (touchesStaged) {
168
+ // Check if rule is ignored
169
+ const ruleMatch = check.label.match(/\[([^\]]+)\]/);
170
+ const ruleId = ruleMatch ? ruleMatch[1] : null;
171
+ if (!ruleId || !ignored.includes(ruleId)) {
172
+ violations.push(check);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ if (violations.length > 0) {
180
+ console.log(`\n✘ [forge-hooks] ${violations.length} violacion(es) CRITICAL/ERROR bloquean el commit:\n`);
181
+ for (const v of violations) {
182
+ console.log(` [${v.severity}] ${v.label}`);
183
+ if (v.fix) console.log(` → Fix: ${v.fix}`);
184
+ }
185
+ console.log(`\n Para ignorar: node .opencode/skills/forge/scripts/hook.mjs ignore <rule-id>`);
186
+ console.log(` Para saltar: git commit --no-verify\n`);
187
+ process.exit(1);
188
+ }
189
+
190
+ process.exit(0);
191
+ }
192
+
193
+ function cmdIgnore(ruleId) {
194
+ if (!ruleId) {
195
+ console.error("Uso: node hook.mjs ignore <rule-id>");
196
+ process.exit(1);
197
+ }
198
+ const ignored = loadIgnored();
199
+ if (ignored.includes(ruleId)) {
200
+ console.log(`forge-hooks: regla "${ruleId}" ya está ignorada`);
201
+ } else {
202
+ ignored.push(ruleId);
203
+ saveIgnored(ignored);
204
+ console.log(`forge-hooks: regla "${ruleId}" añadida a ignorados`);
205
+ }
206
+ }
207
+
208
+ function cmdUnignore(ruleId) {
209
+ if (!ruleId) {
210
+ console.error("Uso: node hook.mjs unignore <rule-id>");
211
+ process.exit(1);
212
+ }
213
+ const ignored = loadIgnored().filter(r => r !== ruleId);
214
+ saveIgnored(ignored);
215
+ console.log(`forge-hooks: regla "${ruleId}" eliminada de ignorados`);
216
+ }
217
+
218
+ function cmdListIgnored() {
219
+ const ignored = loadIgnored();
220
+ if (ignored.length === 0) {
221
+ console.log("forge-hooks: no hay reglas ignoradas");
222
+ } else {
223
+ console.log("── Reglas ignoradas ──");
224
+ for (const r of ignored) console.log(` ${r}`);
225
+ }
226
+ }
227
+
228
+ // ── CLI ──
229
+
230
+ const [,, action, arg] = process.argv;
231
+
232
+ switch (action) {
233
+ case "install": cmdInstall(); break;
234
+ case "uninstall": cmdUninstall(); break;
235
+ case "status": cmdStatus(); break;
236
+ case "check": await cmdCheck(); break;
237
+ case "ignore": cmdIgnore(arg); break;
238
+ case "unignore": cmdUnignore(arg); break;
239
+ case "list-ignored": cmdListIgnored(); break;
240
+ default:
241
+ console.log("Uso: node hook.mjs <install|uninstall|status|check|ignore|unignore|list-ignored>");
242
+ console.log("\n install → Instalar hook pre-commit");
243
+ console.log(" uninstall → Eliminar hook pre-commit");
244
+ console.log(" status → Mostrar estado del hook");
245
+ console.log(" check → Validar archivos staged");
246
+ console.log(" ignore <rule-id> → Ignorar regla específica");
247
+ console.log(" unignore <rule-id>→ Dejar de ignorar regla");
248
+ console.log(" list-ignored → Listar reglas ignoradas");
249
+ process.exit(1);
250
+ }