septum 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/bin/septum.ts +4 -0
  4. package/package.json +62 -0
  5. package/src/cli/commands/check.ts +161 -0
  6. package/src/cli/commands/hook.ts +188 -0
  7. package/src/cli/commands/ingest.ts +29 -0
  8. package/src/cli/commands/init.ts +211 -0
  9. package/src/cli/commands/locate.ts +61 -0
  10. package/src/cli/commands/query.ts +64 -0
  11. package/src/cli/commands/serve.ts +5 -0
  12. package/src/cli/commands/slice.ts +90 -0
  13. package/src/cli/commands/sync.ts +31 -0
  14. package/src/cli/index.ts +178 -0
  15. package/src/cli/templates/hooks.ts +75 -0
  16. package/src/cli/templates/rules.ts +38 -0
  17. package/src/cli/templates/skill.ts +66 -0
  18. package/src/core/boundary/evaluator.ts +210 -0
  19. package/src/core/config/loader.ts +84 -0
  20. package/src/core/config/schema.ts +39 -0
  21. package/src/core/database/client.ts +132 -0
  22. package/src/core/database/repositories/dependency.repository.ts +279 -0
  23. package/src/core/database/repositories/domain.repository.ts +198 -0
  24. package/src/core/database/repositories/file.repository.ts +99 -0
  25. package/src/core/database/repositories/meta.repository.ts +33 -0
  26. package/src/core/database/repositories/symbol.repository.ts +424 -0
  27. package/src/core/database/repository.ts +242 -0
  28. package/src/core/database/schema.sql +92 -0
  29. package/src/core/discovery/topology-detector.ts +418 -0
  30. package/src/core/ingestion/hasher.ts +5 -0
  31. package/src/core/ingestion/pipeline.ts +438 -0
  32. package/src/core/parser/boundary-tracker.ts +128 -0
  33. package/src/core/parser/extractors/base.ts +60 -0
  34. package/src/core/parser/extractors/frontend.ts +194 -0
  35. package/src/core/parser/extractors/go.ts +176 -0
  36. package/src/core/parser/extractors/laravel-semantic.ts +426 -0
  37. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -0
  38. package/src/core/parser/extractors/nestjs-semantic.ts +119 -0
  39. package/src/core/parser/extractors/php.ts +149 -0
  40. package/src/core/parser/extractors/python.ts +226 -0
  41. package/src/core/parser/extractors/registry.ts +49 -0
  42. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -0
  43. package/src/core/parser/extractors/typescript.ts +177 -0
  44. package/src/core/parser/tree-sitter.ts +39 -0
  45. package/src/core/resolver/call-graph-tracer.ts +668 -0
  46. package/src/core/resolver/module-resolver.ts +323 -0
  47. package/src/core/resolver/symbol-locator.ts +405 -0
  48. package/src/core/resolver/vertical-slice-tracer.ts +236 -0
  49. package/src/core/session/session-manager.ts +56 -0
  50. package/src/core/telemetry/telemetry.ts +86 -0
  51. package/src/index.ts +18 -0
  52. package/src/mcp/schemas.ts +68 -0
  53. package/src/mcp/server.ts +602 -0
  54. package/src/mcp/tools/check-boundary.ts +102 -0
  55. package/src/mcp/tools/get-domain-catalog.ts +91 -0
  56. package/src/mcp/tools/get-feature-context.ts +116 -0
  57. package/src/mcp/tools/get-symbol-hotspots.ts +49 -0
  58. package/src/mcp/tools/get-symbol-impact.ts +26 -0
  59. package/src/mcp/tools/get-symbol.ts +63 -0
  60. package/src/mcp/tools/locate-symbol.ts +74 -0
  61. package/src/mcp/tools/register-domain.ts +64 -0
  62. package/src/mcp/tools/trace-vertical-slice.ts +102 -0
  63. package/src/types/index.ts +296 -0
