project-graph-mcp 1.3.0 → 1.5.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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Code Compression for AI Context
3
+ *
4
+ * Terser-based minification of JS source files for token-efficient AI consumption.
5
+ * Preserves exported names and structure while stripping comments, whitespace,
6
+ * and redundant syntax. Optionally generates a JSDoc legend header.
7
+ */
8
+
9
+ import { readFileSync } from 'fs';
10
+ import { basename, extname } from 'path';
11
+ import { minify } from '../vendor/terser.mjs';
12
+ import { parse } from '../vendor/acorn.mjs';
13
+ import { simple as walk } from '../vendor/walk.mjs';
14
+
15
+ /** Supported file extensions for compression */
16
+ const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.tsx']);
17
+
18
+ /**
19
+ * Estimate token count (rough: ~4 chars per token for code)
20
+ * @param {string} text
21
+ * @returns {number}
22
+ */
23
+ function estimateTokens(text) {
24
+ return Math.ceil(text.length / 4);
25
+ }
26
+
27
+ /**
28
+ * Extract JSDoc legend from source — exported symbols with their descriptions
29
+ * @param {string} source - Original source code
30
+ * @param {string} filePath
31
+ * @returns {string} Compact legend string
32
+ */
33
+ function extractLegend(source, filePath) {
34
+ const lines = [];
35
+ lines.push(`--- ${basename(filePath)} ---`);
36
+
37
+ try {
38
+ const ast = parse(source, { ecmaVersion: 2022, sourceType: 'module', locations: true });
39
+
40
+ walk(ast, {
41
+ ExportNamedDeclaration(node) {
42
+ const decl = node.declaration;
43
+ if (!decl) return;
44
+
45
+ // Extract preceding JSDoc comment — first line description only
46
+ let jsdoc = '';
47
+ if (node.start > 0) {
48
+ // Only look at the 500 chars immediately before node to avoid module-level JSDoc
49
+ const searchStart = Math.max(0, node.start - 500);
50
+ const beforeNode = source.slice(searchStart, node.start).trimEnd();
51
+ const jsdocMatch = beforeNode.match(/\/\*\*[\s\S]*?\*\/\s*$/);
52
+ if (jsdocMatch) {
53
+ // Ensure the JSDoc block is close to the declaration (within 3 blank lines)
54
+ const gap = source.slice(searchStart + jsdocMatch.index + jsdocMatch[0].length, node.start);
55
+ if (gap.split('\n').length <= 3) {
56
+ const desc = jsdocMatch[0]
57
+ .replace(/\/\*\*\s*\n?/, '')
58
+ .replace(/\s*\*\//, '')
59
+ .split('\n')
60
+ .map(l => l.replace(/^\s*\*\s?/, '').trim())
61
+ .filter(l => l && !l.startsWith('@'))
62
+ .join(' ')
63
+ .trim();
64
+ if (desc) jsdoc = desc.length > 80 ? desc.slice(0, 77) + '...' : desc;
65
+ }
66
+ }
67
+ }
68
+
69
+ if (decl.type === 'FunctionDeclaration') {
70
+ const name = decl.id?.name || 'anonymous';
71
+ const paramList = decl.params.map(p => {
72
+ if (p.type === 'Identifier') return p.name;
73
+ if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') return p.left.name + '=';
74
+ return '...';
75
+ }).join(',');
76
+ const line = `${decl.async ? 'async ' : ''}${name}(${paramList})`;
77
+ lines.push(jsdoc ? `${line}|${jsdoc}` : line);
78
+ }
79
+
80
+ if (decl.type === 'ClassDeclaration') {
81
+ const name = decl.id?.name || 'AnonymousClass';
82
+ const ext = decl.superClass ? ` extends ${decl.superClass.name || '?'}` : '';
83
+ lines.push(`class ${name}${ext}${jsdoc ? '|' + jsdoc : ''}`);
84
+
85
+ // List methods
86
+ for (const method of decl.body.body) {
87
+ if (method.type === 'MethodDefinition' && method.key?.name) {
88
+ const mParams = method.value.params.map(p => {
89
+ if (p.type === 'Identifier') return p.name;
90
+ if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') return p.left.name + '=';
91
+ return '...';
92
+ }).join(',');
93
+ lines.push(` .${method.key.name}(${mParams})`);
94
+ }
95
+ }
96
+ }
97
+
98
+ if (decl.type === 'VariableDeclaration') {
99
+ for (const declarator of decl.declarations) {
100
+ if (declarator.id?.name) {
101
+ lines.push(`${decl.kind} ${declarator.id.name}${jsdoc ? '|' + jsdoc : ''}`);
102
+ }
103
+ }
104
+ }
105
+ },
106
+ });
107
+ } catch (e) {
108
+ lines.push(`PARSE_ERROR: ${e.message}`);
109
+ }
110
+
111
+ return lines.join('\n');
112
+ }
113
+
114
+ /**
115
+ * Compress a source file for AI consumption
116
+ * @param {string} filePath - Path to JS/MJS file
117
+ * @param {Object} [options]
118
+ * @param {boolean} [options.beautify=true] - Readable multi-line output
119
+ * @param {boolean} [options.legend=true] - Add compact legend header
120
+ * @returns {Promise<{code: string, legend: string, original: number, compressed: number, savings: string}>}
121
+ */
122
+ export async function compressFile(filePath, options = {}) {
123
+ const { beautify = true, legend: includeLegend = true } = options;
124
+
125
+ // Validate file extension
126
+ const ext = extname(filePath).toLowerCase();
127
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
128
+ throw new Error(`Unsupported file type: ${ext}. Supported: ${[...SUPPORTED_EXTENSIONS].join(', ')}`);
129
+ }
130
+
131
+ const source = readFileSync(filePath, 'utf-8');
132
+ const originalTokens = estimateTokens(source);
133
+
134
+ // Handle empty files
135
+ if (!source.trim()) {
136
+ return {
137
+ code: '',
138
+ legend: '',
139
+ original: 0,
140
+ compressed: 0,
141
+ savings: '0%',
142
+ };
143
+ }
144
+
145
+ const terserOptions = {
146
+ compress: {
147
+ dead_code: true,
148
+ drop_console: false,
149
+ passes: 2,
150
+ },
151
+ mangle: false, // Preserve all names for AI readability
152
+ module: true, // Support ES modules
153
+ output: {
154
+ beautify,
155
+ comments: false, // Strip all comments — legend replaces them
156
+ semicolons: !beautify,
157
+ },
158
+ };
159
+
160
+ let compressedSource;
161
+ try {
162
+ const result = await minify(source, terserOptions);
163
+ if (result.error) {
164
+ throw result.error;
165
+ }
166
+ compressedSource = result.code;
167
+ } catch (e) {
168
+ // Graceful fallback: return original code stripped of comments
169
+ compressedSource = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '').replace(/\n{3,}/g, '\n\n').trim();
170
+ }
171
+
172
+ const legend = includeLegend ? extractLegend(source, filePath) : '';
173
+ const compressedCode = legend
174
+ ? `/*\n${legend}\n*/\n${compressedSource}`
175
+ : compressedSource;
176
+
177
+ const compressedTokens = estimateTokens(compressedCode);
178
+ const savings = originalTokens > 0
179
+ ? Math.round((1 - compressedTokens / originalTokens) * 100)
180
+ : 0;
181
+
182
+ return {
183
+ code: compressedCode,
184
+ legend,
185
+ original: originalTokens,
186
+ compressed: compressedTokens,
187
+ savings: `${savings}%`,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Edit a function/class in a source file by symbol name.
193
+ * Agent sends new code (compressed or full); server replaces in the original file.
194
+ * Supports: replace entire function, replace function body only, or add new function.
195
+ *
196
+ * @param {string} filePath - Path to JS/MJS file
197
+ * @param {string} symbol - Function or class name to edit
198
+ * @param {string} newCode - New code for the symbol (full function/class definition)
199
+ * @param {Object} [options]
200
+ * @param {boolean} [options.beautify=true] - Beautify the result after editing
201
+ * @param {boolean} [options.dryRun=false] - Preview without writing
202
+ * @returns {Promise<{success: boolean, file: string, symbol: string, oldRange: {start: number, end: number}, newLength: number, dryRun?: boolean}>}
203
+ */
204
+ export async function editCompressed(filePath, symbol, newCode, options = {}) {
205
+ const { beautify: shouldBeautify = true, dryRun = false } = options;
206
+
207
+ const source = readFileSync(filePath, 'utf-8');
208
+
209
+ // Parse AST to find the symbol
210
+ let ast;
211
+ try {
212
+ ast = parse(source, {
213
+ ecmaVersion: 'latest',
214
+ sourceType: 'module',
215
+ locations: true,
216
+ });
217
+ } catch (e) {
218
+ throw new Error(`Failed to parse ${filePath}: ${e.message}`);
219
+ }
220
+
221
+ // Find the symbol (function or class) and its range
222
+ const match = findSymbolRange(ast, source, symbol);
223
+ if (!match) {
224
+ throw new Error(`Symbol "${symbol}" not found in ${filePath}`);
225
+ }
226
+
227
+ // Build new source: before + newCode + after
228
+ const before = source.slice(0, match.start);
229
+ const after = source.slice(match.end);
230
+ let newSource = before + newCode + after;
231
+
232
+ // Optionally beautify the result
233
+ if (shouldBeautify) {
234
+ try {
235
+ const result = await minify(newSource, {
236
+ compress: false,
237
+ mangle: false,
238
+ module: true,
239
+ output: { beautify: true, comments: true, semicolons: false },
240
+ });
241
+ if (result.code) {
242
+ newSource = result.code;
243
+ }
244
+ } catch {
245
+ // If beautify fails, use raw replacement
246
+ }
247
+ }
248
+
249
+ // Validate the new source parses correctly
250
+ try {
251
+ parse(newSource, { ecmaVersion: 'latest', sourceType: 'module' });
252
+ } catch (e) {
253
+ throw new Error(`Edit would create invalid syntax: ${e.message}`);
254
+ }
255
+
256
+ if (!dryRun) {
257
+ const { writeFileSync } = await import('fs');
258
+ writeFileSync(filePath, newSource, 'utf-8');
259
+ }
260
+
261
+ return {
262
+ success: true,
263
+ file: filePath,
264
+ symbol,
265
+ oldRange: { start: match.start, end: match.end },
266
+ newLength: newCode.length,
267
+ ...(dryRun ? { dryRun: true } : {}),
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Find the character range of a symbol (function or class) in source.
273
+ * Handles: FunctionDeclaration, ExportNamedDeclaration wrapping functions,
274
+ * ClassDeclaration, variable-assigned functions (const foo = ...).
275
+ *
276
+ * @param {Object} ast - Acorn AST
277
+ * @param {string} source - Full source code
278
+ * @param {string} symbol - Symbol name to find
279
+ * @returns {{start: number, end: number, type: string}|null}
280
+ */
281
+ function findSymbolRange(ast, source, symbol) {
282
+ let match = null;
283
+
284
+ walk(ast, {
285
+ FunctionDeclaration(node) {
286
+ if (node.id?.name === symbol) {
287
+ match = { start: node.start, end: node.end, type: 'FunctionDeclaration' };
288
+ }
289
+ },
290
+ ClassDeclaration(node) {
291
+ if (node.id?.name === symbol) {
292
+ match = { start: node.start, end: node.end, type: 'ClassDeclaration' };
293
+ }
294
+ },
295
+ VariableDeclaration(node) {
296
+ for (const decl of node.declarations) {
297
+ if (decl.id?.name === symbol) {
298
+ match = { start: node.start, end: node.end, type: 'VariableDeclaration' };
299
+ }
300
+ }
301
+ },
302
+ ExportNamedDeclaration(node) {
303
+ if (node.declaration) {
304
+ const decl = node.declaration;
305
+ const name = decl.id?.name || decl.declarations?.[0]?.id?.name;
306
+ if (name === symbol) {
307
+ match = { start: node.start, end: node.end, type: 'ExportNamedDeclaration' };
308
+ }
309
+ }
310
+ },
311
+ ExportDefaultDeclaration(node) {
312
+ if (node.declaration?.id?.name === symbol) {
313
+ match = { start: node.start, end: node.end, type: 'ExportDefaultDeclaration' };
314
+ }
315
+ },
316
+ });
317
+
318
+ return match;
319
+ }