septum 0.1.2 → 0.1.4

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.
Files changed (46) hide show
  1. package/README.md +7 -2
  2. package/package.json +1 -1
  3. package/src/cli/commands/slice.ts +4 -46
  4. package/src/cli/commands/topology.ts +119 -0
  5. package/src/cli/index.ts +11 -0
  6. package/src/cli/templates/skill.ts +7 -4
  7. package/src/core/config/loader.ts +21 -8
  8. package/src/core/database/repositories/dependency.repository.ts +70 -0
  9. package/src/core/database/repositories/environment.repository.ts +207 -0
  10. package/src/core/database/repositories/file.repository.ts +7 -0
  11. package/src/core/database/repositories/symbol.repository.ts +28 -9
  12. package/src/core/database/repository.ts +19 -1
  13. package/src/core/database/schema.ts +153 -120
  14. package/src/core/discovery/topology-detector.ts +71 -3
  15. package/src/core/ingestion/pipeline.ts +98 -1
  16. package/src/core/parser/behavioral-analyzer.ts +119 -0
  17. package/src/core/parser/boundary-tracker.ts +160 -0
  18. package/src/core/parser/extractors/base.ts +2 -0
  19. package/src/core/parser/extractors/blade.ts +158 -0
  20. package/src/core/parser/extractors/go.ts +5 -1
  21. package/src/core/parser/extractors/laravel-semantic.ts +506 -31
  22. package/src/core/parser/extractors/php.ts +5 -1
  23. package/src/core/parser/extractors/python.ts +3 -1
  24. package/src/core/parser/extractors/registry.ts +21 -0
  25. package/src/core/parser/extractors/typescript.ts +5 -1
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  28. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  29. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  30. package/src/core/parser/tree-sitter.ts +2 -0
  31. package/src/core/resolver/path-resolver.ts +147 -0
  32. package/src/core/resolver/vertical-slice-tracer.ts +56 -8
  33. package/src/core/topology/environment-detector.ts +860 -0
  34. package/src/core/topology/types.ts +83 -0
  35. package/src/index.ts +3 -0
  36. package/src/mcp/schemas.ts +264 -46
  37. package/src/mcp/server.ts +130 -26
  38. package/src/mcp/tools/check-boundary.ts +58 -8
  39. package/src/mcp/tools/get-domain-catalog.ts +31 -1
  40. package/src/mcp/tools/get-environment-topology.ts +141 -0
  41. package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
  42. package/src/mcp/tools/locate-symbol.ts +119 -3
  43. package/src/mcp/tools/register-domain.ts +1 -1
  44. package/src/mcp/tools/trace-vertical-slice.ts +11 -65
  45. package/src/types/index.ts +20 -1
  46. package/src/version.ts +3 -3
