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,438 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import type { ParsedFileAST } from "../../types/index.ts";
4
+ import type { ValidatedSeptumConfig } from "../config/schema.ts";
5
+ import type { SeptumRepository } from "../database/repository.ts";
6
+ import { detectArchetype } from "../parser/extractors/base.ts";
7
+ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts";
8
+ import { TopologyDetector } from "../discovery/topology-detector.ts";
9
+ import { ASTParserEngine } from "../parser/tree-sitter.ts";
10
+ import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
11
+ import { computeContentHash } from "./hasher.ts";
12
+
13
+ export interface IngestionMetrics {
14
+ domains_processed: number;
15
+ files_scanned: number;
16
+ files_updated: number;
17
+ files_skipped: number;
18
+ symbols_indexed: number;
19
+ dependencies_indexed: number;
20
+ duration_ms: number;
21
+ }
22
+
23
+ export class IngestionPipeline {
24
+ private repo: SeptumRepository;
25
+ private parserEngine: ASTParserEngine;
26
+ private customWorkspaceRoot?: string;
27
+
28
+ constructor(repo: SeptumRepository, workspaceRoot?: string) {
29
+ this.repo = repo;
30
+ this.parserEngine = new ASTParserEngine();
31
+ if (workspaceRoot) {
32
+ this.customWorkspaceRoot = resolve(workspaceRoot);
33
+ }
34
+ }
35
+
36
+ public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
37
+ const startTime = performance.now();
38
+ let filesScanned = 0;
39
+ let filesUpdated = 0;
40
+ let filesSkipped = 0;
41
+ let symbolsIndexed = 0;
42
+ let dependenciesIndexed = 0;
43
+
44
+ let domainNames = Object.keys(config.domains);
45
+ const projectRoot = this.customWorkspaceRoot || process.cwd();
46
+
47
+ // Auto-discovery mode if no domains explicitly declared in config
48
+ if (domainNames.length === 0) {
49
+ const discovered = this.autoDiscoverDomains(projectRoot);
50
+ for (const [dName, dCfg] of Object.entries(discovered)) {
51
+ config.domains[dName] = {
52
+ root: dCfg.root,
53
+ description: dCfg.description,
54
+ allowed_dependencies: dCfg.allowed_dependencies || [],
55
+ forbidden_dependencies: dCfg.forbidden_dependencies || [],
56
+ archetypes: dCfg.archetypes || {},
57
+ };
58
+ }
59
+ domainNames = Object.keys(config.domains);
60
+ }
61
+
62
+ for (const domainName of domainNames) {
63
+ const domainCfg = config.domains[domainName];
64
+ const domainId = this.repo.upsertDomain(domainName, domainCfg);
65
+
66
+ if (!existsSync(domainCfg.root)) {
67
+ continue;
68
+ }
69
+
70
+ const workspaceRoot = this.resolveWorkspaceRoot(config, domainCfg.root);
71
+ const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
72
+ const childDomainRoots = domainNames
73
+ .filter((d) => d !== domainName)
74
+ .map((d) => config.domains[d].root.replace(/\\/g, "/").replace(/^\.\/?/, ""))
75
+ .filter((r) => {
76
+ if (!r) return false;
77
+ if (!normCurrentRoot) return true;
78
+ return r.startsWith(normCurrentRoot + "/");
79
+ });
80
+
81
+ const collectedFiles = this.collectFiles(
82
+ domainCfg.root,
83
+ workspaceRoot,
84
+ config.settings.ignore_patterns,
85
+ childDomainRoots
86
+ );
87
+ const activeRelPaths: string[] = [];
88
+
89
+ const pendingUpdates: Array<{
90
+ fullPath: string;
91
+ relPath: string;
92
+ contentHash: string;
93
+ lineCount: number;
94
+ archetype: ReturnType<typeof detectArchetype>;
95
+ mtimeMs: number;
96
+ sizeBytes: number;
97
+ ast: ParsedFileAST;
98
+ }> = [];
99
+
100
+ for (const { fullPath, relPath } of collectedFiles) {
101
+ filesScanned++;
102
+ activeRelPaths.push(relPath);
103
+
104
+ let stat;
105
+ try {
106
+ stat = statSync(fullPath);
107
+ } catch {
108
+ continue;
109
+ }
110
+
111
+ const mtimeMs = Math.round(stat.mtimeMs);
112
+ const sizeBytes = stat.size;
113
+ const existingFile = this.repo.getFileByPath(relPath);
114
+
115
+ // Tier 1: Fast OS metadata check (inode mtime + size + domain match)
116
+ if (
117
+ existingFile &&
118
+ existingFile.domain_id === domainId &&
119
+ existingFile.mtime_ms === mtimeMs &&
120
+ existingFile.size_bytes === sizeBytes
121
+ ) {
122
+ filesSkipped++;
123
+ continue;
124
+ }
125
+
126
+ // Tier 2: Content Hash verification
127
+ const content = readFileSync(fullPath, "utf-8");
128
+ const currentHash = computeContentHash(content);
129
+
130
+ if (
131
+ existingFile &&
132
+ existingFile.domain_id === domainId &&
133
+ existingFile.content_hash === currentHash
134
+ ) {
135
+ // Content identical despite mtime bump (e.g. touch) - update metadata without re-parsing AST
136
+ this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
137
+ filesSkipped++;
138
+ continue;
139
+ }
140
+
141
+ filesUpdated++;
142
+ const lineCount = content.split("\n").length;
143
+ const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
144
+ const ast = await this.parserEngine.parseFile(fullPath, content);
145
+
146
+ pendingUpdates.push({
147
+ fullPath,
148
+ relPath,
149
+ contentHash: currentHash,
150
+ lineCount,
151
+ archetype,
152
+ mtimeMs,
153
+ sizeBytes,
154
+ ast,
155
+ });
156
+ }
157
+
158
+ // Batch ACID Transaction per domain
159
+ this.repo.runInTransaction(() => {
160
+ for (const item of pendingUpdates) {
161
+ const fileId = this.repo.upsertFile(
162
+ domainId,
163
+ item.relPath,
164
+ item.archetype,
165
+ item.contentHash,
166
+ item.lineCount,
167
+ item.mtimeMs,
168
+ item.sizeBytes
169
+ );
170
+
171
+ this.repo.replaceFileSymbols(fileId, item.ast.symbols);
172
+ this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
173
+
174
+ symbolsIndexed += item.ast.symbols.length;
175
+ dependenciesIndexed += item.ast.dependencies.length;
176
+ }
177
+
178
+ // State Reconciliation: Prune deleted files from DB
179
+ this.repo.deleteFilesNotInPaths(domainId, activeRelPaths);
180
+ });
181
+ }
182
+
183
+ // Framework-Agnostic Semantic Vertical Slice Extraction (via Registry Strategy)
184
+ const detectedTopology = TopologyDetector.detect(projectRoot);
185
+ const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(
186
+ projectRoot,
187
+ detectedTopology.framework
188
+ );
189
+
190
+ this.repo.clearVerticalSlices();
191
+
192
+ if (semanticSlices.length > 0) {
193
+ for (const slice of semanticSlices) {
194
+ this.repo.upsertVerticalSlice(slice);
195
+ }
196
+ } else {
197
+ // Heuristic Call-Graph Tracer fallback for Clean/Hexagonal or generic architectures
198
+ const callGraphTracer = new CallGraphTracer(this.repo, projectRoot);
199
+ const graphSlices = callGraphTracer.traceAllSlices();
200
+ for (const slice of graphSlices) {
201
+ this.repo.upsertVerticalSlice(slice);
202
+ }
203
+ }
204
+
205
+ const durationMs = Math.round(performance.now() - startTime);
206
+
207
+ // Update SSOT repo_meta watermark
208
+ this.repo.setMeta("last_sync", new Date().toISOString());
209
+ this.repo.setMeta("files_scanned", String(filesScanned));
210
+ this.repo.setMeta("files_updated", String(filesUpdated));
211
+ this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
212
+ this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
213
+
214
+ return {
215
+ domains_processed: domainNames.length,
216
+ files_scanned: filesScanned,
217
+ files_updated: filesUpdated,
218
+ files_skipped: filesSkipped,
219
+ symbols_indexed: symbolsIndexed,
220
+ dependencies_indexed: dependenciesIndexed,
221
+ duration_ms: durationMs,
222
+ };
223
+ }
224
+
225
+ public autoDiscoverDomains(projectRoot: string): Record<string, import("../../types/index.ts").DomainConfig> {
226
+ const domains: Record<string, import("../../types/index.ts").DomainConfig> = {};
227
+
228
+ // 1. Modular / DDD check
229
+ const candidateDirs = [
230
+ { name: "domains", rel: "src/domains" },
231
+ { name: "features", rel: "src/features" },
232
+ { name: "modules", rel: "src/modules" },
233
+ { name: "domain", rel: "app/Domain" },
234
+ { name: "domains_app", rel: "app/Domains" },
235
+ ];
236
+
237
+ for (const c of candidateDirs) {
238
+ const fullDir = join(projectRoot, c.rel);
239
+ if (existsSync(fullDir)) {
240
+ try {
241
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
242
+ .filter((d) => d.isDirectory())
243
+ .map((d) => d.name);
244
+
245
+ if (subdirs.length > 0) {
246
+ for (const sub of subdirs) {
247
+ domains[sub.toLowerCase()] = {
248
+ root: `${c.rel}/${sub}`,
249
+ description: `${sub} domain (auto-discovered)`,
250
+ allowed_dependencies: [],
251
+ forbidden_dependencies: [],
252
+ archetypes: {
253
+ service: "*Service.*",
254
+ model: "*Model.*",
255
+ repository: "*Repository.*",
256
+ },
257
+ };
258
+ }
259
+ return domains;
260
+ }
261
+ } catch {}
262
+ }
263
+ }
264
+
265
+ // 2. Framework: Laravel
266
+ const hasArtisan = existsSync(join(projectRoot, "artisan"));
267
+ let isLaravel = hasArtisan;
268
+ if (!isLaravel && existsSync(join(projectRoot, "composer.json"))) {
269
+ try {
270
+ const comp = readFileSync(join(projectRoot, "composer.json"), "utf-8");
271
+ if (comp.includes("laravel/framework")) isLaravel = true;
272
+ } catch {}
273
+ }
274
+
275
+ if (isLaravel) {
276
+ domains["app"] = {
277
+ root: "app",
278
+ description: "Core Laravel Application (auto-discovered)",
279
+ allowed_dependencies: [],
280
+ forbidden_dependencies: [],
281
+ archetypes: {
282
+ controller: "app/Http/Controllers/**",
283
+ service: "app/Services/**",
284
+ model: "app/Models/**",
285
+ repository: "app/Repositories/**",
286
+ action: "app/Actions/**",
287
+ job: "app/Jobs/**",
288
+ event: "app/Events/**",
289
+ listener: "app/Listeners/**",
290
+ request: "app/Http/Requests/**",
291
+ middleware: "app/Http/Middleware/**",
292
+ },
293
+ };
294
+ return domains;
295
+ }
296
+
297
+ // 3. Framework: NestJS
298
+ const hasNestCli = existsSync(join(projectRoot, "nest-cli.json"));
299
+ let isNest = hasNestCli;
300
+ if (!isNest && existsSync(join(projectRoot, "package.json"))) {
301
+ try {
302
+ const pkg = readFileSync(join(projectRoot, "package.json"), "utf-8");
303
+ if (pkg.includes("@nestjs/core")) isNest = true;
304
+ } catch {}
305
+ }
306
+
307
+ if (isNest) {
308
+ domains["src"] = {
309
+ root: "src",
310
+ description: "NestJS Source Domain (auto-discovered)",
311
+ allowed_dependencies: [],
312
+ forbidden_dependencies: [],
313
+ archetypes: {
314
+ controller: "src/**/*.controller.ts",
315
+ service: "src/**/*.service.ts",
316
+ repository: "src/**/*.repository.ts",
317
+ },
318
+ };
319
+ return domains;
320
+ }
321
+
322
+ // 4. Framework: Go
323
+ if (existsSync(join(projectRoot, "go.mod"))) {
324
+ if (existsSync(join(projectRoot, "internal"))) {
325
+ domains["internal"] = {
326
+ root: "internal",
327
+ description: "Internal business logic packages",
328
+ };
329
+ }
330
+ if (existsSync(join(projectRoot, "pkg"))) {
331
+ domains["pkg"] = {
332
+ root: "pkg",
333
+ description: "Public library packages",
334
+ };
335
+ }
336
+ if (existsSync(join(projectRoot, "cmd"))) {
337
+ domains["cmd"] = {
338
+ root: "cmd",
339
+ description: "Application command entry points",
340
+ };
341
+ }
342
+ if (Object.keys(domains).length > 0) return domains;
343
+ }
344
+
345
+ // 5. General project fallback (src or root)
346
+ if (existsSync(join(projectRoot, "src"))) {
347
+ domains["src"] = {
348
+ root: "src",
349
+ description: "Default Source Domain (auto-discovered)",
350
+ };
351
+ } else {
352
+ domains["root"] = {
353
+ root: ".",
354
+ description: "Root Domain (auto-discovered)",
355
+ };
356
+ }
357
+
358
+ return domains;
359
+ }
360
+
361
+ private collectFiles(
362
+ dir: string,
363
+ workspaceRoot: string,
364
+ ignorePatterns: string[],
365
+ childDomainRoots: string[] = []
366
+ ): Array<{ fullPath: string; relPath: string }> {
367
+ const results: Array<{ fullPath: string; relPath: string }> = [];
368
+
369
+ const walk = (currentDir: string) => {
370
+ let entries: string[] = [];
371
+ try {
372
+ entries = readdirSync(currentDir);
373
+ } catch {
374
+ return;
375
+ }
376
+
377
+ for (const entry of entries) {
378
+ const fullPath = join(currentDir, entry);
379
+ const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
380
+
381
+ if (this.shouldIgnore(relPath, ignorePatterns)) {
382
+ continue;
383
+ }
384
+
385
+ if (childDomainRoots.some((child) => relPath === child || relPath.startsWith(child + "/"))) {
386
+ continue;
387
+ }
388
+
389
+ try {
390
+ const stat = statSync(fullPath);
391
+ if (stat.isDirectory()) {
392
+ walk(fullPath);
393
+ } else if (stat.isFile() && this.isSourceFile(fullPath)) {
394
+ results.push({ fullPath, relPath });
395
+ }
396
+ } catch {
397
+ // ignore unreadable files
398
+ }
399
+ }
400
+ };
401
+
402
+ walk(dir);
403
+ return results;
404
+ }
405
+
406
+ private resolveWorkspaceRoot(config: ValidatedSeptumConfig, domainRoot: string): string {
407
+ if (this.customWorkspaceRoot) {
408
+ return this.customWorkspaceRoot;
409
+ }
410
+ const settings = config.settings as Record<string, unknown> | undefined;
411
+ if (typeof settings?.workspace_root === "string" && settings.workspace_root.trim()) {
412
+ return resolve(settings.workspace_root);
413
+ }
414
+ if (config.settings.db_path && config.settings.db_path !== ":memory:") {
415
+ const dbDir = dirname(resolve(config.settings.db_path));
416
+ const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
417
+ const absDomainRoot = resolve(domainRoot);
418
+ if (absDomainRoot.startsWith(candidate)) {
419
+ return candidate;
420
+ }
421
+ }
422
+ return process.cwd();
423
+ }
424
+
425
+ private shouldIgnore(path: string, ignorePatterns: string[]): boolean {
426
+ for (const pattern of ignorePatterns) {
427
+ const cleanPattern = pattern.replace(/\/\*\*|\*|\*\*/g, "");
428
+ if (path.includes(cleanPattern)) {
429
+ return true;
430
+ }
431
+ }
432
+ return false;
433
+ }
434
+
435
+ private isSourceFile(path: string): boolean {
436
+ return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs)$/i.test(path);
437
+ }
438
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Utility to find the end line (1-based index) of a code block.
3
+ * Handles brace matching with string and comment awareness for C-like languages (TS, JS, PHP, Go).
4
+ */
5
+ export function findBraceBlockEnd(lines: string[], startLineIndex: number): number {
6
+ let depth = 0;
7
+ let hasOpened = false;
8
+ let inMultiLineComment = false;
9
+
10
+ for (let i = startLineIndex; i < lines.length; i++) {
11
+ const line = lines[i];
12
+ let inSingleQuote = false;
13
+ let inDoubleQuote = false;
14
+ let inTemplateString = false;
15
+
16
+ for (let j = 0; j < line.length; j++) {
17
+ const char = line[j];
18
+ const prevChar = j > 0 ? line[j - 1] : "";
19
+ const nextChar = j + 1 < line.length ? line[j + 1] : "";
20
+
21
+ // Multi-line comment boundary
22
+ if (inMultiLineComment) {
23
+ if (char === "*" && nextChar === "/") {
24
+ inMultiLineComment = false;
25
+ j++; // skip '/'
26
+ }
27
+ continue;
28
+ }
29
+
30
+ // Check start of multi-line comment /*
31
+ if (!inSingleQuote && !inDoubleQuote && !inTemplateString && char === "/" && nextChar === "*") {
32
+ inMultiLineComment = true;
33
+ j++;
34
+ continue;
35
+ }
36
+
37
+ // Check single-line comment //
38
+ if (!inSingleQuote && !inDoubleQuote && !inTemplateString && char === "/" && nextChar === "/") {
39
+ break; // ignore rest of line
40
+ }
41
+
42
+ // String quote handling (with escape character check)
43
+ if (char === "'" && !inDoubleQuote && !inTemplateString && prevChar !== "\\") {
44
+ inSingleQuote = !inSingleQuote;
45
+ continue;
46
+ }
47
+ if (char === '"' && !inSingleQuote && !inTemplateString && prevChar !== "\\") {
48
+ inDoubleQuote = !inDoubleQuote;
49
+ continue;
50
+ }
51
+ if (char === "`" && !inSingleQuote && !inDoubleQuote && prevChar !== "\\") {
52
+ inTemplateString = !inTemplateString;
53
+ continue;
54
+ }
55
+
56
+ if (inSingleQuote || inDoubleQuote || inTemplateString) {
57
+ continue;
58
+ }
59
+
60
+ // Brace tracking
61
+ if (char === "{") {
62
+ depth++;
63
+ hasOpened = true;
64
+ } else if (char === "}") {
65
+ if (hasOpened) {
66
+ depth--;
67
+ if (depth === 0) {
68
+ return i + 1; // 1-based line number
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // Safety guard: If we haven't seen an opening brace within 40 lines or hit a declaration-only statement
75
+ if (!hasOpened) {
76
+ const trimmed = line.trim();
77
+ if (trimmed.endsWith(";") && i > startLineIndex) {
78
+ return i + 1;
79
+ }
80
+ if (i - startLineIndex > 40) {
81
+ return startLineIndex + 1;
82
+ }
83
+ }
84
+ }
85
+
86
+ // Fallback: If braces were unclosed or not found, return start line
87
+ return startLineIndex + 1;
88
+ }
89
+
90
+ /**
91
+ * Utility to find the end line (1-based index) of an indentation-based block (Python).
92
+ */
93
+ export function findPythonBlockEnd(lines: string[], startLineIndex: number): number {
94
+ if (startLineIndex >= lines.length) return startLineIndex + 1;
95
+
96
+ const startLine = lines[startLineIndex];
97
+ const baseIndentMatch = startLine.match(/^(\s*)/);
98
+ const baseIndent = baseIndentMatch ? baseIndentMatch[1].length : 0;
99
+
100
+ let lastContentLine = startLineIndex + 1;
101
+ let bodyStarted = false;
102
+
103
+ for (let i = startLineIndex + 1; i < lines.length; i++) {
104
+ const line = lines[i];
105
+ const trimmed = line.trim();
106
+
107
+ // Skip empty lines or pure comment lines
108
+ if (!trimmed || trimmed.startsWith("#")) {
109
+ continue;
110
+ }
111
+
112
+ const currentIndentMatch = line.match(/^(\s*)/);
113
+ const currentIndent = currentIndentMatch ? currentIndentMatch[1].length : 0;
114
+
115
+ if (currentIndent > baseIndent) {
116
+ bodyStarted = true;
117
+ lastContentLine = i + 1;
118
+ } else {
119
+ // Returned to same or lesser indentation -> block ended
120
+ if (bodyStarted) {
121
+ return lastContentLine;
122
+ }
123
+ break;
124
+ }
125
+ }
126
+
127
+ return lastContentLine;
128
+ }
@@ -0,0 +1,60 @@
1
+ import type { ArchetypeKind, ParsedFileAST } from "../../../types/index.ts";
2
+
3
+ export interface CodeExtractor {
4
+ canHandle(filePath: string): boolean;
5
+ extract(filePath: string, content: string): Promise<ParsedFileAST>;
6
+ }
7
+
8
+ export function detectArchetype(filePath: string, archetypesMap: Record<string, string>): ArchetypeKind {
9
+ const normalized = filePath.replace(/\\/g, "/");
10
+
11
+ // Check user-configured archetype glob patterns
12
+ for (const [archetype, pattern] of Object.entries(archetypesMap)) {
13
+ if (matchesSimpleGlob(normalized, pattern)) {
14
+ return archetype as ArchetypeKind;
15
+ }
16
+ }
17
+
18
+ // Built-in convention heuristics
19
+ const lower = normalized.toLowerCase();
20
+ if (lower.includes("controller")) return "controller";
21
+ if (lower.includes("service")) return "service";
22
+ if (lower.includes("repository") || lower.includes("repo")) return "repository";
23
+ if (lower.includes("model") || lower.includes("entities") || lower.includes("entity")) return "model";
24
+ if (lower.includes("action")) return "action";
25
+ if (lower.includes("job") || lower.includes("queue")) return "job";
26
+ if (lower.includes("event")) return "event";
27
+ if (lower.includes("listener")) return "listener";
28
+ if (lower.includes("middleware")) return "middleware";
29
+ if (lower.includes("request")) return "request";
30
+ if (lower.includes("resource")) return "resource";
31
+ if (lower.includes("util") || lower.includes("helper")) return "util";
32
+
33
+ return "unknown";
34
+ }
35
+
36
+ function matchesSimpleGlob(path: string, pattern: string): boolean {
37
+ const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
38
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
39
+
40
+ const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
41
+ const regexStr = escaped
42
+ .replace(/\*\*\//g, "(?:.*\\/)?")
43
+ .replace(/\*\*/g, ".*")
44
+ .replace(/\*/g, "[^/]*")
45
+ .replace(/\?/g, "[^/]");
46
+
47
+ const regex = new RegExp(`^${regexStr}$`, "i");
48
+ if (regex.test(normalizedPath)) {
49
+ return true;
50
+ }
51
+
52
+ // If pattern does not contain directory separators, allow matching against the basename
53
+ if (!normalizedPattern.includes("/")) {
54
+ const base = normalizedPath.split("/").pop() ?? "";
55
+ return regex.test(base);
56
+ }
57
+
58
+ return false;
59
+ }
60
+