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.
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Full Analysis - Comprehensive Code Health Report
3
3
  * Runs all analysis tools and generates a health score
4
+ *
5
+ * Uses incremental caching for per-file metrics (complexity, undocumented, jsdocConsistency).
6
+ * Cross-file metrics (dead code, similarity) always run dynamically.
4
7
  */
5
8
 
9
+ import { readFileSync, readdirSync, statSync } from 'fs';
10
+ import { join, relative, resolve } from 'path';
6
11
  import { getDeadCode } from './dead-code.js';
7
- import { getUndocumentedSummary } from './undocumented.js';
12
+ import { checkUndocumentedFile } from './undocumented.js';
8
13
  import { getSimilarFunctions } from './similar-functions.js';
9
- import { getComplexity } from './complexity.js';
14
+ import { analyzeComplexityFile } from './complexity.js';
10
15
  import { getLargeFiles } from './large-files.js';
11
16
  import { getOutdatedPatterns } from './outdated-patterns.js';
12
17
  import { getTableUsage } from './db-analysis.js';
18
+ import { checkJSDocFile } from './jsdoc-checker.js';
19
+ import { readCache, writeCache, computeContentHash, isCacheValid } from './analysis-cache.js';
20
+ import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
21
+ import { getWorkspaceRoot } from './workspace.js';
13
22
 
14
23
  /**
15
24
  * @typedef {Object} AnalysisResult
@@ -75,10 +84,21 @@ function calculateHealthScore(results) {
75
84
  const warningPatterns = results.outdated.stats?.bySeverity?.warning || 0;
76
85
  const outdatedPenalty = Math.min(errorPatterns * 3 + warningPatterns * 1, 10);
77
86
  score -= outdatedPenalty;
78
- if (results.outdated.redundantDeps.length > 0) {
87
+ if (results.outdated.redundantDeps?.length > 0) {
79
88
  topIssues.push(`${results.outdated.redundantDeps.length} redundant npm dependencies`);
80
89
  }
81
90
 
91
+ // JSDoc consistency penalty: -2 per error, -1 per warning (max -15)
92
+ if (results.jsdocConsistency) {
93
+ const jsdocErrors = results.jsdocConsistency.errors || 0;
94
+ const jsdocWarnings = results.jsdocConsistency.warnings || 0;
95
+ const jsdocPenalty = Math.min(jsdocErrors * 2 + jsdocWarnings * 1, 15);
96
+ score -= jsdocPenalty;
97
+ if (jsdocErrors > 0) {
98
+ topIssues.push(`${jsdocErrors} JSDoc consistency errors`);
99
+ }
100
+ }
101
+
82
102
  // Clamp score
83
103
  score = Math.max(0, Math.min(100, Math.round(score)));
84
104
 
@@ -92,8 +112,158 @@ function calculateHealthScore(results) {
92
112
  return { score, rating, topIssues: topIssues.slice(0, 5) };
93
113
  }
94
114
 
115
+ /**
116
+ * Find all JS files in directory
117
+ * @param {string} dir
118
+ * @param {string} rootDir
119
+ * @returns {string[]}
120
+ */
121
+ function findJSFiles(dir, rootDir = dir) {
122
+ if (dir === rootDir) parseGitignore(rootDir);
123
+ const files = [];
124
+ try {
125
+ for (const entry of readdirSync(dir)) {
126
+ const fullPath = join(dir, entry);
127
+ const relativePath = relative(rootDir, fullPath);
128
+ const stat = statSync(fullPath);
129
+ if (stat.isDirectory()) {
130
+ if (!shouldExcludeDir(entry, relativePath)) {
131
+ files.push(...findJSFiles(fullPath, rootDir));
132
+ }
133
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
134
+ if (!shouldExcludeFile(entry, relativePath)) {
135
+ files.push(fullPath);
136
+ }
137
+ }
138
+ }
139
+ } catch (e) { /* dir not found */ }
140
+ return files;
141
+ }
142
+
143
+ /**
144
+ * Run cacheable per-file analyses with cache support
145
+ * Returns aggregated complexity, undocumented, and jsdoc results
146
+ * @param {string} dir
147
+ * @param {string} contextDir
148
+ * @returns {{ complexity: Object[], undocumented: Object[], jsdocIssues: Object[], cacheStats: { hits: number, misses: number } }}
149
+ */
150
+ function runCacheableAnalyses(dir, contextDir) {
151
+ const resolvedDir = resolve(dir);
152
+ const wsRoot = getWorkspaceRoot();
153
+ const files = findJSFiles(dir);
154
+
155
+ const allComplexity = [];
156
+ const allUndocumented = [];
157
+ const allJsdocIssues = [];
158
+ let cacheHits = 0;
159
+ let cacheMisses = 0;
160
+
161
+ for (const file of files) {
162
+ const relPath = relative(resolvedDir, file);
163
+ // Cache key: workspace-relative (src/parser.js), matches graph paths
164
+ const cacheKey = relative(wsRoot, file);
165
+ let code;
166
+ try {
167
+ code = readFileSync(file, 'utf-8');
168
+ } catch (e) {
169
+ continue; // File deleted between findJSFiles and read
170
+ }
171
+ const contentHash = computeContentHash(code);
172
+
173
+ // Check cache (key: workspace-relative)
174
+ const cached = readCache(contextDir, cacheKey);
175
+
176
+ if (cached && isCacheValid(cached, cached.sig, contentHash, 'content')) {
177
+ // Cache hit — use cached results
178
+ cacheHits++;
179
+ if (cached.complexity) allComplexity.push(...cached.complexity);
180
+ if (cached.undocumented) allUndocumented.push(...cached.undocumented);
181
+ if (cached.jsdocIssues) allJsdocIssues.push(...cached.jsdocIssues);
182
+ } else {
183
+ // Cache miss — compute fresh
184
+ cacheMisses++;
185
+ const complexity = analyzeComplexityFile(code, relPath);
186
+ const undocumented = checkUndocumentedFile(code, relPath, 'tests');
187
+ const jsdocIssues = checkJSDocFile(code, relPath);
188
+
189
+ allComplexity.push(...complexity);
190
+ allUndocumented.push(...undocumented);
191
+ allJsdocIssues.push(...jsdocIssues);
192
+
193
+ // Save to cache (key: workspace-relative)
194
+ writeCache(contextDir, cacheKey, {
195
+ sig: cached?.sig || contentHash,
196
+ contentHash,
197
+ complexity,
198
+ undocumented,
199
+ jsdocIssues,
200
+ });
201
+ }
202
+ }
203
+
204
+ return {
205
+ complexity: allComplexity,
206
+ undocumented: allUndocumented,
207
+ jsdocIssues: allJsdocIssues,
208
+ cacheStats: { hits: cacheHits, misses: cacheMisses },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Aggregate complexity items into summary format
214
+ * @param {Object[]} items
215
+ * @param {number} minComplexity
216
+ * @returns {Object}
217
+ */
218
+ function aggregateComplexity(items, minComplexity = 5) {
219
+ let filtered = items.filter(i => i.complexity >= minComplexity);
220
+ filtered.sort((a, b) => b.complexity - a.complexity);
221
+
222
+ const stats = {
223
+ low: filtered.filter(i => i.rating === 'low').length,
224
+ moderate: filtered.filter(i => i.rating === 'moderate').length,
225
+ high: filtered.filter(i => i.rating === 'high').length,
226
+ critical: filtered.filter(i => i.rating === 'critical').length,
227
+ average: filtered.length > 0
228
+ ? Math.round(filtered.reduce((s, i) => s + i.complexity, 0) / filtered.length * 10) / 10
229
+ : 0,
230
+ };
231
+
232
+ return { total: filtered.length, stats, items: filtered.slice(0, 30) };
233
+ }
234
+
235
+ /**
236
+ * Aggregate undocumented items into summary format
237
+ * @param {Object[]} items
238
+ * @returns {Object}
239
+ */
240
+ function aggregateUndocumented(items) {
241
+ const byType = {
242
+ class: items.filter(i => i.type === 'class').length,
243
+ function: items.filter(i => i.type === 'function').length,
244
+ method: items.filter(i => i.type === 'method').length,
245
+ };
246
+ return { total: items.length, byType, items: items.slice(0, 20) };
247
+ }
248
+
249
+ /**
250
+ * Aggregate JSDoc issues into summary format
251
+ * @param {Object[]} issues
252
+ * @returns {{ issues: Object[], summary: Object }}
253
+ */
254
+ function aggregateJSDoc(issues) {
255
+ const errors = issues.filter(i => i.severity === 'error').length;
256
+ const warnings = issues.filter(i => i.severity === 'warning').length;
257
+ const byFile = {};
258
+ for (const issue of issues) {
259
+ byFile[issue.file] = (byFile[issue.file] || 0) + 1;
260
+ }
261
+ return { issues, summary: { total: issues.length, errors, warnings, byFile } };
262
+ }
263
+
95
264
  /**
96
265
  * Run full analysis on directory
266
+ * Uses incremental cache for per-file metrics; cross-file metrics always recompute.
97
267
  * @param {string} dir
98
268
  * @param {Object} [options]
99
269
  * @param {boolean} [options.includeItems=false] - Include individual items
@@ -101,15 +271,21 @@ function calculateHealthScore(results) {
101
271
  */
102
272
  export async function getFullAnalysis(dir, options = {}) {
103
273
  const includeItems = options.includeItems || false;
274
+ const resolvedDir = resolve(dir);
275
+ const contextDir = join(getWorkspaceRoot(), '.context');
276
+
277
+ // Run cacheable per-file analyses (complexity, undocumented, jsdoc)
278
+ const cached = runCacheableAnalyses(dir, contextDir);
279
+ const complexity = aggregateComplexity(cached.complexity);
280
+ const undocumented = aggregateUndocumented(cached.undocumented);
281
+ const jsdocCheck = aggregateJSDoc(cached.jsdocIssues);
104
282
 
105
- // Run all analyses in parallel
106
- const [deadCode, undocumented, similar, complexity, largeFiles, outdated, dbUsage] = await Promise.all([
107
- getDeadCode(dir),
108
- getUndocumentedSummary(dir, 'tests'),
109
- getSimilarFunctions(dir, { threshold: 70 }),
110
- getComplexity(dir, { minComplexity: 5 }),
111
- getLargeFiles(dir),
112
- getOutdatedPatterns(dir),
283
+ // Run cross-file analyses (always dynamic — NOT cacheable per-file)
284
+ const [deadCode, similar, largeFiles, outdated, dbUsage] = await Promise.all([
285
+ getDeadCode(dir).catch(() => ({ total: 0, byType: {}, items: [] })),
286
+ getSimilarFunctions(dir, { threshold: 70 }).catch(() => ({ total: 0, pairs: [] })),
287
+ getLargeFiles(dir).catch(() => ({ total: 0, stats: {}, items: [] })),
288
+ getOutdatedPatterns(dir).catch(() => ({ codePatterns: [], redundantDeps: [], stats: { totalPatterns: 0, bySeverity: {}, byPattern: {}, redundantDeps: 0 } })),
113
289
  getTableUsage(dir).catch(() => ({ tables: [], totalTables: 0, totalQueries: 0 })),
114
290
  ]);
115
291
 
@@ -121,6 +297,7 @@ export async function getFullAnalysis(dir, options = {}) {
121
297
  complexity,
122
298
  largeFiles,
123
299
  outdated,
300
+ jsdocConsistency: jsdocCheck.summary,
124
301
  });
125
302
 
126
303
  // Build result
@@ -154,6 +331,13 @@ export async function getFullAnalysis(dir, options = {}) {
154
331
  redundantDeps: outdated.redundantDeps,
155
332
  ...(includeItems && { codePatterns: outdated.codePatterns.slice(0, 10) }),
156
333
  },
334
+ jsdocConsistency: {
335
+ total: jsdocCheck.summary.total,
336
+ errors: jsdocCheck.summary.errors,
337
+ warnings: jsdocCheck.summary.warnings,
338
+ ...(includeItems && { issues: jsdocCheck.issues.slice(0, 10) }),
339
+ },
340
+ cache: cached.cacheStats,
157
341
  overall,
158
342
  };
159
343
 
@@ -172,3 +356,115 @@ export async function getFullAnalysis(dir, options = {}) {
172
356
 
173
357
  return result;
174
358
  }
359
+
360
+ /**
361
+ * Quick health check — runs only cached per-file metrics, skips cross-file.
362
+ * @param {string} dir - Path to scan
363
+ * @returns {{healthScore: number, complexity: number, undocumented: number, jsdocIssues: number}}
364
+ */
365
+ export function getAnalysisSummaryOnly(dir) {
366
+ const contextDir = join(getWorkspaceRoot(), '.context');
367
+ const cached = runCacheableAnalyses(dir, contextDir);
368
+ const complexity = aggregateComplexity(cached.complexity);
369
+ const undocumented = aggregateUndocumented(cached.undocumented);
370
+ const jsdocCheck = aggregateJSDoc(cached.jsdocIssues);
371
+
372
+ // Reuse the same health score formula as getFullAnalysis
373
+ const overall = calculateHealthScore({
374
+ deadCode: { total: 0 },
375
+ undocumented,
376
+ similar: { total: 0 },
377
+ complexity,
378
+ largeFiles: { total: 0 },
379
+ outdated: { stats: { totalPatterns: 0 } },
380
+ jsdocConsistency: jsdocCheck.summary,
381
+ });
382
+
383
+ return {
384
+ healthScore: overall.score,
385
+ grade: overall.rating,
386
+ complexity: complexity.total,
387
+ undocumented: undocumented.total,
388
+ jsdocIssues: jsdocCheck.summary.total,
389
+ cache: cached.cacheStats,
390
+ note: 'Partial score — cross-file analyses skipped for speed. Run get_full_analysis for complete health check.',
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Streaming analysis — yields results as each sub-analysis completes.
396
+ * Useful for large codebases where waiting for all analyses is too slow.
397
+ * @param {string} dir - Path to scan
398
+ * @param {Object} [options]
399
+ * @param {boolean} [options.includeItems=false]
400
+ * @returns {AsyncGenerator<{type: string, data: Object}>}
401
+ */
402
+ export async function* getFullAnalysisStreaming(dir, options = {}) {
403
+ const includeItems = options.includeItems || false;
404
+ const contextDir = join(getWorkspaceRoot(), '.context');
405
+
406
+ // Phase 1: Cached per-file analyses (fast)
407
+ const cached = runCacheableAnalyses(dir, contextDir);
408
+
409
+ const complexity = aggregateComplexity(cached.complexity);
410
+ yield { type: 'complexity', data: {
411
+ total: complexity.total,
412
+ stats: complexity.stats,
413
+ ...(includeItems && { items: complexity.items.slice(0, 10) }),
414
+ }};
415
+
416
+ const undocumented = aggregateUndocumented(cached.undocumented);
417
+ yield { type: 'undocumented', data: {
418
+ total: undocumented.total,
419
+ byType: undocumented.byType,
420
+ ...(includeItems && { items: undocumented.items.slice(0, 10) }),
421
+ }};
422
+
423
+ const jsdocCheck = aggregateJSDoc(cached.jsdocIssues);
424
+ yield { type: 'jsdocConsistency', data: {
425
+ total: jsdocCheck.summary.total,
426
+ errors: jsdocCheck.summary.errors,
427
+ warnings: jsdocCheck.summary.warnings,
428
+ ...(includeItems && { issues: jsdocCheck.issues.slice(0, 10) }),
429
+ }};
430
+
431
+ yield { type: 'cache', data: cached.cacheStats };
432
+
433
+ // Phase 2: Cross-file analyses (slow, one at a time)
434
+ try {
435
+ const deadCode = await getDeadCode(dir);
436
+ yield { type: 'deadCode', data: {
437
+ total: deadCode.total,
438
+ byType: deadCode.byType,
439
+ ...(includeItems && { items: deadCode.items.slice(0, 10) }),
440
+ }};
441
+ } catch { yield { type: 'deadCode', data: { total: 0, byType: {}, error: 'analysis failed' } }; }
442
+
443
+ try {
444
+ const similar = await getSimilarFunctions(dir, { threshold: 70 });
445
+ yield { type: 'similar', data: {
446
+ total: similar.total,
447
+ ...(includeItems && { pairs: similar.pairs.slice(0, 5) }),
448
+ }};
449
+ } catch { yield { type: 'similar', data: { total: 0, error: 'analysis failed' } }; }
450
+
451
+ try {
452
+ const largeFiles = await getLargeFiles(dir);
453
+ yield { type: 'largeFiles', data: {
454
+ total: largeFiles.total,
455
+ stats: largeFiles.stats,
456
+ ...(includeItems && { items: largeFiles.items.slice(0, 10) }),
457
+ }};
458
+ } catch { yield { type: 'largeFiles', data: { total: 0, error: 'analysis failed' } }; }
459
+
460
+ try {
461
+ const outdated = await getOutdatedPatterns(dir);
462
+ yield { type: 'outdated', data: {
463
+ totalPatterns: outdated.stats.totalPatterns,
464
+ redundantDeps: outdated.redundantDeps,
465
+ ...(includeItems && { codePatterns: outdated.codePatterns.slice(0, 10) }),
466
+ }};
467
+ } catch { yield { type: 'outdated', data: { totalPatterns: 0, error: 'analysis failed' } }; }
468
+
469
+ yield { type: 'done', data: { phases: 2, timestamp: new Date().toISOString() } };
470
+ }
@@ -13,121 +13,19 @@ export const AGENT_INSTRUCTIONS = `
13
13
  - **State Management**: Use \`this.init$\` for local state and \`this.sub()\` for reactivity.
14
14
  - **Directives**: Use \`itemize\` for lists, \`js-d-kit\` for static generation.
15
15
 
16
- ## 2. Test Annotations (@test/@expect)
17
- Universal verification checklist system. Works for **any** test type.
18
-
19
- ### Syntax
20
- \`\`\`javascript
21
- /**
22
- * Method description
23
- *
24
- * @test {type}: {description}
25
- * @expect {type}: {description}
26
- */
27
- async myMethod() { ... }
28
- \`\`\`
29
-
30
- ### @test Types by Category
31
-
32
- #### 🌐 Browser / UI
33
- | Type | Description | Example |
34
- |------|-------------|---------|
35
- | \`click\` | Click element | \`@test click: Click submit button\` |
36
- | \`key\` | Keyboard input | \`@test key: Press Enter\` |
37
- | \`drag\` | Drag and drop | \`@test drag: Drag item to list\` |
38
- | \`type\` | Text input | \`@test type: Enter email in field\` |
39
- | \`scroll\` | Scroll action | \`@test scroll: Scroll to bottom\` |
40
- | \`hover\` | Mouse hover | \`@test hover: Hover over menu\` |
41
-
42
- #### 🔌 API / Function
43
- | Type | Description | Example |
44
- |------|-------------|---------|
45
- | \`request\` | HTTP request | \`@test request: POST /api/users\` |
46
- | \`call\` | Function call | \`@test call: Call with valid params\` |
47
- | \`invoke\` | Method invoke | \`@test invoke: Trigger event\` |
48
- | \`mock\` | Mock setup | \`@test mock: Mock external service\` |
49
-
50
- #### 💻 CLI / Process
51
- | Type | Description | Example |
52
- |------|-------------|---------|
53
- | \`run\` | Run command | \`@test run: Run with --help flag\` |
54
- | \`exec\` | Execute script | \`@test exec: Execute build script\` |
55
- | \`spawn\` | Spawn process | \`@test spawn: Start server\` |
56
- | \`input\` | Stdin input | \`@test input: Enter password\` |
57
-
58
- #### 🔗 Integration / System
59
- | Type | Description | Example |
60
- |------|-------------|---------|
61
- | \`setup\` | Test setup | \`@test setup: Create test database\` |
62
- | \`action\` | Main action | \`@test action: Run migration\` |
63
- | \`teardown\` | Cleanup | \`@test teardown: Remove temp files\` |
64
- | \`wait\` | Wait condition | \`@test wait: Wait for DB connection\` |
65
-
66
- ### @expect Types by Category
67
-
68
- #### 🌐 Browser / UI
69
- | Type | Description | Example |
70
- |------|-------------|---------|
71
- | \`attr\` | Attribute check | \`@expect attr: disabled attribute set\` |
72
- | \`visual\` | Visual change | \`@expect visual: Button turns green\` |
73
- | \`element\` | Element exists | \`@expect element: Modal appears\` |
74
- | \`text\` | Text content | \`@expect text: Shows "Success"\` |
75
-
76
- #### 🔌 API / Function
77
- | Type | Description | Example |
78
- |------|-------------|---------|
79
- | \`status\` | HTTP status | \`@expect status: 201 Created\` |
80
- | \`body\` | Response body | \`@expect body: Contains user ID\` |
81
- | \`headers\` | Response headers | \`@expect headers: Content-Type JSON\` |
82
- | \`error\` | Error thrown | \`@expect error: Throws ValidationError\` |
83
-
84
- #### 💻 CLI / Process
85
- | Type | Description | Example |
86
- |------|-------------|---------|
87
- | \`output\` | Stdout content | \`@expect output: Prints version\` |
88
- | \`exitcode\` | Exit code | \`@expect exitcode: Returns 0\` |
89
- | \`file\` | File created | \`@expect file: Creates config.json\` |
90
- | \`stderr\` | Stderr content | \`@expect stderr: No errors\` |
91
-
92
- #### 🔗 Integration / System
93
- | Type | Description | Example |
94
- |------|-------------|---------|
95
- | \`state\` | State change | \`@expect state: User logged in\` |
96
- | \`log\` | Log entry | \`@expect log: Info message logged\` |
97
- | \`event\` | Event fired | \`@expect event: 'updated' emitted\` |
98
- | \`db\` | Database change | \`@expect db: Row inserted\` |
99
-
100
- ### Full Example
101
- \`\`\`javascript
102
- /**
103
- * Create new user via API
104
- *
105
- * @test request: POST /api/users with valid data
106
- * @test call: Validate email format
107
- *
108
- * @expect status: 201 Created
109
- * @expect body: Contains user ID and email
110
- * @expect db: User row created in database
111
- * @expect event: 'user.created' event emitted
112
- */
113
- async createUser(data) {
114
- // ...
115
- }
116
- \`\`\`
117
-
118
- ## 3. General Coding Rules
16
+ ## 2. General Coding Rules
119
17
  - **ESM Only**: Use \`import\` / \`export\`. No \`require\`.
120
18
  - **No Dependencies**: Avoid adding new npm packages unless critical.
121
19
  - **Comments**: Write clear JSDoc for all public methods.
122
20
  - **Async/Await**: Prefer async/await over promises.
123
21
 
124
- ## 4. MCP Tools Usage
22
+ ## 3. MCP Tools Usage
125
23
  - **Graph**: Use \`get_skeleton\` first to map the codebase.
126
24
  - **Deep Dive**: Use \`expand\` to read class details.
127
25
  - **Tests**: Use \`get_pending_tests\` to see what needs verification.
128
26
  - **Guidelines**: Use \`get_agent_instructions\` to refresh these rules.
129
27
 
130
- ## 5. Custom Rules System
28
+ ## 4. Custom Rules System
131
29
  Configurable code analysis with auto-detection.
132
30
 
133
31
  ### Available Tools