@@ -0,0 +1,405 @@
1
+ import type { FileRecord, SymbolKind, SymbolRecord } from "../database/repository.ts";
2
+ import type { SeptumRepository } from "../database/repository.ts";
3
+
4
+ export interface ParsedSymbolQuery {
5
+ raw: string;
6
+ container?: string; // e.g. "OrderController" or "App\\Http\\Controllers\\OrderController"
7
+ member?: string; // e.g. "calculateTotal"
8
+ kind?: SymbolKind;
9
+ }
10
+
11
+ export interface SuggestionMatch {
12
+ name: string;
13
+ kind: string;
14
+ signature: string;
15
+ line_start: number;
16
+ line_end: number;
17
+ similarity_score: number; // 0.0 - 1.0
18
+ file_path?: string;
19
+ }
20
+
21
+ export interface SymbolLocationResult {
22
+ query: string;
23
+ parsed: ParsedSymbolQuery;
24
+ found: boolean;
25
+ file_path?: string;
26
+ container_name?: string;
27
+ exact_symbol?: {
28
+ name: string;
29
+ kind: string;
30
+ signature: string;
31
+ visibility: string;
32
+ line_start: number;
33
+ line_end: number;
34
+ };
35
+ sibling_methods?: string[];
36
+ suggestions: SuggestionMatch[];
37
+ message: string;
38
+ }
39
+
40
+ export class SymbolLocator {
41
+ constructor(private repo: SeptumRepository) {}
42
+
43
+ /**
44
+ * Parse error log strings, method call syntax, or symbol names into container & member.
45
+ * Examples:
46
+ * - "Method OrderController::calculateTotal() does not exist"
47
+ * - "Call to undefined method App\\Http\\Controllers\\OrderController::calculateTotal()"
48
+ * - "OrderController->calculateTotal()"
49
+ * - "OrderController.calculateTotal"
50
+ * - "OrderController"
51
+ * - "calculateTotal"
52
+ */
53
+ public parseQuery(rawQuery: string): ParsedSymbolQuery {
54
+ const trimmed = rawQuery.trim();
55
+
56
+ // 1. Error string matching: "Method X::y() does not exist" or "Call to undefined method X::y()"
57
+ const methodNotExistMatch = trimmed.match(/(?:Method|method)\s+([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)\s*\(\)?/i);
58
+ if (methodNotExistMatch) {
59
+ return {
60
+ raw: trimmed,
61
+ container: methodNotExistMatch[1],
62
+ member: methodNotExistMatch[2],
63
+ kind: "method",
64
+ };
65
+ }
66
+
67
+ // 2. Python / JS error: "'X' object has no attribute 'y'"
68
+ const pyAttrMatch = trimmed.match(/['"]?([A-Za-z0-9_]+)['"]?\s+object has no attribute\s+['"]?([A-Za-z0-9_]+)['"]?/i);
69
+ if (pyAttrMatch) {
70
+ return {
71
+ raw: trimmed,
72
+ container: pyAttrMatch[1],
73
+ member: pyAttrMatch[2],
74
+ };
75
+ }
76
+
77
+ // 3. Class::member or Class->member or Class.member
78
+ const staticCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)(?:\(\))?$/);
79
+ if (staticCallMatch) {
80
+ return {
81
+ raw: trimmed,
82
+ container: staticCallMatch[1],
83
+ member: staticCallMatch[2],
84
+ kind: "method",
85
+ };
86
+ }
87
+
88
+ const arrowCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)->([A-Za-z0-9_]+)(?:\(\))?$/);
89
+ if (arrowCallMatch) {
90
+ return {
91
+ raw: trimmed,
92
+ container: arrowCallMatch[1],
93
+ member: arrowCallMatch[2],
94
+ kind: "method",
95
+ };
96
+ }
97
+
98
+ const dotCallMatch = trimmed.match(/^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)(?:\(\))?$/);
99
+ if (dotCallMatch) {
100
+ return {
101
+ raw: trimmed,
102
+ container: dotCallMatch[1],
103
+ member: dotCallMatch[2],
104
+ };
105
+ }
106
+
107
+ // 4. Function call: "calculateTotal()"
108
+ const funcMatch = trimmed.match(/^([A-Za-z0-9_]+)\(\)$/);
109
+ if (funcMatch) {
110
+ return {
111
+ raw: trimmed,
112
+ member: funcMatch[1],
113
+ kind: "function",
114
+ };
115
+ }
116
+
117
+ // 5. Bare word: could be class or function
118
+ return {
119
+ raw: trimmed,
120
+ member: trimmed,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Locate the symbol or diagnostic resolution in the database.
126
+ */
127
+ public locate(rawQuery: string, domainFilter?: string): SymbolLocationResult {
128
+ const parsed = this.parseQuery(rawQuery);
129
+
130
+ // If a container (Class / Interface) is specified
131
+ if (parsed.container) {
132
+ return this.locateWithContainer(parsed, domainFilter);
133
+ }
134
+
135
+ // If only member or bare name is provided
136
+ return this.locateBareMember(parsed, domainFilter);
137
+ }
138
+
139
+ private locateWithContainer(
140
+ parsed: ParsedSymbolQuery,
141
+ domainFilter?: string
142
+ ): SymbolLocationResult {
143
+ const containerQuery = parsed.container!;
144
+ const simpleContainerName = containerQuery.split(/\\|\//).pop() || containerQuery;
145
+ const memberName = parsed.member;
146
+
147
+ // Search for container in symbols table or files table
148
+ const containers = this.repo.findContainers(simpleContainerName);
149
+
150
+ if (containers.length === 0) {
151
+ return {
152
+ query: parsed.raw,
153
+ parsed,
154
+ found: false,
155
+ suggestions: [],
156
+ message: `Container '${containerQuery}' was not found in any cataloged files. Run 'septum ingest' if recently added.`,
157
+ };
158
+ }
159
+
160
+ // Pick primary container matching
161
+ const primary = containers[0];
162
+ const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
163
+
164
+ // Extract methods in this container / file
165
+ const methodSymbols = fileSymbols.filter(
166
+ (s) => s.kind === "method" || s.kind === "function"
167
+ );
168
+
169
+ const siblingMethodNames = Array.from(
170
+ new Set(
171
+ methodSymbols.map((m) => {
172
+ if (m.name.includes("::")) {
173
+ return m.name.split("::")[1];
174
+ }
175
+ return m.name;
176
+ })
177
+ )
178
+ );
179
+
180
+ if (!memberName) {
181
+ // User only asked for the container
182
+ return {
183
+ query: parsed.raw,
184
+ parsed,
185
+ found: true,
186
+ file_path: primary.file.path,
187
+ container_name: simpleContainerName,
188
+ exact_symbol: primary.symbol
189
+ ? {
190
+ name: primary.symbol.name,
191
+ kind: primary.symbol.kind,
192
+ signature: primary.symbol.signature,
193
+ visibility: primary.symbol.visibility,
194
+ line_start: primary.symbol.line_start,
195
+ line_end: primary.symbol.line_end,
196
+ }
197
+ : undefined,
198
+ sibling_methods: siblingMethodNames,
199
+ suggestions: [],
200
+ message: `Found container '${simpleContainerName}' in ${primary.file.path} with ${siblingMethodNames.length} methods.`,
201
+ };
202
+ }
203
+
204
+ // Search for exact member match
205
+ const exactMatch = methodSymbols.find((s) => {
206
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
207
+ return bareName.toLowerCase() === memberName.toLowerCase();
208
+ });
209
+
210
+ if (exactMatch) {
211
+ return {
212
+ query: parsed.raw,
213
+ parsed,
214
+ found: true,
215
+ file_path: primary.file.path,
216
+ container_name: simpleContainerName,
217
+ exact_symbol: {
218
+ name: exactMatch.name,
219
+ kind: exactMatch.kind,
220
+ signature: exactMatch.signature,
221
+ visibility: exactMatch.visibility,
222
+ line_start: exactMatch.line_start,
223
+ line_end: exactMatch.line_end,
224
+ },
225
+ sibling_methods: siblingMethodNames,
226
+ suggestions: [],
227
+ message: `Exact method '${memberName}' found in ${primary.file.path} at line ${exactMatch.line_start}.`,
228
+ };
229
+ }
230
+
231
+ // Member NOT found in container: calculate fuzzy suggestions
232
+ const suggestions: SuggestionMatch[] = methodSymbols
233
+ .map((s) => {
234
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
235
+ const score = calculateSimilarity(memberName, bareName);
236
+ return {
237
+ name: bareName,
238
+ kind: s.kind,
239
+ signature: s.signature,
240
+ line_start: s.line_start,
241
+ line_end: s.line_end,
242
+ similarity_score: Math.round(score * 100) / 100,
243
+ };
244
+ })
245
+ .filter((s) => s.similarity_score >= 0.25)
246
+ .sort((a, b) => b.similarity_score - a.similarity_score)
247
+ .slice(0, 5);
248
+
249
+ const topSuggestion = suggestions.length > 0 ? suggestions[0].name : null;
250
+ const hint = topSuggestion
251
+ ? ` Method '${memberName}' does not exist! Did you mean '${topSuggestion}'?`
252
+ : ` Method '${memberName}' does not exist.`;
253
+
254
+ return {
255
+ query: parsed.raw,
256
+ parsed,
257
+ found: false,
258
+ file_path: primary.file.path,
259
+ container_name: simpleContainerName,
260
+ sibling_methods: siblingMethodNames,
261
+ suggestions,
262
+ message: `File: ${primary.file.path}.${hint} Available methods: [${siblingMethodNames.join(", ")}].`,
263
+ };
264
+ }
265
+
266
+ private locateBareMember(
267
+ parsed: ParsedSymbolQuery,
268
+ domainFilter?: string
269
+ ): SymbolLocationResult {
270
+ const targetName = parsed.member!;
271
+ const directMatches = this.repo.findSymbolsByName(targetName);
272
+
273
+ if (directMatches.length > 0) {
274
+ const first = directMatches[0];
275
+ return {
276
+ query: parsed.raw,
277
+ parsed,
278
+ found: true,
279
+ file_path: first.file_path,
280
+ container_name: first.name.includes("::") ? first.name.split("::")[0] : undefined,
281
+ exact_symbol: {
282
+ name: first.name,
283
+ kind: first.kind,
284
+ signature: first.signature,
285
+ visibility: first.visibility,
286
+ line_start: first.line_start,
287
+ line_end: first.line_end,
288
+ },
289
+ suggestions: [],
290
+ message: `Found exact symbol '${first.name}' in ${first.file_path}:${first.line_start}`,
291
+ };
292
+ }
293
+
294
+ // If bare name is a class name
295
+ const containers = this.repo.findContainers(targetName);
296
+ if (containers.length > 0) {
297
+ const primary = containers[0];
298
+ const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
299
+ const siblingMethods = fileSymbols
300
+ .filter((s) => s.kind === "method" || s.kind === "function")
301
+ .map((s) => (s.name.includes("::") ? s.name.split("::")[1] : s.name));
302
+
303
+ return {
304
+ query: parsed.raw,
305
+ parsed,
306
+ found: true,
307
+ file_path: primary.file.path,
308
+ container_name: targetName,
309
+ exact_symbol: primary.symbol
310
+ ? {
311
+ name: primary.symbol.name,
312
+ kind: primary.symbol.kind,
313
+ signature: primary.symbol.signature,
314
+ visibility: primary.symbol.visibility,
315
+ line_start: primary.symbol.line_start,
316
+ line_end: primary.symbol.line_end,
317
+ }
318
+ : undefined,
319
+ sibling_methods: Array.from(new Set(siblingMethods)),
320
+ suggestions: [],
321
+ message: `Found container '${targetName}' in ${primary.file.path}`,
322
+ };
323
+ }
324
+
325
+ // Nothing found directly: Search all symbols for fuzzy suggestions
326
+ const allSymbols = this.repo.getAllSymbolsWithFiles();
327
+ const suggestions: SuggestionMatch[] = allSymbols
328
+ .map((s) => {
329
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
330
+ const score = calculateSimilarity(targetName, bareName);
331
+ return {
332
+ name: s.name,
333
+ kind: s.kind,
334
+ signature: `${s.signature} (${s.file_path}:${s.line_start})`,
335
+ line_start: s.line_start,
336
+ line_end: s.line_end,
337
+ similarity_score: Math.round(score * 100) / 100,
338
+ };
339
+ })
340
+ .filter((s) => s.similarity_score >= 0.35)
341
+ .sort((a, b) => b.similarity_score - a.similarity_score)
342
+ .slice(0, 5);
343
+
344
+ return {
345
+ query: parsed.raw,
346
+ parsed,
347
+ found: false,
348
+ suggestions,
349
+ message: `Symbol '${targetName}' not found. ${suggestions.length > 0 ? `Did you mean '${suggestions[0].name}'?` : ""}`,
350
+ };
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Standard Levenshtein distance with token substring bonus.
356
+ */
357
+ export function calculateSimilarity(source: string, target: string): number {
358
+ const s1 = source.toLowerCase();
359
+ const s2 = target.toLowerCase();
360
+
361
+ if (s1 === s2) return 1.0;
362
+ if (!s1 || !s2) return 0.0;
363
+
364
+ // Substring inclusion bonus
365
+ if (s2.includes(s1) || s1.includes(s2)) {
366
+ const minLen = Math.min(s1.length, s2.length);
367
+ const maxLen = Math.max(s1.length, s2.length);
368
+ return 0.5 + 0.5 * (minLen / maxLen);
369
+ }
370
+
371
+ // Token-based matching (e.g. calculateTotal vs recalculateOrder shares 'calculate')
372
+ const tokenize = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[\s_-]+/);
373
+ const t1 = tokenize(s1);
374
+ const t2 = tokenize(s2);
375
+ const sharedTokens = t1.filter((token) => t2.some((t) => t.includes(token) || token.includes(t)));
376
+ const tokenBonus = sharedTokens.length > 0 ? 0.3 : 0.0;
377
+
378
+ const len1 = s1.length;
379
+ const len2 = s2.length;
380
+ const matrix: number[][] = [];
381
+
382
+ for (let i = 0; i <= len1; i++) {
383
+ matrix[i] = [i];
384
+ }
385
+ for (let j = 0; j <= len2; j++) {
386
+ matrix[0][j] = j;
387
+ }
388
+
389
+ for (let i = 1; i <= len1; i++) {
390
+ for (let j = 1; j <= len2; j++) {
391
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
392
+ matrix[i][j] = Math.min(
393
+ matrix[i - 1][j] + 1, // deletion
394
+ matrix[i][j - 1] + 1, // insertion
395
+ matrix[i - 1][j - 1] + cost // substitution
396
+ );
397
+ }
398
+ }
399
+
400
+ const distance = matrix[len1][len2];
401
+ const maxLen = Math.max(len1, len2);
402
+ const rawSimilarity = 1.0 - distance / maxLen;
403
+
404
+ return Math.min(1.0, Math.max(0.0, rawSimilarity + tokenBonus));
405
+ }
@@ -0,0 +1,236 @@
1
+ import type {
2
+ ExecutionChainNode,
3
+ VerticalSliceRecord,
4
+ VerticalSliceTraceResponse,
5
+ } from "../../types/index.ts";
6
+ import type { SeptumRepository } from "../database/repository.ts";
7
+ import { CallGraphTracer } from "./call-graph-tracer.ts";
8
+
9
+ export class VerticalSliceTracer {
10
+ constructor(private repo: SeptumRepository) {}
11
+
12
+ /**
13
+ * Trace an end-to-end vertical slice from route/action/intent query.
14
+ * Examples of queries:
15
+ * - "POST /orders/{id}/status"
16
+ * - "/orders/{id}/status"
17
+ * - "OrderController@updateStatus"
18
+ * - "OrderController::updateStatus"
19
+ * - "orders.update_status"
20
+ * - "createOrder"
21
+ * - "Ubah status order di dashboard"
22
+ */
23
+ public trace(rawQuery: string): VerticalSliceTraceResponse {
24
+ const trimmed = rawQuery.trim();
25
+ let allSlices = this.repo.getAllVerticalSlices();
26
+
27
+ // Lazy fallback: If no slices cataloged, attempt heuristic call-graph tracing
28
+ if (allSlices.length === 0) {
29
+ try {
30
+ const tracer = new CallGraphTracer(this.repo);
31
+ const traced = tracer.traceAllSlices();
32
+ if (traced.length > 0) {
33
+ for (const s of traced) {
34
+ this.repo.upsertVerticalSlice(s);
35
+ }
36
+ allSlices = this.repo.getAllVerticalSlices();
37
+ }
38
+ } catch (err: unknown) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ console.error(`[Septum Slice Warning] Failed on-demand trace of vertical slices: ${msg}`);
41
+ }
42
+ }
43
+
44
+ if (allSlices.length === 0) {
45
+ return {
46
+ query: trimmed,
47
+ found: false,
48
+ message: "No vertical slices cataloged. Run 'septum ingest' to extract framework routes and MVC slices.",
49
+ };
50
+ }
51
+
52
+ // 1. Exact HTTP Method + URI match: "POST /orders/{id}/status" or "POST /api/orders"
53
+ const methodUriMatch = trimmed.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\/[^\s]+)$/i);
54
+ if (methodUriMatch) {
55
+ const httpMethod = methodUriMatch[1].toUpperCase();
56
+ const uri = methodUriMatch[2].toLowerCase();
57
+ const exact = allSlices.find(
58
+ (s) =>
59
+ s.http_method.toUpperCase() === httpMethod &&
60
+ (s.route_uri.toLowerCase() === uri || this.urisMatch(s.route_uri, uri))
61
+ );
62
+ if (exact) {
63
+ return this.formatSuccessResponse(trimmed, exact, "exact");
64
+ }
65
+ }
66
+
67
+ // 2. URI-only match: "/orders" or "orders/{id}"
68
+ const uriMatches = allSlices.filter((s) => s.route_uri.toLowerCase() === lowerTrimmed);
69
+ if (uriMatches.length > 0) {
70
+ if (uriMatches.length === 1) {
71
+ return this.formatSuccessResponse(trimmed, uriMatches[0], "exact");
72
+ }
73
+ // Multiple matches (e.g. GET /orders and POST /orders) -> return primary with alternatives
74
+ return this.formatSuccessResponse(trimmed, uriMatches[0], "exact", uriMatches.slice(1));
75
+ }
76
+
77
+ // 3. Named Route match: "orders.status.update" or "admin.users.index"
78
+ const nameMatch = allSlices.find(
79
+ (s) => s.route_name && s.route_name.toLowerCase() === lowerTrimmed
80
+ );
81
+ if (nameMatch) {
82
+ return this.formatSuccessResponse(trimmed, nameMatch, "exact");
83
+ }
84
+
85
+ // 4. Controller@Action match: "OrderController@updateStatus" or "OrderController"
86
+ const controllerActionMatch = trimmed.match(/^([A-Za-z0-9_]+)(?:@([A-Za-z0-9_]+))?$/);
87
+ if (controllerActionMatch) {
88
+ const [, ctrl, act] = controllerActionMatch;
89
+ const lowerCtrl = ctrl.toLowerCase();
90
+ const lowerAct = act ? act.toLowerCase() : undefined;
91
+
92
+ const exact = allSlices.find((s) => {
93
+ const ctrlMatch = s.controller_class.toLowerCase() === lowerCtrl || s.controller_class.toLowerCase().endsWith(lowerCtrl);
94
+ if (!ctrlMatch) return false;
95
+ if (lowerAct) return s.action_name.toLowerCase() === lowerAct;
96
+ return true;
97
+ });
98
+ if (exact) {
99
+ return this.formatSuccessResponse(trimmed, exact, "exact");
100
+ }
101
+ }
102
+
103
+ // 5. Symbol Match in Execution Chain or Action Name (e.g. "createOrder", "CreateOrderUseCase", "OrderController")
104
+ const symbolMatch = allSlices.find((s) => {
105
+ if (s.action_name.toLowerCase() === lowerTrimmed) return true;
106
+ if (s.controller_class.toLowerCase() === lowerTrimmed) return true;
107
+ if (s.execution_chain_json) {
108
+ try {
109
+ const chain: ExecutionChainNode[] = JSON.parse(s.execution_chain_json);
110
+ return chain.some(
111
+ (node) =>
112
+ node.symbol.toLowerCase() === lowerTrimmed ||
113
+ node.symbol.toLowerCase().includes(lowerTrimmed) ||
114
+ (node.file && node.file.toLowerCase().includes(lowerTrimmed))
115
+ );
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+ return false;
121
+ });
122
+ if (symbolMatch) {
123
+ return this.formatSuccessResponse(trimmed, symbolMatch, "inferred");
124
+ }
125
+
126
+ // 6. Semantic / Token Scoring for Natural Language (e.g. "Ubah status order di dashboard")
127
+ const tokens = this.tokenize(trimmed);
128
+ const scoredSlices = allSlices
129
+ .map((slice) => {
130
+ let score = 0;
131
+ const textCorpus = [
132
+ slice.route_uri,
133
+ slice.route_name || "",
134
+ slice.controller_class,
135
+ slice.action_name,
136
+ slice.execution_chain_json || "",
137
+ ].join(" ").toLowerCase();
138
+
139
+ for (const token of tokens) {
140
+ if (textCorpus.includes(token)) {
141
+ score += 1;
142
+ if (slice.route_uri.toLowerCase().includes(token)) score += 2;
143
+ if (slice.action_name.toLowerCase().includes(token)) score += 2;
144
+ }
145
+ }
146
+ return { slice, score };
147
+ })
148
+ .filter((item) => item.score > 0)
149
+ .sort((a, b) => b.score - a.score);
150
+
151
+ if (scoredSlices.length > 0) {
152
+ const primary = scoredSlices[0].slice;
153
+ const alternatives = scoredSlices.slice(1, 4).map((item) => item.slice);
154
+ return this.formatSuccessResponse(trimmed, primary, "inferred", alternatives);
155
+ }
156
+
157
+ return {
158
+ query: trimmed,
159
+ found: false,
160
+ alternatives: allSlices.slice(0, 5).map((s) => ({
161
+ method: s.http_method,
162
+ uri: s.route_uri,
163
+ controller: s.controller_class,
164
+ action: s.action_name,
165
+ })),
166
+ message: `No vertical slice found matching query '${trimmed}'.`,
167
+ };
168
+ }
169
+
170
+ private urisMatch(uriA: string, uriB: string): boolean {
171
+ const normA = uriA.toLowerCase().replace(/\{[^}]+\}/g, ":param");
172
+ const normB = uriB.toLowerCase().replace(/\{[^}]+\}/g, ":param").replace(/:[a-zA-Z0-9_]+/g, ":param");
173
+ return normA === normB;
174
+ }
175
+
176
+ private tokenize(str: string): string[] {
177
+ return str
178
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
179
+ .toLowerCase()
180
+ .replace(/[^a-z0-9_]+/g, " ")
181
+ .split(/\s+/)
182
+ .filter((t) => t.length > 2 && !["ubah", "di", "dan", "yang", "the", "for", "and"].includes(t));
183
+ }
184
+
185
+ private formatSuccessResponse(
186
+ query: string,
187
+ record: VerticalSliceRecord,
188
+ confidence: "exact" | "inferred" = "exact",
189
+ alternatives?: VerticalSliceRecord[]
190
+ ): VerticalSliceTraceResponse {
191
+ let chain: ExecutionChainNode[] = [];
192
+ if (record.execution_chain_json) {
193
+ try {
194
+ chain = JSON.parse(record.execution_chain_json);
195
+ } catch (err: unknown) {
196
+ const msg = err instanceof Error ? err.message : String(err);
197
+ console.error(`[Septum Slice Error] Corrupted execution chain JSON for slice ${record.id}: ${msg}`);
198
+ }
199
+ }
200
+
201
+ let message = `Vertical slice resolved (${confidence}): ${record.http_method} ${record.route_uri} -> ${record.controller_class}@${record.action_name}`;
202
+ if (chain.length > 0) {
203
+ const stageLines = chain.map((node) => {
204
+ const stageLabel = `[${node.stage.toUpperCase()}]`.padEnd(14, " ");
205
+ const loc = node.file ? ` (${node.file}${node.line ? `:${node.line}` : ""})` : "";
206
+ return `${stageLabel} ${node.symbol}${loc}`;
207
+ });
208
+ message = `=== VERTICAL SLICE TRACE (${confidence.toUpperCase()}) ===\n` + stageLines.join("\n");
209
+ }
210
+
211
+ return {
212
+ query,
213
+ found: true,
214
+ confidence,
215
+ is_exact_match: confidence === "exact",
216
+ architecture_style: record.architecture_style,
217
+ entrypoint: {
218
+ method: record.http_method,
219
+ uri: record.route_uri,
220
+ name: record.route_name,
221
+ controller: record.controller_class,
222
+ action: record.action_name,
223
+ file: record.controller_file,
224
+ line: record.controller_line,
225
+ },
226
+ chain,
227
+ alternatives: alternatives?.map((a) => ({
228
+ method: a.http_method,
229
+ uri: a.route_uri,
230
+ controller: a.controller_class,
231
+ action: a.action_name,
232
+ })),
233
+ message,
234
+ };
235
+ }
236
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ActiveFeatureSession } from "../../types/index.ts";
4
+
5
+ export class SessionManager {
6
+ private static readonly SESSION_FILE = ".septum/session.json";
7
+
8
+ /**
9
+ * Save the active feature session to .septum/session.json
10
+ */
11
+ public static saveActiveSession(workspaceRoot: string, session: ActiveFeatureSession): void {
12
+ const septumDir = join(workspaceRoot, ".septum");
13
+ if (!existsSync(septumDir)) {
14
+ mkdirSync(septumDir, { recursive: true });
15
+ }
16
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
17
+ writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
18
+ }
19
+
20
+ /**
21
+ * Get the active feature session, or null if no session exists or file is invalid
22
+ */
23
+ public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
24
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
25
+ if (!existsSync(sessionPath)) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ const raw = readFileSync(sessionPath, "utf-8");
31
+ const parsed = JSON.parse(raw) as ActiveFeatureSession;
32
+ if (parsed && typeof parsed.feature_key === "string" && Array.isArray(parsed.touchpoints)) {
33
+ return parsed;
34
+ }
35
+ return null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Clear the active feature session
43
+ */
44
+ public static clearActiveSession(workspaceRoot: string): boolean {
45
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
46
+ if (existsSync(sessionPath)) {
47
+ try {
48
+ rmSync(sessionPath, { force: true });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ }