septum 0.1.6 → 0.1.8
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 +38 -37
- package/package.json +6 -5
- package/src/cli/commands/check.ts +34 -122
- package/src/cli/commands/hook.ts +68 -16
- package/src/cli/commands/init.ts +440 -121
- package/src/cli/commands/sync.ts +27 -17
- package/src/cli/commands/topology.ts +89 -47
- package/src/cli/index.ts +7 -68
- package/src/cli/scaffold/harness-config.ts +697 -0
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +8 -8
- package/src/cli/templates/skill.ts +11 -18
- package/src/cli/ui/prompts.ts +122 -0
- package/src/core/boundary/evaluator.ts +21 -25
- package/src/core/boundary/staged-auditor.ts +50 -0
- package/src/core/config/loader.ts +21 -24
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +80 -16
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +147 -65
- package/src/core/database/repository.ts +17 -5
- package/src/core/database/schema.ts +1 -1
- package/src/core/discovery/topology-detector.ts +71 -19
- package/src/core/ingestion/pipeline.ts +274 -126
- package/src/core/parser/extractors/frontend.ts +1 -0
- package/src/core/parser/extractors/go.ts +2 -2
- package/src/core/parser/extractors/laravel-semantic.ts +9 -8
- package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
- package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
- package/src/core/parser/extractors/php.ts +2 -2
- package/src/core/parser/extractors/python.ts +2 -2
- package/src/core/parser/extractors/typescript.ts +2 -2
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- 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 +3 -0
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/module-resolver.ts +1 -10
- package/src/core/resolver/path-resolver.ts +57 -4
- package/src/core/resolver/vertical-slice-tracer.ts +1 -0
- package/src/core/resources/ignore.ts +35 -0
- package/src/core/resources/limits.ts +14 -0
- package/src/core/resources/source-files.ts +62 -0
- package/src/core/resources/work-queue.ts +74 -0
- package/src/core/session/session-manager.ts +67 -38
- package/src/core/telemetry/telemetry.ts +12 -1
- package/src/core/topology/environment-detector.ts +21 -11
- package/src/mcp/schemas.ts +381 -8
- package/src/mcp/server.ts +326 -529
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +97 -43
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +15 -7
- package/src/mcp/tools/get-environment-topology.ts +2 -2
- package/src/mcp/tools/get-feature-context.ts +9 -12
- package/src/mcp/tools/get-symbol-impact.ts +5 -2
- package/src/mcp/tools/get-symbol.ts +2 -1
- package/src/mcp/tools/locate-symbol.ts +28 -5
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +40 -21
- package/src/mcp/tools/slice.ts +15 -0
- package/src/mcp/tools/symbol.ts +82 -0
- package/src/mcp/workspace-manager.ts +159 -0
- package/src/mcp/workspace-watcher.ts +202 -0
- package/src/types/index.ts +21 -1
- package/src/types/wasm.d.ts +4 -0
- package/src/cli/commands/ingest.ts +0 -29
- package/src/cli/commands/locate.ts +0 -61
- package/src/cli/commands/query.ts +0 -104
- package/src/cli/commands/slice.ts +0 -50
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { ExecutionChainNode, VerticalSliceRecord } from "../../../types/index.ts";
|
|
@@ -62,7 +63,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
62
63
|
const parsedRoutes: ParsedRoute[] = [];
|
|
63
64
|
|
|
64
65
|
for (const routeFile of routeFiles) {
|
|
65
|
-
const content =
|
|
66
|
+
const content = readOptionalSourceFile(routeFile, "utf-8");
|
|
66
67
|
parsedRoutes.push(...this.parseRoutesContent(content));
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -97,7 +98,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
97
98
|
|
|
98
99
|
let behavioralInfo: import("../behavioral-analyzer.ts").MethodBehavioralSummary | null = null;
|
|
99
100
|
if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
|
|
100
|
-
const controllerCode =
|
|
101
|
+
const controllerCode = readOptionalSourceFile(controllerInfo.filePath, "utf-8");
|
|
101
102
|
const actionBody = this.extractActionBody(controllerCode, route.actionName);
|
|
102
103
|
behavioralInfo = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
103
104
|
|
|
@@ -105,7 +106,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
105
106
|
const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
|
|
106
107
|
if (requestClassName) {
|
|
107
108
|
const reqFile = this.findClassFile("Requests", requestClassName);
|
|
108
|
-
const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(
|
|
109
|
+
const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(readOptionalSourceFile(reqFile, "utf-8")) : {};
|
|
109
110
|
formRequestInfo = {
|
|
110
111
|
className: requestClassName,
|
|
111
112
|
filePath: reqFile ? path.relative(this.projectRoot, reqFile) : undefined,
|
|
@@ -117,7 +118,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
117
118
|
const modelClassName = this.detectModelClass(controllerCode, route.actionName, actionBody);
|
|
118
119
|
if (modelClassName) {
|
|
119
120
|
const modFile = this.findClassFile("Models", modelClassName);
|
|
120
|
-
const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(
|
|
121
|
+
const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(readOptionalSourceFile(modFile, "utf-8")) : { fillable: [], casts: {} };
|
|
121
122
|
modelInfo = {
|
|
122
123
|
className: modelClassName,
|
|
123
124
|
filePath: modFile ? path.relative(this.projectRoot, modFile) : undefined,
|
|
@@ -509,7 +510,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
509
510
|
|
|
510
511
|
for (const c of candidates) {
|
|
511
512
|
if (fs.existsSync(c)) {
|
|
512
|
-
const lines =
|
|
513
|
+
const lines = readOptionalSourceFile(c, "utf-8").split("\n");
|
|
513
514
|
let targetLine = 1;
|
|
514
515
|
for (let i = 0; i < lines.length; i++) {
|
|
515
516
|
if (new RegExp(`function\\s+${actionName}\\s*\\(`, "i").test(lines[i])) {
|
|
@@ -814,7 +815,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
814
815
|
|
|
815
816
|
for (const file of jobFiles) {
|
|
816
817
|
const fullPath = path.join(jobsDir, file);
|
|
817
|
-
const content =
|
|
818
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
818
819
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
819
820
|
if (!classMatch) continue;
|
|
820
821
|
|
|
@@ -890,7 +891,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
890
891
|
|
|
891
892
|
for (const file of files) {
|
|
892
893
|
const fullPath = path.join(commandsDir, file);
|
|
893
|
-
const content =
|
|
894
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
894
895
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
895
896
|
if (!classMatch) continue;
|
|
896
897
|
|
|
@@ -969,7 +970,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
969
970
|
|
|
970
971
|
for (const file of files) {
|
|
971
972
|
const fullPath = path.join(listenersDir, file);
|
|
972
|
-
const content =
|
|
973
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
973
974
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
974
975
|
if (!classMatch) continue;
|
|
975
976
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
4
|
+
import { blankCommentsAndStrings } from "../boundary-tracker.ts";
|
|
3
5
|
import type { SemanticSliceExtractor, VerticalSliceCandidate } from "./semantic-extractor.interface.ts";
|
|
4
6
|
|
|
5
7
|
export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
@@ -18,7 +20,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
18
20
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
19
21
|
if (fs.existsSync(pkgPath)) {
|
|
20
22
|
try {
|
|
21
|
-
const pkg = JSON.parse(
|
|
23
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
22
24
|
if (pkg.bin || pkg.dependencies?.["@modelcontextprotocol/sdk"]) {
|
|
23
25
|
return true;
|
|
24
26
|
}
|
|
@@ -44,11 +46,13 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
44
46
|
for (const file of toolFiles) {
|
|
45
47
|
const fullPath = path.join(mcpToolsDir, file);
|
|
46
48
|
const relPath = path.relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
47
|
-
const content =
|
|
49
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
48
50
|
const baseName = path.basename(file, path.extname(file));
|
|
49
51
|
const snakeBase = baseName.replace(/-/g, "_");
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
|
|
53
|
+
// MCP-07: Only create ingress slices for whitelisted public MCP tools
|
|
54
|
+
const toolIdentifier = registeredTools.get(snakeBase) || registeredTools.get(`${toolPrefix}_${snakeBase}`);
|
|
55
|
+
if (!toolIdentifier) continue;
|
|
52
56
|
|
|
53
57
|
// Detect handler function (both sync and async) and its 1-indexed line number
|
|
54
58
|
const handlerMatch = /export\s+(?:async\s+)?function\s+([a-zA-Z0-9_]+)/.exec(content);
|
|
@@ -143,7 +147,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
143
147
|
for (const file of commandFiles) {
|
|
144
148
|
const fullPath = path.join(cliCommandsDir, file);
|
|
145
149
|
const relPath = path.relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
146
|
-
const content =
|
|
150
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
147
151
|
const commandName = path.basename(file, path.extname(file));
|
|
148
152
|
|
|
149
153
|
const handlerMatch = /export\s+(?:async\s+)?function\s+([a-zA-Z0-9_]+)/.exec(content);
|
|
@@ -235,7 +239,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
235
239
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
236
240
|
if (fs.existsSync(pkgPath)) {
|
|
237
241
|
try {
|
|
238
|
-
const pkg = JSON.parse(
|
|
242
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
239
243
|
const rawName = typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : "mcp";
|
|
240
244
|
const toolPrefix = rawName.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase() || "mcp";
|
|
241
245
|
let cliBinName = rawName;
|
|
@@ -257,17 +261,53 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
257
261
|
if (!fs.existsSync(serverPath)) return map;
|
|
258
262
|
|
|
259
263
|
try {
|
|
260
|
-
const content =
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
264
|
+
const content = readOptionalSourceFile(serverPath, "utf-8");
|
|
265
|
+
const masked = blankCommentsAndStrings(content);
|
|
266
|
+
const scan = (start: number, namesInArray: boolean) => {
|
|
267
|
+
let depth = 0;
|
|
268
|
+
const names: string[] = [];
|
|
269
|
+
const property = /\b([A-Za-z_]\w*)\s*:\s*(['"])?/y;
|
|
270
|
+
for (let i = start; i < masked.length; i++) {
|
|
271
|
+
const ch = masked[i];
|
|
272
|
+
if ("[{(".includes(ch)) depth++;
|
|
273
|
+
else if ("]})".includes(ch)) {
|
|
274
|
+
if (--depth === 0) break;
|
|
275
|
+
}
|
|
276
|
+
if (depth !== (namesInArray ? 2 : 1) || (i > 0 && /\w/.test(masked[i - 1]))) continue;
|
|
277
|
+
property.lastIndex = i;
|
|
278
|
+
const match = property.exec(masked);
|
|
279
|
+
if (!match) continue;
|
|
280
|
+
if (!namesInArray) names.push(match[1]);
|
|
281
|
+
else if (match[1] === "name" && match[2]) {
|
|
282
|
+
const quoteStart = i + match[0].lastIndexOf(match[2]);
|
|
283
|
+
const quoteEnd = masked.indexOf(match[2], quoteStart + 1);
|
|
284
|
+
const name = content.slice(quoteStart + 1, quoteEnd);
|
|
285
|
+
if (/^[A-Za-z0-9_-]+$/.test(name)) names.push(name);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Unbalanced declarations are not evidence of public registration.
|
|
289
|
+
if (depth !== 0) return;
|
|
290
|
+
for (const fullToolName of names) {
|
|
291
|
+
const normalized = fullToolName.replace(/-/g, "_");
|
|
292
|
+
map.set(normalized, fullToolName);
|
|
293
|
+
const withoutPrefix = normalized.replace(/^[a-z0-9]+_/, "");
|
|
294
|
+
if (withoutPrefix) map.set(withoutPrefix, fullToolName);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const listCall = /setRequestHandler\s*\(\s*ListToolsRequestSchema\b/.exec(masked);
|
|
298
|
+
if (listCall) {
|
|
299
|
+
const callStart = masked.indexOf("(", listCall.index);
|
|
300
|
+
let depth = 1;
|
|
301
|
+
let end = callStart + 1;
|
|
302
|
+
for (; end < masked.length && depth; end++) {
|
|
303
|
+
if (masked[end] === "(") depth++;
|
|
304
|
+
else if (masked[end] === ")") depth--;
|
|
270
305
|
}
|
|
306
|
+
const tools = /\btools\s*:\s*\[/.exec(masked.slice(callStart, end));
|
|
307
|
+
if (tools && depth === 0) scan(callStart + tools.index + tools[0].length - 1, true);
|
|
308
|
+
} else {
|
|
309
|
+
const schemas = /\bTOOL_SCHEMAS\s*=\s*\{/.exec(masked);
|
|
310
|
+
if (schemas) scan(schemas.index + schemas[0].length - 1, false);
|
|
271
311
|
}
|
|
272
312
|
} catch {}
|
|
273
313
|
return map;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { SemanticSliceExtractor, VerticalSliceCandidate } from "./semantic-extractor.interface.ts";
|
|
@@ -14,7 +15,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
|
|
|
14
15
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
15
16
|
if (fs.existsSync(pkgPath)) {
|
|
16
17
|
try {
|
|
17
|
-
const pkg = JSON.parse(
|
|
18
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
18
19
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
19
20
|
if (deps["@nestjs/core"]) return true;
|
|
20
21
|
} catch {}
|
|
@@ -32,7 +33,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
|
|
|
32
33
|
|
|
33
34
|
for (const file of controllerFiles) {
|
|
34
35
|
const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
|
|
35
|
-
const content =
|
|
36
|
+
const content = readOptionalSourceFile(file, "utf-8");
|
|
36
37
|
|
|
37
38
|
// Extract @Controller('prefix')
|
|
38
39
|
const controllerMatch = /@Controller\s*\(\s*['"]?([^'")]*)['"]?\s*\)[\s\S]*?export\s+class\s+([A-Za-z0-9_]+)/.exec(
|
|
@@ -44,10 +44,10 @@ export class PHPExtractor implements CodeExtractor {
|
|
|
44
44
|
this.extractFromAst(rootNode, content)
|
|
45
45
|
);
|
|
46
46
|
if (astResult) {
|
|
47
|
-
return astResult;
|
|
47
|
+
return { ...astResult, status: "parsed" };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
return this.extractFallback(content);
|
|
50
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
private isExternalNamespace(cleaned: string): boolean {
|
|
@@ -79,10 +79,10 @@ export class PythonExtractor implements CodeExtractor {
|
|
|
79
79
|
this.extractFromAst(rootNode, content)
|
|
80
80
|
);
|
|
81
81
|
if (astResult) {
|
|
82
|
-
return astResult;
|
|
82
|
+
return { ...astResult, status: "parsed" };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
return this.extractFallback(content);
|
|
85
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
private extractFallback(content: string): ParsedFileAST {
|
|
@@ -46,10 +46,10 @@ export class TypeScriptExtractor implements CodeExtractor {
|
|
|
46
46
|
);
|
|
47
47
|
|
|
48
48
|
if (treeSitterResult) {
|
|
49
|
-
return treeSitterResult;
|
|
49
|
+
return { ...treeSitterResult, status: "parsed" };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
return this.extractFallback(content);
|
|
52
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
private resolveLanguageKey(filePath: string): string {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type {
|
|
@@ -35,7 +36,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
35
36
|
for (const migFile of migrationFiles) {
|
|
36
37
|
const fullPath = path.join(migrationsDir, migFile);
|
|
37
38
|
try {
|
|
38
|
-
const content =
|
|
39
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
39
40
|
this.extractColumnsFromMigration(content, tableName, fieldsMap);
|
|
40
41
|
} catch {
|
|
41
42
|
// Skip unreadable files
|
|
@@ -66,7 +67,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
66
67
|
: path.join(projectRoot, entityFilePath);
|
|
67
68
|
if (fs.existsSync(fullEntityPath)) {
|
|
68
69
|
try {
|
|
69
|
-
const content =
|
|
70
|
+
const content = readOptionalSourceFile(fullEntityPath, "utf-8");
|
|
70
71
|
const tableMatch = /protected\s+\$table\s*=\s*['"]([^'"]+)['"]/.exec(content);
|
|
71
72
|
if (tableMatch) {
|
|
72
73
|
return tableMatch[1];
|
|
@@ -120,7 +121,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
120
121
|
if (matched.length === 0) {
|
|
121
122
|
for (const f of files) {
|
|
122
123
|
try {
|
|
123
|
-
const content =
|
|
124
|
+
const content = readOptionalSourceFile(path.join(migrationsDir, f), "utf-8");
|
|
124
125
|
if (
|
|
125
126
|
content.includes(`Schema::create('${tableName}'`) ||
|
|
126
127
|
content.includes(`Schema::create("${tableName}"`) ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type {
|
|
@@ -38,7 +39,7 @@ export class PrismaSchemaProvider implements StackSchemaProvider {
|
|
|
38
39
|
if (!schemaPath) return null;
|
|
39
40
|
|
|
40
41
|
try {
|
|
41
|
-
const content =
|
|
42
|
+
const content = readOptionalSourceFile(schemaPath, "utf-8");
|
|
42
43
|
const modelRegex = new RegExp(`model\\s+${entitySymbol}\\s*\\{([^}]+)\\}`, "m");
|
|
43
44
|
const match = modelRegex.exec(content);
|
|
44
45
|
if (!match) return null;
|
|
@@ -8,6 +8,9 @@ import { PythonExtractor } from "./extractors/python.ts";
|
|
|
8
8
|
import { TypeScriptExtractor } from "./extractors/typescript.ts";
|
|
9
9
|
|
|
10
10
|
export class ASTParserEngine {
|
|
11
|
+
public static isSourceFile(filePath: string): boolean {
|
|
12
|
+
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|php|py|go|rs|java|cs|rb|vue|astro|svelte)$/i.test(filePath);
|
|
13
|
+
}
|
|
11
14
|
private extractors: CodeExtractor[];
|
|
12
15
|
|
|
13
16
|
constructor() {
|
|
@@ -24,12 +27,14 @@ export class ASTParserEngine {
|
|
|
24
27
|
public async parseFile(filePath: string, content: string): Promise<ParsedFileAST> {
|
|
25
28
|
for (const extractor of this.extractors) {
|
|
26
29
|
if (extractor.canHandle(filePath)) {
|
|
27
|
-
|
|
30
|
+
const result = await extractor.extract(filePath, content);
|
|
31
|
+
return { status: "parsed", ...result };
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
// Default fallback extractor for unknown file types
|
|
32
36
|
return {
|
|
37
|
+
status: "unsupported",
|
|
33
38
|
symbols: [],
|
|
34
39
|
dependencies: [],
|
|
35
40
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" };
|
|
2
|
+
import typescript from "tree-sitter-wasms/out/tree-sitter-typescript.wasm" with { type: "file" };
|
|
3
|
+
import tsx from "tree-sitter-wasms/out/tree-sitter-tsx.wasm" with { type: "file" };
|
|
4
|
+
import javascript from "tree-sitter-wasms/out/tree-sitter-javascript.wasm" with { type: "file" };
|
|
5
|
+
import php from "tree-sitter-wasms/out/tree-sitter-php.wasm" with { type: "file" };
|
|
6
|
+
import python from "tree-sitter-wasms/out/tree-sitter-python.wasm" with { type: "file" };
|
|
7
|
+
import go from "tree-sitter-wasms/out/tree-sitter-go.wasm" with { type: "file" };
|
|
8
|
+
|
|
9
|
+
export const WASM_ASSETS: Record<string, string> = {
|
|
10
|
+
"tree-sitter.wasm": runtime,
|
|
11
|
+
"tree-sitter-typescript.wasm": typescript,
|
|
12
|
+
"tree-sitter-tsx.wasm": tsx,
|
|
13
|
+
"tree-sitter-javascript.wasm": javascript,
|
|
14
|
+
"tree-sitter-php.wasm": php,
|
|
15
|
+
"tree-sitter-python.wasm": python,
|
|
16
|
+
"tree-sitter-go.wasm": go,
|
|
17
|
+
};
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import Parser from "web-tree-sitter";
|
|
5
|
+
import { WASM_ASSETS } from "./wasm-assets.ts";
|
|
5
6
|
|
|
6
7
|
const GRAMMAR_BY_KEY: Record<string, string> = {
|
|
7
8
|
// Extensions
|
|
@@ -74,6 +75,8 @@ export function resolveWasmPath(fileName: string, isCoreRuntime: boolean = false
|
|
|
74
75
|
candidates.push(path.join(envDir, "out", fileName));
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
if (WASM_ASSETS[fileName]) candidates.push(WASM_ASSETS[fileName]);
|
|
79
|
+
|
|
77
80
|
// Tier 2: Walk up from import.meta.dir to package root
|
|
78
81
|
let currentDir = getModuleDir();
|
|
79
82
|
for (let depth = 0; depth < 7; depth++) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../resources/source-files.ts";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { dirname, join, normalize, relative, resolve } from "node:path";
|
|
3
4
|
import type {
|
|
@@ -86,7 +87,7 @@ export class CallGraphTracer {
|
|
|
86
87
|
if (!existsSync(fullPath)) continue;
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
|
-
const content =
|
|
90
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
90
91
|
const lines = content.split("\n");
|
|
91
92
|
|
|
92
93
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -414,7 +415,7 @@ export class CallGraphTracer {
|
|
|
414
415
|
fileSymbols[0];
|
|
415
416
|
|
|
416
417
|
const symbolDisplay = mainSymbol
|
|
417
|
-
?
|
|
418
|
+
? mainSymbol.name
|
|
418
419
|
: resolvedFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") || "Module";
|
|
419
420
|
|
|
420
421
|
// Prevent adding duplicate stages if already present, or add as refinement
|
|
@@ -428,7 +429,7 @@ export class CallGraphTracer {
|
|
|
428
429
|
symbol: symbolDisplay,
|
|
429
430
|
file: resolvedFile.path,
|
|
430
431
|
line: mainSymbol?.line_start || 1,
|
|
431
|
-
description: `${stage.toUpperCase()}: ${symbolDisplay}`,
|
|
432
|
+
description: `${stage.toUpperCase()} (dependency): ${symbolDisplay}`,
|
|
432
433
|
});
|
|
433
434
|
}
|
|
434
435
|
|
|
@@ -97,16 +97,7 @@ export class ModuleResolver {
|
|
|
97
97
|
filePath: string,
|
|
98
98
|
config: ValidatedSeptumConfig
|
|
99
99
|
): string | null {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for (const [domainName, domainCfg] of Object.entries(config.domains)) {
|
|
103
|
-
const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
|
|
104
|
-
if (this.isSubPathOrSame(absPath, domainAbsRoot)) {
|
|
105
|
-
return domainName;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return null;
|
|
100
|
+
return this.matchDomainByPath(filePath, config);
|
|
110
101
|
}
|
|
111
102
|
|
|
112
103
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
1
|
+
import { existsSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export const PROJECT_ROOT_MARKERS = [
|
|
5
5
|
"package.json",
|
|
@@ -26,6 +26,7 @@ export function canonicalizePath(targetPath: string): string {
|
|
|
26
26
|
return targetPath;
|
|
27
27
|
}
|
|
28
28
|
let abs = normalize(resolve(targetPath));
|
|
29
|
+
try { if (existsSync(abs)) abs = realpathSync(abs); } catch { /* Keep lexical paths for missing or inaccessible files. */ }
|
|
29
30
|
if (process.platform === "win32") {
|
|
30
31
|
abs = abs.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
|
|
31
32
|
}
|
|
@@ -104,7 +105,7 @@ export function findProjectRoot(fromPath: string): string {
|
|
|
104
105
|
// Filesystem traversal error fallback
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
return canonicalizePath(dirname(resolve(fromPath)));
|
|
108
|
+
return canonicalizePath(existsSync(fromPath) && statSync(fromPath).isDirectory() ? fromPath : dirname(resolve(fromPath)));
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
@@ -117,7 +118,8 @@ export function findProjectRoot(fromPath: string): string {
|
|
|
117
118
|
export function resolveWorkspaceRoot(pathHint?: string): string {
|
|
118
119
|
if (pathHint && typeof pathHint === "string" && pathHint.trim()) {
|
|
119
120
|
const trimmed = pathHint.trim().replace(/^['"]|['"]$/g, "");
|
|
120
|
-
const
|
|
121
|
+
const absHint = resolve(trimmed);
|
|
122
|
+
const candidate = existsSync(absHint) && statSync(absHint).isDirectory() ? canonicalizePath(absHint) : findProjectRoot(trimmed);
|
|
121
123
|
if (candidate && existsSync(candidate) && !isAppHostDirectory(candidate)) {
|
|
122
124
|
setLastKnownProjectRoot(candidate);
|
|
123
125
|
return candidate;
|
|
@@ -145,3 +147,54 @@ export function resolveWorkspaceRoot(pathHint?: string): string {
|
|
|
145
147
|
// Fallback if inside app host directory: keep looking in lastKnown or return cwd
|
|
146
148
|
return canonicalizePath(cwd);
|
|
147
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verifies that candidatePath is strictly contained within workspaceRoot.
|
|
153
|
+
* Resolves symlinks (realpath) if the path exists, and checks ancestor realpath if not yet created.
|
|
154
|
+
*/
|
|
155
|
+
export function isPathContained(candidatePath: string, workspaceRoot: string): boolean {
|
|
156
|
+
if (!candidatePath || !workspaceRoot) return false;
|
|
157
|
+
try {
|
|
158
|
+
const canonicalWorkspace = canonicalizePath(workspaceRoot);
|
|
159
|
+
const resolvedTarget = isAbsolute(candidatePath) ? normalize(candidatePath) : resolve(canonicalWorkspace, candidatePath);
|
|
160
|
+
|
|
161
|
+
// Lexical containment
|
|
162
|
+
const relLexical = relative(canonicalWorkspace, resolvedTarget).replace(/\\/g, "/");
|
|
163
|
+
if (relLexical === ".." || relLexical.startsWith("../") || isAbsolute(relLexical)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Realpath / symlink containment
|
|
168
|
+
if (existsSync(resolvedTarget)) {
|
|
169
|
+
const realTarget = realpathSync(resolvedTarget);
|
|
170
|
+
const relReal = relative(canonicalWorkspace, realTarget).replace(/\\/g, "/");
|
|
171
|
+
if (relReal === ".." || relReal.startsWith("../") || isAbsolute(relReal)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
let cur = dirname(resolvedTarget);
|
|
176
|
+
while (!existsSync(cur)) {
|
|
177
|
+
const parent = dirname(cur);
|
|
178
|
+
if (parent === cur) break;
|
|
179
|
+
cur = parent;
|
|
180
|
+
}
|
|
181
|
+
if (existsSync(cur)) {
|
|
182
|
+
const realCur = realpathSync(cur);
|
|
183
|
+
const relCur = relative(canonicalWorkspace, realCur).replace(/\\/g, "/");
|
|
184
|
+
if (relCur === ".." || relCur.startsWith("../") || isAbsolute(relCur)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function assertPathContained(candidatePath: string, workspaceRoot: string, contextDescription = "Path"): void {
|
|
197
|
+
if (!isPathContained(candidatePath, workspaceRoot)) {
|
|
198
|
+
throw new Error(`${contextDescription} '${candidatePath}' escapes workspace boundary '${workspaceRoot}'.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -81,6 +81,7 @@ export class VerticalSliceTracer {
|
|
|
81
81
|
if (exact) {
|
|
82
82
|
return this.formatSuccessResponse(trimmed, exact, "exact", undefined, effectiveMaxDepth);
|
|
83
83
|
}
|
|
84
|
+
return { query: trimmed, found: false, max_depth_applied: effectiveMaxDepth, message: `No vertical slice found matching query '${trimmed}'.` };
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// 2. URI-only match: "/orders" or "orders/{id}"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function shouldIgnoreSourcePath(filePath: string, ignorePatterns: string[]): boolean {
|
|
2
|
+
if (filePath.replace(/\\/g, "/").split("/").some(segment => ["node_modules", "vendor", ".git", ".septum", ".strata", ".codegraph", "dist", "build", ".output", ".nuxt", ".next", ".cache", "coverage", ".turbo"].includes(segment))) return true;
|
|
3
|
+
const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
4
|
+
for (const pattern of ignorePatterns) {
|
|
5
|
+
const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
|
|
6
|
+
if (!normPattern) continue;
|
|
7
|
+
|
|
8
|
+
if (normPattern === "*" || normPattern === "**") {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (normPath === normPattern) return true;
|
|
13
|
+
|
|
14
|
+
if (normPattern.endsWith("/**")) {
|
|
15
|
+
const prefix = normPattern.slice(0, -3);
|
|
16
|
+
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
17
|
+
} else if (normPattern.endsWith("/*")) {
|
|
18
|
+
const prefix = normPattern.slice(0, -2);
|
|
19
|
+
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
20
|
+
} else if (normPath.startsWith(normPattern + "/")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const segments = normPath.split("/");
|
|
29
|
+
if (segments.includes(normPattern)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const RESOURCE_LIMITS = Object.freeze({
|
|
2
|
+
workspaces: 4,
|
|
3
|
+
workspaceIdleMs: 10 * 60_000,
|
|
4
|
+
watches: 2048,
|
|
5
|
+
pendingPaths: 1000,
|
|
6
|
+
debounceMs: 300,
|
|
7
|
+
pollMs: 5000,
|
|
8
|
+
verifyMs: 60_000,
|
|
9
|
+
sourceBytes: 2 * 1024 * 1024,
|
|
10
|
+
batchFiles: 50,
|
|
11
|
+
batchBytes: 1024 * 1024,
|
|
12
|
+
maxQueuePending: 50,
|
|
13
|
+
shutdownMs: 5000,
|
|
14
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { computeContentHash } from "../ingestion/hasher.ts";
|
|
5
|
+
import { RESOURCE_LIMITS } from './limits.ts';
|
|
6
|
+
|
|
7
|
+
export interface SkippedSourceFile { path: string; reason: 'file_too_large' }
|
|
8
|
+
export class SourceFileLimitError extends Error {
|
|
9
|
+
constructor(public readonly filePath: string) { super(`Source file exceeds ${RESOURCE_LIMITS.sourceBytes} bytes: ${filePath}`); }
|
|
10
|
+
}
|
|
11
|
+
export interface SourceReadFingerprint { path: string; hash: string; size: number; mtime: number }
|
|
12
|
+
const reads = new AsyncLocalStorage<{skipped: Map<string, SkippedSourceFile>; inputs: Map<string, SourceReadFingerprint>}>();
|
|
13
|
+
|
|
14
|
+
// Read at most the admission limit, even if the file grows after stat.
|
|
15
|
+
export function readSourceFile(filePath: string, _encoding?: string): string {
|
|
16
|
+
const fd = openSync(filePath, 'r');
|
|
17
|
+
try {
|
|
18
|
+
const size = fstatSync(fd).size;
|
|
19
|
+
if (size > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
|
|
20
|
+
let buffer = Buffer.allocUnsafe(Math.min(Math.max(size + 1, 4096), RESOURCE_LIMITS.sourceBytes + 1));
|
|
21
|
+
let count = 0;
|
|
22
|
+
while (true) {
|
|
23
|
+
const read = readSync(fd, buffer, count, buffer.length - count, null);
|
|
24
|
+
if (!read) return buffer.toString('utf8', 0, count);
|
|
25
|
+
count += read;
|
|
26
|
+
if (count > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
|
|
27
|
+
if (count === buffer.length) {
|
|
28
|
+
const expanded = Buffer.allocUnsafe(Math.min(buffer.length * 2, RESOURCE_LIMITS.sourceBytes + 1));
|
|
29
|
+
buffer.copy(expanded, 0, 0, count);
|
|
30
|
+
buffer = expanded;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readOptionalSourceFile(filePath: string, encoding?: string): string {
|
|
37
|
+
try {
|
|
38
|
+
const content = readSourceFile(filePath, encoding);
|
|
39
|
+
const collector = reads.getStore();
|
|
40
|
+
if (collector) {
|
|
41
|
+
const stat = statSync(filePath);
|
|
42
|
+
collector.inputs.set(filePath, {path:filePath,hash:computeContentHash(content),size:stat.size,mtime:stat.mtimeMs});
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
48
|
+
const collector = reads.getStore();
|
|
49
|
+
if (!collector) throw error;
|
|
50
|
+
collector.skipped.set(filePath, { path: filePath, reason: 'file_too_large' });
|
|
51
|
+
const stat = statSync(filePath);
|
|
52
|
+
collector.inputs.set(filePath, {path:filePath,hash:'file_too_large',size:stat.size,mtime:stat.mtimeMs});
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function collectSourceSkips<T>(root: string, read: () => Promise<T>): Promise<{ value: T; skipped: SkippedSourceFile[]; inputs: SourceReadFingerprint[] }> {
|
|
58
|
+
const collector = {skipped: new Map<string, SkippedSourceFile>(), inputs: new Map<string, SourceReadFingerprint>()};
|
|
59
|
+
const value = await reads.run(collector, read);
|
|
60
|
+
const rel = (file: {path:string}) => relative(root, file.path).replace(/\\/g, '/');
|
|
61
|
+
return {value, skipped:[...collector.skipped.values()].map(file => ({...file,path:rel(file)})), inputs:[...collector.inputs.values()].map(file => ({...file,path:rel(file)}))};
|
|
62
|
+
}
|