ctxo-mcp 0.2.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/LICENSE +21 -0
- package/README.md +0 -0
- package/dist/chunk-54ETLIQX.js +145 -0
- package/dist/chunk-54ETLIQX.js.map +1 -0
- package/dist/chunk-P7JUSY3I.js +410 -0
- package/dist/chunk-P7JUSY3I.js.map +1 -0
- package/dist/cli-router-PIWHLS5F.js +1118 -0
- package/dist/cli-router-PIWHLS5F.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +786 -0
- package/dist/index.js.map +1 -0
- package/dist/json-index-reader-PNLPAS42.js +8 -0
- package/dist/json-index-reader-PNLPAS42.js.map +1 -0
- package/dist/staleness-detector-5AN223FM.js +39 -0
- package/dist/staleness-detector-5AN223FM.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
RevertDetector,
|
|
4
|
+
SimpleGitAdapter,
|
|
5
|
+
SqliteStorageAdapter
|
|
6
|
+
} from "./chunk-P7JUSY3I.js";
|
|
7
|
+
import {
|
|
8
|
+
JsonIndexReader
|
|
9
|
+
} from "./chunk-54ETLIQX.js";
|
|
10
|
+
|
|
11
|
+
// src/cli/index-command.ts
|
|
12
|
+
import { execFileSync } from "child_process";
|
|
13
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync3, existsSync as existsSync3, statSync, readdirSync } from "fs";
|
|
14
|
+
import { join as join4, relative, extname as extname3 } from "path";
|
|
15
|
+
|
|
16
|
+
// src/core/staleness/content-hasher.ts
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
var ContentHasher = class {
|
|
19
|
+
hash(content) {
|
|
20
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// src/adapters/language/ts-morph-adapter.ts
|
|
25
|
+
import {
|
|
26
|
+
Project,
|
|
27
|
+
SyntaxKind,
|
|
28
|
+
Node,
|
|
29
|
+
ScriptTarget
|
|
30
|
+
} from "ts-morph";
|
|
31
|
+
import { extname, dirname, join, normalize } from "path";
|
|
32
|
+
var SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
33
|
+
var TsMorphAdapter = class {
|
|
34
|
+
extensions = SUPPORTED_EXTENSIONS;
|
|
35
|
+
tier = "full";
|
|
36
|
+
project;
|
|
37
|
+
constructor() {
|
|
38
|
+
this.project = new Project({
|
|
39
|
+
compilerOptions: {
|
|
40
|
+
target: ScriptTarget.ES2022,
|
|
41
|
+
allowJs: true,
|
|
42
|
+
jsx: 2,
|
|
43
|
+
// React
|
|
44
|
+
skipLibCheck: true
|
|
45
|
+
},
|
|
46
|
+
useInMemoryFileSystem: true
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
isSupported(filePath) {
|
|
50
|
+
const ext = extname(filePath).toLowerCase();
|
|
51
|
+
return SUPPORTED_EXTENSIONS.includes(ext);
|
|
52
|
+
}
|
|
53
|
+
extractSymbols(filePath, source) {
|
|
54
|
+
const sourceFile = this.parseSource(filePath, source);
|
|
55
|
+
if (!sourceFile) return [];
|
|
56
|
+
try {
|
|
57
|
+
const symbols = [];
|
|
58
|
+
this.extractFunctions(sourceFile, filePath, symbols);
|
|
59
|
+
this.extractClasses(sourceFile, filePath, symbols);
|
|
60
|
+
this.extractInterfaces(sourceFile, filePath, symbols);
|
|
61
|
+
this.extractTypeAliases(sourceFile, filePath, symbols);
|
|
62
|
+
this.extractVariables(sourceFile, filePath, symbols);
|
|
63
|
+
return symbols;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`[ctxo:ts-morph] Symbol extraction failed for ${filePath}: ${err.message}`);
|
|
66
|
+
return [];
|
|
67
|
+
} finally {
|
|
68
|
+
this.cleanupSourceFile(filePath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
extractEdges(filePath, source) {
|
|
72
|
+
const sourceFile = this.parseSource(filePath, source);
|
|
73
|
+
if (!sourceFile) return [];
|
|
74
|
+
try {
|
|
75
|
+
const edges = [];
|
|
76
|
+
this.extractImportEdges(sourceFile, filePath, edges);
|
|
77
|
+
this.extractInheritanceEdges(sourceFile, filePath, edges);
|
|
78
|
+
this.extractCallEdges(sourceFile, filePath, edges);
|
|
79
|
+
return edges;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`[ctxo:ts-morph] Edge extraction failed for ${filePath}: ${err.message}`);
|
|
82
|
+
return [];
|
|
83
|
+
} finally {
|
|
84
|
+
this.cleanupSourceFile(filePath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
extractComplexity(filePath, source) {
|
|
88
|
+
const sourceFile = this.parseSource(filePath, source);
|
|
89
|
+
if (!sourceFile) return [];
|
|
90
|
+
try {
|
|
91
|
+
const metrics = [];
|
|
92
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
93
|
+
if (!this.isExported(fn)) continue;
|
|
94
|
+
const name = fn.getName();
|
|
95
|
+
if (!name) continue;
|
|
96
|
+
const symbolId = this.buildSymbolId(filePath, name, "function");
|
|
97
|
+
metrics.push({ symbolId, cyclomatic: this.countCyclomaticComplexity(fn) });
|
|
98
|
+
}
|
|
99
|
+
for (const cls of sourceFile.getClasses()) {
|
|
100
|
+
const className = cls.getName();
|
|
101
|
+
if (!className || !this.isExported(cls)) continue;
|
|
102
|
+
for (const method of cls.getMethods()) {
|
|
103
|
+
const methodName = method.getName();
|
|
104
|
+
const symbolId = this.buildSymbolId(filePath, `${className}.${methodName}`, "method");
|
|
105
|
+
metrics.push({ symbolId, cyclomatic: this.countCyclomaticComplexity(method) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return metrics;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(`[ctxo:ts-morph] Complexity extraction failed for ${filePath}: ${err.message}`);
|
|
111
|
+
return [];
|
|
112
|
+
} finally {
|
|
113
|
+
this.cleanupSourceFile(filePath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── Symbol Extraction ───────────────────────────────────────
|
|
117
|
+
extractFunctions(sourceFile, filePath, symbols) {
|
|
118
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
119
|
+
if (!this.isExported(fn)) continue;
|
|
120
|
+
const name = fn.getName();
|
|
121
|
+
if (!name) continue;
|
|
122
|
+
symbols.push({
|
|
123
|
+
symbolId: this.buildSymbolId(filePath, name, "function"),
|
|
124
|
+
name,
|
|
125
|
+
kind: "function",
|
|
126
|
+
startLine: fn.getStartLineNumber() - 1,
|
|
127
|
+
endLine: fn.getEndLineNumber() - 1
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
extractClasses(sourceFile, filePath, symbols) {
|
|
132
|
+
for (const cls of sourceFile.getClasses()) {
|
|
133
|
+
const name = cls.getName();
|
|
134
|
+
if (!name || !this.isExported(cls)) continue;
|
|
135
|
+
symbols.push({
|
|
136
|
+
symbolId: this.buildSymbolId(filePath, name, "class"),
|
|
137
|
+
name,
|
|
138
|
+
kind: "class",
|
|
139
|
+
startLine: cls.getStartLineNumber() - 1,
|
|
140
|
+
endLine: cls.getEndLineNumber() - 1
|
|
141
|
+
});
|
|
142
|
+
for (const method of cls.getMethods()) {
|
|
143
|
+
const methodName = method.getName();
|
|
144
|
+
symbols.push({
|
|
145
|
+
symbolId: this.buildSymbolId(filePath, `${name}.${methodName}`, "method"),
|
|
146
|
+
name: `${name}.${methodName}`,
|
|
147
|
+
kind: "method",
|
|
148
|
+
startLine: method.getStartLineNumber() - 1,
|
|
149
|
+
endLine: method.getEndLineNumber() - 1
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
extractInterfaces(sourceFile, filePath, symbols) {
|
|
155
|
+
for (const iface of sourceFile.getInterfaces()) {
|
|
156
|
+
if (!this.isExported(iface)) continue;
|
|
157
|
+
const name = iface.getName();
|
|
158
|
+
symbols.push({
|
|
159
|
+
symbolId: this.buildSymbolId(filePath, name, "interface"),
|
|
160
|
+
name,
|
|
161
|
+
kind: "interface",
|
|
162
|
+
startLine: iface.getStartLineNumber() - 1,
|
|
163
|
+
endLine: iface.getEndLineNumber() - 1
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
extractTypeAliases(sourceFile, filePath, symbols) {
|
|
168
|
+
for (const typeAlias of sourceFile.getTypeAliases()) {
|
|
169
|
+
if (!this.isExported(typeAlias)) continue;
|
|
170
|
+
const name = typeAlias.getName();
|
|
171
|
+
symbols.push({
|
|
172
|
+
symbolId: this.buildSymbolId(filePath, name, "type"),
|
|
173
|
+
name,
|
|
174
|
+
kind: "type",
|
|
175
|
+
startLine: typeAlias.getStartLineNumber() - 1,
|
|
176
|
+
endLine: typeAlias.getEndLineNumber() - 1
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
extractVariables(sourceFile, filePath, symbols) {
|
|
181
|
+
for (const stmt of sourceFile.getVariableStatements()) {
|
|
182
|
+
if (!this.isExported(stmt)) continue;
|
|
183
|
+
for (const decl of stmt.getDeclarations()) {
|
|
184
|
+
const name = decl.getName();
|
|
185
|
+
symbols.push({
|
|
186
|
+
symbolId: this.buildSymbolId(filePath, name, "variable"),
|
|
187
|
+
name,
|
|
188
|
+
kind: "variable",
|
|
189
|
+
startLine: stmt.getStartLineNumber() - 1,
|
|
190
|
+
endLine: stmt.getEndLineNumber() - 1
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ── Edge Extraction ─────────────────────────────────────────
|
|
196
|
+
extractImportEdges(sourceFile, filePath, edges) {
|
|
197
|
+
const fileSymbolId = this.buildSymbolId(filePath, sourceFile.getBaseName().replace(/\.[^.]+$/, ""), "variable");
|
|
198
|
+
const fromSymbols = this.findExportedSymbolsInFile(filePath);
|
|
199
|
+
const fromSymbol = fromSymbols.length > 0 ? fromSymbols[0] : fileSymbolId;
|
|
200
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
201
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
202
|
+
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const normalizedTarget = this.resolveRelativeImport(filePath, moduleSpecifier);
|
|
206
|
+
for (const named of imp.getNamedImports()) {
|
|
207
|
+
const importedName = named.getName();
|
|
208
|
+
edges.push({
|
|
209
|
+
from: fromSymbol,
|
|
210
|
+
to: this.resolveImportTarget(normalizedTarget, importedName),
|
|
211
|
+
kind: "imports"
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const defaultImport = imp.getDefaultImport();
|
|
215
|
+
if (defaultImport) {
|
|
216
|
+
edges.push({
|
|
217
|
+
from: fromSymbol,
|
|
218
|
+
to: this.resolveImportTarget(normalizedTarget, defaultImport.getText()),
|
|
219
|
+
kind: "imports"
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
extractInheritanceEdges(sourceFile, filePath, edges) {
|
|
225
|
+
for (const cls of sourceFile.getClasses()) {
|
|
226
|
+
const className = cls.getName();
|
|
227
|
+
if (!className || !this.isExported(cls)) continue;
|
|
228
|
+
const classSymbolId = this.buildSymbolId(filePath, className, "class");
|
|
229
|
+
const baseClass = cls.getExtends();
|
|
230
|
+
if (baseClass) {
|
|
231
|
+
const baseName = baseClass.getExpression().getText();
|
|
232
|
+
edges.push({
|
|
233
|
+
from: classSymbolId,
|
|
234
|
+
to: this.resolveSymbolReference(sourceFile, baseName, "class"),
|
|
235
|
+
kind: "extends"
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
for (const impl of cls.getImplements()) {
|
|
239
|
+
const ifaceName = impl.getExpression().getText();
|
|
240
|
+
edges.push({
|
|
241
|
+
from: classSymbolId,
|
|
242
|
+
to: this.resolveSymbolReference(sourceFile, ifaceName, "interface"),
|
|
243
|
+
kind: "implements"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
extractCallEdges(sourceFile, filePath, edges) {
|
|
249
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
250
|
+
if (!this.isExported(fn)) continue;
|
|
251
|
+
const fnName = fn.getName();
|
|
252
|
+
if (!fnName) continue;
|
|
253
|
+
const fnSymbolId = this.buildSymbolId(filePath, fnName, "function");
|
|
254
|
+
for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
255
|
+
const calledName = call.getExpression().getText().split(".").pop();
|
|
256
|
+
if (!calledName || calledName === fnName) continue;
|
|
257
|
+
const resolved = this.resolveLocalCallTarget(sourceFile, filePath, calledName);
|
|
258
|
+
if (resolved) {
|
|
259
|
+
edges.push({ from: fnSymbolId, to: resolved, kind: "calls" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const cls of sourceFile.getClasses()) {
|
|
264
|
+
const className = cls.getName();
|
|
265
|
+
if (!className || !this.isExported(cls)) continue;
|
|
266
|
+
for (const method of cls.getMethods()) {
|
|
267
|
+
const methodName = method.getName();
|
|
268
|
+
const methodSymbolId = this.buildSymbolId(filePath, `${className}.${methodName}`, "method");
|
|
269
|
+
for (const call of method.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
270
|
+
const calledText = call.getExpression().getText();
|
|
271
|
+
const calledName = calledText.split(".").pop();
|
|
272
|
+
if (!calledName || calledName === methodName) continue;
|
|
273
|
+
if (calledText.startsWith("this.")) continue;
|
|
274
|
+
const resolved = this.resolveLocalCallTarget(sourceFile, filePath, calledName);
|
|
275
|
+
if (resolved) {
|
|
276
|
+
edges.push({ from: methodSymbolId, to: resolved, kind: "calls" });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
resolveLocalCallTarget(sourceFile, filePath, calledName) {
|
|
283
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
284
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
285
|
+
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) continue;
|
|
286
|
+
for (const named of imp.getNamedImports()) {
|
|
287
|
+
if (named.getName() === calledName) {
|
|
288
|
+
const targetFile = this.resolveRelativeImport(filePath, moduleSpecifier);
|
|
289
|
+
return this.resolveImportTarget(targetFile, calledName);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
294
|
+
if (fn.getName() === calledName) {
|
|
295
|
+
return this.buildSymbolId(filePath, calledName, "function");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return void 0;
|
|
299
|
+
}
|
|
300
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
301
|
+
parseSource(filePath, source) {
|
|
302
|
+
try {
|
|
303
|
+
const existing = this.project.getSourceFile(filePath);
|
|
304
|
+
if (existing) {
|
|
305
|
+
this.project.removeSourceFile(existing);
|
|
306
|
+
}
|
|
307
|
+
return this.project.createSourceFile(filePath, source, { overwrite: true });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error(`[ctxo:ts-morph] Parse failed for ${filePath}: ${err.message}`);
|
|
310
|
+
return void 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
cleanupSourceFile(filePath) {
|
|
314
|
+
const existing = this.project.getSourceFile(filePath);
|
|
315
|
+
if (existing) {
|
|
316
|
+
this.project.removeSourceFile(existing);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
buildSymbolId(filePath, name, kind) {
|
|
320
|
+
return `${filePath}::${name}::${kind}`;
|
|
321
|
+
}
|
|
322
|
+
resolveRelativeImport(fromFile, moduleSpecifier) {
|
|
323
|
+
const fromDir = dirname(fromFile);
|
|
324
|
+
let resolved = normalize(join(fromDir, moduleSpecifier)).replace(/\\/g, "/");
|
|
325
|
+
if (resolved.endsWith(".js")) {
|
|
326
|
+
resolved = resolved.slice(0, -3) + ".ts";
|
|
327
|
+
} else if (resolved.endsWith(".jsx")) {
|
|
328
|
+
resolved = resolved.slice(0, -4) + ".tsx";
|
|
329
|
+
} else if (!extname(resolved)) {
|
|
330
|
+
resolved += ".ts";
|
|
331
|
+
}
|
|
332
|
+
return resolved;
|
|
333
|
+
}
|
|
334
|
+
resolveImportTarget(targetFile, name) {
|
|
335
|
+
const targetSourceFile = this.project.getSourceFile(targetFile);
|
|
336
|
+
if (targetSourceFile) {
|
|
337
|
+
for (const fn of targetSourceFile.getFunctions()) {
|
|
338
|
+
if (fn.getName() === name && this.isExported(fn)) {
|
|
339
|
+
return this.buildSymbolId(targetFile, name, "function");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const cls of targetSourceFile.getClasses()) {
|
|
343
|
+
if (cls.getName() === name && this.isExported(cls)) {
|
|
344
|
+
return this.buildSymbolId(targetFile, name, "class");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const iface of targetSourceFile.getInterfaces()) {
|
|
348
|
+
if (iface.getName() === name && this.isExported(iface)) {
|
|
349
|
+
return this.buildSymbolId(targetFile, name, "interface");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const t of targetSourceFile.getTypeAliases()) {
|
|
353
|
+
if (t.getName() === name && this.isExported(t)) {
|
|
354
|
+
return this.buildSymbolId(targetFile, name, "type");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const kind = this.inferSymbolKind(name);
|
|
359
|
+
return `${targetFile}::${name}::${kind}`;
|
|
360
|
+
}
|
|
361
|
+
resolveSymbolReference(sourceFile, name, defaultKind) {
|
|
362
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
363
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
364
|
+
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) continue;
|
|
365
|
+
for (const named of imp.getNamedImports()) {
|
|
366
|
+
if (named.getName() === name) {
|
|
367
|
+
const targetFile = imp.getModuleSpecifierSourceFile()?.getFilePath() ?? moduleSpecifier;
|
|
368
|
+
return `${this.normalizeFilePath(targetFile)}::${name}::${defaultKind}`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return `${sourceFile.getFilePath()}::${name}::${defaultKind}`;
|
|
373
|
+
}
|
|
374
|
+
inferSymbolKind(name) {
|
|
375
|
+
if (/^I[A-Z]/.test(name) && name.length > 2) return "interface";
|
|
376
|
+
if (/^[A-Z][A-Z_0-9]+$/.test(name)) return "variable";
|
|
377
|
+
if (/^[A-Z]/.test(name)) return "class";
|
|
378
|
+
return "function";
|
|
379
|
+
}
|
|
380
|
+
normalizeFilePath(filePath) {
|
|
381
|
+
return filePath.replace(/^\//, "");
|
|
382
|
+
}
|
|
383
|
+
findExportedSymbolsInFile(filePath) {
|
|
384
|
+
const sourceFile = this.project.getSourceFile(filePath);
|
|
385
|
+
if (!sourceFile) return [];
|
|
386
|
+
const ids = [];
|
|
387
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
388
|
+
if (!this.isExported(fn)) continue;
|
|
389
|
+
const name = fn.getName();
|
|
390
|
+
if (name) ids.push(this.buildSymbolId(filePath, name, "function"));
|
|
391
|
+
}
|
|
392
|
+
for (const cls of sourceFile.getClasses()) {
|
|
393
|
+
if (!this.isExported(cls)) continue;
|
|
394
|
+
const name = cls.getName();
|
|
395
|
+
if (name) ids.push(this.buildSymbolId(filePath, name, "class"));
|
|
396
|
+
}
|
|
397
|
+
for (const iface of sourceFile.getInterfaces()) {
|
|
398
|
+
if (!this.isExported(iface)) continue;
|
|
399
|
+
ids.push(this.buildSymbolId(filePath, iface.getName(), "interface"));
|
|
400
|
+
}
|
|
401
|
+
for (const t of sourceFile.getTypeAliases()) {
|
|
402
|
+
if (!this.isExported(t)) continue;
|
|
403
|
+
ids.push(this.buildSymbolId(filePath, t.getName(), "type"));
|
|
404
|
+
}
|
|
405
|
+
for (const stmt of sourceFile.getVariableStatements()) {
|
|
406
|
+
if (!this.isExported(stmt)) continue;
|
|
407
|
+
for (const decl of stmt.getDeclarations()) {
|
|
408
|
+
ids.push(this.buildSymbolId(filePath, decl.getName(), "variable"));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return ids;
|
|
412
|
+
}
|
|
413
|
+
isExported(node) {
|
|
414
|
+
if (Node.isExportable(node)) {
|
|
415
|
+
return node.isExported();
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
countCyclomaticComplexity(node) {
|
|
420
|
+
let complexity = 1;
|
|
421
|
+
node.forEachDescendant((child) => {
|
|
422
|
+
switch (child.getKind()) {
|
|
423
|
+
case SyntaxKind.IfStatement:
|
|
424
|
+
case SyntaxKind.ConditionalExpression:
|
|
425
|
+
case SyntaxKind.ForStatement:
|
|
426
|
+
case SyntaxKind.ForInStatement:
|
|
427
|
+
case SyntaxKind.ForOfStatement:
|
|
428
|
+
case SyntaxKind.WhileStatement:
|
|
429
|
+
case SyntaxKind.DoStatement:
|
|
430
|
+
case SyntaxKind.CaseClause:
|
|
431
|
+
case SyntaxKind.CatchClause:
|
|
432
|
+
case SyntaxKind.BinaryExpression: {
|
|
433
|
+
const text = child.getText();
|
|
434
|
+
if (child.getKind() === SyntaxKind.BinaryExpression) {
|
|
435
|
+
if (text.includes("&&") || text.includes("||") || text.includes("??")) {
|
|
436
|
+
complexity++;
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
complexity++;
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
return complexity;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/adapters/language/language-adapter-registry.ts
|
|
450
|
+
import { extname as extname2 } from "path";
|
|
451
|
+
var LanguageAdapterRegistry = class {
|
|
452
|
+
adaptersByExtension = /* @__PURE__ */ new Map();
|
|
453
|
+
register(adapter) {
|
|
454
|
+
for (const ext of adapter.extensions) {
|
|
455
|
+
this.adaptersByExtension.set(ext.toLowerCase(), adapter);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
getAdapter(filePath) {
|
|
459
|
+
if (!filePath) return void 0;
|
|
460
|
+
const ext = extname2(filePath).toLowerCase();
|
|
461
|
+
if (!ext) return void 0;
|
|
462
|
+
return this.adaptersByExtension.get(ext);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/adapters/storage/json-index-writer.ts
|
|
467
|
+
import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs";
|
|
468
|
+
import { dirname as dirname2, join as join2, resolve, sep } from "path";
|
|
469
|
+
var JsonIndexWriter = class {
|
|
470
|
+
indexDir;
|
|
471
|
+
constructor(ctxoRoot) {
|
|
472
|
+
this.indexDir = join2(ctxoRoot, "index");
|
|
473
|
+
}
|
|
474
|
+
write(fileIndex) {
|
|
475
|
+
if (!fileIndex.file) {
|
|
476
|
+
throw new Error("FileIndex.file must not be empty");
|
|
477
|
+
}
|
|
478
|
+
const targetPath = this.resolveIndexPath(fileIndex.file);
|
|
479
|
+
mkdirSync(dirname2(targetPath), { recursive: true });
|
|
480
|
+
const sorted = this.sortKeys(fileIndex);
|
|
481
|
+
const json = JSON.stringify(sorted, null, 2);
|
|
482
|
+
writeFileSync(targetPath, json, "utf-8");
|
|
483
|
+
}
|
|
484
|
+
delete(relativePath) {
|
|
485
|
+
const targetPath = this.resolveIndexPath(relativePath);
|
|
486
|
+
if (existsSync(targetPath)) {
|
|
487
|
+
unlinkSync(targetPath);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
resolveIndexPath(relativePath) {
|
|
491
|
+
const resolved = resolve(this.indexDir, `${relativePath}.json`);
|
|
492
|
+
const normalizedDir = resolve(this.indexDir) + sep;
|
|
493
|
+
if (!resolved.startsWith(normalizedDir)) {
|
|
494
|
+
throw new Error(`Path traversal detected: ${relativePath}`);
|
|
495
|
+
}
|
|
496
|
+
return resolved;
|
|
497
|
+
}
|
|
498
|
+
sortKeys(obj) {
|
|
499
|
+
if (Array.isArray(obj)) {
|
|
500
|
+
return obj.map((item) => this.sortKeys(item));
|
|
501
|
+
}
|
|
502
|
+
if (obj !== null && typeof obj === "object") {
|
|
503
|
+
const sorted = {};
|
|
504
|
+
for (const key of Object.keys(obj).sort()) {
|
|
505
|
+
sorted[key] = this.sortKeys(obj[key]);
|
|
506
|
+
}
|
|
507
|
+
return sorted;
|
|
508
|
+
}
|
|
509
|
+
return obj;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// src/adapters/storage/schema-manager.ts
|
|
514
|
+
import { readFileSync, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
515
|
+
import { join as join3, dirname as dirname3 } from "path";
|
|
516
|
+
var CURRENT_SCHEMA_VERSION = "1.0.0";
|
|
517
|
+
var SchemaManager = class {
|
|
518
|
+
versionFilePath;
|
|
519
|
+
constructor(ctxoRoot) {
|
|
520
|
+
this.versionFilePath = join3(ctxoRoot, "index", "schema-version");
|
|
521
|
+
}
|
|
522
|
+
currentVersion() {
|
|
523
|
+
return CURRENT_SCHEMA_VERSION;
|
|
524
|
+
}
|
|
525
|
+
readStoredVersion() {
|
|
526
|
+
if (!existsSync2(this.versionFilePath)) {
|
|
527
|
+
return void 0;
|
|
528
|
+
}
|
|
529
|
+
return readFileSync(this.versionFilePath, "utf-8").trim();
|
|
530
|
+
}
|
|
531
|
+
writeVersion() {
|
|
532
|
+
mkdirSync2(dirname3(this.versionFilePath), { recursive: true });
|
|
533
|
+
writeFileSync2(this.versionFilePath, CURRENT_SCHEMA_VERSION, "utf-8");
|
|
534
|
+
}
|
|
535
|
+
isCompatible() {
|
|
536
|
+
const stored = this.readStoredVersion();
|
|
537
|
+
if (!stored) return false;
|
|
538
|
+
return stored === CURRENT_SCHEMA_VERSION;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/cli/index-command.ts
|
|
543
|
+
var IndexCommand = class {
|
|
544
|
+
projectRoot;
|
|
545
|
+
ctxoRoot;
|
|
546
|
+
constructor(projectRoot, ctxoRoot) {
|
|
547
|
+
this.projectRoot = projectRoot;
|
|
548
|
+
this.ctxoRoot = ctxoRoot ?? join4(projectRoot, ".ctxo");
|
|
549
|
+
}
|
|
550
|
+
async run(options = {}) {
|
|
551
|
+
if (options.check) {
|
|
552
|
+
return this.runCheck();
|
|
553
|
+
}
|
|
554
|
+
const registry = new LanguageAdapterRegistry();
|
|
555
|
+
registry.register(new TsMorphAdapter());
|
|
556
|
+
const writer = new JsonIndexWriter(this.ctxoRoot);
|
|
557
|
+
const schemaManager = new SchemaManager(this.ctxoRoot);
|
|
558
|
+
const hasher = new ContentHasher();
|
|
559
|
+
const gitAdapter = new SimpleGitAdapter(this.projectRoot);
|
|
560
|
+
const revertDetector = new RevertDetector();
|
|
561
|
+
let files;
|
|
562
|
+
if (options.file) {
|
|
563
|
+
const fullPath = join4(this.projectRoot, options.file);
|
|
564
|
+
files = [fullPath];
|
|
565
|
+
console.error(`[ctxo] Incremental re-index: ${options.file}`);
|
|
566
|
+
} else {
|
|
567
|
+
const workspaces = this.discoverWorkspaces();
|
|
568
|
+
files = [];
|
|
569
|
+
for (const ws of workspaces) {
|
|
570
|
+
const wsFiles = this.discoverFilesIn(ws);
|
|
571
|
+
files.push(...wsFiles);
|
|
572
|
+
}
|
|
573
|
+
console.error(`[ctxo] Building codebase index... Found ${files.length} source files`);
|
|
574
|
+
}
|
|
575
|
+
const indices = [];
|
|
576
|
+
let processed = 0;
|
|
577
|
+
for (const filePath of files) {
|
|
578
|
+
const adapter = registry.getAdapter(filePath);
|
|
579
|
+
if (!adapter) continue;
|
|
580
|
+
const relativePath = relative(this.projectRoot, filePath).replace(/\\/g, "/");
|
|
581
|
+
try {
|
|
582
|
+
const source = readFileSync2(filePath, "utf-8");
|
|
583
|
+
const lastModified = Math.floor(Date.now() / 1e3);
|
|
584
|
+
const symbols = adapter.extractSymbols(relativePath, source);
|
|
585
|
+
const edges = adapter.extractEdges(relativePath, source);
|
|
586
|
+
const complexity = adapter.extractComplexity(relativePath, source);
|
|
587
|
+
let intent = [];
|
|
588
|
+
let antiPatterns = [];
|
|
589
|
+
if (!options.skipHistory) {
|
|
590
|
+
const commits = await gitAdapter.getCommitHistory(relativePath);
|
|
591
|
+
intent = commits.map((c) => ({
|
|
592
|
+
hash: c.hash,
|
|
593
|
+
message: c.message,
|
|
594
|
+
date: c.date,
|
|
595
|
+
kind: "commit"
|
|
596
|
+
}));
|
|
597
|
+
antiPatterns = revertDetector.detect(commits);
|
|
598
|
+
}
|
|
599
|
+
const fileIndex = {
|
|
600
|
+
file: relativePath,
|
|
601
|
+
lastModified,
|
|
602
|
+
contentHash: hasher.hash(source),
|
|
603
|
+
symbols,
|
|
604
|
+
edges,
|
|
605
|
+
complexity,
|
|
606
|
+
intent,
|
|
607
|
+
antiPatterns
|
|
608
|
+
};
|
|
609
|
+
writer.write(fileIndex);
|
|
610
|
+
indices.push(fileIndex);
|
|
611
|
+
processed++;
|
|
612
|
+
if (processed % 50 === 0) {
|
|
613
|
+
console.error(`[ctxo] Processed ${processed}/${files.length} files`);
|
|
614
|
+
}
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.error(`[ctxo] Skipped ${relativePath}: ${err.message}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
schemaManager.writeVersion();
|
|
620
|
+
const storage = new SqliteStorageAdapter(this.ctxoRoot);
|
|
621
|
+
try {
|
|
622
|
+
await storage.initEmpty();
|
|
623
|
+
storage.bulkWrite(indices);
|
|
624
|
+
} finally {
|
|
625
|
+
storage.close();
|
|
626
|
+
}
|
|
627
|
+
if (!options.skipSideEffects) {
|
|
628
|
+
this.ensureGitignore();
|
|
629
|
+
}
|
|
630
|
+
console.error(`[ctxo] Index complete: ${processed} files indexed`);
|
|
631
|
+
}
|
|
632
|
+
discoverFilesIn(root) {
|
|
633
|
+
try {
|
|
634
|
+
const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
635
|
+
cwd: root,
|
|
636
|
+
encoding: "utf-8",
|
|
637
|
+
maxBuffer: 10 * 1024 * 1024
|
|
638
|
+
});
|
|
639
|
+
return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => this.isSupportedExtension(line)).map((line) => join4(root, line));
|
|
640
|
+
} catch {
|
|
641
|
+
console.error(`[ctxo] git ls-files failed for ${root}`);
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
discoverWorkspaces() {
|
|
646
|
+
const pkgPath = join4(this.projectRoot, "package.json");
|
|
647
|
+
if (!existsSync3(pkgPath)) return [this.projectRoot];
|
|
648
|
+
try {
|
|
649
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
650
|
+
const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
|
|
651
|
+
if (!workspaces || workspaces.length === 0) return [this.projectRoot];
|
|
652
|
+
const resolved = [];
|
|
653
|
+
for (const ws of workspaces) {
|
|
654
|
+
if (ws.endsWith("/*") || ws.endsWith("\\*")) {
|
|
655
|
+
const parentDir = join4(this.projectRoot, ws.slice(0, -2));
|
|
656
|
+
if (existsSync3(parentDir)) {
|
|
657
|
+
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
|
|
658
|
+
if (entry.isDirectory()) {
|
|
659
|
+
resolved.push(join4(parentDir, entry.name));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
const wsPath = join4(this.projectRoot, ws);
|
|
665
|
+
if (existsSync3(wsPath)) {
|
|
666
|
+
resolved.push(wsPath);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (resolved.length === 0) return [this.projectRoot];
|
|
671
|
+
console.error(`[ctxo] Monorepo detected: ${resolved.length} workspace(s)`);
|
|
672
|
+
return resolved;
|
|
673
|
+
} catch {
|
|
674
|
+
return [this.projectRoot];
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
isSupportedExtension(filePath) {
|
|
678
|
+
const ext = extname3(filePath).toLowerCase();
|
|
679
|
+
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
|
|
680
|
+
}
|
|
681
|
+
async runCheck() {
|
|
682
|
+
console.error("[ctxo] Checking index freshness...");
|
|
683
|
+
const hasher = new ContentHasher();
|
|
684
|
+
const files = this.discoverFilesIn(this.projectRoot);
|
|
685
|
+
const reader = new (await import("./json-index-reader-PNLPAS42.js")).JsonIndexReader(this.ctxoRoot);
|
|
686
|
+
const indices = reader.readAll();
|
|
687
|
+
const indexedMap = new Map(indices.map((i) => [i.file, i]));
|
|
688
|
+
let staleCount = 0;
|
|
689
|
+
for (const filePath of files) {
|
|
690
|
+
const ext = extname3(filePath).toLowerCase();
|
|
691
|
+
if (![".ts", ".tsx", ".js", ".jsx"].includes(ext)) continue;
|
|
692
|
+
const relativePath = relative(this.projectRoot, filePath).replace(/\\/g, "/");
|
|
693
|
+
const indexed = indexedMap.get(relativePath);
|
|
694
|
+
if (!indexed) {
|
|
695
|
+
console.error(`[ctxo] NOT INDEXED: ${relativePath}`);
|
|
696
|
+
staleCount++;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (!existsSync3(filePath)) {
|
|
700
|
+
console.error(`[ctxo] DELETED: ${relativePath}`);
|
|
701
|
+
staleCount++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const mtime = Math.floor(statSync(filePath).mtimeMs / 1e3);
|
|
705
|
+
if (mtime <= indexed.lastModified) continue;
|
|
706
|
+
if (indexed.contentHash) {
|
|
707
|
+
const source = readFileSync2(filePath, "utf-8");
|
|
708
|
+
const currentHash = hasher.hash(source);
|
|
709
|
+
if (currentHash === indexed.contentHash) continue;
|
|
710
|
+
}
|
|
711
|
+
console.error(`[ctxo] STALE: ${relativePath}`);
|
|
712
|
+
staleCount++;
|
|
713
|
+
}
|
|
714
|
+
if (staleCount > 0) {
|
|
715
|
+
console.error(`[ctxo] ${staleCount} file(s) need re-indexing. Run "ctxo index"`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
console.error("[ctxo] Index is up to date");
|
|
719
|
+
}
|
|
720
|
+
ensureGitignore() {
|
|
721
|
+
const gitignorePath = join4(this.projectRoot, ".gitignore");
|
|
722
|
+
const cachePattern = ".ctxo/.cache/";
|
|
723
|
+
const suffix = `
|
|
724
|
+
# Ctxo local cache (never committed)
|
|
725
|
+
${cachePattern}
|
|
726
|
+
`;
|
|
727
|
+
const existing = existsSync3(gitignorePath) ? readFileSync2(gitignorePath, "utf-8") : "";
|
|
728
|
+
if (existing.includes(cachePattern)) return;
|
|
729
|
+
writeFileSync3(gitignorePath, existing + suffix, "utf-8");
|
|
730
|
+
console.error("[ctxo] Added .ctxo/.cache/ to .gitignore");
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// src/cli/sync-command.ts
|
|
735
|
+
import { join as join5 } from "path";
|
|
736
|
+
var SyncCommand = class {
|
|
737
|
+
ctxoRoot;
|
|
738
|
+
constructor(projectRoot) {
|
|
739
|
+
this.ctxoRoot = join5(projectRoot, ".ctxo");
|
|
740
|
+
}
|
|
741
|
+
async run() {
|
|
742
|
+
console.error("[ctxo] Rebuilding SQLite cache from committed JSON index...");
|
|
743
|
+
const storage = new SqliteStorageAdapter(this.ctxoRoot);
|
|
744
|
+
try {
|
|
745
|
+
await storage.init();
|
|
746
|
+
} finally {
|
|
747
|
+
storage.close();
|
|
748
|
+
}
|
|
749
|
+
console.error("[ctxo] Sync complete");
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// src/cli/status-command.ts
|
|
754
|
+
import { join as join6 } from "path";
|
|
755
|
+
import { existsSync as existsSync4 } from "fs";
|
|
756
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
757
|
+
var StatusCommand = class {
|
|
758
|
+
projectRoot;
|
|
759
|
+
ctxoRoot;
|
|
760
|
+
constructor(projectRoot) {
|
|
761
|
+
this.projectRoot = projectRoot;
|
|
762
|
+
this.ctxoRoot = join6(projectRoot, ".ctxo");
|
|
763
|
+
}
|
|
764
|
+
run() {
|
|
765
|
+
const indexDir = join6(this.ctxoRoot, "index");
|
|
766
|
+
if (!existsSync4(indexDir)) {
|
|
767
|
+
console.error('[ctxo] No index found. Run "ctxo index" first.');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const schemaManager = new SchemaManager(this.ctxoRoot);
|
|
771
|
+
const version = schemaManager.readStoredVersion() ?? "unknown";
|
|
772
|
+
const reader = new JsonIndexReader(this.ctxoRoot);
|
|
773
|
+
const indices = reader.readAll();
|
|
774
|
+
const totalSymbols = indices.reduce((sum, idx) => sum + idx.symbols.length, 0);
|
|
775
|
+
const totalEdges = indices.reduce((sum, idx) => sum + idx.edges.length, 0);
|
|
776
|
+
console.error(`[ctxo] Index Status`);
|
|
777
|
+
console.error(` Schema version: ${version}`);
|
|
778
|
+
console.error(` Indexed files: ${indices.length}`);
|
|
779
|
+
console.error(` Total symbols: ${totalSymbols}`);
|
|
780
|
+
console.error(` Total edges: ${totalEdges}`);
|
|
781
|
+
const cacheExists = existsSync4(join6(this.ctxoRoot, ".cache", "symbols.db"));
|
|
782
|
+
console.error(` SQLite cache: ${cacheExists ? "present" : "missing (run ctxo sync)"}`);
|
|
783
|
+
if (indices.length > 0) {
|
|
784
|
+
console.error("");
|
|
785
|
+
console.error(" Files:");
|
|
786
|
+
const sourceFiles = this.getSourceFiles();
|
|
787
|
+
for (const idx of indices.sort((a, b) => a.file.localeCompare(b.file))) {
|
|
788
|
+
const ts = new Date(idx.lastModified * 1e3).toISOString();
|
|
789
|
+
const isOrphaned = sourceFiles.size > 0 && !sourceFiles.has(idx.file);
|
|
790
|
+
const badge = isOrphaned ? " [orphaned]" : "";
|
|
791
|
+
console.error(` ${idx.file} ${ts} (${idx.symbols.length} symbols, ${idx.edges.length} edges)${badge}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
getSourceFiles() {
|
|
796
|
+
try {
|
|
797
|
+
const output = execFileSync2("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
798
|
+
cwd: this.projectRoot,
|
|
799
|
+
encoding: "utf-8"
|
|
800
|
+
});
|
|
801
|
+
return new Set(output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0));
|
|
802
|
+
} catch {
|
|
803
|
+
return /* @__PURE__ */ new Set();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// src/cli/verify-command.ts
|
|
809
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
810
|
+
import { join as join7 } from "path";
|
|
811
|
+
import { tmpdir } from "os";
|
|
812
|
+
var VerifyCommand = class {
|
|
813
|
+
projectRoot;
|
|
814
|
+
constructor(projectRoot) {
|
|
815
|
+
this.projectRoot = projectRoot;
|
|
816
|
+
}
|
|
817
|
+
async run() {
|
|
818
|
+
console.error("[ctxo] Verifying index freshness...");
|
|
819
|
+
const tempDir = mkdtempSync(join7(tmpdir(), "ctxo-verify-"));
|
|
820
|
+
try {
|
|
821
|
+
const tempCtxo = join7(tempDir, ".ctxo");
|
|
822
|
+
const indexCmd = new IndexCommand(this.projectRoot, tempCtxo);
|
|
823
|
+
await indexCmd.run({ skipSideEffects: true });
|
|
824
|
+
const committedReader = new JsonIndexReader(join7(this.projectRoot, ".ctxo"));
|
|
825
|
+
const freshReader = new JsonIndexReader(tempCtxo);
|
|
826
|
+
const committedIndices = committedReader.readAll();
|
|
827
|
+
const freshIndices = freshReader.readAll();
|
|
828
|
+
const serialize = (i) => JSON.stringify({ symbols: i.symbols, edges: i.edges, intent: i.intent, antiPatterns: i.antiPatterns });
|
|
829
|
+
const committedMap = new Map(committedIndices.map((i) => [i.file, serialize(i)]));
|
|
830
|
+
const freshMap = new Map(freshIndices.map((i) => [i.file, serialize(i)]));
|
|
831
|
+
let stale = false;
|
|
832
|
+
for (const [file, freshData] of freshMap) {
|
|
833
|
+
const committed = committedMap.get(file);
|
|
834
|
+
if (committed !== freshData) {
|
|
835
|
+
console.error(`[ctxo] STALE: ${file}`);
|
|
836
|
+
stale = true;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const file of committedMap.keys()) {
|
|
840
|
+
if (!freshMap.has(file)) {
|
|
841
|
+
console.error(`[ctxo] REMOVED: ${file}`);
|
|
842
|
+
stale = true;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (stale) {
|
|
846
|
+
console.error('[ctxo] Index is STALE \u2014 run "ctxo index" and commit .ctxo/index/');
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
console.error("[ctxo] Index is up to date");
|
|
850
|
+
} finally {
|
|
851
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// src/cli/init-command.ts
|
|
857
|
+
import { join as join8 } from "path";
|
|
858
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, chmodSync } from "fs";
|
|
859
|
+
var CTXO_START = "# ctxo-start";
|
|
860
|
+
var CTXO_END = "# ctxo-end";
|
|
861
|
+
var POST_COMMIT_CONTENT = `
|
|
862
|
+
${CTXO_START}
|
|
863
|
+
# Incremental re-index on commit (only changed files)
|
|
864
|
+
if command -v ctxo >/dev/null 2>&1; then
|
|
865
|
+
for file in $(git diff --name-only HEAD~1 HEAD 2>/dev/null); do
|
|
866
|
+
ctxo index --file "$file" 2>/dev/null || true
|
|
867
|
+
done
|
|
868
|
+
fi
|
|
869
|
+
${CTXO_END}
|
|
870
|
+
`.trim();
|
|
871
|
+
var POST_MERGE_CONTENT = `
|
|
872
|
+
${CTXO_START}
|
|
873
|
+
# Rebuild SQLite cache after merge (index updated via git pull)
|
|
874
|
+
if command -v ctxo >/dev/null 2>&1; then
|
|
875
|
+
ctxo sync 2>/dev/null || true
|
|
876
|
+
fi
|
|
877
|
+
${CTXO_END}
|
|
878
|
+
`.trim();
|
|
879
|
+
var InitCommand = class {
|
|
880
|
+
projectRoot;
|
|
881
|
+
constructor(projectRoot) {
|
|
882
|
+
this.projectRoot = projectRoot;
|
|
883
|
+
}
|
|
884
|
+
run() {
|
|
885
|
+
const hooksDir = join8(this.projectRoot, ".git", "hooks");
|
|
886
|
+
if (!existsSync5(join8(this.projectRoot, ".git"))) {
|
|
887
|
+
console.error('[ctxo] Not a git repository. Run "git init" first.');
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
mkdirSync3(hooksDir, { recursive: true });
|
|
891
|
+
this.installHook(hooksDir, "post-commit", POST_COMMIT_CONTENT);
|
|
892
|
+
this.installHook(hooksDir, "post-merge", POST_MERGE_CONTENT);
|
|
893
|
+
console.error("[ctxo] Git hooks installed (post-commit, post-merge)");
|
|
894
|
+
}
|
|
895
|
+
installHook(hooksDir, hookName, hookContent) {
|
|
896
|
+
const hookPath = join8(hooksDir, hookName);
|
|
897
|
+
let existing = "";
|
|
898
|
+
if (existsSync5(hookPath)) {
|
|
899
|
+
existing = readFileSync3(hookPath, "utf-8");
|
|
900
|
+
if (existing.includes(CTXO_START)) {
|
|
901
|
+
console.error(`[ctxo] ${hookName} hook already has ctxo block \u2014 skipping`);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
existing = "#!/bin/sh\n";
|
|
906
|
+
}
|
|
907
|
+
const updated = existing.endsWith("\n") ? existing + "\n" + hookContent + "\n" : existing + "\n\n" + hookContent + "\n";
|
|
908
|
+
writeFileSync4(hookPath, updated, "utf-8");
|
|
909
|
+
chmodSync(hookPath, 493);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
// src/cli/watch-command.ts
|
|
914
|
+
import { join as join9, extname as extname4, relative as relative2 } from "path";
|
|
915
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
916
|
+
|
|
917
|
+
// src/adapters/watcher/chokidar-watcher-adapter.ts
|
|
918
|
+
import { watch } from "chokidar";
|
|
919
|
+
var DEFAULT_IGNORED = [
|
|
920
|
+
"**/node_modules/**",
|
|
921
|
+
"**/.git/**",
|
|
922
|
+
"**/.ctxo/**",
|
|
923
|
+
"**/dist/**",
|
|
924
|
+
"**/coverage/**"
|
|
925
|
+
];
|
|
926
|
+
var ChokidarWatcherAdapter = class {
|
|
927
|
+
projectRoot;
|
|
928
|
+
ignored;
|
|
929
|
+
watcher;
|
|
930
|
+
constructor(projectRoot, ignored) {
|
|
931
|
+
this.projectRoot = projectRoot;
|
|
932
|
+
this.ignored = ignored ?? DEFAULT_IGNORED;
|
|
933
|
+
}
|
|
934
|
+
start(handler) {
|
|
935
|
+
if (this.watcher) {
|
|
936
|
+
throw new Error("Watcher already started. Call stop() before starting again.");
|
|
937
|
+
}
|
|
938
|
+
this.watcher = watch(this.projectRoot, {
|
|
939
|
+
ignored: this.ignored,
|
|
940
|
+
persistent: true,
|
|
941
|
+
ignoreInitial: true
|
|
942
|
+
});
|
|
943
|
+
this.watcher.on("add", (path) => handler("add", path));
|
|
944
|
+
this.watcher.on("change", (path) => handler("change", path));
|
|
945
|
+
this.watcher.on("unlink", (path) => handler("unlink", path));
|
|
946
|
+
}
|
|
947
|
+
async stop() {
|
|
948
|
+
if (this.watcher) {
|
|
949
|
+
await this.watcher.close();
|
|
950
|
+
this.watcher = void 0;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// src/cli/watch-command.ts
|
|
956
|
+
var SUPPORTED_EXTENSIONS2 = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
957
|
+
var DEBOUNCE_MS = 300;
|
|
958
|
+
var WatchCommand = class {
|
|
959
|
+
projectRoot;
|
|
960
|
+
ctxoRoot;
|
|
961
|
+
constructor(projectRoot) {
|
|
962
|
+
this.projectRoot = projectRoot;
|
|
963
|
+
this.ctxoRoot = join9(projectRoot, ".ctxo");
|
|
964
|
+
}
|
|
965
|
+
async run() {
|
|
966
|
+
console.error("[ctxo] Starting file watcher...");
|
|
967
|
+
const registry = new LanguageAdapterRegistry();
|
|
968
|
+
registry.register(new TsMorphAdapter());
|
|
969
|
+
const writer = new JsonIndexWriter(this.ctxoRoot);
|
|
970
|
+
const storage = new SqliteStorageAdapter(this.ctxoRoot);
|
|
971
|
+
await storage.init();
|
|
972
|
+
const hasher = new ContentHasher();
|
|
973
|
+
const gitAdapter = new SimpleGitAdapter(this.projectRoot);
|
|
974
|
+
const revertDetector = new RevertDetector();
|
|
975
|
+
const watcher = new ChokidarWatcherAdapter(this.projectRoot);
|
|
976
|
+
const pendingFiles = /* @__PURE__ */ new Map();
|
|
977
|
+
const reindexFile = async (filePath) => {
|
|
978
|
+
const ext = extname4(filePath).toLowerCase();
|
|
979
|
+
if (!SUPPORTED_EXTENSIONS2.has(ext)) return;
|
|
980
|
+
const adapter = registry.getAdapter(filePath);
|
|
981
|
+
if (!adapter) return;
|
|
982
|
+
const relativePath = relative2(this.projectRoot, filePath).replace(/\\/g, "/");
|
|
983
|
+
try {
|
|
984
|
+
const source = readFileSync4(filePath, "utf-8");
|
|
985
|
+
const lastModified = Math.floor(Date.now() / 1e3);
|
|
986
|
+
const symbols = adapter.extractSymbols(relativePath, source);
|
|
987
|
+
const edges = adapter.extractEdges(relativePath, source);
|
|
988
|
+
const complexity = adapter.extractComplexity(relativePath, source);
|
|
989
|
+
const commits = await gitAdapter.getCommitHistory(relativePath);
|
|
990
|
+
const intent = commits.map((c) => ({
|
|
991
|
+
hash: c.hash,
|
|
992
|
+
message: c.message,
|
|
993
|
+
date: c.date,
|
|
994
|
+
kind: "commit"
|
|
995
|
+
}));
|
|
996
|
+
const antiPatterns = revertDetector.detect(commits);
|
|
997
|
+
const fileIndex = {
|
|
998
|
+
file: relativePath,
|
|
999
|
+
lastModified,
|
|
1000
|
+
contentHash: hasher.hash(source),
|
|
1001
|
+
symbols,
|
|
1002
|
+
edges,
|
|
1003
|
+
complexity,
|
|
1004
|
+
intent,
|
|
1005
|
+
antiPatterns
|
|
1006
|
+
};
|
|
1007
|
+
writer.write(fileIndex);
|
|
1008
|
+
storage.writeSymbolFile(fileIndex);
|
|
1009
|
+
console.error(`[ctxo] Re-indexed: ${relativePath}`);
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
console.error(`[ctxo] Failed to re-index ${relativePath}: ${err.message}`);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const debouncedReindex = (filePath) => {
|
|
1015
|
+
const existing = pendingFiles.get(filePath);
|
|
1016
|
+
if (existing) clearTimeout(existing);
|
|
1017
|
+
pendingFiles.set(
|
|
1018
|
+
filePath,
|
|
1019
|
+
setTimeout(() => {
|
|
1020
|
+
pendingFiles.delete(filePath);
|
|
1021
|
+
reindexFile(filePath);
|
|
1022
|
+
}, DEBOUNCE_MS)
|
|
1023
|
+
);
|
|
1024
|
+
};
|
|
1025
|
+
watcher.start((event, filePath) => {
|
|
1026
|
+
if (event === "unlink") {
|
|
1027
|
+
const ext = extname4(filePath).toLowerCase();
|
|
1028
|
+
if (!SUPPORTED_EXTENSIONS2.has(ext)) return;
|
|
1029
|
+
const relativePath = relative2(this.projectRoot, filePath).replace(/\\/g, "/");
|
|
1030
|
+
writer.delete(relativePath);
|
|
1031
|
+
storage.deleteSymbolFile(relativePath);
|
|
1032
|
+
console.error(`[ctxo] Removed from index: ${relativePath}`);
|
|
1033
|
+
} else {
|
|
1034
|
+
debouncedReindex(filePath);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
console.error("[ctxo] Watching for file changes... (Ctrl+C to stop)");
|
|
1038
|
+
const cleanup = () => {
|
|
1039
|
+
console.error("\n[ctxo] Stopping watcher...");
|
|
1040
|
+
for (const timeout of pendingFiles.values()) {
|
|
1041
|
+
clearTimeout(timeout);
|
|
1042
|
+
}
|
|
1043
|
+
watcher.stop().then(() => {
|
|
1044
|
+
storage.close();
|
|
1045
|
+
console.error("[ctxo] Watcher stopped");
|
|
1046
|
+
process.exit(0);
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
process.on("SIGINT", cleanup);
|
|
1050
|
+
process.on("SIGTERM", cleanup);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
// src/cli/cli-router.ts
|
|
1055
|
+
var CliRouter = class {
|
|
1056
|
+
projectRoot;
|
|
1057
|
+
constructor(projectRoot) {
|
|
1058
|
+
this.projectRoot = projectRoot;
|
|
1059
|
+
}
|
|
1060
|
+
async route(args) {
|
|
1061
|
+
const command = args[0];
|
|
1062
|
+
if (!command || command === "--help" || command === "-h") {
|
|
1063
|
+
this.printHelp();
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
switch (command) {
|
|
1067
|
+
case "index": {
|
|
1068
|
+
const fileIdx = args.indexOf("--file");
|
|
1069
|
+
const fileArg = fileIdx !== -1 ? args[fileIdx + 1] : void 0;
|
|
1070
|
+
if (fileIdx !== -1 && (!fileArg || fileArg.startsWith("--"))) {
|
|
1071
|
+
console.error("[ctxo] --file requires a path argument");
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
const checkArg = args.includes("--check");
|
|
1075
|
+
const skipHistory = args.includes("--skip-history");
|
|
1076
|
+
await new IndexCommand(this.projectRoot).run({ file: fileArg, check: checkArg, skipHistory });
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case "sync":
|
|
1080
|
+
await new SyncCommand(this.projectRoot).run();
|
|
1081
|
+
break;
|
|
1082
|
+
case "watch":
|
|
1083
|
+
await new WatchCommand(this.projectRoot).run();
|
|
1084
|
+
break;
|
|
1085
|
+
case "verify-index":
|
|
1086
|
+
await new VerifyCommand(this.projectRoot).run();
|
|
1087
|
+
break;
|
|
1088
|
+
case "status":
|
|
1089
|
+
new StatusCommand(this.projectRoot).run();
|
|
1090
|
+
break;
|
|
1091
|
+
case "init":
|
|
1092
|
+
new InitCommand(this.projectRoot).run();
|
|
1093
|
+
break;
|
|
1094
|
+
default:
|
|
1095
|
+
console.error(`[ctxo] Unknown command: "${command}". Run "ctxo --help" for usage.`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
printHelp() {
|
|
1100
|
+
console.error(`
|
|
1101
|
+
ctxo \u2014 MCP server for dependency-aware codebase context
|
|
1102
|
+
|
|
1103
|
+
Usage:
|
|
1104
|
+
ctxo Start MCP server (stdio transport)
|
|
1105
|
+
ctxo index Build full codebase index
|
|
1106
|
+
ctxo sync Rebuild SQLite cache from committed JSON index
|
|
1107
|
+
ctxo watch Start file watcher for incremental re-indexing
|
|
1108
|
+
ctxo verify-index CI gate: fail if index is stale
|
|
1109
|
+
ctxo status Show index manifest
|
|
1110
|
+
ctxo init Install git hooks
|
|
1111
|
+
ctxo --help Show this help message
|
|
1112
|
+
`.trim());
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
export {
|
|
1116
|
+
CliRouter
|
|
1117
|
+
};
|
|
1118
|
+
//# sourceMappingURL=cli-router-PIWHLS5F.js.map
|