@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.
- package/README.md +130 -8
- package/package.json +2 -2
- package/skills/forge/SKILL.md +86 -15
- package/skills/forge/profiles/express-drizzle.md +107 -0
- package/skills/forge/profiles/fastify-mongodb.md +103 -0
- package/skills/forge/profiles/fastify-prisma.md +81 -0
- package/skills/forge/profiles/nestjs-mongodb.md +92 -0
- package/skills/forge/profiles/nestjs-postgres.md +98 -0
- package/skills/forge/reference/api-design.md +62 -0
- package/skills/forge/reference/assay.md +82 -0
- package/skills/forge/reference/cast.md +81 -7
- package/skills/forge/reference/data-patterns.md +86 -0
- package/skills/forge/reference/di-strategies.md +50 -0
- package/skills/forge/reference/errors.md +65 -0
- package/skills/forge/reference/events.md +95 -0
- package/skills/forge/reference/help.md +40 -0
- package/skills/forge/reference/hooks.md +62 -0
- package/skills/forge/reference/observability.md +66 -0
- package/skills/forge/reference/patterns.md +52 -0
- package/skills/forge/reference/principles.md +6 -0
- package/skills/forge/reference/reforge.md +69 -5
- package/skills/forge/reference/relocate.md +15 -2
- package/skills/forge/reference/security-patterns.md +87 -0
- package/skills/forge/reference/testing-patterns.md +69 -0
- package/skills/forge/scripts/assay.mjs +481 -0
- package/skills/forge/scripts/context.mjs +147 -43
- package/skills/forge/scripts/detect.mjs +371 -22
- package/skills/forge/scripts/forge-api.mjs +373 -0
- package/skills/forge/scripts/forge-config.mjs +268 -0
- package/skills/forge/scripts/forge-signals.mjs +131 -0
- package/skills/forge/scripts/forge-state.mjs +97 -0
- package/skills/forge/scripts/formatter.mjs +133 -0
- package/skills/forge/scripts/graph.mjs +5 -21
- package/skills/forge/scripts/hook.mjs +250 -0
- package/skills/forge/scripts/inspect.mjs +171 -22
- package/skills/forge/scripts/parse-imports.mjs +249 -0
- package/skills/forge/scripts/pin.mjs +151 -0
- package/skills/forge/scripts/posttool.mjs +224 -0
- package/skills/forge/scripts/profile.mjs +124 -20
- package/skills/forge/scripts/registry/rules.mjs +344 -0
- package/skills/forge/scripts/rename.mjs +669 -0
- package/skills/forge/scripts/rollback.mjs +213 -0
- package/skills/forge/scripts/update.mjs +114 -0
- package/skills/forge/templates/feature/domain-error.ts.md +9 -0
- package/skills/forge/templates/feature/domain-event.ts.md +9 -0
- package/skills/forge/templates/feature/event-handler.ts.md +10 -0
- package/skills/forge/templates/feature/use-case.ts.md +10 -2
- package/skills/forge/tests/core.test.mjs +403 -0
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { join, basename } from "path";
|
|
3
|
+
import { join, basename, relative } from "path";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
4
5
|
import { buildContext } from "./context.mjs";
|
|
5
6
|
import { detectProfile, detectProfileExtended } from "./profile.mjs";
|
|
6
7
|
import { buildDependencyGraph } from "./chain.mjs";
|
|
7
|
-
import { allChecks } from "./detect.mjs";
|
|
8
|
+
import { allChecks, checkStructure, checkLayers, checkDecorators } from "./detect.mjs";
|
|
9
|
+
import { saveHistory, updateStateFromAudit } from "./forge-config.mjs";
|
|
8
10
|
|
|
9
11
|
const ROOT = process.cwd();
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
const GREEN = "\x1b[32m";
|
|
13
|
-
const RED = "\x1b[31m";
|
|
14
|
-
const YELLOW = "\x1b[33m";
|
|
15
|
-
const BOLD = "\x1b[1m";
|
|
16
|
-
const RESET = "\x1b[0m";
|
|
17
|
-
const DIM = "\x1b[2m";
|
|
18
|
-
const GRAY = "\x1b[90m";
|
|
19
|
-
|
|
20
|
-
const SEVERITY_COLORS = {
|
|
21
|
-
CRITICAL: RED,
|
|
22
|
-
ERROR: RED,
|
|
23
|
-
WARNING: YELLOW,
|
|
24
|
-
INFO: CYAN,
|
|
25
|
-
SUGGESTION: GRAY,
|
|
26
|
-
};
|
|
13
|
+
import { CYAN, GREEN, RED, YELLOW, BOLD, RESET, DIM, GRAY, SEVERITY_COLORS, col, formatJson, formatCheck, scoreBar, formatReport } from "./formatter.mjs";
|
|
27
14
|
|
|
28
15
|
const CAT_NAMES = {
|
|
29
16
|
structure: "Estructura",
|
|
@@ -73,7 +60,7 @@ function buildReport(result) {
|
|
|
73
60
|
return { total, max: maxTotal, categories: result.categories, violations, recommendations, severityCounts: countBySeverity(violations) };
|
|
74
61
|
}
|
|
75
62
|
|
|
76
|
-
function printReport(report, ctx, profile, graph, archGraph) {
|
|
63
|
+
function printReport(report, ctx, profile, graph, archGraph, profileExtended) {
|
|
77
64
|
const barLen = 40;
|
|
78
65
|
const pct = report.max > 0 ? Math.round((report.total / report.max) * 100) : 0;
|
|
79
66
|
|
|
@@ -162,6 +149,36 @@ function printReport(report, ctx, profile, graph, archGraph) {
|
|
|
162
149
|
console.log();
|
|
163
150
|
}
|
|
164
151
|
|
|
152
|
+
/* Contextual suggestions */
|
|
153
|
+
const suggestions = [];
|
|
154
|
+
const v = report.severityCounts;
|
|
155
|
+
if ((v.CRITICAL || 0) > 0) suggestions.push("forge quench — revisar reglas CRITICAL en detalle");
|
|
156
|
+
if ((v.ERROR || 0) > 0) suggestions.push("forge quench — corregir violaciones ERROR antes de avanzar");
|
|
157
|
+
if (graph.hasCycles) suggestions.push("forge reforge — eliminar ciclos del grafo de dependencias");
|
|
158
|
+
if (ctx.platform.exists && ctx.platform.components.length < 3) suggestions.push("forge forge — bootstrap de componentes platform faltantes");
|
|
159
|
+
if (!ctx.platform.exists) suggestions.push("forge forge — iniciar bootstrap de platform");
|
|
160
|
+
if (ctx.features.legacy.length > 0) suggestions.push(`forge relocate — migrar ${ctx.features.legacy.length} feature(s) legacy a estructura hexagonal`);
|
|
161
|
+
if (archGraph && archGraph.stats.dependencyHealth < 70) suggestions.push("forge temper — endurecer inyección de dependencias");
|
|
162
|
+
if (archGraph && archGraph.stats.riskScore > 50) suggestions.push("forge reforge — reducir riesgo arquitectónico");
|
|
163
|
+
if (pct >= 80) suggestions.push("forge inspect — mantener auditoría periódica");
|
|
164
|
+
if (pct < 50) suggestions.push("forge inspect — auditoría focalizada tras correcciones");
|
|
165
|
+
|
|
166
|
+
// Profile-derived suggestions
|
|
167
|
+
if (profileExtended) {
|
|
168
|
+
for (const dep of profileExtended.depIssues || []) {
|
|
169
|
+
suggestions.push(`profiler: ${dep}`);
|
|
170
|
+
}
|
|
171
|
+
for (const s of profileExtended.suggestions || []) {
|
|
172
|
+
suggestions.push(`profiler: ${s}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (suggestions.length > 0) {
|
|
177
|
+
console.log(` ${BOLD}${CYAN}Siguientes pasos sugeridos${RESET}`);
|
|
178
|
+
suggestions.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
179
|
+
console.log();
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
console.log("═".repeat(58) + "\n");
|
|
166
183
|
}
|
|
167
184
|
|
|
@@ -169,24 +186,156 @@ function printJson(report, ctx, profile, graph, archGraph) {
|
|
|
169
186
|
console.log(JSON.stringify({ ...report, profile, context: ctx, dependencies: graph, architectureGraph: archGraph }, null, 2));
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Get files changed vs default branch (or working tree as fallback).
|
|
191
|
+
*/
|
|
192
|
+
function getChangedFiles() {
|
|
193
|
+
// Try 1: changes vs default branch (committed but not merged)
|
|
194
|
+
let files = [];
|
|
195
|
+
try {
|
|
196
|
+
const defaultBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
|
|
197
|
+
.trim().replace("refs/remotes/origin/", "");
|
|
198
|
+
const raw = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMR", `${defaultBranch}...HEAD`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
199
|
+
files = raw.trim().split("\n").filter(Boolean);
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
202
|
+
// Try 2: working tree changes (unstaged + uncommitted)
|
|
203
|
+
if (files.length === 0) {
|
|
204
|
+
try {
|
|
205
|
+
const raw = execFileSync("git", ["status", "--porcelain"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
206
|
+
files = raw.split("\n").filter(Boolean).map(l => l.slice(3));
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Try 3: staged changes only
|
|
211
|
+
if (files.length === 0) {
|
|
212
|
+
try {
|
|
213
|
+
const raw = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
214
|
+
files = raw.trim().split("\n").filter(Boolean);
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return files;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Determine which features are affected by changed files.
|
|
223
|
+
*/
|
|
224
|
+
function getChangedFeatures(changedFiles, allFeatures) {
|
|
225
|
+
if (changedFiles.length === 0) return [];
|
|
226
|
+
const affected = new Set();
|
|
227
|
+
for (const file of changedFiles) {
|
|
228
|
+
const match = file.match(/src\/features\/([^/]+)\//);
|
|
229
|
+
if (match) affected.add(match[1]);
|
|
230
|
+
}
|
|
231
|
+
return allFeatures.filter(f => affected.has(f));
|
|
232
|
+
}
|
|
233
|
+
|
|
172
234
|
async function main() {
|
|
173
235
|
const args = process.argv.slice(2);
|
|
174
236
|
const isJson = args.includes("--json");
|
|
237
|
+
const isDiff = args.includes("--diff");
|
|
175
238
|
const filterSeverity = args.includes("--severity") ? args[args.indexOf("--severity") + 1] : null;
|
|
176
239
|
|
|
177
240
|
const ctx = await buildContext();
|
|
178
|
-
const
|
|
241
|
+
const profileExtended = detectProfileExtended(ctx);
|
|
242
|
+
const profile = profileExtended.profile;
|
|
179
243
|
const chainGraph = buildDependencyGraph();
|
|
180
244
|
const archGraph = ctx.graph;
|
|
181
245
|
const features = ctx.features.migrated;
|
|
182
|
-
const result = allChecks(features, archGraph, ctx);
|
|
183
246
|
|
|
247
|
+
if (isDiff) {
|
|
248
|
+
const changedFiles = getChangedFiles();
|
|
249
|
+
const changedFeatures = getChangedFeatures(changedFiles, features);
|
|
250
|
+
|
|
251
|
+
if (!isJson) {
|
|
252
|
+
console.log(`\n${CYAN}═══ Diff-Aware Audit ═══${RESET}`);
|
|
253
|
+
console.log(`${DIM}Archivos cambiados: ${changedFiles.length}${RESET}`);
|
|
254
|
+
if (changedFiles.length > 0) {
|
|
255
|
+
for (const f of changedFiles.slice(0, 10)) {
|
|
256
|
+
console.log(` ${DIM}${f}${RESET}`);
|
|
257
|
+
}
|
|
258
|
+
if (changedFiles.length > 10) console.log(` ${DIM}... (+${changedFiles.length - 10})${RESET}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (changedFeatures.length === 0) {
|
|
263
|
+
if (!isJson) {
|
|
264
|
+
console.log(`\n${YELLOW}⚠ No hay features afectados por los cambios.${RESET}`);
|
|
265
|
+
console.log(`${DIM}Los cambios están fuera de src/features/ o no hay features migrados.${RESET}\n`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log(JSON.stringify({ diff: { changedFiles: changedFiles.length, changedFeatures: [], affectedFeatures: false } }));
|
|
268
|
+
}
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!isJson) {
|
|
273
|
+
console.log(`${DIM}Features afectados: ${changedFeatures.join(", ")}${RESET}\n`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Run only feature-specific checks on changed features
|
|
277
|
+
const result = {
|
|
278
|
+
structure: checkStructure(changedFeatures),
|
|
279
|
+
layers: checkLayers(changedFeatures),
|
|
280
|
+
decorators: checkDecorators(changedFeatures),
|
|
281
|
+
graph: archGraph ? { score: 0, checks: [{ severity: "INFO", label: `Grafo: ${archGraph.stats.totalNodes} nodos, ${archGraph.stats.violations} violaciones (global)`, pass: true }] } : { score: 0, checks: [] },
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Provide a simpler report
|
|
285
|
+
let totalScore = result.structure.score + result.layers.score + (result.decorators?.score || 0);
|
|
286
|
+
const maxScore = 60; // structure 20 + layers 20 + decorators 20
|
|
287
|
+
const pct = Math.round((totalScore / maxScore) * 100);
|
|
288
|
+
|
|
289
|
+
if (!isJson) {
|
|
290
|
+
console.log(`${BOLD}Score en features afectados: ${pct >= 80 ? GREEN : pct >= 50 ? YELLOW : RED}${totalScore}/${maxScore} (${pct}%)${RESET}\n`);
|
|
291
|
+
|
|
292
|
+
for (const [key, cat] of Object.entries(result)) {
|
|
293
|
+
const name = key.charAt(0).toUpperCase() + key.slice(1);
|
|
294
|
+
console.log(` ${BOLD}${name} (${cat.score}/${cat.score === 0 && cat.checks.length > 0 ? "—" : maxScore})${RESET}`);
|
|
295
|
+
for (const check of cat.checks) {
|
|
296
|
+
const icon = check.pass ? `${GREEN}✔${RESET}` : `${RED}✘${RESET}`;
|
|
297
|
+
const sev = check.pass ? "" : ` ${SEVERITY_COLORS[check.severity]}[${check.severity}]${RESET}`;
|
|
298
|
+
const detail = check.detail ? ` ${GRAY}— ${check.detail}${RESET}` : "";
|
|
299
|
+
console.log(` ${icon}${sev} ${check.label}${detail}`);
|
|
300
|
+
if (!check.pass && check.fix) {
|
|
301
|
+
console.log(` ${DIM}→ Fix: ${check.fix}${RESET}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
console.log();
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
console.log(JSON.stringify({
|
|
308
|
+
diff: { changedFiles: changedFiles.length, changedFeatures, totalScore, maxScore, pct },
|
|
309
|
+
categories: result,
|
|
310
|
+
}, null, 2));
|
|
311
|
+
}
|
|
312
|
+
updateStateFromAudit({ total: totalScore, grade: `${pct}%`, violations: [], health: "diff", context: { features: { total: features.length, migrated: features, legacy: [] } } });
|
|
313
|
+
saveHistory({ score: totalScore, grade: `${pct}%`, violationCount: 0, totalFeatures: features.length, migratedFeatures: features.length });
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const result = allChecks(features, archGraph, ctx);
|
|
184
318
|
const report = buildReport({ categories: result });
|
|
185
319
|
|
|
320
|
+
updateStateFromAudit({
|
|
321
|
+
total: report.total,
|
|
322
|
+
grade: report.grade,
|
|
323
|
+
violations: report.violations || [],
|
|
324
|
+
health: report.total >= 80 ? "healthy" : report.total >= 50 ? "fair" : "poor",
|
|
325
|
+
context: { features: { total: features.length, migrated: features, legacy: ctx.features?.legacy || [] } },
|
|
326
|
+
});
|
|
327
|
+
saveHistory({
|
|
328
|
+
score: report.total,
|
|
329
|
+
grade: report.grade,
|
|
330
|
+
violationCount: (report.violations || []).length,
|
|
331
|
+
totalFeatures: features.length,
|
|
332
|
+
migratedFeatures: features.length,
|
|
333
|
+
});
|
|
334
|
+
|
|
186
335
|
if (isJson) {
|
|
187
336
|
printJson(report, ctx, profile, chainGraph, archGraph);
|
|
188
337
|
} else {
|
|
189
|
-
printReport(report, ctx, profile, chainGraph, archGraph);
|
|
338
|
+
printReport(report, ctx, profile, chainGraph, archGraph, profileExtended);
|
|
190
339
|
}
|
|
191
340
|
}
|
|
192
341
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared import parser for Forge.
|
|
5
|
+
* Uses @typescript-eslint/parser (AST) when available, falls back to regex.
|
|
6
|
+
* Handles imports, re-exports, dynamic imports, type-only imports, barrel exports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { createRequire } from "module";
|
|
12
|
+
|
|
13
|
+
const ROOT = process.cwd();
|
|
14
|
+
|
|
15
|
+
// ── AST Parser (try to load from project or skill node_modules) ──
|
|
16
|
+
let tsParser = null;
|
|
17
|
+
|
|
18
|
+
function tryLoadTSParser() {
|
|
19
|
+
if (tsParser !== null) return tsParser;
|
|
20
|
+
|
|
21
|
+
const candidates = [
|
|
22
|
+
join(ROOT, "node_modules", "@typescript-eslint", "parser"),
|
|
23
|
+
join(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "@typescript-eslint", "parser"),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const dir of candidates) {
|
|
27
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
28
|
+
try {
|
|
29
|
+
const req = createRequire(dir);
|
|
30
|
+
const parser = req("@typescript-eslint/parser");
|
|
31
|
+
tsParser = parser;
|
|
32
|
+
return parser;
|
|
33
|
+
} catch {
|
|
34
|
+
// fall through
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tsParser = false; // not available
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseWithAST(content, filePath) {
|
|
44
|
+
const parser = tryLoadTSParser();
|
|
45
|
+
if (!parser) return null;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const ast = parser.parse(content, {
|
|
49
|
+
jsx: filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") || undefined,
|
|
50
|
+
range: false,
|
|
51
|
+
loc: false,
|
|
52
|
+
tokens: false,
|
|
53
|
+
comment: false,
|
|
54
|
+
errorOnUnknownASTType: false,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const imports = [];
|
|
58
|
+
|
|
59
|
+
for (const node of ast.body) {
|
|
60
|
+
// import ... from '...'
|
|
61
|
+
if (node.type === "ImportDeclaration" && node.source?.value) {
|
|
62
|
+
imports.push(node.source.value);
|
|
63
|
+
}
|
|
64
|
+
// export { ... } from '...', export * from '...'
|
|
65
|
+
if (
|
|
66
|
+
(node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
|
|
67
|
+
node.source?.value
|
|
68
|
+
) {
|
|
69
|
+
imports.push(node.source.value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Dynamic imports — walk the tree
|
|
74
|
+
function walk(node) {
|
|
75
|
+
if (!node || typeof node !== "object") return;
|
|
76
|
+
if (Array.isArray(node)) {
|
|
77
|
+
for (const child of node) walk(child);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ImportExpression (import('...'))
|
|
82
|
+
if (node.type === "ImportExpression" && node.source?.value) {
|
|
83
|
+
imports.push(node.source.value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Legacy CallExpression dynamic import
|
|
87
|
+
if (node.type === "CallExpression" && node.callee?.name === "import" && node.arguments?.[0]?.value) {
|
|
88
|
+
imports.push(node.arguments[0].value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const key of Object.keys(node)) {
|
|
92
|
+
walk(node[key]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
walk(ast);
|
|
97
|
+
|
|
98
|
+
return [...new Set(imports)];
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseWithASTWithLines(content, filePath) {
|
|
105
|
+
const parser = tryLoadTSParser();
|
|
106
|
+
if (!parser) return null;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const ast = parser.parse(content, {
|
|
110
|
+
jsx: filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") || undefined,
|
|
111
|
+
range: true,
|
|
112
|
+
loc: true,
|
|
113
|
+
tokens: false,
|
|
114
|
+
comment: false,
|
|
115
|
+
errorOnUnknownASTType: false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const imports = [];
|
|
119
|
+
|
|
120
|
+
function addSource(node, source) {
|
|
121
|
+
if (source?.value && node.loc) {
|
|
122
|
+
imports.push({ file: filePath, source: source.value, line: node.loc.start.line });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const node of ast.body) {
|
|
127
|
+
if (node.type === "ImportDeclaration" && node.source?.value) {
|
|
128
|
+
imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
|
|
129
|
+
}
|
|
130
|
+
if (node.type === "ExportNamedDeclaration" && node.source?.value) {
|
|
131
|
+
imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
|
|
132
|
+
}
|
|
133
|
+
if (node.type === "ExportAllDeclaration" && node.source?.value) {
|
|
134
|
+
imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function walk(node) {
|
|
139
|
+
if (!node || typeof node !== "object") return;
|
|
140
|
+
if (Array.isArray(node)) {
|
|
141
|
+
for (const child of node) walk(child);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (node.type === "ImportExpression" && node.source?.value && node.loc) {
|
|
145
|
+
imports.push({ file: filePath, source: node.source.value, line: node.loc.start.line });
|
|
146
|
+
}
|
|
147
|
+
if (node.type === "CallExpression" && node.callee?.name === "import" && node.arguments?.[0]?.value && node.loc) {
|
|
148
|
+
imports.push({ file: filePath, source: node.arguments[0].value, line: node.loc.start.line });
|
|
149
|
+
}
|
|
150
|
+
for (const key of Object.keys(node)) {
|
|
151
|
+
walk(node[key]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
walk(ast);
|
|
155
|
+
|
|
156
|
+
return imports;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Regex fallback (same as before) ──
|
|
163
|
+
|
|
164
|
+
const PATTERNS = [
|
|
165
|
+
/import\s+(?:type\s+)?(?:\{[^}]*\}|[^;{]+?)\s+from\s+['"]([^'"]+)['"]/g,
|
|
166
|
+
/export\s+(?:type\s+)?(?:\{[^}]*\}|\*)\s+from\s+['"]([^'"]+)['"]/g,
|
|
167
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
168
|
+
/import\s+['"]([^'"]+)['"]/g,
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
function parseWithRegex(content) {
|
|
172
|
+
const imports = [];
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
for (const re of PATTERNS) {
|
|
175
|
+
re.lastIndex = 0;
|
|
176
|
+
let match;
|
|
177
|
+
while ((match = re.exec(content)) !== null) {
|
|
178
|
+
const path = match[1];
|
|
179
|
+
if (path && !seen.has(path)) {
|
|
180
|
+
seen.add(path);
|
|
181
|
+
imports.push(path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return imports;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseWithRegexLines(content, filePath) {
|
|
189
|
+
const imports = [];
|
|
190
|
+
const lines = content.split("\n");
|
|
191
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
|
+
const line = lines[i];
|
|
193
|
+
for (const re of PATTERNS) {
|
|
194
|
+
re.lastIndex = 0;
|
|
195
|
+
const matches = [...line.matchAll(re)];
|
|
196
|
+
for (const m of matches) {
|
|
197
|
+
const path = m[1];
|
|
198
|
+
if (path) {
|
|
199
|
+
imports.push({ file: filePath, source: path, line: i + 1 });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return imports;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Public API ──
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse import/export paths from source code.
|
|
211
|
+
* Returns array of unique import path strings.
|
|
212
|
+
* Uses AST parser when available, falls back to regex.
|
|
213
|
+
*/
|
|
214
|
+
export function parseImportPaths(content, filePath) {
|
|
215
|
+
const astResult = filePath ? parseWithAST(content, filePath) : null;
|
|
216
|
+
if (astResult) return astResult;
|
|
217
|
+
const regexResult = parseWithAST(content); // second try without filePath hint
|
|
218
|
+
if (regexResult) return regexResult;
|
|
219
|
+
return parseWithRegex(content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Parse import/export statements with line numbers.
|
|
224
|
+
* Returns array of { file, source, line }.
|
|
225
|
+
* Uses AST parser when available, falls back to regex.
|
|
226
|
+
*/
|
|
227
|
+
export function parseImportsWithLines(content, filePath) {
|
|
228
|
+
const astResult = parseWithASTWithLines(content, filePath);
|
|
229
|
+
if (astResult) return astResult;
|
|
230
|
+
return parseWithRegexLines(content, filePath);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ── CLI test ── */
|
|
234
|
+
if (process.argv[1] && process.argv[1].endsWith("parse-imports.mjs")) {
|
|
235
|
+
const fs = await import("fs");
|
|
236
|
+
const target = process.argv[2];
|
|
237
|
+
if (!target) {
|
|
238
|
+
console.error("Usage: node parse-imports.mjs <file>");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const content = fs.readFileSync(target, "utf-8");
|
|
242
|
+
const paths = parseImportPaths(content, target);
|
|
243
|
+
const lines = parseImportsWithLines(content, target);
|
|
244
|
+
console.log("── Import paths ──");
|
|
245
|
+
for (const p of paths) console.log(` ${p}`);
|
|
246
|
+
console.log(`\n── With line numbers ──`);
|
|
247
|
+
for (const l of lines) console.log(` ${l.line}: ${l.source}`);
|
|
248
|
+
console.log(`\nParser: ${tryLoadTSParser() ? "AST" : "regex (fallback)"}`);
|
|
249
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Nail/unnail Forge sub-commands as standalone skill shortcuts.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node pin.mjs nail <command>
|
|
7
|
+
* node pin.mjs unnail <command>
|
|
8
|
+
*
|
|
9
|
+
* `nail cast` creates a lightweight /cast skill that redirects to /forge cast.
|
|
10
|
+
* `unnail cast` removes that shortcut.
|
|
11
|
+
*
|
|
12
|
+
* Scans harness directories and creates/removes the pin in all of them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
20
|
+
|
|
21
|
+
const HARNESS_DIRS = ['.claude', '.cursor', '.gemini', '.codex', '.agents', '.opencode', '.kiro'];
|
|
22
|
+
|
|
23
|
+
const VALID_COMMANDS = ['forge', 'cast', 'inspect', 'quench', 'temper', 'chain', 'graph', 'relocate', 'reforge', 'smelt', 'inscribe'];
|
|
24
|
+
|
|
25
|
+
const PIN_MARKER = '<!-- forge-pinned-skill -->';
|
|
26
|
+
|
|
27
|
+
function findProjectRoot(startDir = process.cwd()) {
|
|
28
|
+
let dir = resolve(startDir);
|
|
29
|
+
while (dir !== '/') {
|
|
30
|
+
if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, '.git'))) {
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
const parent = resolve(dir, '..');
|
|
34
|
+
if (parent === dir) break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
return resolve(startDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findHarnessDirs(projectRoot) {
|
|
41
|
+
const dirs = [];
|
|
42
|
+
for (const harness of HARNESS_DIRS) {
|
|
43
|
+
const skillsDir = join(projectRoot, harness, 'skills');
|
|
44
|
+
const forgeDir = join(skillsDir, 'forge');
|
|
45
|
+
if (existsSync(forgeDir) || existsSync(join(skillsDir, 'i-forge'))) {
|
|
46
|
+
dirs.push(skillsDir);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return dirs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nail(command, projectRoot) {
|
|
53
|
+
const harnessDirs = findHarnessDirs(projectRoot);
|
|
54
|
+
if (harnessDirs.length === 0) {
|
|
55
|
+
console.log('No harness directories with Forge installed found.');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content = `---
|
|
60
|
+
name: ${command}
|
|
61
|
+
description: "Shortcut for /forge ${command}."
|
|
62
|
+
argument-hint: ""
|
|
63
|
+
user-invocable: true
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
${PIN_MARKER}
|
|
67
|
+
|
|
68
|
+
This is a pinned shortcut for \`{{command_prefix}}forge ${command}\`.
|
|
69
|
+
|
|
70
|
+
Invoke {{command_prefix}}forge ${command}, passing along any arguments provided here, and follow its instructions.
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
let created = 0;
|
|
74
|
+
for (const skillsDir of harnessDirs) {
|
|
75
|
+
const skillDir = join(skillsDir, command);
|
|
76
|
+
if (existsSync(skillDir)) {
|
|
77
|
+
const existingMd = join(skillDir, 'SKILL.md');
|
|
78
|
+
if (existsSync(existingMd)) {
|
|
79
|
+
const existing = readFileSync(existingMd, 'utf-8');
|
|
80
|
+
if (!existing.includes(PIN_MARKER)) {
|
|
81
|
+
console.log(` SKIP: ${skillDir} (non-pinned skill already exists)`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(skillDir, { recursive: true });
|
|
87
|
+
writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8');
|
|
88
|
+
console.log(` + ${skillDir}`);
|
|
89
|
+
created++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (created > 0) {
|
|
93
|
+
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
|
|
94
|
+
console.log(`You can now use /${command} directly.`);
|
|
95
|
+
}
|
|
96
|
+
return created > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function unnail(command, projectRoot) {
|
|
100
|
+
const harnessDirs = findHarnessDirs(projectRoot);
|
|
101
|
+
let removed = 0;
|
|
102
|
+
|
|
103
|
+
for (const skillsDir of harnessDirs) {
|
|
104
|
+
const skillDir = join(skillsDir, command);
|
|
105
|
+
if (!existsSync(skillDir)) continue;
|
|
106
|
+
const skillMd = join(skillDir, 'SKILL.md');
|
|
107
|
+
if (!existsSync(skillMd)) continue;
|
|
108
|
+
const content = readFileSync(skillMd, 'utf-8');
|
|
109
|
+
if (!content.includes(PIN_MARKER)) {
|
|
110
|
+
console.log(` SKIP: ${skillDir} (not a pinned skill)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
114
|
+
console.log(` - ${skillDir}`);
|
|
115
|
+
removed++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (removed > 0) {
|
|
119
|
+
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`No pinned '${command}' shortcut found.`);
|
|
122
|
+
}
|
|
123
|
+
return removed > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [,, action, command] = process.argv;
|
|
127
|
+
|
|
128
|
+
if (!action || !command) {
|
|
129
|
+
console.log('Usage: node pin.mjs <nail|unnail> <command>');
|
|
130
|
+
console.log(`\nAvailable: ${VALID_COMMANDS.join(', ')}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (action !== 'nail' && action !== 'unnail') {
|
|
135
|
+
console.error(`Unknown action: ${action}. Use 'nail' or 'unnail'.`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!VALID_COMMANDS.includes(command)) {
|
|
140
|
+
console.error(`Unknown command: ${command}`);
|
|
141
|
+
console.error(`Available: ${VALID_COMMANDS.join(', ')}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const root = findProjectRoot();
|
|
146
|
+
|
|
147
|
+
if (action === 'nail') {
|
|
148
|
+
nail(command, root);
|
|
149
|
+
} else {
|
|
150
|
+
unnail(command, root);
|
|
151
|
+
}
|