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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/bin/septum.ts +4 -0
- package/package.json +62 -0
- package/src/cli/commands/check.ts +161 -0
- package/src/cli/commands/hook.ts +188 -0
- package/src/cli/commands/ingest.ts +29 -0
- package/src/cli/commands/init.ts +211 -0
- package/src/cli/commands/locate.ts +61 -0
- package/src/cli/commands/query.ts +64 -0
- package/src/cli/commands/serve.ts +5 -0
- package/src/cli/commands/slice.ts +90 -0
- package/src/cli/commands/sync.ts +31 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/templates/hooks.ts +75 -0
- package/src/cli/templates/rules.ts +38 -0
- package/src/cli/templates/skill.ts +66 -0
- package/src/core/boundary/evaluator.ts +210 -0
- package/src/core/config/loader.ts +84 -0
- package/src/core/config/schema.ts +39 -0
- package/src/core/database/client.ts +132 -0
- package/src/core/database/repositories/dependency.repository.ts +279 -0
- package/src/core/database/repositories/domain.repository.ts +198 -0
- package/src/core/database/repositories/file.repository.ts +99 -0
- package/src/core/database/repositories/meta.repository.ts +33 -0
- package/src/core/database/repositories/symbol.repository.ts +424 -0
- package/src/core/database/repository.ts +242 -0
- package/src/core/database/schema.sql +92 -0
- package/src/core/discovery/topology-detector.ts +418 -0
- package/src/core/ingestion/hasher.ts +5 -0
- package/src/core/ingestion/pipeline.ts +438 -0
- package/src/core/parser/boundary-tracker.ts +128 -0
- package/src/core/parser/extractors/base.ts +60 -0
- package/src/core/parser/extractors/frontend.ts +194 -0
- package/src/core/parser/extractors/go.ts +176 -0
- package/src/core/parser/extractors/laravel-semantic.ts +426 -0
- package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -0
- package/src/core/parser/extractors/nestjs-semantic.ts +119 -0
- package/src/core/parser/extractors/php.ts +149 -0
- package/src/core/parser/extractors/python.ts +226 -0
- package/src/core/parser/extractors/registry.ts +49 -0
- package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -0
- package/src/core/parser/extractors/typescript.ts +177 -0
- package/src/core/parser/tree-sitter.ts +39 -0
- package/src/core/resolver/call-graph-tracer.ts +668 -0
- package/src/core/resolver/module-resolver.ts +323 -0
- package/src/core/resolver/symbol-locator.ts +405 -0
- package/src/core/resolver/vertical-slice-tracer.ts +236 -0
- package/src/core/session/session-manager.ts +56 -0
- package/src/core/telemetry/telemetry.ts +86 -0
- package/src/index.ts +18 -0
- package/src/mcp/schemas.ts +68 -0
- package/src/mcp/server.ts +602 -0
- package/src/mcp/tools/check-boundary.ts +102 -0
- package/src/mcp/tools/get-domain-catalog.ts +91 -0
- package/src/mcp/tools/get-feature-context.ts +116 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +49 -0
- package/src/mcp/tools/get-symbol-impact.ts +26 -0
- package/src/mcp/tools/get-symbol.ts +63 -0
- package/src/mcp/tools/locate-symbol.ts +74 -0
- package/src/mcp/tools/register-domain.ts +64 -0
- package/src/mcp/tools/trace-vertical-slice.ts +102 -0
- 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
|
+
|