@grafema/cli 0.2.5-beta → 0.2.7

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 (105) hide show
  1. package/README.md +12 -0
  2. package/dist/cli.js +6 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/analyze.d.ts +3 -10
  5. package/dist/commands/analyze.d.ts.map +1 -1
  6. package/dist/commands/analyze.js +5 -347
  7. package/dist/commands/analyze.js.map +1 -1
  8. package/dist/commands/analyzeAction.d.ts +28 -0
  9. package/dist/commands/analyzeAction.d.ts.map +1 -0
  10. package/dist/commands/analyzeAction.js +243 -0
  11. package/dist/commands/analyzeAction.js.map +1 -0
  12. package/dist/commands/check.js +2 -2
  13. package/dist/commands/check.js.map +1 -1
  14. package/dist/commands/context.d.ts +16 -0
  15. package/dist/commands/context.d.ts.map +1 -0
  16. package/dist/commands/context.js +238 -0
  17. package/dist/commands/context.js.map +1 -0
  18. package/dist/commands/doctor/checks.js +1 -1
  19. package/dist/commands/doctor/checks.js.map +1 -1
  20. package/dist/commands/explain.d.ts.map +1 -1
  21. package/dist/commands/explain.js +4 -3
  22. package/dist/commands/explain.js.map +1 -1
  23. package/dist/commands/file.d.ts +15 -0
  24. package/dist/commands/file.d.ts.map +1 -0
  25. package/dist/commands/file.js +144 -0
  26. package/dist/commands/file.js.map +1 -0
  27. package/dist/commands/impact.d.ts.map +1 -1
  28. package/dist/commands/impact.js +2 -3
  29. package/dist/commands/impact.js.map +1 -1
  30. package/dist/commands/init.d.ts.map +1 -1
  31. package/dist/commands/init.js +13 -1
  32. package/dist/commands/init.js.map +1 -1
  33. package/dist/commands/ls.d.ts.map +1 -1
  34. package/dist/commands/ls.js +3 -2
  35. package/dist/commands/ls.js.map +1 -1
  36. package/dist/commands/query.d.ts +8 -0
  37. package/dist/commands/query.d.ts.map +1 -1
  38. package/dist/commands/query.js +158 -51
  39. package/dist/commands/query.js.map +1 -1
  40. package/dist/commands/schema.d.ts.map +1 -1
  41. package/dist/commands/schema.js +3 -2
  42. package/dist/commands/schema.js.map +1 -1
  43. package/dist/commands/server.d.ts.map +1 -1
  44. package/dist/commands/server.js +8 -59
  45. package/dist/commands/server.js.map +1 -1
  46. package/dist/commands/setup-skill.d.ts +17 -0
  47. package/dist/commands/setup-skill.d.ts.map +1 -0
  48. package/dist/commands/setup-skill.js +131 -0
  49. package/dist/commands/setup-skill.js.map +1 -0
  50. package/dist/commands/trace.d.ts.map +1 -1
  51. package/dist/commands/trace.js +20 -10
  52. package/dist/commands/trace.js.map +1 -1
  53. package/dist/plugins/builtinPlugins.d.ts +10 -0
  54. package/dist/plugins/builtinPlugins.d.ts.map +1 -0
  55. package/dist/plugins/builtinPlugins.js +68 -0
  56. package/dist/plugins/builtinPlugins.js.map +1 -0
  57. package/dist/plugins/pluginLoader.d.ts +16 -0
  58. package/dist/plugins/pluginLoader.d.ts.map +1 -0
  59. package/dist/plugins/pluginLoader.js +101 -0
  60. package/dist/plugins/pluginLoader.js.map +1 -0
  61. package/dist/plugins/pluginResolver.js +38 -0
  62. package/dist/utils/codePreview.d.ts +1 -0
  63. package/dist/utils/codePreview.d.ts.map +1 -1
  64. package/dist/utils/codePreview.js +5 -3
  65. package/dist/utils/codePreview.js.map +1 -1
  66. package/dist/utils/formatNode.d.ts +1 -1
  67. package/dist/utils/formatNode.d.ts.map +1 -1
  68. package/dist/utils/formatNode.js +2 -2
  69. package/dist/utils/formatNode.js.map +1 -1
  70. package/dist/utils/pathUtils.d.ts +2 -0
  71. package/dist/utils/pathUtils.d.ts.map +1 -0
  72. package/dist/utils/pathUtils.js +9 -0
  73. package/dist/utils/pathUtils.js.map +1 -0
  74. package/dist/utils/progressRenderer.d.ts +4 -0
  75. package/dist/utils/progressRenderer.d.ts.map +1 -1
  76. package/dist/utils/progressRenderer.js +23 -4
  77. package/dist/utils/progressRenderer.js.map +1 -1
  78. package/package.json +7 -9
  79. package/skills/grafema-codebase-analysis/SKILL.md +295 -0
  80. package/skills/grafema-codebase-analysis/references/node-edge-types.md +123 -0
  81. package/skills/grafema-codebase-analysis/references/query-patterns.md +205 -0
  82. package/src/cli.ts +8 -2
  83. package/src/commands/analyze.ts +5 -435
  84. package/src/commands/analyzeAction.ts +284 -0
  85. package/src/commands/check.ts +2 -2
  86. package/src/commands/context.ts +309 -0
  87. package/src/commands/doctor/checks.ts +1 -1
  88. package/src/commands/explain.ts +4 -3
  89. package/src/commands/explore.tsx +7 -5
  90. package/src/commands/file.ts +179 -0
  91. package/src/commands/impact.ts +2 -3
  92. package/src/commands/init.ts +13 -1
  93. package/src/commands/ls.ts +3 -2
  94. package/src/commands/query.ts +167 -52
  95. package/src/commands/schema.ts +3 -2
  96. package/src/commands/server.ts +8 -64
  97. package/src/commands/setup-skill.ts +162 -0
  98. package/src/commands/trace.ts +18 -9
  99. package/src/plugins/builtinPlugins.ts +108 -0
  100. package/src/plugins/pluginLoader.ts +123 -0
  101. package/src/plugins/pluginResolver.js +38 -0
  102. package/src/utils/codePreview.ts +7 -3
  103. package/src/utils/formatNode.ts +3 -3
  104. package/src/utils/pathUtils.ts +9 -0
  105. package/src/utils/progressRenderer.ts +25 -4
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Setup-skill command - Install Grafema Agent Skill into a project
3
+ */
4
+
5
+ import { Command } from 'commander';
6
+ import { resolve, join } from 'path';
7
+ import { existsSync, mkdirSync, readFileSync, cpSync } from 'fs';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
11
+
12
+ /** Default install paths per platform */
13
+ const PLATFORM_PATHS: Record<string, string> = {
14
+ claude: '.claude/skills',
15
+ gemini: '.gemini/skills',
16
+ cursor: '.cursor/skills',
17
+ };
18
+
19
+ const SKILL_DIR_NAME = 'grafema-codebase-analysis';
20
+
21
+ interface SetupSkillOptions {
22
+ outputDir?: string;
23
+ platform?: string;
24
+ force?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Get the bundled skill source directory.
29
+ * In the published package, skills/ is at the package root alongside dist/.
30
+ */
31
+ function getSkillSourceDir(): string {
32
+ // __dirname is dist/commands/ -> go up to package root, then into skills/
33
+ return join(__dirname, '..', '..', 'skills', SKILL_DIR_NAME);
34
+ }
35
+
36
+ /**
37
+ * Read version from skill metadata.
38
+ */
39
+ function getSkillVersion(skillDir: string): string | null {
40
+ const skillMd = join(skillDir, 'SKILL.md');
41
+ if (!existsSync(skillMd)) return null;
42
+
43
+ const content = readFileSync(skillMd, 'utf-8');
44
+ const versionMatch = content.match(/version:\s*"?([^"\n]+)"?/);
45
+ return versionMatch ? versionMatch[1].trim() : null;
46
+ }
47
+
48
+ /**
49
+ * Resolve the target directory for skill installation.
50
+ */
51
+ function resolveTargetDir(projectPath: string, options: SetupSkillOptions): string {
52
+ if (options.outputDir) {
53
+ return resolve(options.outputDir, SKILL_DIR_NAME);
54
+ }
55
+
56
+ const platform = options.platform || 'claude';
57
+ const basePath = PLATFORM_PATHS[platform];
58
+ if (!basePath) {
59
+ throw new Error(
60
+ `Unknown platform: ${platform}. Supported: ${Object.keys(PLATFORM_PATHS).join(', ')}`
61
+ );
62
+ }
63
+
64
+ return join(projectPath, basePath, SKILL_DIR_NAME);
65
+ }
66
+
67
+ /**
68
+ * Copy skill directory recursively.
69
+ */
70
+ function copySkill(sourceDir: string, targetDir: string): void {
71
+ mkdirSync(targetDir, { recursive: true });
72
+ cpSync(sourceDir, targetDir, { recursive: true });
73
+ }
74
+
75
+ /**
76
+ * Install the Grafema Agent Skill into a project directory.
77
+ * Returns true if skill was installed, false if skipped.
78
+ */
79
+ export function installSkill(projectPath: string, options: SetupSkillOptions = {}): boolean {
80
+ const sourceDir = getSkillSourceDir();
81
+
82
+ if (!existsSync(sourceDir)) {
83
+ throw new Error(`Skill source not found at ${sourceDir}. Package may be corrupted.`);
84
+ }
85
+
86
+ const targetDir = resolveTargetDir(projectPath, options);
87
+
88
+ // Check if already installed
89
+ if (existsSync(targetDir) && !options.force) {
90
+ const installedVersion = getSkillVersion(targetDir);
91
+ const sourceVersion = getSkillVersion(sourceDir);
92
+
93
+ if (installedVersion === sourceVersion) {
94
+ return false; // Same version, skip
95
+ }
96
+
97
+ // Different version — warn but don't overwrite without --force
98
+ console.log(` Skill exists (v${installedVersion}), latest is v${sourceVersion}`);
99
+ console.log(' Use --force to update, or run: grafema setup-skill --force');
100
+ return false;
101
+ }
102
+
103
+ copySkill(sourceDir, targetDir);
104
+ return true;
105
+ }
106
+
107
+ export const setupSkillCommand = new Command('setup-skill')
108
+ .description('Install Grafema Agent Skill into your project')
109
+ .argument('[path]', 'Project path', '.')
110
+ .option('--output-dir <path>', 'Custom output directory (overrides --platform)')
111
+ .option('--platform <name>', 'Target platform: claude, gemini, cursor', 'claude')
112
+ .option('-f, --force', 'Overwrite existing skill')
113
+ .addHelpText('after', `
114
+ Examples:
115
+ grafema setup-skill Install for Claude Code (.claude/skills/)
116
+ grafema setup-skill --platform gemini Install for Gemini CLI (.gemini/skills/)
117
+ grafema setup-skill --force Update existing skill
118
+ grafema setup-skill --output-dir ./my-skills/
119
+ `)
120
+ .action(async (path: string, options: SetupSkillOptions) => {
121
+ const projectPath = resolve(path);
122
+ const sourceDir = getSkillSourceDir();
123
+
124
+ if (!existsSync(sourceDir)) {
125
+ console.error('✗ Skill source not found. Package may be corrupted.');
126
+ process.exit(1);
127
+ }
128
+
129
+ const targetDir = resolveTargetDir(projectPath, options);
130
+
131
+ // Check if already installed
132
+ if (existsSync(targetDir) && !options.force) {
133
+ const installedVersion = getSkillVersion(targetDir);
134
+ const sourceVersion = getSkillVersion(sourceDir);
135
+
136
+ if (installedVersion === sourceVersion) {
137
+ console.log(`✓ Grafema skill already installed (v${installedVersion})`);
138
+ console.log(` Location: ${targetDir}`);
139
+ return;
140
+ }
141
+
142
+ console.log(` Skill exists (v${installedVersion}), latest is v${sourceVersion}`);
143
+ console.log(' Use --force to update');
144
+ return;
145
+ }
146
+
147
+ try {
148
+ copySkill(sourceDir, targetDir);
149
+ } catch (err) {
150
+ console.error('✗ Failed to install skill:', (err as Error).message);
151
+ process.exit(1);
152
+ }
153
+
154
+ const sourceVersion = getSkillVersion(sourceDir);
155
+ console.log(`✓ Grafema skill installed (v${sourceVersion})`);
156
+ console.log(` Location: ${targetDir}`);
157
+ console.log('');
158
+ console.log('Next steps:');
159
+ console.log(' 1. Ensure Grafema MCP server is configured in your AI agent');
160
+ console.log(' 2. Run "grafema analyze" to build the code graph');
161
+ console.log(' 3. Your AI agent will now prefer graph queries over reading files');
162
+ });
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { Command } from 'commander';
11
- import { resolve, join } from 'path';
11
+ import { isAbsolute, resolve, join } from 'path';
12
12
  import { existsSync } from 'fs';
13
- import { RFDBServerBackend, parseSemanticId, traceValues, type ValueSource } from '@grafema/core';
13
+ import { RFDBServerBackend, parseSemanticId, parseSemanticIdV2, traceValues, type ValueSource } from '@grafema/core';
14
14
  import { formatNodeDisplay, formatNodeInline } from '../utils/formatNode.js';
15
15
  import { exitWithError } from '../utils/errorFormatter.js';
16
16
 
@@ -227,12 +227,21 @@ async function findVariables(
227
227
  if (name.toLowerCase() === varName.toLowerCase()) {
228
228
  // If scope specified, check if variable is in that scope
229
229
  if (scopeName) {
230
- const parsed = parseSemanticId(node.id);
231
- if (!parsed) continue; // Skip nodes with invalid IDs
230
+ // Try v2 parsing first
231
+ const parsedV2 = parseSemanticIdV2(node.id);
232
+ if (parsedV2) {
233
+ if (!parsedV2.namedParent || parsedV2.namedParent.toLowerCase() !== lowerScopeName) {
234
+ continue;
235
+ }
236
+ } else {
237
+ // Fallback to v1 parsing
238
+ const parsed = parseSemanticId(node.id);
239
+ if (!parsed) continue; // Skip nodes with invalid IDs
232
240
 
233
- // Check if scopeName appears anywhere in the scope chain
234
- if (!parsed.scopePath.some(s => s.toLowerCase() === lowerScopeName)) {
235
- continue;
241
+ // Check if scopeName appears anywhere in the scope chain
242
+ if (!parsed.scopePath.some(s => s.toLowerCase() === lowerScopeName)) {
243
+ continue;
244
+ }
236
245
  }
237
246
  }
238
247
 
@@ -738,7 +747,7 @@ async function handleSinkTrace(
738
747
  const sourcesCount = pv.sources.length;
739
748
  console.log(` - ${JSON.stringify(pv.value)} (${sourcesCount} source${sourcesCount === 1 ? '' : 's'})`);
740
749
  for (const src of pv.sources.slice(0, 3)) {
741
- const relativePath = src.file.startsWith(projectPath)
750
+ const relativePath = isAbsolute(src.file)
742
751
  ? src.file.substring(projectPath.length + 1)
743
752
  : src.file;
744
753
  console.log(` <- ${relativePath}:${src.line}`);
@@ -900,7 +909,7 @@ async function handleRouteTrace(
900
909
  // Format traced values
901
910
  const sources = await Promise.all(
902
911
  traced.map(async (t) => {
903
- const relativePath = t.source.file.startsWith(projectPath)
912
+ const relativePath = isAbsolute(t.source.file)
904
913
  ? t.source.file.substring(projectPath.length + 1)
905
914
  : t.source.file;
906
915
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Built-in plugin registry — maps plugin names to factory functions.
3
+ *
4
+ * Each entry creates a fresh plugin instance. Plugin names match the class names
5
+ * and are referenced by name in .grafema/config.yaml under phases:
6
+ * discovery, indexing, analysis, enrichment, validation.
7
+ */
8
+
9
+ import type { Plugin } from '@grafema/core';
10
+ import {
11
+ // Discovery
12
+ SimpleProjectDiscovery,
13
+ MonorepoServiceDiscovery,
14
+ WorkspaceDiscovery,
15
+ // Indexing
16
+ JSModuleIndexer,
17
+ RustModuleIndexer,
18
+ // Analysis
19
+ JSASTAnalyzer,
20
+ ExpressRouteAnalyzer,
21
+ ExpressResponseAnalyzer,
22
+ NestJSRouteAnalyzer,
23
+ SocketIOAnalyzer,
24
+ DatabaseAnalyzer,
25
+ FetchAnalyzer,
26
+ ServiceLayerAnalyzer,
27
+ ReactAnalyzer,
28
+ RustAnalyzer,
29
+ // Enrichment
30
+ MethodCallResolver,
31
+ ArgumentParameterLinker,
32
+ AliasTracker,
33
+ ValueDomainAnalyzer,
34
+ MountPointResolver,
35
+ ExpressHandlerLinker,
36
+ PrefixEvaluator,
37
+ InstanceOfResolver,
38
+ ImportExportLinker,
39
+ FunctionCallResolver,
40
+ HTTPConnectionEnricher,
41
+ ConfigRoutingMapBuilder,
42
+ ServiceConnectionEnricher,
43
+ RustFFIEnricher,
44
+ RejectionPropagationEnricher,
45
+ CallbackCallResolver,
46
+ // Validation
47
+ CallResolverValidator,
48
+ EvalBanValidator,
49
+ SQLInjectionValidator,
50
+ AwaitInLoopValidator,
51
+ ShadowingDetector,
52
+ GraphConnectivityValidator,
53
+ DataFlowValidator,
54
+ TypeScriptDeadCodeValidator,
55
+ BrokenImportValidator,
56
+ UnconnectedRouteValidator,
57
+ PackageCoverageValidator,
58
+ } from '@grafema/core';
59
+
60
+ export const BUILTIN_PLUGINS: Record<string, () => Plugin> = {
61
+ // Discovery
62
+ SimpleProjectDiscovery: () => new SimpleProjectDiscovery() as Plugin,
63
+ MonorepoServiceDiscovery: () => new MonorepoServiceDiscovery() as Plugin,
64
+ WorkspaceDiscovery: () => new WorkspaceDiscovery() as Plugin,
65
+ // Indexing
66
+ JSModuleIndexer: () => new JSModuleIndexer() as Plugin,
67
+ RustModuleIndexer: () => new RustModuleIndexer() as Plugin,
68
+ // Analysis
69
+ JSASTAnalyzer: () => new JSASTAnalyzer() as Plugin,
70
+ ExpressRouteAnalyzer: () => new ExpressRouteAnalyzer() as Plugin,
71
+ ExpressResponseAnalyzer: () => new ExpressResponseAnalyzer() as Plugin,
72
+ NestJSRouteAnalyzer: () => new NestJSRouteAnalyzer() as Plugin,
73
+ SocketIOAnalyzer: () => new SocketIOAnalyzer() as Plugin,
74
+ DatabaseAnalyzer: () => new DatabaseAnalyzer() as Plugin,
75
+ FetchAnalyzer: () => new FetchAnalyzer() as Plugin,
76
+ ServiceLayerAnalyzer: () => new ServiceLayerAnalyzer() as Plugin,
77
+ ReactAnalyzer: () => new ReactAnalyzer() as Plugin,
78
+ RustAnalyzer: () => new RustAnalyzer() as Plugin,
79
+ // Enrichment
80
+ MethodCallResolver: () => new MethodCallResolver() as Plugin,
81
+ ArgumentParameterLinker: () => new ArgumentParameterLinker() as Plugin,
82
+ AliasTracker: () => new AliasTracker() as Plugin,
83
+ ValueDomainAnalyzer: () => new ValueDomainAnalyzer() as Plugin,
84
+ MountPointResolver: () => new MountPointResolver() as Plugin,
85
+ ExpressHandlerLinker: () => new ExpressHandlerLinker() as Plugin,
86
+ PrefixEvaluator: () => new PrefixEvaluator() as Plugin,
87
+ InstanceOfResolver: () => new InstanceOfResolver() as Plugin,
88
+ ImportExportLinker: () => new ImportExportLinker() as Plugin,
89
+ FunctionCallResolver: () => new FunctionCallResolver() as Plugin,
90
+ HTTPConnectionEnricher: () => new HTTPConnectionEnricher() as Plugin,
91
+ ConfigRoutingMapBuilder: () => new ConfigRoutingMapBuilder() as Plugin,
92
+ ServiceConnectionEnricher: () => new ServiceConnectionEnricher() as Plugin,
93
+ RustFFIEnricher: () => new RustFFIEnricher() as Plugin,
94
+ RejectionPropagationEnricher: () => new RejectionPropagationEnricher() as Plugin,
95
+ CallbackCallResolver: () => new CallbackCallResolver() as Plugin,
96
+ // Validation
97
+ CallResolverValidator: () => new CallResolverValidator() as Plugin,
98
+ EvalBanValidator: () => new EvalBanValidator() as Plugin,
99
+ SQLInjectionValidator: () => new SQLInjectionValidator() as Plugin,
100
+ AwaitInLoopValidator: () => new AwaitInLoopValidator() as Plugin,
101
+ ShadowingDetector: () => new ShadowingDetector() as Plugin,
102
+ GraphConnectivityValidator: () => new GraphConnectivityValidator() as Plugin,
103
+ DataFlowValidator: () => new DataFlowValidator() as Plugin,
104
+ TypeScriptDeadCodeValidator: () => new TypeScriptDeadCodeValidator() as Plugin,
105
+ BrokenImportValidator: () => new BrokenImportValidator() as Plugin,
106
+ UnconnectedRouteValidator: () => new UnconnectedRouteValidator() as Plugin,
107
+ PackageCoverageValidator: () => new PackageCoverageValidator() as Plugin,
108
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Plugin loading — resolves built-in and custom plugins from config.
3
+ *
4
+ * Handles:
5
+ * - ESM resolve hook for custom plugin @grafema/* imports
6
+ * - Loading custom plugins from .grafema/plugins/
7
+ * - Creating plugin instances from config phases
8
+ */
9
+
10
+ import { join } from 'path';
11
+ import { existsSync, readdirSync } from 'fs';
12
+ import { pathToFileURL } from 'url';
13
+ import { register } from 'node:module';
14
+ import type { Plugin, GrafemaConfig } from '@grafema/core';
15
+ import { BUILTIN_PLUGINS } from './builtinPlugins.js';
16
+
17
+ /**
18
+ * Register ESM resolve hook so custom plugins can import @grafema/* packages.
19
+ *
20
+ * Plugins in .grafema/plugins/ do `import { Plugin } from '@grafema/core'`,
21
+ * but @grafema/core isn't in the target project's node_modules/.
22
+ * This hook redirects those imports to the CLI's bundled packages.
23
+ *
24
+ * Uses module.register() (stable Node.js 20.6+ API).
25
+ * Safe to call multiple times — subsequent calls add redundant hooks
26
+ * that short-circuit on the same specifiers.
27
+ */
28
+ let pluginResolverRegistered = false;
29
+
30
+ export function registerPluginResolver(): void {
31
+ if (pluginResolverRegistered) return;
32
+ pluginResolverRegistered = true;
33
+
34
+ const grafemaPackages: Record<string, string> = {};
35
+ for (const pkg of ['@grafema/core', '@grafema/types']) {
36
+ try {
37
+ grafemaPackages[pkg] = import.meta.resolve(pkg);
38
+ } catch {
39
+ // Package not available from CLI context — skip
40
+ }
41
+ }
42
+
43
+ register(
44
+ new URL('./pluginResolver.js', import.meta.url),
45
+ { data: { grafemaPackages } },
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Load custom plugins from .grafema/plugins/ directory
51
+ */
52
+ export async function loadCustomPlugins(
53
+ projectPath: string,
54
+ log: (msg: string) => void
55
+ ): Promise<Record<string, () => Plugin>> {
56
+ const pluginsDir = join(projectPath, '.grafema', 'plugins');
57
+ if (!existsSync(pluginsDir)) {
58
+ return {};
59
+ }
60
+
61
+ // Ensure @grafema/* imports resolve for custom plugins (REG-380)
62
+ registerPluginResolver();
63
+
64
+ const customPlugins: Record<string, () => Plugin> = {};
65
+
66
+ try {
67
+ const files = readdirSync(pluginsDir).filter(
68
+ (f) => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs')
69
+ );
70
+
71
+ for (const file of files) {
72
+ try {
73
+ const pluginPath = join(pluginsDir, file);
74
+ const pluginUrl = pathToFileURL(pluginPath).href;
75
+ const module = await import(pluginUrl);
76
+
77
+ const PluginClass = module.default || module[file.replace(/\.[cm]?js$/, '')];
78
+ if (PluginClass && typeof PluginClass === 'function') {
79
+ const pluginName = PluginClass.name || file.replace(/\.[cm]?js$/, '');
80
+ customPlugins[pluginName] = () => {
81
+ const instance = new PluginClass() as Plugin;
82
+ instance.config.sourceFile = pluginPath;
83
+ return instance;
84
+ };
85
+ log(`Loaded custom plugin: ${pluginName}`);
86
+ }
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ console.warn(`Failed to load plugin ${file}: ${message}`);
90
+ }
91
+ }
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ console.warn(`Error loading custom plugins: ${message}`);
95
+ }
96
+
97
+ return customPlugins;
98
+ }
99
+
100
+ export function createPlugins(
101
+ config: GrafemaConfig['plugins'],
102
+ customPlugins: Record<string, () => Plugin> = {},
103
+ verbose: boolean = false
104
+ ): Plugin[] {
105
+ const plugins: Plugin[] = [];
106
+ const phases: (keyof GrafemaConfig['plugins'])[] = ['discovery', 'indexing', 'analysis', 'enrichment', 'validation'];
107
+
108
+ for (const phase of phases) {
109
+ const names = config[phase] || [];
110
+ for (const name of names) {
111
+ // Check built-in first, then custom
112
+ const factory = BUILTIN_PLUGINS[name] || customPlugins[name];
113
+ if (factory) {
114
+ plugins.push(factory());
115
+ } else if (verbose) {
116
+ // Only show plugin warning in verbose mode
117
+ console.warn(`Plugin not found: ${name} (skipping). Check .grafema/config.yaml or add to .grafema/plugins/`);
118
+ }
119
+ }
120
+ }
121
+
122
+ return plugins;
123
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * ESM resolve hook for custom Grafema plugins.
3
+ *
4
+ * Allows plugins in .grafema/plugins/ to `import { Plugin } from '@grafema/core'`
5
+ * without requiring @grafema/core in the target project's node_modules/.
6
+ *
7
+ * The hook maps @grafema/* bare specifiers to the actual package URLs
8
+ * within the CLI's dependency tree.
9
+ *
10
+ * Registered via module.register() before loading custom plugins.
11
+ * Must be plain JS — loader hooks run in a separate thread.
12
+ */
13
+
14
+ /** @type {Record<string, string>} package name → resolved file URL */
15
+ let grafemaPackages = {};
16
+
17
+ /**
18
+ * Called once when the hook is registered via module.register().
19
+ * @param {{ grafemaPackages: Record<string, string> }} data
20
+ */
21
+ export function initialize(data) {
22
+ grafemaPackages = data.grafemaPackages;
23
+ }
24
+
25
+ /**
26
+ * Resolve hook — intercepts bare specifier imports for @grafema/* packages
27
+ * and redirects them to the CLI's bundled versions.
28
+ *
29
+ * Only exact package name matches are handled (e.g. '@grafema/core').
30
+ * All other specifiers pass through to the default resolver.
31
+ */
32
+ export function resolve(specifier, context, next) {
33
+ if (grafemaPackages[specifier]) {
34
+ return { url: grafemaPackages[specifier], shortCircuit: true };
35
+ }
36
+
37
+ return next(specifier, context);
38
+ }
@@ -6,12 +6,14 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, existsSync } from 'fs';
9
+ import { isAbsolute, join } from 'path';
9
10
 
10
11
  export interface CodePreviewOptions {
11
12
  file: string;
12
13
  line: number;
13
14
  contextBefore?: number; // default: 2
14
15
  contextAfter?: number; // default: 12
16
+ projectPath?: string;
15
17
  }
16
18
 
17
19
  export interface CodePreviewResult {
@@ -25,14 +27,16 @@ export interface CodePreviewResult {
25
27
  * Returns lines around the specified line number with context.
26
28
  */
27
29
  export function getCodePreview(options: CodePreviewOptions): CodePreviewResult | null {
28
- const { file, line, contextBefore = 2, contextAfter = 12 } = options;
30
+ const { file, line, contextBefore = 2, contextAfter = 12, projectPath } = options;
29
31
 
30
- if (!existsSync(file)) {
32
+ const absoluteFile = projectPath && !isAbsolute(file) ? join(projectPath, file) : file;
33
+
34
+ if (!existsSync(absoluteFile)) {
31
35
  return null;
32
36
  }
33
37
 
34
38
  try {
35
- const content = readFileSync(file, 'utf-8');
39
+ const content = readFileSync(absoluteFile, 'utf-8');
36
40
  const allLines = content.split('\n');
37
41
 
38
42
  // Calculate range (1-indexed)
@@ -5,7 +5,7 @@
5
5
  * Semantic IDs are shown as the PRIMARY identifier, with location as secondary.
6
6
  */
7
7
 
8
- import { relative } from 'path';
8
+ import { isAbsolute, relative } from 'path';
9
9
 
10
10
  /**
11
11
  * Format options for node display
@@ -29,7 +29,7 @@ export interface DisplayableNode {
29
29
  type: string;
30
30
  /** Human-readable name */
31
31
  name: string;
32
- /** Absolute file path */
32
+ /** Source file path (relative to project root, or absolute for legacy) */
33
33
  file: string;
34
34
  /** Line number (optional) */
35
35
  line?: number;
@@ -123,6 +123,6 @@ export function formatLocation(
123
123
  projectPath: string
124
124
  ): string {
125
125
  if (!file) return '';
126
- const relPath = relative(projectPath, file);
126
+ const relPath = isAbsolute(file) ? relative(projectPath, file) : file;
127
127
  return line ? `${relPath}:${line}` : relPath;
128
128
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Convert a node file path to relative display format.
3
+ * Handles both legacy absolute paths and new relative paths (REG-408).
4
+ */
5
+ import { relative, isAbsolute } from 'path';
6
+
7
+ export function toRelativeDisplay(file: string, projectPath: string): string {
8
+ return isAbsolute(file) ? relative(projectPath, file) : file;
9
+ }
@@ -45,6 +45,8 @@ export class ProgressRenderer {
45
45
  private totalFiles: number = 0;
46
46
  private processedFiles: number = 0;
47
47
  private servicesAnalyzed: number = 0;
48
+ private totalServices: number = 0;
49
+ private currentService: string = '';
48
50
  private spinnerIndex: number = 0;
49
51
  private isInteractive: boolean;
50
52
  private startTime: number;
@@ -100,6 +102,12 @@ export class ProgressRenderer {
100
102
  if (info.servicesAnalyzed !== undefined) {
101
103
  this.servicesAnalyzed = info.servicesAnalyzed;
102
104
  }
105
+ if (info.totalServices !== undefined) {
106
+ this.totalServices = info.totalServices;
107
+ }
108
+ if (info.currentService !== undefined) {
109
+ this.currentService = info.currentService;
110
+ }
103
111
 
104
112
  // Update spinner
105
113
  this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
@@ -222,11 +230,20 @@ export class ProgressRenderer {
222
230
  }
223
231
  return '';
224
232
  case 'indexing':
225
- case 'analysis':
226
- if (this.totalFiles > 0) {
227
- return ` ${this.processedFiles}/${this.totalFiles} modules`;
233
+ case 'analysis': {
234
+ const parts: string[] = [];
235
+ if (this.totalServices > 0) {
236
+ parts.push(`${this.servicesAnalyzed}/${this.totalServices} services`);
228
237
  }
229
- return '';
238
+ if (this.currentService) {
239
+ // Truncate long service names
240
+ const name = this.currentService.length > 30
241
+ ? '...' + this.currentService.slice(-27)
242
+ : this.currentService;
243
+ parts.push(name);
244
+ }
245
+ return parts.length > 0 ? ` ${parts.join(' | ')}` : '';
246
+ }
230
247
  case 'enrichment':
231
248
  case 'validation':
232
249
  if (this.activePlugins.length > 0) {
@@ -268,6 +285,8 @@ export class ProgressRenderer {
268
285
  processedFiles: number;
269
286
  totalFiles: number;
270
287
  servicesAnalyzed: number;
288
+ totalServices: number;
289
+ currentService: string;
271
290
  spinnerIndex: number;
272
291
  activePlugins: string[];
273
292
  nodeCount: number;
@@ -279,6 +298,8 @@ export class ProgressRenderer {
279
298
  processedFiles: this.processedFiles,
280
299
  totalFiles: this.totalFiles,
281
300
  servicesAnalyzed: this.servicesAnalyzed,
301
+ totalServices: this.totalServices,
302
+ currentService: this.currentService,
282
303
  spinnerIndex: this.spinnerIndex,
283
304
  activePlugins: [...this.activePlugins],
284
305
  nodeCount: this.nodeCount,