project-graph-mcp 1.5.0 → 2.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.
Files changed (125) hide show
  1. package/README.md +171 -31
  2. package/docs/img/explorer-compact.jpg +0 -0
  3. package/docs/img/explorer-expanded.jpg +0 -0
  4. package/package.json +12 -8
  5. package/src/.project-graph-cache.json +1 -1
  6. package/src/analysis/analysis-cache.js +7 -0
  7. package/src/analysis/complexity.js +14 -0
  8. package/src/analysis/custom-rules.js +36 -0
  9. package/src/analysis/db-analysis.js +9 -0
  10. package/src/analysis/dead-code.js +19 -0
  11. package/src/analysis/full-analysis.js +18 -0
  12. package/src/analysis/jsdoc-checker.js +24 -0
  13. package/src/analysis/jsdoc-generator.js +10 -0
  14. package/src/analysis/large-files.js +11 -0
  15. package/src/analysis/outdated-patterns.js +12 -0
  16. package/src/analysis/similar-functions.js +16 -0
  17. package/src/analysis/test-annotations.js +21 -0
  18. package/src/analysis/type-checker.js +8 -0
  19. package/src/analysis/undocumented.js +14 -0
  20. package/src/cli/cli-handlers.js +4 -0
  21. package/src/cli/cli.js +5 -0
  22. package/src/compact/.project-graph-cache.json +1 -0
  23. package/src/compact/ai-context.js +7 -0
  24. package/src/compact/compact-migrate.js +17 -0
  25. package/src/compact/compact.js +18 -0
  26. package/src/compact/compress.js +14 -0
  27. package/src/compact/ctx-to-jsdoc.js +29 -0
  28. package/src/compact/doc-dialect.js +30 -0
  29. package/src/compact/expand.js +37 -0
  30. package/src/compact/framework-references.js +5 -0
  31. package/src/compact/instructions.js +3 -0
  32. package/src/compact/mode-config.js +8 -0
  33. package/src/compact/validate-pipeline.js +9 -0
  34. package/src/core/event-bus.js +9 -0
  35. package/src/core/filters.js +14 -0
  36. package/src/core/graph-builder.js +12 -0
  37. package/src/core/parser.js +31 -0
  38. package/src/core/workspace.js +8 -0
  39. package/src/lang/lang-go.js +17 -0
  40. package/src/lang/lang-python.js +12 -0
  41. package/src/lang/lang-sql.js +23 -0
  42. package/src/lang/lang-typescript.js +9 -0
  43. package/src/lang/lang-utils.js +4 -0
  44. package/src/mcp/mcp-server.js +17 -0
  45. package/src/mcp/tool-defs.js +3 -0
  46. package/src/mcp/tools.js +25 -0
  47. package/src/network/backend-lifecycle.js +19 -0
  48. package/src/network/backend.js +5 -0
  49. package/src/network/local-gateway.js +23 -0
  50. package/src/network/mdns.js +13 -0
  51. package/src/network/server.js +10 -0
  52. package/src/network/web-server.js +34 -0
  53. package/web/.project-graph-cache.json +1 -0
  54. package/web/app.js +17 -0
  55. package/web/components/code-block.js +3 -0
  56. package/web/components/quick-open.js +5 -0
  57. package/web/dashboard-state.js +3 -0
  58. package/web/dashboard.html +27 -0
  59. package/web/dashboard.js +8 -0
  60. package/web/highlight.js +13 -0
  61. package/web/index.html +35 -0
  62. package/web/panels/ActionBoard/ActionBoard.css.js +1 -0
  63. package/web/panels/ActionBoard/ActionBoard.js +4 -0
  64. package/web/panels/ActionBoard/ActionBoard.tpl.js +1 -0
  65. package/web/panels/EventItem/EventItem.css.js +1 -0
  66. package/web/panels/EventItem/EventItem.js +4 -0
  67. package/web/panels/EventItem/EventItem.tpl.js +1 -0
  68. package/web/panels/ProjectItem/ProjectItem.css.js +1 -0
  69. package/web/panels/ProjectItem/ProjectItem.js +5 -0
  70. package/web/panels/ProjectItem/ProjectItem.tpl.js +1 -0
  71. package/web/panels/ProjectList/ProjectList.css.js +1 -0
  72. package/web/panels/ProjectList/ProjectList.js +4 -0
  73. package/web/panels/ProjectList/ProjectList.tpl.js +1 -0
  74. package/web/panels/SettingsPanel/.project-graph-cache.json +1 -0
  75. package/web/panels/SettingsPanel/SettingsPanel.css.js +1 -0
  76. package/web/panels/SettingsPanel/SettingsPanel.js +7 -0
  77. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -0
  78. package/web/panels/code-viewer.js +5 -0
  79. package/web/panels/ctx-panel.js +4 -0
  80. package/web/panels/dep-graph.js +6 -0
  81. package/web/panels/file-tree.js +188 -0
  82. package/web/panels/health-panel.js +3 -0
  83. package/web/panels/live-monitor.js +3 -0
  84. package/web/state.js +17 -0
  85. package/web/style.css +157 -0
  86. package/references/symbiote-3x.md +0 -834
  87. package/src/ai-context.js +0 -113
  88. package/src/analysis-cache.js +0 -155
  89. package/src/cli-handlers.js +0 -271
  90. package/src/cli.js +0 -95
  91. package/src/compact.js +0 -207
  92. package/src/complexity.js +0 -237
  93. package/src/compress.js +0 -319
  94. package/src/ctx-to-jsdoc.js +0 -514
  95. package/src/custom-rules.js +0 -584
  96. package/src/db-analysis.js +0 -194
  97. package/src/dead-code.js +0 -468
  98. package/src/doc-dialect.js +0 -716
  99. package/src/filters.js +0 -227
  100. package/src/framework-references.js +0 -177
  101. package/src/full-analysis.js +0 -470
  102. package/src/graph-builder.js +0 -299
  103. package/src/instructions.js +0 -73
  104. package/src/jsdoc-checker.js +0 -351
  105. package/src/jsdoc-generator.js +0 -203
  106. package/src/lang-go.js +0 -285
  107. package/src/lang-python.js +0 -197
  108. package/src/lang-sql.js +0 -309
  109. package/src/lang-typescript.js +0 -190
  110. package/src/lang-utils.js +0 -124
  111. package/src/large-files.js +0 -163
  112. package/src/mcp-server.js +0 -675
  113. package/src/mode-config.js +0 -127
  114. package/src/outdated-patterns.js +0 -296
  115. package/src/parser.js +0 -662
  116. package/src/server.js +0 -28
  117. package/src/similar-functions.js +0 -279
  118. package/src/test-annotations.js +0 -323
  119. package/src/tool-defs.js +0 -793
  120. package/src/tools.js +0 -470
  121. package/src/type-checker.js +0 -188
  122. package/src/undocumented.js +0 -259
  123. package/src/workspace.js +0 -70
  124. /package/{AGENT_ROLE.md → docs/examples/AGENT_ROLE.md} +0 -0
  125. /package/{AGENT_ROLE_MINIMAL.md → docs/examples/AGENT_ROLE_MINIMAL.md} +0 -0
