septum 0.1.5 → 0.1.7
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/README.md +23 -3
- package/package.json +5 -5
- package/src/cli/commands/check.ts +32 -122
- package/src/cli/commands/hook.ts +45 -12
- package/src/cli/commands/init.ts +6 -4
- package/src/cli/commands/query.ts +45 -5
- package/src/cli/commands/slice.ts +4 -2
- package/src/cli/index.ts +17 -6
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +35 -23
- package/src/cli/templates/skill.ts +47 -51
- package/src/core/boundary/evaluator.ts +217 -24
- package/src/core/boundary/staged-auditor.ts +47 -0
- package/src/core/config/loader.ts +29 -12
- package/src/core/database/repositories/dependency.repository.ts +252 -66
- package/src/core/database/repositories/domain.repository.ts +96 -39
- package/src/core/database/repositories/file.repository.ts +20 -19
- package/src/core/database/repositories/symbol.repository.ts +115 -5
- package/src/core/database/repository.ts +19 -2
- package/src/core/database/schema.ts +3 -2
- package/src/core/ingestion/pipeline.ts +96 -60
- package/src/core/parser/boundary-tracker.ts +145 -0
- package/src/core/parser/extractors/frontend.ts +44 -90
- package/src/core/parser/extractors/go.ts +200 -21
- package/src/core/parser/extractors/laravel-semantic.ts +18 -12
- package/src/core/parser/extractors/mcp-cli-semantic.ts +173 -44
- package/src/core/parser/extractors/nestjs-semantic.ts +20 -5
- package/src/core/parser/extractors/php.ts +293 -31
- package/src/core/parser/extractors/python.ts +242 -10
- package/src/core/parser/extractors/typescript.ts +604 -117
- package/src/core/parser/tree-sitter.ts +6 -1
- package/src/core/parser/wasm-assets.ts +17 -0
- package/src/core/parser/wasm-loader.ts +274 -0
- package/src/core/resolver/call-graph-tracer.ts +49 -17
- package/src/core/resolver/module-resolver.ts +88 -26
- package/src/core/resolver/vertical-slice-tracer.ts +34 -11
- package/src/core/session/session-manager.ts +55 -39
- package/src/core/topology/environment-detector.ts +109 -6
- package/src/mcp/schemas.ts +146 -29
- package/src/mcp/server.ts +177 -114
- package/src/mcp/tools/check-boundary.ts +159 -41
- package/src/mcp/tools/get-domain-catalog.ts +30 -15
- package/src/mcp/tools/get-environment-topology.ts +3 -6
- package/src/mcp/tools/get-feature-context.ts +35 -20
- package/src/mcp/tools/get-symbol-hotspots.ts +4 -10
- package/src/mcp/tools/locate-symbol.ts +37 -16
- package/src/mcp/tools/register-domain.ts +3 -12
- package/src/mcp/tools/register-feature.ts +25 -32
- package/src/mcp/tools/trace-vertical-slice.ts +8 -8
- package/src/types/index.ts +56 -6
- package/src/types/wasm.d.ts +4 -0
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { dirname, join, posix } from "node:path";
|
|
2
3
|
import type { Database } from "bun:sqlite";
|
|
3
4
|
import type {
|
|
4
5
|
ExtractedDependency,
|
|
6
|
+
FileConnectionsSummary,
|
|
5
7
|
GetSymbolImpactResponse,
|
|
6
8
|
InboundCaller,
|
|
7
9
|
VerticalSliceRecord,
|
|
8
10
|
} from "../../../types/index.ts";
|
|
9
11
|
|
|
12
|
+
function escapeRegExp(str: string): string {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export class DependencyRepository {
|
|
11
17
|
constructor(private db: Database) {}
|
|
12
18
|
|
|
@@ -103,6 +109,13 @@ export class DependencyRepository {
|
|
|
103
109
|
return this.db.query<VerticalSliceRecord, []>("SELECT * FROM vertical_slices ORDER BY id ASC").all();
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
public countVerticalSlices(): number {
|
|
113
|
+
const row = this.db
|
|
114
|
+
.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM vertical_slices")
|
|
115
|
+
.get();
|
|
116
|
+
return row?.cnt ?? 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
public clearVerticalSlices(domainId?: number): void {
|
|
107
120
|
if (domainId) {
|
|
108
121
|
this.db.query("DELETE FROM vertical_slices WHERE domain_id = ?").run(domainId);
|
|
@@ -135,6 +148,107 @@ export class DependencyRepository {
|
|
|
135
148
|
return this.db.query<any, []>(query).all();
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
public getDomainFileConnections(domainId?: number): Map<string, FileConnectionsSummary> {
|
|
152
|
+
const allFiles = this.db
|
|
153
|
+
.query<{ id: number; domain_id: number; path: string }, []>(
|
|
154
|
+
"SELECT id, domain_id, path FROM files ORDER BY path ASC"
|
|
155
|
+
)
|
|
156
|
+
.all();
|
|
157
|
+
|
|
158
|
+
const fileById = new Map<number, { id: number; domain_id: number; path: string }>();
|
|
159
|
+
const fileByNormPath = new Map<string, string>();
|
|
160
|
+
const fileByNoExtPath = new Map<string, string>();
|
|
161
|
+
|
|
162
|
+
for (const f of allFiles) {
|
|
163
|
+
const norm = f.path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
164
|
+
fileById.set(f.id, { ...f, path: norm });
|
|
165
|
+
fileByNormPath.set(norm.toLowerCase(), f.path);
|
|
166
|
+
const noExt = norm.replace(/\.[^/.]+$/, "").toLowerCase();
|
|
167
|
+
fileByNoExtPath.set(noExt, f.path);
|
|
168
|
+
if (noExt.endsWith("/index")) {
|
|
169
|
+
fileByNoExtPath.set(noExt.slice(0, -"/index".length), f.path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resolveInternalTarget = (sourcePath: string, rawTarget: string): string | null => {
|
|
174
|
+
const cleaned = rawTarget.trim().replace(/^['"]|['"]$/g, "").replace(/\\/g, "/");
|
|
175
|
+
if (!cleaned) return null;
|
|
176
|
+
|
|
177
|
+
if (cleaned.startsWith(".")) {
|
|
178
|
+
const srcDir = posix.dirname(sourcePath.replace(/\\/g, "/"));
|
|
179
|
+
const joined = posix.normalize(posix.join(srcDir, cleaned)).replace(/^\.\//, "");
|
|
180
|
+
const lowerJoined = joined.toLowerCase();
|
|
181
|
+
const exact = fileByNormPath.get(lowerJoined) ?? fileByNoExtPath.get(lowerJoined.replace(/\.[^/.]+$/, ""));
|
|
182
|
+
if (exact) return exact;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const aliasClean = cleaned.replace(/^@\//, "src/").replace(/^\.\//, "");
|
|
186
|
+
const lowerAlias = aliasClean.toLowerCase();
|
|
187
|
+
const byAlias = fileByNormPath.get(lowerAlias) ?? fileByNoExtPath.get(lowerAlias.replace(/\.[^/.]+$/, ""));
|
|
188
|
+
if (byAlias) return byAlias;
|
|
189
|
+
|
|
190
|
+
for (const [noExt, origPath] of fileByNoExtPath.entries()) {
|
|
191
|
+
if (noExt.endsWith(`/${lowerAlias}`)) {
|
|
192
|
+
return origPath;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const importsFromMap = new Map<string, Set<string>>();
|
|
199
|
+
const importedByMap = new Map<string, Set<string>>();
|
|
200
|
+
for (const f of allFiles) {
|
|
201
|
+
importsFromMap.set(f.path, new Set());
|
|
202
|
+
importedByMap.set(f.path, new Set());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const allInternalDeps = this.db
|
|
206
|
+
.query<{ source_file_id: number; target_symbol_or_path: string }, []>(
|
|
207
|
+
"SELECT source_file_id, target_symbol_or_path FROM dependencies WHERE is_external = 0"
|
|
208
|
+
)
|
|
209
|
+
.all();
|
|
210
|
+
|
|
211
|
+
for (const dep of allInternalDeps) {
|
|
212
|
+
const srcFile = fileById.get(dep.source_file_id);
|
|
213
|
+
if (!srcFile) continue;
|
|
214
|
+
const resolvedTarget = resolveInternalTarget(srcFile.path, dep.target_symbol_or_path);
|
|
215
|
+
if (resolvedTarget && resolvedTarget !== srcFile.path) {
|
|
216
|
+
importsFromMap.get(srcFile.path)?.add(resolvedTarget);
|
|
217
|
+
if (!importedByMap.has(resolvedTarget)) {
|
|
218
|
+
importedByMap.set(resolvedTarget, new Set());
|
|
219
|
+
}
|
|
220
|
+
importedByMap.get(resolvedTarget)?.add(srcFile.path);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = new Map<string, FileConnectionsSummary>();
|
|
225
|
+
for (const f of allFiles) {
|
|
226
|
+
if (domainId !== undefined && f.domain_id !== domainId) continue;
|
|
227
|
+
const importsFrom = Array.from(importsFromMap.get(f.path) ?? []).sort();
|
|
228
|
+
const importedBy = Array.from(importedByMap.get(f.path) ?? []).sort();
|
|
229
|
+
result.set(f.path, {
|
|
230
|
+
imports_from: importsFrom,
|
|
231
|
+
imported_by_count: importedBy.length,
|
|
232
|
+
imported_by: importedBy,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public getFileConnections(filePath: string): FileConnectionsSummary {
|
|
240
|
+
const clean = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
241
|
+
const allConns = this.getDomainFileConnections();
|
|
242
|
+
return (
|
|
243
|
+
allConns.get(clean) ??
|
|
244
|
+
allConns.get(filePath) ?? {
|
|
245
|
+
imports_from: [],
|
|
246
|
+
imported_by_count: 0,
|
|
247
|
+
imported_by: [],
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
138
252
|
public getSymbolImpact(symbolQuery: string): GetSymbolImpactResponse {
|
|
139
253
|
const trimmed = symbolQuery.trim();
|
|
140
254
|
if (!trimmed) {
|
|
@@ -144,7 +258,7 @@ export class DependencyRepository {
|
|
|
144
258
|
warning: "Empty symbol query provided.",
|
|
145
259
|
impact_summary: {
|
|
146
260
|
total_dependents: 0,
|
|
147
|
-
risk_level: "
|
|
261
|
+
risk_level: "unknown",
|
|
148
262
|
affected_files_count: 0,
|
|
149
263
|
},
|
|
150
264
|
inbound_callers: [],
|
|
@@ -163,38 +277,126 @@ export class DependencyRepository {
|
|
|
163
277
|
targetMethod = parts[1];
|
|
164
278
|
}
|
|
165
279
|
|
|
166
|
-
// 1.
|
|
280
|
+
// 1. Look up target symbol in symbols joined with files to find its defining file_path & stem
|
|
281
|
+
const targetSymRows = this.db
|
|
282
|
+
.query<{ name: string; file_path: string }, [string, string, string, string]>(
|
|
283
|
+
`SELECT s.name, f.path AS file_path
|
|
284
|
+
FROM symbols s
|
|
285
|
+
JOIN files f ON s.file_id = f.id
|
|
286
|
+
WHERE s.name = ? OR s.name = ? OR s.name LIKE ? OR s.name LIKE ?`
|
|
287
|
+
)
|
|
288
|
+
.all(trimmed, targetContainer, `${targetContainer}::%`, `%::${targetContainer}`);
|
|
289
|
+
|
|
290
|
+
const definingFiles = new Set<string>(
|
|
291
|
+
targetSymRows.map((r) => r.file_path.replace(/\\/g, "/"))
|
|
292
|
+
);
|
|
293
|
+
const definingFileStems = Array.from(
|
|
294
|
+
new Set(
|
|
295
|
+
Array.from(definingFiles)
|
|
296
|
+
.map((p) => p.split("/").pop()?.replace(/\.[^.]+$/, "") || "")
|
|
297
|
+
.filter((s) => s.length >= 2)
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// 2. Query dependencies matching targetContainer, trimmed, import_statement, or defining file stem
|
|
302
|
+
const whereClauses: string[] = [
|
|
303
|
+
"d.target_symbol_or_path LIKE ?",
|
|
304
|
+
"d.import_statement LIKE ?",
|
|
305
|
+
"d.target_symbol_or_path LIKE ?",
|
|
306
|
+
];
|
|
307
|
+
const queryParams: string[] = [
|
|
308
|
+
`%${targetContainer}%`,
|
|
309
|
+
`%${targetContainer}%`,
|
|
310
|
+
`%${trimmed}%`,
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
for (const stem of definingFileStems) {
|
|
314
|
+
whereClauses.push("d.target_symbol_or_path LIKE ?");
|
|
315
|
+
queryParams.push(`%${stem}%`);
|
|
316
|
+
}
|
|
317
|
+
|
|
167
318
|
const depSql = `
|
|
168
319
|
SELECT
|
|
169
320
|
f.path AS file,
|
|
170
321
|
d.line_number,
|
|
171
322
|
d.target_symbol_or_path,
|
|
323
|
+
d.import_statement,
|
|
172
324
|
dom.name AS domain_name
|
|
173
325
|
FROM dependencies d
|
|
174
326
|
JOIN files f ON d.source_file_id = f.id
|
|
175
|
-
JOIN domains dom ON f.domain_id = dom.id
|
|
176
|
-
WHERE
|
|
177
|
-
OR d.target_symbol_or_path LIKE ?
|
|
327
|
+
LEFT JOIN domains dom ON f.domain_id = dom.id
|
|
328
|
+
WHERE ${whereClauses.join(" OR ")}
|
|
178
329
|
ORDER BY f.path ASC, d.line_number ASC
|
|
179
330
|
`;
|
|
180
|
-
const
|
|
181
|
-
.query<any, [string, string]>(depSql)
|
|
182
|
-
.all(`%${targetContainer}%`, `%${trimmed}%`);
|
|
331
|
+
const rawDepRows = this.db.query<any, string[]>(depSql).all(...queryParams);
|
|
183
332
|
|
|
184
|
-
const
|
|
333
|
+
const containerWordRegex = new RegExp(`\\b${escapeRegExp(targetContainer)}\\b`);
|
|
334
|
+
const depRows = rawDepRows.filter((row) => {
|
|
335
|
+
const normCallerFile = String(row.file).replace(/\\/g, "/");
|
|
336
|
+
if (definingFiles.has(normCallerFile)) return false;
|
|
185
337
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
338
|
+
const stmt = String(row.import_statement || "");
|
|
339
|
+
const target = String(row.target_symbol_or_path || "");
|
|
340
|
+
|
|
341
|
+
if (containerWordRegex.test(stmt) || containerWordRegex.test(target)) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// If no named specifier braces are present, accept path match against defining file stem
|
|
346
|
+
if (!stmt.includes("{")) {
|
|
347
|
+
for (const stem of definingFileStems) {
|
|
348
|
+
const stemRegex = new RegExp(`(?:^|[\\\\/])${escapeRegExp(stem)}(?:\\.[^\\\\/]+)?$`);
|
|
349
|
+
if (stemRegex.test(target)) {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return false;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// 3. Batch-load all symbols for candidate caller files in ONE query (zero N+1)
|
|
359
|
+
const uniqueCallerFiles = Array.from(new Set(depRows.map((r) => String(r.file))));
|
|
360
|
+
const callerSymbolsByFile = new Map<
|
|
361
|
+
string,
|
|
362
|
+
Array<{ name: string; kind: string; line_start: number; line_end: number }>
|
|
363
|
+
>();
|
|
364
|
+
|
|
365
|
+
if (uniqueCallerFiles.length > 0) {
|
|
366
|
+
const placeholders = uniqueCallerFiles.map(() => "?").join(",");
|
|
367
|
+
const allCallerSyms = this.db
|
|
368
|
+
.query<
|
|
369
|
+
{ name: string; kind: string; line_start: number; line_end: number; path: string },
|
|
370
|
+
string[]
|
|
371
|
+
>(
|
|
372
|
+
`SELECT s.name, s.kind, s.line_start, s.line_end, f.path
|
|
191
373
|
FROM symbols s
|
|
192
374
|
JOIN files f ON s.file_id = f.id
|
|
193
|
-
WHERE f.path
|
|
194
|
-
ORDER BY (s.line_end - s.line_start) ASC
|
|
375
|
+
WHERE f.path IN (${placeholders})
|
|
376
|
+
ORDER BY (s.line_end - s.line_start) ASC`
|
|
195
377
|
)
|
|
196
|
-
.
|
|
378
|
+
.all(...uniqueCallerFiles);
|
|
197
379
|
|
|
380
|
+
for (const sym of allCallerSyms) {
|
|
381
|
+
const list = callerSymbolsByFile.get(sym.path) || [];
|
|
382
|
+
list.push(sym);
|
|
383
|
+
callerSymbolsByFile.set(sym.path, list);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const findEnclosingSymbol = (
|
|
388
|
+
filePath: string,
|
|
389
|
+
lineNum: number
|
|
390
|
+
): { name: string; kind: string } | undefined => {
|
|
391
|
+
const syms = callerSymbolsByFile.get(filePath);
|
|
392
|
+
if (!syms) return undefined;
|
|
393
|
+
return syms.find((s) => lineNum >= s.line_start && lineNum <= s.line_end);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const callersMap = new Map<string, InboundCaller>();
|
|
397
|
+
|
|
398
|
+
for (const row of depRows) {
|
|
399
|
+
const enclosing = findEnclosingSymbol(row.file, row.line_number);
|
|
198
400
|
const callerName = enclosing ? enclosing.name : `${row.file}:${row.line_number}`;
|
|
199
401
|
const key = `${row.file}:${row.line_number}:${callerName}`;
|
|
200
402
|
|
|
@@ -204,16 +406,14 @@ export class DependencyRepository {
|
|
|
204
406
|
caller: callerName,
|
|
205
407
|
line: row.line_number,
|
|
206
408
|
kind: enclosing?.kind || "import",
|
|
207
|
-
domain: row.domain_name,
|
|
409
|
+
domain: row.domain_name ?? undefined,
|
|
208
410
|
});
|
|
209
411
|
}
|
|
210
412
|
}
|
|
211
413
|
|
|
212
|
-
//
|
|
414
|
+
// 4. Scan file content of callers for exact method calls if targetMethod specified
|
|
213
415
|
if (targetMethod) {
|
|
214
|
-
|
|
215
|
-
const uniqueFiles = Array.from(new Set(depRows.map((r) => r.file)));
|
|
216
|
-
for (const filePath of uniqueFiles) {
|
|
416
|
+
for (const filePath of uniqueCallerFiles) {
|
|
217
417
|
try {
|
|
218
418
|
if (fs.existsSync(filePath)) {
|
|
219
419
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
@@ -222,19 +422,11 @@ export class DependencyRepository {
|
|
|
222
422
|
const line = lines[i];
|
|
223
423
|
if (
|
|
224
424
|
line.includes(`->${targetMethod}(`) ||
|
|
225
|
-
line.includes(`::${targetMethod}(`)
|
|
425
|
+
line.includes(`::${targetMethod}(`) ||
|
|
426
|
+
line.includes(`.${targetMethod}(`)
|
|
226
427
|
) {
|
|
227
428
|
const lineNum = i + 1;
|
|
228
|
-
const enclosing =
|
|
229
|
-
.query<{ name: string; kind: string }, [string, number]>(
|
|
230
|
-
`SELECT s.name, s.kind
|
|
231
|
-
FROM symbols s
|
|
232
|
-
JOIN files f ON s.file_id = f.id
|
|
233
|
-
WHERE f.path = ? AND ? BETWEEN s.line_start AND s.line_end
|
|
234
|
-
ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
|
|
235
|
-
)
|
|
236
|
-
.get(filePath, lineNum);
|
|
237
|
-
|
|
429
|
+
const enclosing = findEnclosingSymbol(filePath, lineNum);
|
|
238
430
|
const callerName = enclosing ? enclosing.name : `${filePath}:${lineNum}`;
|
|
239
431
|
const key = `${filePath}:${lineNum}:${callerName}`;
|
|
240
432
|
if (!callersMap.has(key)) {
|
|
@@ -267,18 +459,11 @@ export class DependencyRepository {
|
|
|
267
459
|
riskLevel = "medium";
|
|
268
460
|
}
|
|
269
461
|
|
|
270
|
-
//
|
|
271
|
-
const
|
|
272
|
-
.query<{ count: number }, [string, string, string]>(
|
|
273
|
-
`SELECT COUNT(*) as count FROM symbols
|
|
274
|
-
WHERE name = ? OR name = ? OR name = ? LIMIT 1`
|
|
275
|
-
)
|
|
276
|
-
.get(trimmed, targetContainer, targetMethod || trimmed)?.count ?? 0;
|
|
277
|
-
|
|
278
|
-
const foundInIndex = symbolInDb > 0 || depRows.length > 0;
|
|
462
|
+
// 5. Determine if symbol exists in index
|
|
463
|
+
const foundInIndex = targetSymRows.length > 0 || depRows.length > 0;
|
|
279
464
|
const warning = foundInIndex
|
|
280
465
|
? undefined
|
|
281
|
-
: `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk
|
|
466
|
+
: `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk cannot be assessed without index evidence.`;
|
|
282
467
|
|
|
283
468
|
return {
|
|
284
469
|
target_symbol: trimmed,
|
|
@@ -286,7 +471,7 @@ export class DependencyRepository {
|
|
|
286
471
|
warning,
|
|
287
472
|
impact_summary: {
|
|
288
473
|
total_dependents: inboundCallers.length,
|
|
289
|
-
risk_level: riskLevel,
|
|
474
|
+
risk_level: foundInIndex ? riskLevel : "unknown",
|
|
290
475
|
affected_files_count: affectedFiles.size,
|
|
291
476
|
},
|
|
292
477
|
inbound_callers: inboundCallers,
|
|
@@ -312,42 +497,43 @@ export class DependencyRepository {
|
|
|
312
497
|
.all(cleanPath, `%${cleanPath}`);
|
|
313
498
|
|
|
314
499
|
const matchedFiles = new Set<string>();
|
|
500
|
+
const orConditions: string[] = [];
|
|
501
|
+
const params: string[] = [];
|
|
315
502
|
|
|
316
503
|
if (baseName.length >= 3) {
|
|
504
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
505
|
+
params.push(`%${cleanPath}%`);
|
|
506
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
507
|
+
params.push(`%${baseName}%`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const sym of symbols.slice(0, 8)) {
|
|
511
|
+
const symName = sym.name.split("::").pop() || sym.name;
|
|
512
|
+
if (symName.length >= 4) {
|
|
513
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
514
|
+
params.push(`%${symName}%`);
|
|
515
|
+
orConditions.push("d.import_statement LIKE ?");
|
|
516
|
+
params.push(`%${symName}%`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (orConditions.length > 0) {
|
|
521
|
+
params.push(`%${cleanPath}`);
|
|
317
522
|
const depRows = this.db
|
|
318
|
-
.query<{ file: string }, [
|
|
523
|
+
.query<{ file: string }, string[]>(
|
|
319
524
|
`SELECT DISTINCT f.path AS file
|
|
320
525
|
FROM dependencies d
|
|
321
526
|
JOIN files f ON d.source_file_id = f.id
|
|
322
|
-
WHERE (
|
|
527
|
+
WHERE (${orConditions.join(" OR ")})
|
|
323
528
|
AND f.path NOT LIKE ?`
|
|
324
529
|
)
|
|
325
|
-
.all(
|
|
530
|
+
.all(...params);
|
|
326
531
|
|
|
327
532
|
for (const row of depRows) {
|
|
328
533
|
matchedFiles.add(row.file);
|
|
329
534
|
}
|
|
330
535
|
}
|
|
331
536
|
|
|
332
|
-
for (const sym of symbols.slice(0, 8)) {
|
|
333
|
-
const symName = sym.name.split("::").pop() || sym.name;
|
|
334
|
-
if (symName.length >= 4) {
|
|
335
|
-
const symDeps = this.db
|
|
336
|
-
.query<{ file: string }, [string, string]>(
|
|
337
|
-
`SELECT DISTINCT f.path AS file
|
|
338
|
-
FROM dependencies d
|
|
339
|
-
JOIN files f ON d.source_file_id = f.id
|
|
340
|
-
WHERE d.target_symbol_or_path LIKE ?
|
|
341
|
-
AND f.path NOT LIKE ?
|
|
342
|
-
LIMIT 10`
|
|
343
|
-
)
|
|
344
|
-
.all(`%${symName}%`, `%${cleanPath}`);
|
|
345
|
-
for (const r of symDeps) {
|
|
346
|
-
matchedFiles.add(r.file);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
537
|
const consumerList = Array.from(matchedFiles);
|
|
352
538
|
let riskLevel: "low" | "medium" | "high" = "low";
|
|
353
539
|
if (consumerList.length >= 5) {
|
|
@@ -7,6 +7,15 @@ import type {
|
|
|
7
7
|
SymbolKind,
|
|
8
8
|
SymbolRecord,
|
|
9
9
|
} from "../../../types/index.ts";
|
|
10
|
+
import { DependencyRepository } from "./dependency.repository.ts";
|
|
11
|
+
|
|
12
|
+
const CONTAINER_KINDS = new Set<SymbolKind>([
|
|
13
|
+
"class",
|
|
14
|
+
"interface",
|
|
15
|
+
"trait",
|
|
16
|
+
"struct",
|
|
17
|
+
"enum",
|
|
18
|
+
]);
|
|
10
19
|
|
|
11
20
|
export class DomainRepository {
|
|
12
21
|
constructor(private db: Database) {}
|
|
@@ -70,11 +79,15 @@ export class DomainRepository {
|
|
|
70
79
|
|
|
71
80
|
public getDomainCatalog(
|
|
72
81
|
domainName: string,
|
|
73
|
-
archetypeFilter?: string
|
|
82
|
+
archetypeFilter?: string,
|
|
83
|
+
options?: { detailLevel?: "compact" | "full"; fileFilter?: string }
|
|
74
84
|
): DomainCatalogResponse | null {
|
|
75
85
|
const domain = this.getDomainByName(domainName);
|
|
76
86
|
if (!domain) return null;
|
|
77
87
|
|
|
88
|
+
const detailLevel = options?.detailLevel ?? "compact";
|
|
89
|
+
const fileFilter = options?.fileFilter?.trim();
|
|
90
|
+
|
|
78
91
|
const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
|
|
79
92
|
const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
|
|
80
93
|
|
|
@@ -87,7 +100,14 @@ export class DomainRepository {
|
|
|
87
100
|
}
|
|
88
101
|
filesQuery += " ORDER BY path ASC";
|
|
89
102
|
|
|
90
|
-
const
|
|
103
|
+
const allDomainFiles = this.db.query<FileRecord, any>(filesQuery).all(...params);
|
|
104
|
+
const totalFiles = allDomainFiles.length;
|
|
105
|
+
|
|
106
|
+
const files = fileFilter
|
|
107
|
+
? allDomainFiles.filter((f) =>
|
|
108
|
+
f.path.replace(/\\/g, "/").toLowerCase().includes(fileFilter.replace(/\\/g, "/").toLowerCase())
|
|
109
|
+
)
|
|
110
|
+
: allDomainFiles;
|
|
91
111
|
|
|
92
112
|
const catalogFiles: DomainCatalogResponse["files"] = [];
|
|
93
113
|
|
|
@@ -115,6 +135,10 @@ export class DomainRepository {
|
|
|
115
135
|
}
|
|
116
136
|
}
|
|
117
137
|
|
|
138
|
+
// Batch compute file connections for the domain (zero N+1)
|
|
139
|
+
const depRepo = new DependencyRepository(this.db);
|
|
140
|
+
const connectionsByPath = depRepo.getDomainFileConnections(domain.id);
|
|
141
|
+
|
|
118
142
|
for (const file of files) {
|
|
119
143
|
const symbols = symbolsByFileId.get(file.id) ?? [];
|
|
120
144
|
|
|
@@ -123,6 +147,7 @@ export class DomainRepository {
|
|
|
123
147
|
{
|
|
124
148
|
name: string;
|
|
125
149
|
kind: SymbolKind;
|
|
150
|
+
lineSpan: string;
|
|
126
151
|
properties: string[];
|
|
127
152
|
methods: string[];
|
|
128
153
|
signatures: string[];
|
|
@@ -130,63 +155,66 @@ export class DomainRepository {
|
|
|
130
155
|
>();
|
|
131
156
|
|
|
132
157
|
for (const sym of symbols) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
sym.kind === "interface" ||
|
|
136
|
-
sym.kind === "trait" ||
|
|
137
|
-
sym.kind === "enum"
|
|
138
|
-
) {
|
|
158
|
+
const lineSpan = `${sym.line_start}-${sym.line_end}`;
|
|
159
|
+
if (CONTAINER_KINDS.has(sym.kind)) {
|
|
139
160
|
if (!symbolMap.has(sym.name)) {
|
|
140
161
|
symbolMap.set(sym.name, {
|
|
141
162
|
name: sym.name,
|
|
142
163
|
kind: sym.kind,
|
|
164
|
+
lineSpan,
|
|
143
165
|
properties: [],
|
|
144
166
|
methods: [],
|
|
145
167
|
signatures: [sym.signature],
|
|
146
168
|
});
|
|
147
169
|
}
|
|
148
|
-
} else if (sym.kind === "method") {
|
|
149
|
-
let targetContainer
|
|
170
|
+
} else if (sym.kind === "method" || sym.kind === "property") {
|
|
171
|
+
let targetContainer:
|
|
172
|
+
| {
|
|
173
|
+
name: string;
|
|
174
|
+
kind: SymbolKind;
|
|
175
|
+
lineSpan: string;
|
|
176
|
+
properties: string[];
|
|
177
|
+
methods: string[];
|
|
178
|
+
signatures: string[];
|
|
179
|
+
}
|
|
180
|
+
| undefined;
|
|
181
|
+
|
|
150
182
|
if (sym.name.includes("::")) {
|
|
151
183
|
const containerName = sym.name.split("::")[0];
|
|
152
|
-
|
|
153
|
-
|
|
184
|
+
const candidate = symbolMap.get(containerName);
|
|
185
|
+
if (candidate && CONTAINER_KINDS.has(candidate.kind)) {
|
|
186
|
+
targetContainer = candidate;
|
|
154
187
|
}
|
|
155
188
|
}
|
|
189
|
+
|
|
156
190
|
if (targetContainer) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
kind: sym.kind,
|
|
162
|
-
properties: [],
|
|
163
|
-
methods: [],
|
|
164
|
-
signatures: [sym.signature],
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
} else if (sym.kind === "property") {
|
|
168
|
-
let targetContainer = Array.from(symbolMap.values()).pop();
|
|
169
|
-
if (sym.name.includes("::")) {
|
|
170
|
-
const containerName = sym.name.split("::")[0];
|
|
171
|
-
if (symbolMap.has(containerName)) {
|
|
172
|
-
targetContainer = symbolMap.get(containerName);
|
|
191
|
+
if (sym.kind === "method") {
|
|
192
|
+
targetContainer.methods.push(sym.signature);
|
|
193
|
+
} else {
|
|
194
|
+
targetContainer.properties.push(sym.signature);
|
|
173
195
|
}
|
|
174
|
-
}
|
|
175
|
-
if (targetContainer) {
|
|
176
|
-
targetContainer.properties.push(sym.signature);
|
|
177
196
|
} else {
|
|
178
197
|
symbolMap.set(sym.name, {
|
|
179
198
|
name: sym.name,
|
|
180
199
|
kind: sym.kind,
|
|
181
|
-
|
|
200
|
+
lineSpan,
|
|
201
|
+
properties: sym.kind === "property" ? [sym.signature] : [],
|
|
182
202
|
methods: [],
|
|
183
203
|
signatures: [sym.signature],
|
|
184
204
|
});
|
|
185
205
|
}
|
|
186
206
|
} else {
|
|
207
|
+
if (
|
|
208
|
+
detailLevel === "compact" &&
|
|
209
|
+
(sym.kind === "variable" || sym.kind === "constant") &&
|
|
210
|
+
sym.visibility === "private"
|
|
211
|
+
) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
187
214
|
symbolMap.set(sym.name, {
|
|
188
215
|
name: sym.name,
|
|
189
216
|
kind: sym.kind,
|
|
217
|
+
lineSpan,
|
|
190
218
|
properties: [],
|
|
191
219
|
methods: [],
|
|
192
220
|
signatures: [sym.signature],
|
|
@@ -194,25 +222,54 @@ export class DomainRepository {
|
|
|
194
222
|
}
|
|
195
223
|
}
|
|
196
224
|
|
|
225
|
+
const rawConn = connectionsByPath.get(file.path);
|
|
226
|
+
const fileConnections = {
|
|
227
|
+
imports_from: rawConn?.imports_from ?? [],
|
|
228
|
+
imported_by_count: rawConn?.imported_by_count ?? 0,
|
|
229
|
+
};
|
|
230
|
+
|
|
197
231
|
catalogFiles.push({
|
|
198
232
|
path: file.path,
|
|
199
233
|
archetype: file.archetype,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
234
|
+
line_count: file.line_count,
|
|
235
|
+
connections: fileConnections,
|
|
236
|
+
symbols: Array.from(symbolMap.values()).map((s) => {
|
|
237
|
+
if (detailLevel === "compact") {
|
|
238
|
+
return {
|
|
239
|
+
name: s.name,
|
|
240
|
+
kind: s.kind,
|
|
241
|
+
line_span: s.lineSpan,
|
|
242
|
+
methods_count: s.methods.length > 0 ? s.methods.length : undefined,
|
|
243
|
+
properties_count: s.properties.length > 0 ? s.properties.length : undefined,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
name: s.name,
|
|
248
|
+
kind: s.kind,
|
|
249
|
+
line_span: s.lineSpan,
|
|
250
|
+
methods_count: s.methods.length > 0 ? s.methods.length : undefined,
|
|
251
|
+
properties_count: s.properties.length > 0 ? s.properties.length : undefined,
|
|
252
|
+
properties: s.properties.length > 0 ? s.properties : undefined,
|
|
253
|
+
methods: s.methods.length > 0 ? s.methods : undefined,
|
|
254
|
+
signatures: s.signatures.length > 0 ? s.signatures : undefined,
|
|
255
|
+
};
|
|
256
|
+
}),
|
|
207
257
|
});
|
|
208
258
|
}
|
|
209
259
|
|
|
210
260
|
return {
|
|
211
261
|
domain: domain.name,
|
|
212
262
|
root: domain.root_path,
|
|
263
|
+
detail_level: detailLevel,
|
|
264
|
+
total_files: totalFiles,
|
|
265
|
+
showing_files: catalogFiles.length,
|
|
213
266
|
allowed_dependencies: allowedDeps,
|
|
214
267
|
forbidden_dependencies: forbiddenDeps,
|
|
215
268
|
files: catalogFiles,
|
|
269
|
+
agent_hint:
|
|
270
|
+
detailLevel === "compact"
|
|
271
|
+
? "Compact view (<400 tokens). Use septum_get_symbol({ symbol }) for exact method signatures, or septum_get_domain_catalog({ domain, detail_level: 'full', file_filter: '<path>' }) for full member lists."
|
|
272
|
+
: undefined,
|
|
216
273
|
};
|
|
217
274
|
}
|
|
218
275
|
}
|