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,155 @@
1
+ /**
2
+ * Analysis Cache Manager
3
+ * Persistent file-based cache in .context/.cache/ with dual hashing
4
+ *
5
+ * - sig: interface hash (function names, params, exports) → invalidates docs
6
+ * - contentHash: full source hash → invalidates body-dependent metrics
7
+ *
8
+ * Cacheable (per-file): complexity, undocumented, jsdocConsistency
9
+ * NOT cacheable (cross-file): deadCode, similarity
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { createHash } from 'crypto';
15
+
16
+ /**
17
+ * @typedef {Object} CacheEntry
18
+ * @property {string} sig - Interface hash
19
+ * @property {string} contentHash - Full content hash
20
+ * @property {Object} [complexity] - Cached complexity results
21
+ * @property {Array} [undocumented] - Cached undocumented items
22
+ * @property {Array} [jsdocIssues] - Cached JSDoc consistency issues
23
+ * @property {string} cachedAt - ISO timestamp
24
+ */
25
+
26
+ /**
27
+ * Compute interface signature hash
28
+ * Matches @sig logic from doc-dialect.js
29
+ * @param {Object} fileData - Parsed file data with functions, classes, exports
30
+ * @returns {string} - 8-char hex hash
31
+ */
32
+ export function computeSig(fileData) {
33
+ const parts = [];
34
+
35
+ // Function signatures
36
+ if (fileData.functions) {
37
+ for (const fn of fileData.functions) {
38
+ parts.push(`fn:${fn.name}:${fn.params?.length || 0}`);
39
+ }
40
+ }
41
+
42
+ // Class signatures
43
+ if (fileData.classes) {
44
+ for (const cls of fileData.classes) {
45
+ parts.push(`cls:${cls.name}`);
46
+ if (cls.methods) {
47
+ for (const m of cls.methods) {
48
+ parts.push(`m:${cls.name}.${m}`);
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ // Exports
55
+ if (fileData.exports) {
56
+ for (const exp of fileData.exports) {
57
+ parts.push(`exp:${exp}`);
58
+ }
59
+ }
60
+
61
+ const hash = createHash('md5').update(parts.sort().join('|')).digest('hex');
62
+ return hash.slice(0, 8);
63
+ }
64
+
65
+ /**
66
+ * Compute full content hash (body-inclusive)
67
+ * Used for complexity and other body-dependent metrics
68
+ * @param {string} code - Source code
69
+ * @returns {string} - 8-char hex hash
70
+ */
71
+ export function computeContentHash(code) {
72
+ return createHash('md5').update(code).digest('hex').slice(0, 8);
73
+ }
74
+
75
+ /**
76
+ * Get cache file path for a source file
77
+ * @param {string} contextDir - .context directory path
78
+ * @param {string} relPath - Relative path to source file (e.g., "src/parser.js")
79
+ * @returns {string} - Cache file path
80
+ */
81
+ export function getCachePath(contextDir, relPath) {
82
+ // src/parser.js → .context/.cache/src/parser.json
83
+ const cacheName = relPath.replace(/\.[^.]+$/, '.json');
84
+ return join(contextDir, '.cache', cacheName);
85
+ }
86
+
87
+ /**
88
+ * Read cached analysis for a file
89
+ * @param {string} contextDir
90
+ * @param {string} relPath
91
+ * @returns {CacheEntry|null}
92
+ */
93
+ export function readCache(contextDir, relPath) {
94
+ const cachePath = getCachePath(contextDir, relPath);
95
+ try {
96
+ if (!existsSync(cachePath)) return null;
97
+ return JSON.parse(readFileSync(cachePath, 'utf-8'));
98
+ } catch (e) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Write cache entry for a file
105
+ * @param {string} contextDir
106
+ * @param {string} relPath
107
+ * @param {CacheEntry} data
108
+ */
109
+ export function writeCache(contextDir, relPath, data) {
110
+ const cachePath = getCachePath(contextDir, relPath);
111
+ try {
112
+ mkdirSync(dirname(cachePath), { recursive: true });
113
+ writeFileSync(cachePath, JSON.stringify({
114
+ ...data,
115
+ cachedAt: new Date().toISOString(),
116
+ }, null, 2));
117
+ } catch (e) {
118
+ // Cache write failure is non-fatal
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check if cache is still valid
124
+ * @param {CacheEntry|null} cached
125
+ * @param {string} currentSig - Current interface hash
126
+ * @param {string} currentContentHash - Current content hash
127
+ * @param {'sig'|'content'} level - Which hash to check
128
+ * @returns {boolean}
129
+ */
130
+ export function isCacheValid(cached, currentSig, currentContentHash, level = 'content') {
131
+ if (!cached) return false;
132
+ if (!cached.sig || !cached.contentHash) return false;
133
+
134
+ if (level === 'sig') {
135
+ return cached.sig === currentSig;
136
+ }
137
+
138
+ // For body-dependent metrics, both hashes must match
139
+ return cached.sig === currentSig && cached.contentHash === currentContentHash;
140
+ }
141
+
142
+ /**
143
+ * Invalidate all caches (e.g., after structural changes)
144
+ * @param {string} contextDir
145
+ */
146
+ export function invalidateAllCaches(contextDir) {
147
+ const cacheDir = join(contextDir, '.cache');
148
+ try {
149
+ if (existsSync(cacheDir)) {
150
+ rmSync(cacheDir, { recursive: true, force: true });
151
+ }
152
+ } catch (e) {
153
+ // Non-fatal
154
+ }
155
+ }
@@ -15,7 +15,16 @@ import { getComplexity } from './complexity.js';
15
15
  import { getLargeFiles } from './large-files.js';
16
16
  import { getOutdatedPatterns } from './outdated-patterns.js';
17
17
  import { getFullAnalysis } from './full-analysis.js';
18
+ import { compressFile } from './compress.js';
19
+ import { getProjectDocs, generateContextFiles } from './doc-dialect.js';
20
+ import { getGraph } from './tools.js';
21
+ import { parseProject } from './parser.js';
18
22
  import { resolvePath } from './workspace.js';
23
+ import { checkJSDocConsistency } from './jsdoc-checker.js';
24
+ import { checkTypes } from './type-checker.js';
25
+ import { compactProject, expandProject } from './compact.js';
26
+ import { injectJSDoc, stripJSDoc, validateCtxContracts } from './ctx-to-jsdoc.js';
27
+ import { getConfig, setConfig, getModeDescription, getModeWorkflow } from './mode-config.js';
19
28
 
20
29
  /**
21
30
  * Parse named argument from args array
@@ -137,4 +146,126 @@ export const CLI_HANDLERS = {
137
146
  return getFullAnalysis(getPath(args), { includeItems });
138
147
  },
139
148
  },
149
+
150
+ 'jsdoc-check': {
151
+ handler: async (args) => checkJSDocConsistency(getPath(args)),
152
+ },
153
+
154
+ types: {
155
+ handler: async (args) => {
156
+ const maxDiagnostics = parseInt(getArg(args, 'max')) || 50;
157
+ return checkTypes(getPath(args), { maxDiagnostics });
158
+ },
159
+ },
160
+
161
+ compress: {
162
+ requiresArg: true,
163
+ argError: 'Usage: compress <file> [--no-beautify] [--no-legend]',
164
+ handler: async (args) => {
165
+ const beautify = !args.includes('--no-beautify');
166
+ const legend = !args.includes('--no-legend');
167
+ return compressFile(resolvePath(args[0]), { beautify, legend });
168
+ },
169
+ },
170
+
171
+ docs: {
172
+ requiresArg: true,
173
+ argError: 'Usage: docs <path> [--file=<filename>]',
174
+ handler: async (args) => {
175
+ const projectPath = resolvePath(args[0]);
176
+ const graph = await getGraph(projectPath);
177
+ const file = args.find(a => a.startsWith('--file='))?.split('=')[1];
178
+ return getProjectDocs(graph, projectPath, { file });
179
+ },
180
+ },
181
+
182
+ 'generate-ctx': {
183
+ requiresArg: true,
184
+ argError: 'Usage: generate-ctx <path> [--overwrite] [--scope=focus|all]',
185
+ handler: async (args) => {
186
+ const projectPath = resolvePath(args[0]);
187
+ const graph = await getGraph(projectPath);
188
+ const parsed = await parseProject(projectPath);
189
+ const overwrite = args.includes('--overwrite');
190
+ const scope = args.find(a => a.startsWith('--scope='))?.split('=')[1] || 'all';
191
+ return generateContextFiles(graph, projectPath, parsed, { overwrite, scope });
192
+ },
193
+ },
194
+
195
+ compact: {
196
+ requiresArg: true,
197
+ argError: 'Usage: compact <path> [--dry-run]',
198
+ handler: async (args) => {
199
+ const projectPath = resolvePath(args[0]);
200
+ const dryRun = args.includes('--dry-run');
201
+ return compactProject(projectPath, { dryRun });
202
+ },
203
+ },
204
+
205
+ beautify: {
206
+ requiresArg: true,
207
+ argError: 'Usage: beautify <path> [--dry-run]',
208
+ handler: async (args) => {
209
+ const projectPath = resolvePath(args[0]);
210
+ const dryRun = args.includes('--dry-run');
211
+ return expandProject(projectPath, { dryRun });
212
+ },
213
+ },
214
+
215
+ 'inject-jsdoc': {
216
+ requiresArg: true,
217
+ argError: 'Usage: inject-jsdoc <path> [--dry-run]',
218
+ handler: async (args) => {
219
+ const projectPath = resolvePath(args[0]);
220
+ const dryRun = args.includes('--dry-run');
221
+ return injectJSDoc(projectPath, { dryRun });
222
+ },
223
+ },
224
+
225
+ 'strip-jsdoc': {
226
+ requiresArg: true,
227
+ argError: 'Usage: strip-jsdoc <path> [--dry-run]',
228
+ handler: async (args) => {
229
+ const projectPath = resolvePath(args[0]);
230
+ const dryRun = args.includes('--dry-run');
231
+ return stripJSDoc(projectPath, { dryRun });
232
+ },
233
+ },
234
+
235
+ 'validate-ctx': {
236
+ requiresArg: true,
237
+ argError: 'Usage: validate-ctx <path> [--strict]',
238
+ handler: async (args) => {
239
+ const projectPath = resolvePath(args[0]);
240
+ const strict = args.includes('--strict');
241
+ return validateCtxContracts(projectPath, { strict });
242
+ },
243
+ },
244
+
245
+ mode: {
246
+ requiresArg: true,
247
+ argError: 'Usage: mode <path>',
248
+ handler: async (args) => {
249
+ const dir = resolvePath(args[0]);
250
+ const config = getConfig(dir);
251
+ return {
252
+ ...config,
253
+ description: getModeDescription(config.mode),
254
+ workflow: getModeWorkflow(config.mode),
255
+ };
256
+ },
257
+ },
258
+
259
+ 'set-mode': {
260
+ requiresArg: true,
261
+ argError: 'Usage: set-mode <path> <1|2|3>',
262
+ handler: async (args) => {
263
+ const dir = resolvePath(args[0]);
264
+ const mode = parseInt(args[1], 10);
265
+ if (!mode || ![1, 2, 3].includes(mode)) {
266
+ throw new Error('Mode must be 1, 2, or 3');
267
+ }
268
+ return setConfig(dir, { mode });
269
+ },
270
+ },
140
271
  };
package/src/cli.js CHANGED
@@ -20,7 +20,7 @@ Commands:
20
20
  expand <symbol> Expand minified symbol (e.g., SN, SN.togglePin)
21
21
  deps <symbol> Get dependency tree
22
22
  usages <symbol> Find all usages
23
- pending <path> List pending @test/@expect tests
23
+ pending <path> List pending .ctx.md test checklists
24
24
  summary <path> Get test progress summary
25
25
  undocumented <path> Find missing JSDoc (--level=tests|params|all)
26
26
  deadcode <path> Find unused functions/classes
@@ -30,6 +30,18 @@ Commands:
30
30
  largefiles <path> Find files needing split (--problematic)
31
31
  outdated <path> Find legacy patterns & redundant deps
32
32
  analyze <path> Run ALL checks with Health Score
33
+ jsdoc-check <path> Validate JSDoc ↔ function signatures
34
+ types <path> Run tsc type checking (--max=50)
35
+ compress <file> Compress JS file for AI (--no-beautify, --no-legend)
36
+ compact <path> Compact all JS files — strips comments/whitespace (--dry-run)
37
+ beautify <path> Beautify/expand all JS files — inverse of compact (--dry-run)
38
+ inject-jsdoc <path> Generate JSDoc from .ctx files and inject into source
39
+ strip-jsdoc <path> Strip all JSDoc blocks from source files
40
+ docs <path> Get project docs in doc-dialect format (--file=<name>)
41
+ generate-ctx <path> Generate .context/ docs (--overwrite --scope=focus)
42
+ validate-ctx <path> Validate .ctx contracts against source AST (--strict)
43
+ mode <path> Show current compact code mode and workflow
44
+ set-mode <path> <1|2|3> Set compact code mode (1=compact, 2=full, 3=IDE)
33
45
  filters Show current filter configuration
34
46
  instructions Show agent guidelines (JSDoc, Arch)
35
47
  help Show this help
@@ -37,7 +49,7 @@ Commands:
37
49
  Examples:
38
50
  npx project-graph-mcp skeleton src/components
39
51
  npx project-graph-mcp expand SN
40
- npx project-graph-mcp pending src/
52
+ npx project-graph-mcp compact src/ --dry-run
41
53
  `);
42
54
  }
43
55
 
package/src/compact.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Compact/Beautify — Project-wide code compression and expansion
3
+ *
4
+ * Converts JS files between compact (minified, no comments) and
5
+ * beautified (formatted, readable) forms. Both preserve all names
6
+ * (mangle: false) — only whitespace and comments are affected.
7
+ *
8
+ * Types and documentation live in .ctx files, not in source code.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
12
+ import { join, extname, relative } from 'path';
13
+ import { minify } from '../vendor/terser.mjs';
14
+
15
+ const SUPPORTED = new Set(['.js', '.mjs']);
16
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', '.context', 'dev-docs', '.agent', '.agents']);
17
+
18
+ /**
19
+ * Walk directory for JS files
20
+ * @param {string} dir
21
+ * @param {string} rootDir
22
+ * @returns {string[]} Absolute paths
23
+ */
24
+ function walkJSFiles(dir, rootDir = dir) {
25
+ const results = [];
26
+ try {
27
+ for (const entry of readdirSync(dir)) {
28
+ if (entry.startsWith('.') && entry !== '.') continue;
29
+ const full = join(dir, entry);
30
+ const stat = statSync(full);
31
+ if (stat.isDirectory()) {
32
+ if (!SKIP_DIRS.has(entry)) {
33
+ results.push(...walkJSFiles(full, rootDir));
34
+ }
35
+ } else if (SUPPORTED.has(extname(entry).toLowerCase())) {
36
+ results.push(full);
37
+ }
38
+ }
39
+ } catch { /* skip unreadable */ }
40
+ return results;
41
+ }
42
+
43
+ /**
44
+ * Compact a single file — minify with preserved names
45
+ * @param {string} filePath
46
+ * @returns {Promise<{original: number, compacted: number}>}
47
+ */
48
+ async function compactFile(filePath) {
49
+ const source = readFileSync(filePath, 'utf-8');
50
+ const original = source.length;
51
+
52
+ if (!source.trim()) return { original: 0, compacted: 0 };
53
+
54
+ const result = await minify(source, {
55
+ compress: {
56
+ dead_code: true,
57
+ drop_console: false,
58
+ passes: 2,
59
+ },
60
+ mangle: false,
61
+ module: true,
62
+ output: {
63
+ beautify: false,
64
+ comments: false,
65
+ semicolons: true,
66
+ },
67
+ });
68
+
69
+ if (result.error) throw result.error;
70
+
71
+ writeFileSync(filePath, result.code, 'utf-8');
72
+ return { original, compacted: result.code.length };
73
+ }
74
+
75
+ /**
76
+ * Beautify a single file — format with readable output
77
+ * @param {string} filePath
78
+ * @returns {Promise<{original: number, beautified: number}>}
79
+ */
80
+ async function beautifyFile(filePath) {
81
+ const source = readFileSync(filePath, 'utf-8');
82
+ const original = source.length;
83
+
84
+ if (!source.trim()) return { original: 0, beautified: 0 };
85
+
86
+ const result = await minify(source, {
87
+ compress: false,
88
+ mangle: false,
89
+ module: true,
90
+ output: {
91
+ beautify: true,
92
+ comments: false,
93
+ indent_level: 2,
94
+ semicolons: true,
95
+ },
96
+ });
97
+
98
+ if (result.error) throw result.error;
99
+
100
+ writeFileSync(filePath, result.code + '\n', 'utf-8');
101
+ return { original, beautified: result.code.length };
102
+ }
103
+
104
+ /**
105
+ * Compact all JS files in a directory
106
+ * @param {string} dir - Directory to compact
107
+ * @param {Object} [options]
108
+ * @param {boolean} [options.dryRun=false] - Preview without writing
109
+ * @returns {Promise<{files: number, originalBytes: number, compactedBytes: number, savings: string}>}
110
+ */
111
+ export async function compactProject(dir, options = {}) {
112
+ const { dryRun = false } = options;
113
+ const files = walkJSFiles(dir);
114
+ let totalOriginal = 0;
115
+ let totalCompacted = 0;
116
+ const processed = [];
117
+ const errors = [];
118
+
119
+ for (const filePath of files) {
120
+ const rel = relative(dir, filePath);
121
+ try {
122
+ const source = readFileSync(filePath, 'utf-8');
123
+ totalOriginal += source.length;
124
+
125
+ if (!dryRun) {
126
+ const { compacted } = await compactFile(filePath);
127
+ totalCompacted += compacted;
128
+ } else {
129
+ const result = await minify(source, {
130
+ compress: { dead_code: true, drop_console: false, passes: 2 },
131
+ mangle: false,
132
+ module: true,
133
+ output: { beautify: false, comments: false },
134
+ });
135
+ totalCompacted += result.code?.length || source.length;
136
+ }
137
+
138
+ processed.push(rel);
139
+ } catch (e) {
140
+ errors.push({ file: rel, error: e.message });
141
+ }
142
+ }
143
+
144
+ const savings = totalOriginal > 0
145
+ ? Math.round((1 - totalCompacted / totalOriginal) * 100)
146
+ : 0;
147
+
148
+ return {
149
+ files: processed.length,
150
+ fileList: processed,
151
+ originalBytes: totalOriginal,
152
+ compactedBytes: totalCompacted,
153
+ savings: `${savings}%`,
154
+ errors: errors.length > 0 ? errors : undefined,
155
+ dryRun,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Beautify all JS files in a directory
161
+ * @param {string} dir - Directory to beautify
162
+ * @param {Object} [options]
163
+ * @param {boolean} [options.dryRun=false] - Preview without writing
164
+ * @returns {Promise<{files: number, originalBytes: number, beautifiedBytes: number}>}
165
+ */
166
+ export async function expandProject(dir, options = {}) {
167
+ const { dryRun = false } = options;
168
+ const files = walkJSFiles(dir);
169
+ let totalOriginal = 0;
170
+ let totalBeautified = 0;
171
+ const processed = [];
172
+ const errors = [];
173
+
174
+ for (const filePath of files) {
175
+ const rel = relative(dir, filePath);
176
+ try {
177
+ const source = readFileSync(filePath, 'utf-8');
178
+ totalOriginal += source.length;
179
+
180
+ if (!dryRun) {
181
+ const { beautified } = await beautifyFile(filePath);
182
+ totalBeautified += beautified;
183
+ } else {
184
+ const result = await minify(source, {
185
+ compress: false,
186
+ mangle: false,
187
+ module: true,
188
+ output: { beautify: true, comments: false, indent_level: 2 },
189
+ });
190
+ totalBeautified += result.code?.length || source.length;
191
+ }
192
+
193
+ processed.push(rel);
194
+ } catch (e) {
195
+ errors.push({ file: rel, error: e.message });
196
+ }
197
+ }
198
+
199
+ return {
200
+ files: processed.length,
201
+ fileList: processed,
202
+ originalBytes: totalOriginal,
203
+ beautifiedBytes: totalBeautified,
204
+ errors: errors.length > 0 ? errors : undefined,
205
+ dryRun,
206
+ };
207
+ }
package/src/complexity.js CHANGED
@@ -109,13 +109,12 @@ function getRating(complexity) {
109
109
  }
110
110
 
111
111
  /**
112
- * Analyze complexity of file
113
- * @param {string} filePath
112
+ * Analyze complexity of a single file (per-file export for cache integration)
113
+ * @param {string} code - File source code
114
+ * @param {string} relPath - Relative path for reporting
114
115
  * @returns {ComplexityItem[]}
115
116
  */
116
- function analyzeFile(filePath, rootDir) {
117
- const code = readFileSync(filePath, 'utf-8');
118
- const relPath = relative(rootDir, filePath);
117
+ export function analyzeComplexityFile(code, relPath) {
119
118
  const items = [];
120
119
 
121
120
  let ast;
@@ -140,11 +139,9 @@ function analyzeFile(filePath, rootDir) {
140
139
  },
141
140
 
142
141
  ArrowFunctionExpression(node) {
143
- // Skip small arrow functions
144
142
  if (node.body.type !== 'BlockStatement') return;
145
143
  const complexity = calculateComplexity(node.body);
146
144
  if (complexity > 5) {
147
- // Only report complex arrow functions
148
145
  items.push({
149
146
  name: '(arrow)',
150
147
  type: 'function',
@@ -174,6 +171,23 @@ function analyzeFile(filePath, rootDir) {
174
171
  return items;
175
172
  }
176
173
 
174
+ /**
175
+ * Analyze complexity of file (internal, reads from disk)
176
+ * @param {string} filePath
177
+ * @param {string} rootDir
178
+ * @returns {ComplexityItem[]}
179
+ */
180
+ function analyzeFile(filePath, rootDir) {
181
+ let code;
182
+ try {
183
+ code = readFileSync(filePath, 'utf-8');
184
+ } catch (e) {
185
+ return []; // File deleted between findJSFiles and read
186
+ }
187
+ const relPath = relative(rootDir, filePath);
188
+ return analyzeComplexityFile(code, relPath);
189
+ }
190
+
177
191
  /**
178
192
  * Get complexity analysis for directory
179
193
  * @param {string} dir