@@ -0,0 +1,119 @@
1
+ export interface MethodBehavioralSummary {
2
+ guards: string[];
3
+ hasTransaction: boolean;
4
+ emittedEvents: string[];
5
+ dispatchedJobs: string[];
6
+ mutations: string[];
7
+ }
8
+
9
+ export class BehavioralAnalyzer {
10
+ /**
11
+ * Analyzes the body of a method or function to extract semantic side-effects,
12
+ * authorization guards, database transactions, emitted events, and background job dispatches.
13
+ */
14
+ public static analyzeMethod(code: string): MethodBehavioralSummary {
15
+ const guards = new Set<string>();
16
+ let hasTransaction = false;
17
+ const emittedEvents = new Set<string>();
18
+ const dispatchedJobs = new Set<string>();
19
+ const mutations = new Set<string>();
20
+
21
+ const lines = code.split("\n");
22
+
23
+ for (const rawLine of lines) {
24
+ const line = rawLine.trim();
25
+ if (!line || line.startsWith("//") || line.startsWith("#")) continue;
26
+
27
+ // 1. Transaction Boundaries
28
+ if (
29
+ line.includes("DB::transaction") ||
30
+ line.includes("DB::beginTransaction") ||
31
+ line.includes("this.db.transaction") ||
32
+ line.includes(".transaction(")
33
+ ) {
34
+ hasTransaction = true;
35
+ }
36
+
37
+ // 2. Authorization Guards
38
+ // Laravel: $this->authorize('update', $order); or Gate::authorize(...)
39
+ const authMatch = line.match(/(?:\$this->authorize|Gate::authorize)\s*\(\s*['"]([^'"]+)['"]/);
40
+ if (authMatch) {
41
+ guards.add(authMatch[1]);
42
+ }
43
+ // Laravel: abort_if(!..., 403, '...');
44
+ if (line.includes("abort_if(") || line.includes("abort_unless(")) {
45
+ guards.add("abort_guard");
46
+ }
47
+ // TS / NestJS: @UseGuards(AuthGuard, RolesGuard)
48
+ const guardDecoratorMatch = line.match(/@UseGuards\s*\(([^)]+)\)/);
49
+ if (guardDecoratorMatch) {
50
+ const guardNames = guardDecoratorMatch[1].split(",").map((g) => g.trim());
51
+ for (const g of guardNames) guards.add(g);
52
+ }
53
+
54
+ // 3. Emitted Events
55
+ // Laravel: event(new OrderPlaced($order));
56
+ const eventNewMatch = line.match(/event\s*\(\s*new\s+([A-Za-z0-9_]+)/);
57
+ if (eventNewMatch) {
58
+ emittedEvents.add(eventNewMatch[1]);
59
+ }
60
+ // Laravel: Event::dispatch(new OrderPlaced($order)); or Event::dispatch('name');
61
+ const eventDispatchMatch = line.match(/Event::dispatch\s*\(\s*(?:new\s+)?([A-Za-z0-9_]+)/);
62
+ if (eventDispatchMatch) {
63
+ emittedEvents.add(eventDispatchMatch[1]);
64
+ }
65
+ // Laravel: broadcast(new OrderUpdated($order));
66
+ const broadcastMatch = line.match(/broadcast\s*\(\s*new\s+([A-Za-z0-9_]+)/);
67
+ if (broadcastMatch) {
68
+ emittedEvents.add(broadcastMatch[1]);
69
+ }
70
+ // EventEmitter: this.eventEmitter.emit('order.created', ...)
71
+ const nodeEmitMatch = line.match(/(?:eventEmitter|\$emit)\.emit\s*\(\s*['"]([^'"]+)['"]/);
72
+ if (nodeEmitMatch) {
73
+ emittedEvents.add(nodeEmitMatch[1]);
74
+ }
75
+
76
+ // 4. Dispatched Background Jobs
77
+ // Laravel: ProcessPaymentJob::dispatch($order); or ProcessPayment::dispatchSync(...)
78
+ const jobDispatchStaticMatch = line.match(/([A-Z][a-zA-Z0-9_]*(?:Job|Queue|Task|Process)[a-zA-Z0-9_]*)::dispatch/);
79
+ if (jobDispatchStaticMatch) {
80
+ dispatchedJobs.add(jobDispatchStaticMatch[1]);
81
+ }
82
+ // Laravel: dispatch(new ProcessPayment($order));
83
+ const dispatchNewMatch = line.match(/dispatch\s*\(\s*new\s+([A-Za-z0-9_]+)/);
84
+ if (dispatchNewMatch) {
85
+ dispatchedJobs.add(dispatchNewMatch[1]);
86
+ }
87
+ // BullMQ / Queue: queue.add('process-payment', ...)
88
+ const queueAddMatch = line.match(/(?:queue|queueService)\.add\s*\(\s*['"]([^'"]+)['"]/);
89
+ if (queueAddMatch) {
90
+ dispatchedJobs.add(queueAddMatch[1]);
91
+ }
92
+
93
+ // 5. Model Mutations
94
+ // Laravel / ORM: $order->update([...]);
95
+ const updateMatch = line.match(/\$([a-zA-Z0-9_]+)->update\s*\(/);
96
+ if (updateMatch) {
97
+ mutations.add(`${updateMatch[1]}.update()`);
98
+ }
99
+ // Laravel / ORM: $order->delete();
100
+ const deleteMatch = line.match(/\$([a-zA-Z0-9_]+)->delete\s*\(/);
101
+ if (deleteMatch) {
102
+ mutations.add(`${deleteMatch[1]}.delete()`);
103
+ }
104
+ // Direct property assignment: $order->status = 'completed';
105
+ const assignMatch = line.match(/\$([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*=\s*([^;]+);/);
106
+ if (assignMatch && !["this", "request"].includes(assignMatch[1])) {
107
+ mutations.add(`${assignMatch[1]}.${assignMatch[2]}`);
108
+ }
109
+ }
110
+
111
+ return {
112
+ guards: Array.from(guards),
113
+ hasTransaction,
114
+ emittedEvents: Array.from(emittedEvents),
115
+ dispatchedJobs: Array.from(dispatchedJobs),
116
+ mutations: Array.from(mutations),
117
+ };
118
+ }
119
+ }
@@ -126,3 +126,163 @@ export function findPythonBlockEnd(lines: string[], startLineIndex: number): num
126
126
 
127
127
  return lastContentLine;
128
128
  }
129
+
130
+ /**
131
+ * Calculates the maximum control flow nesting depth within a code block (PHP, TS, JS, Go).
132
+ * Base depth is 1 (the function body itself).
133
+ * Nested control structures (if, else if, for, foreach, while, do, switch, try, catch)
134
+ * that open a block increase the active depth.
135
+ */
136
+ export function calculateControlFlowNesting(
137
+ lines: string[],
138
+ startLineIndex: number,
139
+ endLineIndex: number
140
+ ): number {
141
+ if (startLineIndex >= endLineIndex) return 1;
142
+
143
+ let maxDepth = 1;
144
+ let currentDepth = 1;
145
+ let inMultiLineComment = false;
146
+ let insideBody = false;
147
+
148
+ const controlStack: number[] = [];
149
+ let braceDepth = 0;
150
+ let pendingControl = false;
151
+
152
+ const controlRegex = /\b(if|else\s+if|else|for|foreach|while|do|switch|try|catch)\b/;
153
+
154
+ const startIdx = Math.max(0, startLineIndex);
155
+ const endIdx = Math.min(lines.length - 1, endLineIndex);
156
+
157
+ for (let i = startIdx; i <= endIdx; i++) {
158
+ const rawLine = lines[i];
159
+
160
+ let cleanLine = "";
161
+ let inSingleQuote = false;
162
+ let inDoubleQuote = false;
163
+ let inTemplateString = false;
164
+
165
+ for (let j = 0; j < rawLine.length; j++) {
166
+ const char = rawLine[j];
167
+ const prevChar = j > 0 ? rawLine[j - 1] : "";
168
+ const nextChar = j + 1 < rawLine.length ? rawLine[j + 1] : "";
169
+
170
+ if (inMultiLineComment) {
171
+ if (char === "*" && nextChar === "/") {
172
+ inMultiLineComment = false;
173
+ j++;
174
+ }
175
+ continue;
176
+ }
177
+
178
+ if (!inSingleQuote && !inDoubleQuote && !inTemplateString && char === "/" && nextChar === "*") {
179
+ inMultiLineComment = true;
180
+ j++;
181
+ continue;
182
+ }
183
+
184
+ if (!inSingleQuote && !inDoubleQuote && !inTemplateString && char === "/" && nextChar === "/") {
185
+ break; // Single line comment: ignore rest of line
186
+ }
187
+
188
+ if (char === "'" && !inDoubleQuote && !inTemplateString && prevChar !== "\\") {
189
+ inSingleQuote = !inSingleQuote;
190
+ continue;
191
+ }
192
+ if (char === '"' && !inSingleQuote && !inTemplateString && prevChar !== "\\") {
193
+ inDoubleQuote = !inDoubleQuote;
194
+ continue;
195
+ }
196
+ if (char === "`" && !inSingleQuote && !inDoubleQuote && prevChar !== "\\") {
197
+ inTemplateString = !inTemplateString;
198
+ continue;
199
+ }
200
+
201
+ if (inSingleQuote || inDoubleQuote || inTemplateString) {
202
+ continue;
203
+ }
204
+
205
+ cleanLine += char;
206
+ }
207
+
208
+ const trimmedClean = cleanLine.trim();
209
+ if (!trimmedClean) continue;
210
+
211
+ if (insideBody && controlRegex.test(trimmedClean)) {
212
+ pendingControl = true;
213
+ }
214
+
215
+ for (let j = 0; j < cleanLine.length; j++) {
216
+ const char = cleanLine[j];
217
+ if (char === "{") {
218
+ braceDepth++;
219
+ if (!insideBody) {
220
+ insideBody = true;
221
+ } else if (pendingControl) {
222
+ currentDepth++;
223
+ controlStack.push(braceDepth);
224
+ if (currentDepth > maxDepth) {
225
+ maxDepth = currentDepth;
226
+ }
227
+ pendingControl = false;
228
+ }
229
+ } else if (char === "}") {
230
+ if (controlStack.length > 0 && controlStack[controlStack.length - 1] === braceDepth) {
231
+ controlStack.pop();
232
+ currentDepth = Math.max(1, currentDepth - 1);
233
+ }
234
+ braceDepth = Math.max(0, braceDepth - 1);
235
+ }
236
+ }
237
+
238
+ if (trimmedClean.endsWith(";")) {
239
+ pendingControl = false;
240
+ }
241
+ }
242
+
243
+ return maxDepth;
244
+ }
245
+
246
+ /**
247
+ * Calculates the maximum control flow nesting depth in indentation-based languages (Python).
248
+ */
249
+ export function calculatePythonNesting(
250
+ lines: string[],
251
+ startLineIndex: number,
252
+ endLineIndex: number
253
+ ): number {
254
+ if (startLineIndex >= endLineIndex) return 1;
255
+
256
+ const startLine = lines[startLineIndex];
257
+ const baseIndentMatch = startLine.match(/^(\s*)/);
258
+ const baseIndent = baseIndentMatch ? baseIndentMatch[1].length : 0;
259
+
260
+ const controlRegex = /^\s*(if|elif|else|for|while|try|except|finally|match|case)\b/;
261
+ const indentStack: number[] = [baseIndent];
262
+ let maxDepth = 1;
263
+
264
+ const startIdx = Math.max(0, startLineIndex + 1);
265
+ const endIdx = Math.min(lines.length - 1, endLineIndex);
266
+
267
+ for (let i = startIdx; i <= endIdx; i++) {
268
+ const line = lines[i];
269
+ const trimmed = line.trim();
270
+ if (!trimmed || trimmed.startsWith("#")) continue;
271
+
272
+ const currentIndentMatch = line.match(/^(\s*)/);
273
+ const currentIndent = currentIndentMatch ? currentIndentMatch[1].length : 0;
274
+
275
+ while (indentStack.length > 1 && currentIndent <= indentStack[indentStack.length - 1]) {
276
+ indentStack.pop();
277
+ }
278
+
279
+ if (controlRegex.test(line)) {
280
+ indentStack.push(currentIndent);
281
+ if (indentStack.length > maxDepth) {
282
+ maxDepth = indentStack.length;
283
+ }
284
+ }
285
+ }
286
+
287
+ return maxDepth;
288
+ }
@@ -28,6 +28,8 @@ export function detectArchetype(filePath: string, archetypesMap: Record<string,
28
28
  if (lower.includes("middleware")) return "middleware";
29
29
  if (lower.includes("request")) return "request";
30
30
  if (lower.includes("resource")) return "resource";
31
+ if (lower.includes(".blade.php") || lower.includes("views/") || lower.includes("templates/")) return "view";
32
+ if (lower.includes("route") || lower.includes("router")) return "route";
31
33
  if (lower.includes("util") || lower.includes("helper")) return "util";
32
34
 
33
35
  return "unknown";
@@ -0,0 +1,158 @@
1
+ import type {
2
+ ExtractedDependency,
3
+ ExtractedSymbol,
4
+ ParsedFileAST,
5
+ } from "../../../types/index.ts";
6
+ import type { CodeExtractor } from "./base.ts";
7
+
8
+ export class BladeExtractor implements CodeExtractor {
9
+ public canHandle(filePath: string): boolean {
10
+ return /\.blade\.php$/i.test(filePath);
11
+ }
12
+
13
+ public async extract(filePath: string, content: string): Promise<ParsedFileAST> {
14
+ const symbols: ExtractedSymbol[] = [];
15
+ const dependencies: ExtractedDependency[] = [];
16
+
17
+ const lines = content.split(/\r?\n/);
18
+ const lineCount = lines.length;
19
+
20
+ // 1. Register the view itself as a top-level symbol
21
+ const viewName = this.normalizeViewName(filePath);
22
+ if (viewName) {
23
+ symbols.push({
24
+ name: viewName,
25
+ kind: "view",
26
+ signature: `@view(${viewName})`,
27
+ visibility: "public",
28
+ line_start: 1,
29
+ line_end: Math.max(1, lineCount),
30
+ line_count: Math.max(1, lineCount),
31
+ });
32
+
33
+ // Also register with view: prefix for explicit queries
34
+ symbols.push({
35
+ name: `view:${viewName}`,
36
+ kind: "view",
37
+ signature: `@view(${viewName})`,
38
+ visibility: "public",
39
+ line_start: 1,
40
+ line_end: Math.max(1, lineCount),
41
+ line_count: Math.max(1, lineCount),
42
+ });
43
+ }
44
+
45
+ // 2. Line-by-line scanning for Blade directives, components, and DOM IDs
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ const trimmed = line.trim();
49
+ const lineNum = i + 1;
50
+
51
+ if (!trimmed) continue;
52
+
53
+ // Extends: @extends('layouts.app')
54
+ const extendsMatch = /@extends\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
55
+ if (extendsMatch) {
56
+ dependencies.push({
57
+ target: extendsMatch[1],
58
+ statement: trimmed,
59
+ line_number: lineNum,
60
+ is_external: false,
61
+ });
62
+ }
63
+
64
+ // Includes: @include('partials.store_card'), @includeIf, @includeWhen, @includeUnless
65
+ const includeRegex = /@include(?:If|When|Unless)?\s*\(\s*['"]([^'"]+)['"]/g;
66
+ let incMatch: RegExpExecArray | null;
67
+ while ((incMatch = includeRegex.exec(trimmed)) !== null) {
68
+ dependencies.push({
69
+ target: incMatch[1],
70
+ statement: trimmed,
71
+ line_number: lineNum,
72
+ is_external: false,
73
+ });
74
+ }
75
+
76
+ // Blade Components: <x-store.search-bar or <x-alert
77
+ const componentRegex = /<x-([a-zA-Z0-9_.\-]+)/g;
78
+ let compMatch: RegExpExecArray | null;
79
+ while ((compMatch = componentRegex.exec(trimmed)) !== null) {
80
+ const compTag = compMatch[1];
81
+ // Convert foo-bar to components.foo-bar or components.foo.bar
82
+ const compTarget = `components.${compTag.replace(/-/g, ".")}`;
83
+ dependencies.push({
84
+ target: compTarget,
85
+ statement: trimmed,
86
+ line_number: lineNum,
87
+ is_external: false,
88
+ });
89
+ symbols.push({
90
+ name: `<x-${compTag}>`,
91
+ kind: "component",
92
+ signature: `<x-${compTag}>`,
93
+ visibility: "public",
94
+ line_start: lineNum,
95
+ line_end: lineNum,
96
+ line_count: 1,
97
+ });
98
+ }
99
+
100
+ // Livewire: @livewire('search-dropdown')
101
+ const livewireMatch = /@livewire\s*\(\s*['"]([^'"]+)['"]/.exec(trimmed);
102
+ if (livewireMatch) {
103
+ dependencies.push({
104
+ target: `livewire:${livewireMatch[1]}`,
105
+ statement: trimmed,
106
+ line_number: lineNum,
107
+ is_external: false,
108
+ });
109
+ }
110
+
111
+ // Section: @section('content')
112
+ const sectionMatch = /@section\s*\(\s*['"]([^'"]+)['"]/.exec(trimmed);
113
+ if (sectionMatch) {
114
+ symbols.push({
115
+ name: `section:${sectionMatch[1]}`,
116
+ kind: "property",
117
+ signature: `@section('${sectionMatch[1]}')`,
118
+ visibility: "public",
119
+ line_start: lineNum,
120
+ line_end: lineNum,
121
+ line_count: 1,
122
+ });
123
+ }
124
+
125
+ // DOM ID attribute: id="laptop-slider" or id='laptop-slider'
126
+ const idMatch = /\bid=["']([a-zA-Z0-9_\-]+)["']/.exec(trimmed);
127
+ if (idMatch) {
128
+ const elementId = idMatch[1];
129
+ symbols.push({
130
+ name: `#${elementId}`,
131
+ kind: "property",
132
+ signature: `id="${elementId}"`,
133
+ visibility: "public",
134
+ line_start: lineNum,
135
+ line_end: lineNum,
136
+ line_count: 1,
137
+ });
138
+ }
139
+ }
140
+
141
+ return { symbols, dependencies };
142
+ }
143
+
144
+ /**
145
+ * Derive Laravel view name from path, e.g.:
146
+ * resources/views/partials/store_card.blade.php -> partials.store_card
147
+ */
148
+ private normalizeViewName(filePath: string): string | null {
149
+ const clean = filePath.replace(/\\/g, "/");
150
+ const viewsIdx = clean.indexOf("views/");
151
+ if (viewsIdx !== -1) {
152
+ const sub = clean.slice(viewsIdx + 6).replace(/\.blade\.php$/i, "");
153
+ return sub.replace(/\//g, ".");
154
+ }
155
+ const base = clean.split("/").pop()?.replace(/\.blade\.php$/i, "");
156
+ return base || null;
157
+ }
158
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findBraceBlockEnd } from "../boundary-tracker.ts";
2
+ import { findBraceBlockEnd, calculateControlFlowNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  const GO_STDLIB_PREFIXES = new Set([
@@ -131,6 +131,7 @@ export class GoExtractor implements CodeExtractor {
131
131
  const methodName = methodMatch[1];
132
132
  const visibility: Visibility = /^[A-Z]/.test(methodName) ? "public" : "private";
133
133
  const endLine = findBraceBlockEnd(lines, i);
134
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
134
135
 
135
136
  symbols.push({
136
137
  name: methodName,
@@ -140,6 +141,7 @@ export class GoExtractor implements CodeExtractor {
140
141
  line_start: lineNum,
141
142
  line_end: endLine,
142
143
  line_count: Math.max(1, endLine - lineNum + 1),
144
+ nesting_depth: nestingDepth,
143
145
  });
144
146
  continue;
145
147
  }
@@ -150,6 +152,7 @@ export class GoExtractor implements CodeExtractor {
150
152
  const funcName = funcMatch[1];
151
153
  const visibility: Visibility = /^[A-Z]/.test(funcName) ? "public" : "private";
152
154
  const endLine = findBraceBlockEnd(lines, i);
155
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
153
156
 
154
157
  symbols.push({
155
158
  name: funcName,
@@ -159,6 +162,7 @@ export class GoExtractor implements CodeExtractor {
159
162
  line_start: lineNum,
160
163
  line_end: endLine,
161
164
  line_count: Math.max(1, endLine - lineNum + 1),
165
+ nesting_depth: nestingDepth,
162
166
  });
163
167
  }
164
168
  }