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,351 @@
1
+ /**
2
+ * JSDoc Consistency Checker (AST-based)
3
+ * Validates JSDoc annotations against actual function signatures
4
+ *
5
+ * Checks:
6
+ * - Param count mismatch (JSDoc vs AST)
7
+ * - Param name mismatch
8
+ * - Missing @returns on functions with return statements
9
+ * - Type hint inconsistency (default value vs JSDoc type)
10
+ */
11
+
12
+ import { readFileSync, readdirSync, statSync } from 'fs';
13
+ import { join, relative, resolve } from 'path';
14
+ import { parse } from '../vendor/acorn.mjs';
15
+ import * as walk from '../vendor/walk.mjs';
16
+ import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
17
+
18
+ /**
19
+ * @typedef {Object} JSDocIssue
20
+ * @property {string} file
21
+ * @property {number} line
22
+ * @property {string} name - Function or method name
23
+ * @property {'error'|'warning'} severity
24
+ * @property {string} message
25
+ */
26
+
27
+ /**
28
+ * Find all JS files in directory
29
+ * @param {string} dir
30
+ * @param {string} rootDir
31
+ * @returns {string[]}
32
+ */
33
+ function findJSFiles(dir, rootDir = dir) {
34
+ if (dir === rootDir) parseGitignore(rootDir);
35
+ const files = [];
36
+ try {
37
+ for (const entry of readdirSync(dir)) {
38
+ const fullPath = join(dir, entry);
39
+ const relativePath = relative(rootDir, fullPath);
40
+ const stat = statSync(fullPath);
41
+ if (stat.isDirectory()) {
42
+ if (!shouldExcludeDir(entry, relativePath)) {
43
+ files.push(...findJSFiles(fullPath, rootDir));
44
+ }
45
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
46
+ if (!shouldExcludeFile(entry, relativePath)) {
47
+ files.push(fullPath);
48
+ }
49
+ }
50
+ }
51
+ } catch (e) { /* dir not found */ }
52
+ return files;
53
+ }
54
+
55
+ /**
56
+ * Extract JSDoc comments with positions
57
+ * @param {string} code
58
+ * @returns {Array<{text: string, endLine: number, params: Array<{name: string, type: string}>, hasReturns: boolean}>}
59
+ */
60
+ function extractJSDocComments(code) {
61
+ const comments = [];
62
+ const regex = /\/\*\*[\s\S]*?\*\//g;
63
+ let match;
64
+
65
+ while ((match = regex.exec(code)) !== null) {
66
+ const text = match[0];
67
+ const endLine = code.slice(0, match.index + text.length).split('\n').length;
68
+
69
+ // Parse @param tags — handle nested braces in types like {Array<{text: string}>}
70
+ const params = [];
71
+ const paramStartRegex = /@param\s+\{/g;
72
+ let paramStart;
73
+ while ((paramStart = paramStartRegex.exec(text)) !== null) {
74
+ // Find matching closing brace (balanced)
75
+ let depth = 1;
76
+ let i = paramStart.index + paramStart[0].length;
77
+ while (i < text.length && depth > 0) {
78
+ if (text[i] === '{') depth++;
79
+ else if (text[i] === '}') depth--;
80
+ i++;
81
+ }
82
+ if (depth !== 0) continue;
83
+ const type = text.slice(paramStart.index + paramStart[0].length, i - 1);
84
+ // Extract param name after the closing brace
85
+ const afterType = text.slice(i);
86
+ const nameMatch = afterType.match(/^\s+(\[?\w+(?:\.\w+)*\]?)/);
87
+ if (!nameMatch) continue;
88
+ let name = nameMatch[1];
89
+ // Strip [] from optional params: [opts] → opts
90
+ if (name.startsWith('[')) name = name.slice(1);
91
+ if (name.endsWith(']')) name = name.slice(0, -1);
92
+ // Strip dotted paths: options.includeTests → skip (nested property)
93
+ if (name.includes('.')) continue;
94
+ params.push({ name, type });
95
+ }
96
+
97
+ const hasReturns = /@returns?\s/.test(text);
98
+
99
+ comments.push({ text, endLine, params, hasReturns });
100
+ }
101
+
102
+ return comments;
103
+ }
104
+
105
+ /**
106
+ * Find JSDoc comment before a target line
107
+ * @param {Array} comments
108
+ * @param {number} targetLine
109
+ * @returns {Object|null}
110
+ */
111
+ function findJSDocBefore(comments, targetLine) {
112
+ for (const comment of comments) {
113
+ const gap = targetLine - comment.endLine;
114
+ if (gap >= 0 && gap <= 2) return comment;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Extract parameter name from AST node
121
+ * @param {Object} param
122
+ * @returns {string}
123
+ */
124
+ function extractParamName(param) {
125
+ if (param.type === 'Identifier') return param.name;
126
+ if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier') return param.left.name;
127
+ if (param.type === 'RestElement' && param.argument.type === 'Identifier') return param.argument.name;
128
+ if (param.type === 'ObjectPattern') return 'options';
129
+ if (param.type === 'ArrayPattern') return 'args';
130
+ return 'param';
131
+ }
132
+
133
+ /**
134
+ * Infer expected type from AST default value
135
+ * @param {Object} param
136
+ * @returns {string|null}
137
+ */
138
+ function inferTypeFromDefault(param) {
139
+ if (param.type !== 'AssignmentPattern') return null;
140
+ const def = param.right;
141
+ if (def.type === 'Literal') {
142
+ if (typeof def.value === 'string') return 'string';
143
+ if (typeof def.value === 'number') return 'number';
144
+ if (typeof def.value === 'boolean') return 'boolean';
145
+ }
146
+ if (def.type === 'ArrayExpression') return 'Array';
147
+ if (def.type === 'ObjectExpression') return 'Object';
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Check if function body has return statements with values
153
+ * @param {Object} node - Function AST node
154
+ * @returns {boolean}
155
+ */
156
+ function hasReturnValue(node) {
157
+ let found = false;
158
+ try {
159
+ walk.simple(node.body, {
160
+ ReturnStatement(ret) {
161
+ if (ret.argument) found = true;
162
+ },
163
+ // Don't recurse into nested functions
164
+ FunctionDeclaration() { },
165
+ FunctionExpression() { },
166
+ ArrowFunctionExpression() { },
167
+ });
168
+ } catch (e) { /* walk error */ }
169
+ return found;
170
+ }
171
+
172
+ /**
173
+ * Validate a function's JSDoc against its AST
174
+ * @param {Object} jsdoc - Parsed JSDoc
175
+ * @param {Object[]} astParams - AST param nodes
176
+ * @param {Object} funcNode - AST function node
177
+ * @param {string} name - Function name
178
+ * @param {string} file - File path
179
+ * @param {number} line - Line number
180
+ * @returns {JSDocIssue[]}
181
+ */
182
+ function validateFunction(jsdoc, astParams, funcNode, name, file, line) {
183
+ const issues = [];
184
+
185
+ if (!jsdoc) return issues; // No JSDoc = handled by undocumented checker
186
+
187
+ const docParams = jsdoc.params;
188
+
189
+ // 1. Param count mismatch
190
+ if (docParams.length !== astParams.length) {
191
+ issues.push({
192
+ file, line, name,
193
+ severity: 'error',
194
+ message: `Param count mismatch: JSDoc has ${docParams.length}, function has ${astParams.length}`,
195
+ });
196
+ }
197
+
198
+ // 2. Param name mismatch
199
+ const minLen = Math.min(docParams.length, astParams.length);
200
+ for (let i = 0; i < minLen; i++) {
201
+ const docName = docParams[i].name;
202
+ const astName = extractParamName(astParams[i]);
203
+
204
+ if (docName !== astName && astName !== 'options' && astName !== 'args' && astName !== 'param') {
205
+ issues.push({
206
+ file, line, name,
207
+ severity: 'error',
208
+ message: `Param name mismatch at position ${i}: JSDoc says "${docName}", code has "${astName}"`,
209
+ });
210
+ }
211
+ }
212
+
213
+ // 3. Missing @returns on non-void functions
214
+ if (!jsdoc.hasReturns && hasReturnValue(funcNode)) {
215
+ issues.push({
216
+ file, line, name,
217
+ severity: 'warning',
218
+ message: 'Function returns a value but JSDoc has no @returns',
219
+ });
220
+ }
221
+
222
+ // 4. Type hint inconsistency
223
+ for (let i = 0; i < minLen; i++) {
224
+ const docType = docParams[i].type;
225
+ const inferredType = inferTypeFromDefault(astParams[i]);
226
+
227
+ if (inferredType && docType && docType !== '*') {
228
+ let compatible = docType.includes(inferredType);
229
+ // Union types like 'a'|'b' are valid strings
230
+ if (!compatible && inferredType === 'string' && docType.includes("'") && docType.includes('|')) {
231
+ compatible = true;
232
+ }
233
+ // Type[] shorthand is a valid Array
234
+ if (!compatible && inferredType === 'Array' && docType.includes('[]')) {
235
+ compatible = true;
236
+ }
237
+ if (!compatible) {
238
+ issues.push({
239
+ file, line, name,
240
+ severity: 'warning',
241
+ message: `Type mismatch for "${docParams[i].name}": JSDoc says {${docType}}, default value suggests {${inferredType}}`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ return issues;
248
+ }
249
+
250
+ /**
251
+ * Check JSDoc consistency for a single file (per-file export for cache integration)
252
+ * @param {string} code
253
+ * @param {string} filePath
254
+ * @returns {JSDocIssue[]}
255
+ */
256
+ export function checkJSDocFile(code, filePath) {
257
+ const issues = [];
258
+
259
+ let ast;
260
+ try {
261
+ ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
262
+ } catch (e) {
263
+ return issues;
264
+ }
265
+
266
+ const comments = extractJSDocComments(code);
267
+
268
+ walk.simple(ast, {
269
+ FunctionDeclaration(node) {
270
+ if (!node.id) return;
271
+ const jsdoc = findJSDocBefore(comments, node.loc.start.line);
272
+ if (jsdoc) {
273
+ issues.push(...validateFunction(jsdoc, node.params, node, node.id.name, filePath, node.loc.start.line));
274
+ }
275
+ },
276
+
277
+ // Exported arrow/const functions
278
+ VariableDeclaration(node) {
279
+ for (const decl of node.declarations) {
280
+ if (!decl.init) continue;
281
+ const func = decl.init.type === 'ArrowFunctionExpression' || decl.init.type === 'FunctionExpression'
282
+ ? decl.init : null;
283
+ if (!func || !decl.id?.name) continue;
284
+
285
+ const jsdoc = findJSDocBefore(comments, node.loc.start.line);
286
+ if (jsdoc) {
287
+ issues.push(...validateFunction(jsdoc, func.params, func, decl.id.name, filePath, node.loc.start.line));
288
+ }
289
+ }
290
+ },
291
+
292
+ ClassDeclaration(node) {
293
+ const className = node.id?.name || 'Anonymous';
294
+ for (const element of node.body.body) {
295
+ if (element.type !== 'MethodDefinition') continue;
296
+ const methodName = element.key.name || element.key.value;
297
+ if (!methodName || methodName === 'constructor') continue;
298
+ if (element.kind !== 'method') continue;
299
+
300
+ const funcNode = element.value;
301
+ const jsdoc = findJSDocBefore(comments, element.loc.start.line);
302
+ if (jsdoc) {
303
+ issues.push(...validateFunction(jsdoc, funcNode.params, funcNode, `${className}.${methodName}`, filePath, element.loc.start.line));
304
+ }
305
+ }
306
+ },
307
+ });
308
+
309
+ return issues;
310
+ }
311
+
312
+ /**
313
+ * Check JSDoc consistency across a directory
314
+ * @param {string} dir
315
+ * @returns {{ issues: JSDocIssue[], summary: { total: number, errors: number, warnings: number, byFile: Object } }}
316
+ */
317
+ export function checkJSDocConsistency(dir) {
318
+ const resolvedDir = resolve(dir);
319
+ const files = findJSFiles(dir);
320
+ const allIssues = [];
321
+
322
+ for (const file of files) {
323
+ let content;
324
+ try {
325
+ content = readFileSync(file, 'utf-8');
326
+ } catch (e) {
327
+ continue; // File deleted between findJSFiles and read
328
+ }
329
+ const relPath = relative(resolvedDir, file);
330
+ const issues = checkJSDocFile(content, relPath);
331
+ allIssues.push(...issues);
332
+ }
333
+
334
+ const errors = allIssues.filter(i => i.severity === 'error').length;
335
+ const warnings = allIssues.filter(i => i.severity === 'warning').length;
336
+
337
+ const byFile = {};
338
+ for (const issue of allIssues) {
339
+ byFile[issue.file] = (byFile[issue.file] || 0) + 1;
340
+ }
341
+
342
+ return {
343
+ issues: allIssues,
344
+ summary: {
345
+ total: allIssues.length,
346
+ errors,
347
+ warnings,
348
+ byFile,
349
+ },
350
+ };
351
+ }
@@ -22,11 +22,9 @@ import { getWorkspaceRoot } from './workspace.js';
22
22
  * Generate JSDoc for a single file
23
23
  * @param {string} filePath - Absolute path to file
24
24
  * @param {Object} [options]
25
- * @param {boolean} [options.includeTests=true] - Include @test/@expect placeholders
26
25
  * @returns {JSDocTemplate[]}
27
26
  */
28
27
  export function generateJSDoc(filePath, options = {}) {
29
- const includeTests = options.includeTests !== false;
30
28
  const results = [];
31
29
 
32
30
  const code = readFileSync(filePath, 'utf-8');
@@ -72,7 +70,6 @@ export function generateJSDoc(filePath, options = {}) {
72
70
  name: node.id.name,
73
71
  params: node.params,
74
72
  async: node.async,
75
- includeTests,
76
73
  });
77
74
 
78
75
  results.push({
@@ -102,7 +99,6 @@ export function generateJSDoc(filePath, options = {}) {
102
99
  name: methodName,
103
100
  params: funcNode.params,
104
101
  async: funcNode.async,
105
- includeTests,
106
102
  });
107
103
 
108
104
  results.push({
@@ -126,7 +122,6 @@ export function generateJSDoc(filePath, options = {}) {
126
122
  * @param {string} info.name
127
123
  * @param {Array} info.params
128
124
  * @param {boolean} info.async
129
- * @param {boolean} info.includeTests
130
125
  * @returns {string}
131
126
  */
132
127
  function buildJSDoc(info) {
@@ -145,12 +140,6 @@ function buildJSDoc(info) {
145
140
  // Return type
146
141
  lines.push(` * @returns {${info.async ? 'Promise<*>' : '*'}}`);
147
142
 
148
- // Test annotations (Agentic Verification)
149
- if (info.includeTests) {
150
- lines.push(` * @test TODO: describe test scenario`);
151
- lines.push(` * @expect TODO: expected result`);
152
- }
153
-
154
143
  lines.push(' */');
155
144
  return lines.join('\n');
156
145
  }
@@ -54,6 +54,7 @@ function findJSFiles(dir, rootDir = dir) {
54
54
  /**
55
55
  * Analyze a single file
56
56
  * @param {string} filePath
57
+ * @param {string} rootDir - Root directory for relative path calculation
57
58
  * @returns {LargeFileItem}
58
59
  */
59
60
  function analyzeFile(filePath, rootDir) {
package/src/mcp-server.js CHANGED
@@ -21,11 +21,21 @@ import { getSimilarFunctions } from './similar-functions.js';
21
21
  import { getComplexity } from './complexity.js';
22
22
  import { getLargeFiles } from './large-files.js';
23
23
  import { getOutdatedPatterns } from './outdated-patterns.js';
24
- import { getFullAnalysis } from './full-analysis.js';
24
+ import { getFullAnalysis, getAnalysisSummaryOnly } from './full-analysis.js';
25
25
  import { getCustomRules, setCustomRule, checkCustomRules } from './custom-rules.js';
26
26
  import { getFrameworkReference } from './framework-references.js';
27
27
  import { setRoots, resolvePath } from './workspace.js';
28
28
  import { getDBSchema, getTableUsage, getDBDeadTables } from './db-analysis.js';
29
+ import { compressFile, editCompressed } from './compress.js';
30
+ import { getProjectDocs, generateContextFiles, checkStaleness } from './doc-dialect.js';
31
+ import { getGraph } from './tools.js';
32
+ import { parseProject, discoverSubProjects } from './parser.js';
33
+ import { getAiContext } from './ai-context.js';
34
+ import { checkJSDocConsistency } from './jsdoc-checker.js';
35
+ import { checkTypes } from './type-checker.js';
36
+ import { compactProject, expandProject } from './compact.js';
37
+ import { validateCtxContracts } from './ctx-to-jsdoc.js';
38
+ import { getConfig, setConfig, getModeDescription, getModeWorkflow } from './mode-config.js';
29
39
 
30
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
41
 
@@ -114,6 +124,105 @@ const TOOL_HANDLERS = {
114
124
  get_db_schema: (args) => getDBSchema(resolvePath(args.path)),
115
125
  get_table_usage: (args) => getTableUsage(resolvePath(args.path), args.table),
116
126
  get_db_dead_tables: (args) => getDBDeadTables(resolvePath(args.path)),
127
+
128
+ // AI Context
129
+ get_compressed_file: (args) => compressFile(resolvePath(args.path), {
130
+ beautify: args.beautify,
131
+ legend: args.legend,
132
+ }),
133
+ get_project_docs: async (args) => {
134
+ const projectPath = resolvePath(args.path);
135
+ const graph = await getGraph(projectPath);
136
+ const docs = getProjectDocs(graph, projectPath, { file: args.file });
137
+ // Lazy staleness check — wrapped in try-catch for projects with parse errors
138
+ try {
139
+ const parsed = await parseProject(projectPath);
140
+ const staleness = checkStaleness(projectPath, parsed);
141
+ return { docs, staleFiles: staleness.stale, freshCount: staleness.fresh };
142
+ } catch { return { docs }; }
143
+ },
144
+ generate_context_docs: async (args) => {
145
+ const projectPath = resolvePath(args.path);
146
+ const graph = await getGraph(projectPath);
147
+ const parsed = await parseProject(projectPath);
148
+ return generateContextFiles(graph, projectPath, parsed, {
149
+ overwrite: args.overwrite,
150
+ scope: args.scope,
151
+ });
152
+ },
153
+ check_stale_docs: async (args) => {
154
+ const projectPath = resolvePath(args.path);
155
+ const parsed = await parseProject(projectPath);
156
+ return checkStaleness(projectPath, parsed);
157
+ },
158
+ get_ai_context: async (args) => {
159
+ const projectPath = resolvePath(args.path);
160
+ const result = await getAiContext(projectPath, {
161
+ includeFiles: args.includeFiles,
162
+ includeDocs: args.includeDocs,
163
+ includeSkeleton: args.includeSkeleton,
164
+ });
165
+ // Add staleness info
166
+ try {
167
+ const parsed = await parseProject(projectPath);
168
+ const staleness = checkStaleness(projectPath, parsed);
169
+ result.staleFiles = staleness.stale;
170
+ } catch { /* parse error — skip staleness */ }
171
+ return result;
172
+ },
173
+
174
+ // JSDoc Consistency
175
+ check_jsdoc_consistency: (args) => {
176
+ return checkJSDocConsistency(resolvePath(args.path));
177
+ },
178
+
179
+ // Type Checker (optional tsc)
180
+ check_types: async (args) => {
181
+ return checkTypes(resolvePath(args.path), {
182
+ files: args.files,
183
+ maxDiagnostics: args.maxDiagnostics,
184
+ });
185
+ },
186
+
187
+ // Monorepo & Performance
188
+ discover_sub_projects: (args) => {
189
+ return discoverSubProjects(resolvePath(args.path));
190
+ },
191
+ get_analysis_summary: (args) => {
192
+ return getAnalysisSummaryOnly(resolvePath(args.path));
193
+ },
194
+ compact_project: (args) => {
195
+ return compactProject(resolvePath(args.path), { dryRun: args.dryRun || false });
196
+ },
197
+ beautify_project: (args) => {
198
+ return expandProject(resolvePath(args.path), { dryRun: args.dryRun || false });
199
+ },
200
+ validate_ctx_contracts: (args) => {
201
+ return validateCtxContracts(resolvePath(args.path), { strict: args.strict || false });
202
+ },
203
+ edit_compressed: (args) => {
204
+ return editCompressed(resolvePath(args.path), args.symbol, args.code, {
205
+ beautify: args.beautify !== false,
206
+ dryRun: args.dryRun || false,
207
+ });
208
+ },
209
+ get_mode: (args) => {
210
+ const dir = resolvePath(args.path);
211
+ const config = getConfig(dir);
212
+ return {
213
+ ...config,
214
+ description: getModeDescription(config.mode),
215
+ workflow: getModeWorkflow(config.mode),
216
+ };
217
+ },
218
+ set_mode: (args) => {
219
+ const dir = resolvePath(args.path);
220
+ const updates = { mode: args.mode };
221
+ if (args.beautify !== undefined) updates.beautify = args.beautify;
222
+ if (args.autoValidate !== undefined) updates.autoValidate = args.autoValidate;
223
+ if (args.stripJSDoc !== undefined) updates.stripJSDoc = args.stripJSDoc;
224
+ return setConfig(dir, updates);
225
+ },
117
226
  };
118
227
 
119
228
  /**
@@ -136,6 +245,10 @@ const RESPONSE_HINTS = {
136
245
  hints.push('💡 Large class detected. Run get_complexity() to find refactoring targets.');
137
246
  }
138
247
  hints.push('💡 Use deps() to see what depends on this symbol.');
248
+ // Nudge: document if no .ctx exists
249
+ if (result.file) {
250
+ hints.push(`📝 No .ctx for ${result.file}? Run generate_context_docs({ scope: ["${result.file}"] }) to create documentation.`);
251
+ }
139
252
  return hints;
140
253
  },
141
254
 
@@ -205,6 +318,100 @@ const RESPONSE_HINTS = {
205
318
  get_db_dead_tables: () => [
206
319
  '💡 Dead columns detection is best-effort — verify before removing.',
207
320
  ],
321
+
322
+ get_compressed_file: (result) => {
323
+ const hints = [`💡 Saved ${result.savings} tokens (${result.original} → ${result.compressed}).`];
324
+ hints.push('💡 Use get_ai_context() for full project boot: skeleton + docs + compressed files.');
325
+ if (result.file) {
326
+ hints.push(`📝 Working on ${result.file}? Run generate_context_docs({ scope: ["${result.file}"] }) to document it.`);
327
+ }
328
+ return hints;
329
+ },
330
+
331
+ get_project_docs: (result) => {
332
+ const hints = [
333
+ '💡 Enrich docs by editing .context/*.ctx files — they are git-tracked.',
334
+ '💡 Use generate_context_docs() to create initial .ctx stubs.',
335
+ ];
336
+ if (result.staleFiles?.length > 0) {
337
+ hints.push(`⚠️ ${result.staleFiles.length} .ctx files are STALE: ${result.staleFiles.slice(0, 5).join(', ')}. Run generate_context_docs({ scope: ${JSON.stringify(result.staleFiles)}, overwrite: true }) to update (descriptions will be preserved).`);
338
+ }
339
+ return hints;
340
+ },
341
+
342
+ check_stale_docs: (result) => {
343
+ const hints = [];
344
+ if (result.stale?.length > 0) {
345
+ hints.push(`⚠️ ${result.stale.length} stale: ${result.stale.join(', ')}`);
346
+ hints.push(`💡 Run generate_context_docs({ scope: ${JSON.stringify(result.stale)}, overwrite: true }) — existing descriptions will be preserved.`);
347
+ } else {
348
+ hints.push('✅ All .ctx docs are up to date.');
349
+ }
350
+ if (result.unknown > 0) {
351
+ hints.push(`ℹ️ ${result.unknown} .ctx files without @sig header (pre-staleness format).`);
352
+ }
353
+ return hints;
354
+ },
355
+
356
+ generate_context_docs: (result) => {
357
+ const hints = [];
358
+ if (result.created?.length > 0) {
359
+ hints.push(`✅ Created ${result.created.length} .ctx files with @sig hashes.`);
360
+ }
361
+ if (result.skipped?.length > 0) {
362
+ hints.push(`ℹ️ Skipped ${result.skipped.length} existing files. Use overwrite=true to regenerate (descriptions are preserved via merge).`);
363
+ }
364
+ if (result.templates && Object.keys(result.templates).length > 0) {
365
+ hints.push(`📝 .ctx files have {DESCRIBE} markers. To enrich automatically:`);
366
+ hints.push(` delegate_task({ prompt: "Enrich .context/*.ctx files — replace {DESCRIBE} with compact descriptions", skill: "doc-enricher" })`);
367
+ hints.push(` Or enrich manually: read source files and replace {DESCRIBE} markers with pipe-separated descriptions (max 80 chars).`);
368
+ }
369
+ return hints;
370
+ },
371
+
372
+ get_ai_context: (result) => {
373
+ const hints = [`💡 Context loaded: ${result.totalTokens} tokens (${result.savings} savings vs ${result.vsOriginal} original).`];
374
+ hints.push('💡 Use expand() to drill into specific symbols. Use get_compressed_file() for additional files.');
375
+ if (result.staleFiles?.length > 0) {
376
+ hints.push(`⚠️ ${result.staleFiles.length} .ctx docs are stale. Run generate_context_docs({ scope: ${JSON.stringify(result.staleFiles)}, overwrite: true }) then delegate_task({ skill: "doc-enricher" }) to update.`);
377
+ }
378
+ return hints;
379
+ },
380
+
381
+ validate_ctx_contracts: (result) => {
382
+ const hints = [];
383
+ if (result.summary?.errors > 0) {
384
+ hints.push(`⚠️ ${result.summary.errors} contract violations found. Run generate_context_docs({ overwrite: true }) to regenerate .ctx files.`);
385
+ } else {
386
+ hints.push('✅ All .ctx contracts valid — documentation matches source.');
387
+ }
388
+ return hints;
389
+ },
390
+
391
+ edit_compressed: (result) => {
392
+ const hints = [];
393
+ if (result.success) {
394
+ hints.push(`✅ Symbol "${result.symbol}" replaced in ${result.file}.`);
395
+ hints.push('💡 Run invalidate_cache() to refresh the graph after editing.');
396
+ hints.push('💡 Run validate_ctx_contracts() to check if .ctx docs need updating.');
397
+ }
398
+ return hints;
399
+ },
400
+
401
+ get_mode: (result) => {
402
+ const hints = [`📋 Current mode: ${result.mode} — ${result.description}`];
403
+ if (result.mode === 2) {
404
+ hints.push('💡 Workflow: get_compressed_file() → read → edit_compressed() → write.');
405
+ }
406
+ return hints;
407
+ },
408
+
409
+ set_mode: (result) => {
410
+ if (result.saved) {
411
+ return [`✅ Mode set to ${result.config.mode}. Saved to ${result.path}.`];
412
+ }
413
+ return [];
414
+ },
208
415
  };
209
416
 
210
417
  /**