@@ -1,716 +0,0 @@
1
- /**
2
- * Doc Dialect — Compact documentation format for AI agents
3
- *
4
- * Generates ultra-compact, LLM-readable documentation from project graph.
5
- * Replaces verbose JSDoc with token-efficient .context/ files.
6
- *
7
- * Two generation modes:
8
- * agent — returns rich AST-extracted template for calling agent to enrich (free, default)
9
- * Enrichment is done via agent-pool delegation with doc-enricher skill.
10
- *
11
- * Three doc levels:
12
- * PROJECT — architecture, data flow, key decisions
13
- * FILE — exports, internal mapping, patterns
14
- * FUNCTION — signature→return|description, behavior, edge cases
15
- */
16
-
17
- import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
18
- import { join, basename, extname, dirname, relative } from 'path';
19
- import { execSync } from 'child_process';
20
- import { createHash } from 'crypto';
21
- import { writeCache, computeContentHash } from './analysis-cache.js';
22
- import { analyzeComplexityFile } from './complexity.js';
23
- import { checkUndocumentedFile } from './undocumented.js';
24
- import { checkJSDocFile } from './jsdoc-checker.js';
25
-
26
- // ────────────────────────────────────────────────────────
27
- // SECTION 1: Auto-generated doc-dialect from graph
28
- // ────────────────────────────────────────────────────────
29
-
30
- /**
31
- * Generate doc-dialect string from project graph
32
- * @param {import('./graph-builder.js').Graph} graph - Project graph from buildGraph()
33
- * @param {string} [projectPath] - Project root path (to detect .context/)
34
- * @returns {string} Compact doc-dialect string
35
- */
36
- export function generateDocDialect(graph, projectPath) {
37
- const lines = [];
38
- const projectName = projectPath ? basename(projectPath) : 'unknown';
39
-
40
- // === PROJECT level ===
41
- lines.push(`=== PROJECT: ${projectName} ===`);
42
-
43
- const { stats } = graph;
44
- const parts = [];
45
- if (stats.files > 0) parts.push(`${stats.files} files`);
46
- if (stats.classes > 0) parts.push(`${stats.classes} classes`);
47
- if (stats.functions > 0) parts.push(`${stats.functions} functions`);
48
- if (stats.tables > 0) parts.push(`${stats.tables} tables`);
49
- lines.push(`STATS: ${parts.join('|')}`);
50
-
51
- // Edge summary
52
- const callEdges = graph.edges.filter(e => e[1] === '→').length;
53
- const dbReads = graph.edges.filter(e => e[1] === 'R→').length;
54
- const dbWrites = graph.edges.filter(e => e[1] === 'W→').length;
55
- const edgeParts = [];
56
- if (callEdges > 0) edgeParts.push(`${callEdges} calls`);
57
- if (dbReads > 0) edgeParts.push(`${dbReads} db_reads`);
58
- if (dbWrites > 0) edgeParts.push(`${dbWrites} db_writes`);
59
- if (edgeParts.length > 0) lines.push(`EDGES: ${edgeParts.join('|')}`);
60
-
61
- if (graph.orphans.length > 0) {
62
- lines.push(`ORPHANS: ${graph.orphans.join(',')}`);
63
- }
64
- if (Object.keys(graph.duplicates).length > 0) {
65
- lines.push(`DUPLICATES: ${Object.keys(graph.duplicates).join(',')}`);
66
- }
67
-
68
- // === FILE level ===
69
- const fileNodes = {};
70
- for (const [shortName, node] of Object.entries(graph.nodes)) {
71
- const file = node.f || '?';
72
- if (!fileNodes[file]) fileNodes[file] = [];
73
- fileNodes[file].push({ shortName, ...node });
74
- }
75
-
76
- for (const [file, nodes] of Object.entries(fileNodes)) {
77
- if (file === '?') continue;
78
- lines.push('');
79
- lines.push(`--- ${file} ---`);
80
-
81
- for (const node of nodes) {
82
- const fullName = graph.reverseLegend[node.shortName] || node.shortName;
83
-
84
- if (node.t === 'C') {
85
- const ext = node.x ? ` extends ${node.x}` : '';
86
- const methodCount = node.m?.length || 0;
87
- const propCount = node.$?.length || 0;
88
- const classDesc = [];
89
- if (methodCount > 0) classDesc.push(`${methodCount}m`);
90
- if (propCount > 0) classDesc.push(`${propCount}$`);
91
- lines.push(`class ${fullName}${ext}|${classDesc.join(',')}`);
92
-
93
- if (node.m) {
94
- for (const mShort of node.m) {
95
- const mFull = graph.reverseLegend[mShort] || mShort;
96
- lines.push(` .${mFull}`);
97
- }
98
- }
99
- } else if (node.t === 'F') {
100
- const exported = node.e ? 'export ' : '';
101
- lines.push(`${exported}${fullName}()`);
102
- } else if (node.t === 'T') {
103
- const cols = node.cols?.join(',') || '';
104
- lines.push(`TABLE ${fullName}|${cols}`);
105
- }
106
- }
107
-
108
- // Add edges for this file
109
- const fileEdges = graph.edges.filter(e => {
110
- const fromNode = graph.nodes[e[0]];
111
- return fromNode?.f === file;
112
- });
113
- if (fileEdges.length > 0) {
114
- const edgeStrs = fileEdges.map(e => {
115
- const fromFull = graph.reverseLegend[e[0]] || e[0];
116
- const toFull = graph.reverseLegend[e[2]?.split('.')[0]] || e[2];
117
- return `${fromFull}${e[1]}${toFull}`;
118
- });
119
- const unique = [...new Set(edgeStrs)];
120
- if (unique.length <= 5) {
121
- lines.push(`CALLS: ${unique.join('|')}`);
122
- } else {
123
- lines.push(`CALLS: ${unique.slice(0, 5).join('|')}|+${unique.length - 5} more`);
124
- }
125
- }
126
- }
127
-
128
- return lines.join('\n');
129
- }
130
-
131
- // ────────────────────────────────────────────────────────
132
- // SECTION 2: Read existing .context/ files (mirror + colocated)
133
- // ────────────────────────────────────────────────────────
134
-
135
- /**
136
- * Recursively walk a directory collecting .ctx files
137
- * @param {string} dir - Directory to walk
138
- * @param {string} base - Base directory for relative paths
139
- * @returns {Array<{relPath: string, absPath: string}>}
140
- */
141
- function walkCtxFiles(dir, base) {
142
- const results = [];
143
- if (!existsSync(dir)) return results;
144
- try {
145
- for (const entry of readdirSync(dir)) {
146
- const full = join(dir, entry);
147
- try {
148
- const stat = statSync(full);
149
- if (stat.isDirectory()) {
150
- results.push(...walkCtxFiles(full, base));
151
- } else if (entry.endsWith('.ctx') || entry.endsWith('.ctx.md')) {
152
- results.push({ relPath: relative(base, full), absPath: full });
153
- }
154
- } catch { /* skip unreadable */ }
155
- }
156
- } catch { /* skip unreadable dirs */ }
157
- return results;
158
- }
159
-
160
- /**
161
- * Resolve .ctx path for a source file.
162
- * Priority: colocated (src/parser.ctx) > mirror (.context/src/parser.ctx)
163
- * @param {string} projectPath - Project root
164
- * @param {string} sourceFile - Relative source file path (e.g. src/parser.js)
165
- * @returns {string|null} Resolved .ctx path or null
166
- */
167
- function resolveCtxPath(projectPath, sourceFile) {
168
- const ctxName = basename(sourceFile, extname(sourceFile)) + '.ctx';
169
- const sourceDir = dirname(sourceFile);
170
-
171
- // 1. Colocated: src/parser.ctx (next to src/parser.js)
172
- const colocated = join(projectPath, sourceDir, ctxName);
173
- if (existsSync(colocated)) return colocated;
174
-
175
- // 2. Mirror: .context/src/parser.ctx
176
- const mirrored = join(projectPath, '.context', sourceDir, ctxName);
177
- if (existsSync(mirrored)) return mirrored;
178
-
179
- return null;
180
- }
181
-
182
- /**
183
- * Resolve companion .ctx.md path for a source file.
184
- * Priority: colocated (src/parser.ctx.md) > mirror (.context/src/parser.ctx.md)
185
- * @param {string} projectPath
186
- * @param {string} sourceFile
187
- * @returns {string|null}
188
- */
189
- function resolveCtxMdPath(projectPath, sourceFile) {
190
- const ctxMdName = basename(sourceFile, extname(sourceFile)) + '.ctx.md';
191
- const sourceDir = dirname(sourceFile);
192
-
193
- const colocated = join(projectPath, sourceDir, ctxMdName);
194
- if (existsSync(colocated)) return colocated;
195
-
196
- const mirrored = join(projectPath, '.context', sourceDir, ctxMdName);
197
- if (existsSync(mirrored)) return mirrored;
198
-
199
- return null;
200
- }
201
-
202
- /**
203
- * Read existing doc-dialect files: .context/ mirror + colocated overrides
204
- * @param {string} projectPath - Project root path
205
- * @returns {{ combined: string, files: string[], hasProjectCtx: boolean }}
206
- */
207
- export function readContextDocs(projectPath) {
208
- const contextDir = join(projectPath, '.context');
209
- const collected = new Map(); // relPath → content (dedup)
210
-
211
- // 1. Walk .context/ recursively (mirror structure)
212
- for (const { relPath, absPath } of walkCtxFiles(contextDir, contextDir)) {
213
- try {
214
- const content = readFileSync(absPath, 'utf-8').trim();
215
- if (content) collected.set(relPath, content);
216
- } catch { /* skip */ }
217
- }
218
-
219
- // 2. Walk project for colocated .ctx files (override mirror)
220
- // Only check directories that contain source files
221
- const projectCtxFiles = walkCtxFiles(projectPath, projectPath)
222
- .filter(f => !f.relPath.startsWith('.context'));
223
- for (const { relPath, absPath } of projectCtxFiles) {
224
- try {
225
- const content = readFileSync(absPath, 'utf-8').trim();
226
- if (content) collected.set(relPath, content); // overrides mirror
227
- } catch { /* skip */ }
228
- }
229
-
230
- // Sort: project.ctx first, then alphabetical
231
- const sortedKeys = [...collected.keys()].sort((a, b) => {
232
- if (a === 'project.ctx') return -1;
233
- if (b === 'project.ctx') return 1;
234
- if (basename(a).startsWith('_')) return -1;
235
- if (basename(b).startsWith('_')) return 1;
236
- return a.localeCompare(b);
237
- });
238
-
239
- const files = sortedKeys;
240
- const sections = sortedKeys.map(k => collected.get(k));
241
- const hasProjectCtx = collected.has('project.ctx');
242
-
243
- return {
244
- combined: sections.join('\n\n'),
245
- files,
246
- hasProjectCtx,
247
- };
248
- }
249
-
250
- /**
251
- * Get project documentation: merge auto-generated + manual .context/ files
252
- * Priority for specific file: colocated > mirror > auto-generated
253
- * @param {import('./graph-builder.js').Graph} graph
254
- * @param {string} projectPath
255
- * @param {Object} [options]
256
- * @param {string} [options.file] - Specific file to get docs for
257
- * @returns {string}
258
- */
259
- export function getProjectDocs(graph, projectPath, options = {}) {
260
- const { file } = options;
261
-
262
- // Read manual docs (mirror + colocated)
263
- const manual = readContextDocs(projectPath);
264
-
265
- // If requesting specific file docs
266
- if (file) {
267
- // Try colocated → mirror → auto-generated
268
- const ctxPath = resolveCtxPath(projectPath, file);
269
- let result = '';
270
- if (ctxPath) {
271
- result = readFileSync(ctxPath, 'utf-8').trim();
272
- } else {
273
- // Fall back to auto-generated for this file
274
- const autoFull = generateDocDialect(graph, projectPath);
275
- const fileHeader = `--- ${file} ---`;
276
- const idx = autoFull.indexOf(fileHeader);
277
- if (idx === -1) {
278
- return `No documentation found for: ${file}`;
279
- }
280
- const nextHeader = autoFull.indexOf('\n---', idx + fileHeader.length);
281
- result = nextHeader === -1
282
- ? autoFull.slice(idx).trim()
283
- : autoFull.slice(idx, nextHeader).trim();
284
- }
285
-
286
- // Append companion .ctx.md if exists
287
- const ctxMdPath = resolveCtxMdPath(projectPath, file);
288
- if (ctxMdPath) {
289
- const notes = readFileSync(ctxMdPath, 'utf-8').trim();
290
- if (notes && !notes.match(/^#[^\n]*\n+## Notes\n+## TODO\n+## Decisions\s*$/)) {
291
- result += '\n\n' + notes;
292
- }
293
- }
294
- return result;
295
- }
296
-
297
- // Full docs: manual first, then auto-generated
298
- if (manual.combined) {
299
- const auto = generateDocDialect(graph, projectPath);
300
- return `${manual.combined}\n\n${auto}`;
301
- }
302
-
303
- return generateDocDialect(graph, projectPath);
304
- }
305
-
306
- // ────────────────────────────────────────────────────────
307
- // SECTION 3: Generate .context/ files
308
- // ────────────────────────────────────────────────────────
309
-
310
- // ────────────────────────────────────────────────────────
311
- // SECTION 3.1: AST Signature for Staleness Detection
312
- // ────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Compute structural signature hash from parsed AST.
316
- * Only includes interface-level elements (names, exports, params, methods).
317
- * Ignores function bodies, comments, formatting.
318
- * @param {string} file - Relative file path
319
- * @param {Object} parsed - ParseResult from parseProject()
320
- * @returns {string} 8-char hex hash
321
- */
322
- function computeSignature(file, parsed) {
323
- const parts = [];
324
- for (const fn of (parsed.functions || []).filter(f => f.file === file)) {
325
- parts.push(`F:${fn.exported ? 'e' : ''}:${fn.name}(${fn.params?.join(',') || ''})`);
326
- }
327
- for (const cls of (parsed.classes || []).filter(c => c.file === file)) {
328
- const methods = cls.methods?.sort().join(',') || '';
329
- parts.push(`C:${cls.name}:${cls.extends || ''}:${methods}`);
330
- }
331
- parts.sort();
332
- return createHash('md5').update(parts.join('|')).digest('hex').slice(0, 8);
333
- }
334
-
335
- /**
336
- * Parse existing .ctx file and extract user-written descriptions.
337
- * Returns a map: key (e.g. "parseProject", "PATTERNS") → description text.
338
- * Used for merge strategy: preserve existing descriptions when regenerating.
339
- * @param {string} content - .ctx file content
340
- * @returns {Map<string, string>} key → description
341
- */
342
- function parseCtxDescriptions(content) {
343
- const descriptions = new Map();
344
- for (const line of content.split('\n')) {
345
- const trimmed = line.trim();
346
- // Skip headers, empty lines, meta, enrich instructions
347
- if (!trimmed || trimmed.startsWith('---') || trimmed.startsWith('@sig') ||
348
- trimmed.startsWith('@enrich') || trimmed.startsWith('Rules:') ||
349
- trimmed.startsWith('Save this') ||
350
- trimmed.startsWith('CALLS→') || trimmed.startsWith('R→') || trimmed.startsWith('W→')) {
351
- continue;
352
- }
353
- // PATTERNS: description or EDGE_CASES: description
354
- const metaMatch = trimmed.match(/^(PATTERNS|EDGE_CASES):\s*(.+)/);
355
- if (metaMatch && metaMatch[2] !== '{DESCRIBE}') {
356
- descriptions.set(metaMatch[1], metaMatch[2]);
357
- continue;
358
- }
359
- // Function/class: name()|description or .method()|description
360
- const funcMatch = trimmed.match(/^(?:export\s+)?(?:class\s+)?\.?([\w]+)\([^)]*\)(?:[^|]*?)\|(.+)/);
361
- if (funcMatch && funcMatch[2] !== '{DESCRIBE}') {
362
- descriptions.set(funcMatch[1], funcMatch[2]);
363
- continue;
364
- }
365
- // Class with meta: class Name extends X|meta|description
366
- const classMatch = trimmed.match(/^class\s+([\w]+)[^|]*\|[^|]*\|(.+)/);
367
- if (classMatch && classMatch[2] !== '{DESCRIBE}') {
368
- descriptions.set(classMatch[1], classMatch[2]);
369
- }
370
- }
371
- return descriptions;
372
- }
373
-
374
- /**
375
- * Check staleness of .ctx files against current AST.
376
- * @param {string} projectPath - Project root
377
- * @param {Object} parsed - ParseResult from parseProject()
378
- * @returns {{ stale: string[], fresh: number, unknown: number }}
379
- */
380
- export function checkStaleness(projectPath, parsed) {
381
- const contextDir = join(projectPath, '.context');
382
- const stale = [];
383
- let fresh = 0;
384
- let unknown = 0;
385
-
386
- for (const { relPath, absPath } of walkCtxFiles(contextDir, contextDir)) {
387
- try {
388
- const content = readFileSync(absPath, 'utf-8');
389
- const sigMatch = content.match(/@sig\s+(\w+)/);
390
- const fileMatch = content.match(/^--- (.+) ---/m);
391
-
392
- if (!sigMatch || !fileMatch) {
393
- unknown++;
394
- continue;
395
- }
396
-
397
- const currentSig = computeSignature(fileMatch[1], parsed);
398
- if (currentSig !== sigMatch[1]) {
399
- stale.push(fileMatch[1]);
400
- } else {
401
- fresh++;
402
- }
403
- } catch { /* skip unreadable */ }
404
- }
405
-
406
- // Also check colocated .ctx files
407
- for (const { absPath } of walkCtxFiles(projectPath, projectPath)
408
- .filter(f => !f.relPath.startsWith('.context'))) {
409
- try {
410
- const content = readFileSync(absPath, 'utf-8');
411
- const sigMatch = content.match(/@sig\s+(\w+)/);
412
- const fileMatch = content.match(/^--- (.+) ---/m);
413
-
414
- if (!sigMatch || !fileMatch) {
415
- unknown++;
416
- continue;
417
- }
418
-
419
- const currentSig = computeSignature(fileMatch[1], parsed);
420
- if (currentSig !== sigMatch[1]) {
421
- stale.push(fileMatch[1]);
422
- } else {
423
- fresh++;
424
- }
425
- } catch { /* skip */ }
426
- }
427
-
428
- return { stale, fresh, unknown };
429
- }
430
-
431
- /**
432
- * Build rich AST-extracted template for a single file.
433
- * Uses ParseResult data for signatures, calls, db access.
434
- * @param {string} file - Relative file path
435
- * @param {Array} nodes - Graph nodes for this file
436
- * @param {import('./graph-builder.js').Graph} graph
437
- * @param {Object} parsed - ParseResult from parseProject()
438
- * @param {Map<string, string>} [existingDescriptions] - Merge: preserved descriptions
439
- * @returns {string} Rich template in doc-dialect format
440
- */
441
- function buildFileTemplate(file, nodes, graph, parsed, existingDescriptions) {
442
- const sig = computeSignature(file, parsed);
443
- const lines = [`--- ${file} ---`, `@sig ${sig}`];
444
- const desc = existingDescriptions || new Map();
445
-
446
- // Build lookup: funcName → ParseResult func data
447
- const funcLookup = {};
448
- for (const func of parsed.functions || []) {
449
- if (func.file === file) {
450
- funcLookup[func.name] = func;
451
- }
452
- }
453
- const classLookup = {};
454
- for (const cls of parsed.classes || []) {
455
- if (cls.file === file) {
456
- classLookup[cls.name] = cls;
457
- }
458
- }
459
-
460
- for (const node of nodes) {
461
- const fullName = graph.reverseLegend[node.shortName] || node.shortName;
462
-
463
- if (node.t === 'C') {
464
- const cls = classLookup[fullName] || {};
465
- const ext = node.x ? ` extends ${node.x}` : '';
466
- const methodCount = node.m?.length || 0;
467
- const propCount = node.$?.length || 0;
468
- const meta = [];
469
- if (methodCount > 0) meta.push(`${methodCount}m`);
470
- if (propCount > 0) meta.push(`${propCount}$`);
471
- const classDesc = desc.get(fullName) || '{DESCRIBE}';
472
- lines.push(`class ${fullName}${ext}|${meta.join(',')}|${classDesc}`);
473
-
474
- if (node.m) {
475
- for (const mShort of node.m) {
476
- const mFull = graph.reverseLegend[mShort] || mShort;
477
- const methodDesc = desc.get(mFull) || '{DESCRIBE}';
478
- lines.push(` .${mFull}()|${methodDesc}`);
479
- }
480
- }
481
- // Class-level calls
482
- if (cls.calls?.length > 0) {
483
- lines.push(` CALLS→${cls.calls.slice(0, 8).join(',')}`);
484
- }
485
- if (cls.dbReads?.length > 0) lines.push(` R→${cls.dbReads.join(',')}`);
486
- if (cls.dbWrites?.length > 0) lines.push(` W→${cls.dbWrites.join(',')}`);
487
-
488
- } else if (node.t === 'F') {
489
- const func = funcLookup[fullName] || {};
490
- const exported = node.e ? 'export ' : '';
491
- const paramStr = func.params?.length > 0 ? func.params.join(',') : '';
492
- const returnType = func.returns ? `→${func.returns}` : '';
493
- const calls = func.calls?.slice(0, 6) || [];
494
- const callStr = calls.length > 0 ? `→${calls.join(',')}` : '';
495
- const funcDesc = desc.get(fullName) || '{DESCRIBE}';
496
- lines.push(`${exported}${fullName}(${paramStr})${returnType}${callStr}|${funcDesc}`);
497
-
498
- if (func.dbReads?.length > 0) lines.push(` R→${func.dbReads.join(',')}`);
499
- if (func.dbWrites?.length > 0) lines.push(` W→${func.dbWrites.join(',')}`);
500
- }
501
- }
502
-
503
- const patternsDesc = desc.get('PATTERNS') || '{DESCRIBE}';
504
- const edgeCasesDesc = desc.get('EDGE_CASES') || '{DESCRIBE}';
505
- lines.push(`PATTERNS: ${patternsDesc}`);
506
- lines.push(`EDGE_CASES: ${edgeCasesDesc}`);
507
-
508
- const body = lines.join('\n');
509
-
510
- // Self-enriching: embed instructions only when {DESCRIBE} markers remain
511
- if (body.includes('{DESCRIBE}')) {
512
- lines.splice(2, 0,
513
- `@enrich: Replace each {DESCRIBE} below. Read ${file} for context.`,
514
- ` Rules: max 80ch, pipe|separated, abbrev (fn/ret/cfg/init/auth/db/msg).`,
515
- ` Save this file after filling all markers. Remove @enrich lines when done.`
516
- );
517
- }
518
-
519
- return lines.join('\n');
520
- }
521
-
522
- /**
523
- * Generate .context/ files from project graph + ParseResult.
524
- * Creates templates with {DESCRIBE} markers for agents to fill.
525
- * Use agent-pool with doc-enricher skill to auto-fill descriptions.
526
- *
527
- * @param {import('./graph-builder.js').Graph} graph
528
- * @param {string} projectPath
529
- * @param {Object} parsed - ParseResult from parseProject()
530
- * @param {Object} [options]
531
- * @param {boolean} [options.overwrite=false]
532
- * @param {string|string[]} [options.scope='all'] - 'all', 'focus' (git diff), or array of file paths
533
- * @returns {Promise<{ created: string[], skipped: string[], templates?: Object }>}
534
- */
535
- export async function generateContextFiles(graph, projectPath, parsed, options = {}) {
536
- const { overwrite = false, scope = 'all' } = options;
537
- const contextDir = join(projectPath, '.context');
538
- const created = [];
539
- const skipped = [];
540
- const templates = {};
541
-
542
- // Ensure .context/ exists
543
- if (!existsSync(contextDir)) {
544
- mkdirSync(contextDir, { recursive: true });
545
- }
546
-
547
- // Generate project.ctx
548
- const projectCtxPath = join(contextDir, 'project.ctx');
549
- if (!existsSync(projectCtxPath) || overwrite) {
550
- const projectName = basename(projectPath);
551
- const { stats } = graph;
552
- const statParts = [];
553
- if (stats.files > 0) statParts.push(`${stats.files} files`);
554
- if (stats.classes > 0) statParts.push(`${stats.classes} classes`);
555
- if (stats.functions > 0) statParts.push(`${stats.functions} functions`);
556
-
557
- let projectContent = [
558
- `=== PROJECT: ${projectName} ===`,
559
- `ARCH: {DESCRIBE}`,
560
- `FLOW: {DESCRIBE}`,
561
- `STATS: ${statParts.join('|')}`,
562
- ].join('\n') + '\n';
563
-
564
-
565
- writeFileSync(projectCtxPath, projectContent, 'utf-8');
566
- created.push('project.ctx');
567
- templates['project.ctx'] = projectContent;
568
- } else {
569
- skipped.push('project.ctx');
570
- }
571
-
572
- // Group graph nodes by file
573
- const fileNodes = {};
574
- for (const [shortName, node] of Object.entries(graph.nodes)) {
575
- const file = node.f;
576
- if (!file) continue;
577
- if (!fileNodes[file]) fileNodes[file] = [];
578
- fileNodes[file].push({ shortName, ...node });
579
- }
580
-
581
- // Resolve scope filter
582
- let scopeFilter = null;
583
- if (scope === 'focus') {
584
- // Git diff: recently changed files
585
- try {
586
- const diff = execSync('git diff --name-only HEAD~5', {
587
- cwd: projectPath,
588
- encoding: 'utf-8',
589
- });
590
- scopeFilter = new Set(
591
- diff.split('\n')
592
- .filter(f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.ts'))
593
- .map(f => f.trim())
594
- .filter(Boolean)
595
- );
596
- } catch {
597
- // Git not available — fall back to all
598
- scopeFilter = null;
599
- }
600
- } else if (Array.isArray(scope)) {
601
- scopeFilter = new Set(scope);
602
- }
603
- // scope === 'all' → scopeFilter stays null → process everything
604
-
605
- // Generate per-file .ctx in mirror structure (.context/src/parser.ctx)
606
- const BATCH_SIZE = 5;
607
- const fileEntries = Object.entries(fileNodes).filter(([file]) => {
608
- // Apply scope filter
609
- return !scopeFilter || scopeFilter.has(file);
610
- });
611
-
612
- // Process files in batches of BATCH_SIZE for concurrency
613
- for (let i = 0; i < fileEntries.length; i += BATCH_SIZE) {
614
- const batch = fileEntries.slice(i, i + BATCH_SIZE);
615
- const results = await Promise.allSettled(
616
- batch.map(([file, nodes]) =>
617
- processFileCtx(file, nodes, graph, parsed, contextDir, projectPath, overwrite)
618
- )
619
- );
620
-
621
- for (const result of results) {
622
- if (result.status === 'fulfilled' && result.value) {
623
- const { action, path, template } = result.value;
624
- if (action === 'created') {
625
- created.push(path);
626
- templates[path] = template;
627
- } else {
628
- skipped.push(path);
629
- }
630
- }
631
- }
632
- }
633
-
634
- const result = { created, skipped };
635
-
636
- // Include templates so agent can enrich via delegation
637
- if (created.length > 0) {
638
- result.templates = templates;
639
- }
640
-
641
- return result;
642
- }
643
-
644
- /**
645
- * Process a single file: generate .ctx, warm cache, create .ctx.md stub
646
- * @param {string} file - Relative file path
647
- * @param {Array} nodes - Graph nodes for this file
648
- * @param {Object} graph - Project graph
649
- * @param {Object} parsed - Parsed project data
650
- * @param {string} contextDir - .context/ directory path
651
- * @param {string} projectPath - Project root
652
- * @param {boolean} overwrite - Whether to overwrite existing
653
- * @returns {Promise<{action: string, path: string, template?: string}>}
654
- */
655
- async function processFileCtx(file, nodes, graph, parsed, contextDir, projectPath, overwrite) {
656
- const ctxName = basename(file, extname(file)) + '.ctx';
657
- const fileDir = dirname(file);
658
- const ctxDir = join(contextDir, fileDir);
659
- const ctxPath = join(ctxDir, ctxName);
660
- const ctxRelPath = join(fileDir, ctxName);
661
-
662
- // Check colocated override — skip if exists and not overwriting
663
- const colocatedPath = join(projectPath, fileDir, ctxName);
664
- if ((existsSync(ctxPath) || existsSync(colocatedPath)) && !overwrite) {
665
- return { action: 'skipped', path: ctxRelPath };
666
- }
667
-
668
- // Ensure mirror subdirectory exists
669
- if (!existsSync(ctxDir)) {
670
- mkdirSync(ctxDir, { recursive: true });
671
- }
672
-
673
- // Merge strategy: preserve existing descriptions
674
- let existingDescriptions;
675
- const existingCtxPath = existsSync(colocatedPath) ? colocatedPath : (existsSync(ctxPath) ? ctxPath : null);
676
- if (existingCtxPath && overwrite) {
677
- try {
678
- const oldContent = readFileSync(existingCtxPath, 'utf-8');
679
- existingDescriptions = parseCtxDescriptions(oldContent);
680
- } catch { /* ignore */ }
681
- }
682
-
683
- let content = buildFileTemplate(file, nodes, graph, parsed, existingDescriptions);
684
-
685
- writeFileSync(ctxPath, content + '\n', 'utf-8');
686
-
687
- // Cache warm-up: pre-compute per-file analysis during AST pass
688
- try {
689
- const srcPath = join(projectPath, file);
690
- if (existsSync(srcPath) && file.endsWith('.js')) {
691
- const srcCode = readFileSync(srcPath, 'utf-8');
692
- const contentHash = computeContentHash(srcCode);
693
- const complexity = analyzeComplexityFile(srcCode, file);
694
- const undocumented = checkUndocumentedFile(srcCode, file, 'tests');
695
- const jsdocIssues = checkJSDocFile(srcCode, file);
696
-
697
- writeCache(contextDir, file, {
698
- sig: contentHash,
699
- contentHash,
700
- complexity,
701
- undocumented,
702
- jsdocIssues,
703
- });
704
- }
705
- } catch { /* warm-up failure is non-fatal */ }
706
-
707
- // Two-tier: create companion .ctx.md for agent notes (never overwrite)
708
- const ctxMdName = basename(file, extname(file)) + '.ctx.md';
709
- const ctxMdPath = join(ctxDir, ctxMdName);
710
- if (!existsSync(ctxMdPath)) {
711
- const mdStub = `# ${basename(file)}\n\n## Notes\n\n## TODO\n\n## Decisions\n`;
712
- writeFileSync(ctxMdPath, mdStub, 'utf-8');
713
- }
714
-
715
- return { action: 'created', path: ctxRelPath, template: content };
716
- }