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.
- package/AGENT_ROLE.md +87 -30
- package/AGENT_ROLE_MINIMAL.md +23 -8
- package/README.md +100 -14
- package/package.json +1 -1
- package/src/.project-graph-cache.json +1 -0
- package/src/ai-context.js +113 -0
- package/src/analysis-cache.js +155 -0
- package/src/cli-handlers.js +131 -0
- package/src/cli.js +14 -2
- package/src/compact.js +207 -0
- package/src/complexity.js +21 -7
- package/src/compress.js +319 -0
- package/src/ctx-to-jsdoc.js +514 -0
- package/src/custom-rules.js +1 -0
- package/src/doc-dialect.js +716 -0
- package/src/full-analysis.js +307 -11
- package/src/instructions.js +3 -105
- package/src/jsdoc-checker.js +351 -0
- package/src/jsdoc-generator.js +0 -11
- package/src/large-files.js +1 -0
- package/src/mcp-server.js +208 -1
- package/src/mode-config.js +127 -0
- package/src/outdated-patterns.js +1 -0
- package/src/parser.js +223 -13
- package/src/similar-functions.js +1 -0
- package/src/test-annotations.js +203 -181
- package/src/tool-defs.js +270 -2
- package/src/tools.js +1 -1
- package/src/type-checker.js +188 -0
- package/src/undocumented.js +11 -12
- package/src/workspace.js +1 -1
- package/vendor/terser.mjs +49 -0
|
@@ -0,0 +1,716 @@
|
|
|
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
|
+
}
|