septum 0.1.4 → 0.1.6

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 (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -105
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +216 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +213 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +104 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +50 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +220 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +50 -38
  17. package/src/cli/templates/skill.ts +65 -69
  18. package/src/core/boundary/evaluator.ts +429 -210
  19. package/src/core/config/loader.ts +117 -97
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -44
  22. package/src/core/database/repositories/dependency.repository.ts +552 -349
  23. package/src/core/database/repositories/domain.repository.ts +275 -198
  24. package/src/core/database/repositories/file.repository.ts +117 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +482 -443
  27. package/src/core/database/repository.ts +281 -260
  28. package/src/core/database/schema.ts +154 -153
  29. package/src/core/discovery/topology-detector.ts +486 -486
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +606 -535
  32. package/src/core/parser/boundary-tracker.ts +433 -288
  33. package/src/core/parser/extractors/base.ts +62 -62
  34. package/src/core/parser/extractors/blade.ts +158 -158
  35. package/src/core/parser/extractors/frontend.ts +147 -194
  36. package/src/core/parser/extractors/go.ts +408 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1045 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +328 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +134 -119
  40. package/src/core/parser/extractors/php.ts +415 -153
  41. package/src/core/parser/extractors/python.ts +460 -228
  42. package/src/core/parser/extractors/registry.ts +70 -70
  43. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  44. package/src/core/parser/extractors/typescript.ts +668 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/parser/wasm-loader.ts +271 -0
  47. package/src/core/resolver/call-graph-tracer.ts +698 -668
  48. package/src/core/resolver/module-resolver.ts +479 -342
  49. package/src/core/resolver/path-resolver.ts +147 -147
  50. package/src/core/resolver/symbol-locator.ts +543 -543
  51. package/src/core/resolver/vertical-slice-tracer.ts +323 -285
  52. package/src/core/session/session-manager.ts +56 -56
  53. package/src/core/telemetry/telemetry.ts +86 -86
  54. package/src/core/topology/environment-detector.ts +109 -6
  55. package/src/index.ts +21 -21
  56. package/src/mcp/schemas.ts +695 -286
  57. package/src/mcp/server.ts +927 -716
  58. package/src/mcp/tools/check-boundary.ts +307 -152
  59. package/src/mcp/tools/get-domain-catalog.ts +155 -121
  60. package/src/mcp/tools/get-environment-topology.ts +3 -6
  61. package/src/mcp/tools/get-feature-context.ts +227 -116
  62. package/src/mcp/tools/get-symbol-hotspots.ts +54 -59
  63. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  64. package/src/mcp/tools/get-symbol.ts +65 -63
  65. package/src/mcp/tools/locate-symbol.ts +169 -190
  66. package/src/mcp/tools/register-domain.ts +59 -64
  67. package/src/mcp/tools/register-feature.ts +91 -0
  68. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  69. package/src/types/index.ts +366 -315
  70. package/src/version.ts +3 -3
@@ -1,342 +1,479 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import type { ValidatedSeptumConfig } from "../config/schema.ts";
4
- import type { SeptumRepository } from "../database/repository.ts";
5
-
6
- export interface PathAliasRule {
7
- prefix: string;
8
- targets: string[];
9
- }
10
-
11
- export interface Psr4Rule {
12
- prefix: string;
13
- baseDir: string;
14
- }
15
-
16
- export class ModuleResolver {
17
- private projectRoot: string;
18
- private repo?: SeptumRepository;
19
- private tsPaths: PathAliasRule[] = [];
20
- private tsBaseUrl: string = ".";
21
- private psr4Rules: Psr4Rule[] = [];
22
- private configsLoaded = false;
23
-
24
- constructor(projectRoot: string = process.cwd(), repo?: SeptumRepository) {
25
- this.projectRoot = path.resolve(projectRoot);
26
- this.repo = repo;
27
- }
28
-
29
- public setRepository(repo: SeptumRepository): void {
30
- this.repo = repo;
31
- }
32
-
33
- /**
34
- * Lazily loads project configuration files (tsconfig.json, composer.json)
35
- */
36
- public ensureConfigLoaded(): void {
37
- if (this.configsLoaded) return;
38
- this.loadTsConfig();
39
- this.loadComposerJson();
40
- this.configsLoaded = true;
41
- }
42
-
43
- /**
44
- * Resolves the domain that owns the given source file.
45
- */
46
- public resolveSourceDomain(
47
- filePath: string,
48
- config: ValidatedSeptumConfig
49
- ): string | null {
50
- const absPath = this.toAbsolutePath(filePath);
51
-
52
- for (const [domainName, domainCfg] of Object.entries(config.domains)) {
53
- const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
54
- if (this.isSubPathOrSame(absPath, domainAbsRoot)) {
55
- return domainName;
56
- }
57
- }
58
-
59
- return null;
60
- }
61
-
62
- /**
63
- * Deterministically resolves an imported module/target to its target domain.
64
- */
65
- public resolveTargetDomain(
66
- targetStatement: string,
67
- sourceFilePath: string | undefined,
68
- config: ValidatedSeptumConfig
69
- ): string | null {
70
- this.ensureConfigLoaded();
71
-
72
- const normalizedTarget = targetStatement.trim().replace(/^['"]|['"]$/g, "");
73
- if (!normalizedTarget) return null;
74
-
75
- // 1. Relative imports: ./ or ../ or Python dot relative . or ..
76
- if (normalizedTarget.startsWith("./") || normalizedTarget.startsWith("../")) {
77
- if (sourceFilePath) {
78
- const sourceAbs = this.toAbsolutePath(sourceFilePath);
79
- const sourceDir = path.dirname(sourceAbs);
80
- const resolvedCandidate = path.resolve(sourceDir, normalizedTarget);
81
- const domainFromPath = this.matchDomainByPath(resolvedCandidate, config);
82
- if (domainFromPath) return domainFromPath;
83
- }
84
- } else if (normalizedTarget.startsWith(".")) {
85
- // Python style relative import (e.g. .service, ..billing.service)
86
- if (sourceFilePath) {
87
- const sourceAbs = this.toAbsolutePath(sourceFilePath);
88
- const sourceDir = path.dirname(sourceAbs);
89
- const dotMatch = /^\.+/.exec(normalizedTarget);
90
- if (dotMatch) {
91
- const dotCount = dotMatch[0].length;
92
- const remainder = normalizedTarget.slice(dotCount).replace(/\./g, path.sep);
93
- const upSteps = "../".repeat(Math.max(0, dotCount - 1));
94
- const resolvedCandidate = path.resolve(sourceDir, upSteps, remainder);
95
- const domainFromPath = this.matchDomainByPath(resolvedCandidate, config);
96
- if (domainFromPath) return domainFromPath;
97
- }
98
- }
99
- }
100
-
101
- // 2. Workspace Package Name Resolution (e.g. @repo/shared-types, @repo/design-system)
102
- const domainFromPackage = this.matchDomainByPackageName(normalizedTarget, config);
103
- if (domainFromPackage) return domainFromPackage;
104
-
105
- // 3. Python Absolute Dot-notation: domains.billing.service or app.domains.billing
106
- if (normalizedTarget.includes(".") && !normalizedTarget.includes("/")) {
107
- const asPath = normalizedTarget.replace(/\./g, path.sep);
108
- const candidatePath = path.resolve(this.projectRoot, asPath);
109
- const domainFromPython = this.matchDomainByPath(candidatePath, config);
110
- if (domainFromPython) return domainFromPython;
111
- }
112
-
113
- // 3. TypeScript Path Aliases (tsconfig.json paths)
114
- const tsResolved = this.resolveTsPathAlias(normalizedTarget);
115
- if (tsResolved) {
116
- const domainFromTs = this.matchDomainByPath(tsResolved, config);
117
- if (domainFromTs) return domainFromTs;
118
- }
119
-
120
- // 4. PHP PSR-4 Namespaces (composer.json autoload)
121
- const phpResolved = this.resolvePsr4Namespace(normalizedTarget);
122
- if (phpResolved) {
123
- const domainFromPhp = this.matchDomainByPath(phpResolved, config);
124
- if (domainFromPhp) return domainFromPhp;
125
- }
126
-
127
- // 4. Database-driven direct file lookup
128
- if (this.repo) {
129
- const fileRecord = this.repo.getFileWithDomain(normalizedTarget);
130
- if (fileRecord) {
131
- return fileRecord.domain_name;
132
- }
133
-
134
- // 5. Database-driven symbol lookup
135
- const symbolName = this.extractSymbolName(normalizedTarget);
136
- if (symbolName) {
137
- const symbolRecord = this.repo.findSymbolWithDomain(symbolName);
138
- if (symbolRecord) {
139
- return symbolRecord.domain_name;
140
- }
141
- }
142
- }
143
-
144
- // 6. Root path segment matching (fallback for explicit domain root references)
145
- const normalizedSlash = normalizedTarget.replace(/\\/g, "/").toLowerCase();
146
- for (const [domainName, domainCfg] of Object.entries(config.domains)) {
147
- const domainRootSlash = domainCfg.root.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
148
- const lowerDomain = domainName.toLowerCase();
149
-
150
- if (
151
- normalizedSlash.includes(`/${domainRootSlash}/`) ||
152
- normalizedSlash.startsWith(`${domainRootSlash}/`) ||
153
- normalizedSlash === domainRootSlash ||
154
- normalizedSlash.includes(`/${lowerDomain}/`) ||
155
- normalizedSlash.endsWith(`/${lowerDomain}`)
156
- ) {
157
- return domainName;
158
- }
159
- }
160
-
161
- return null;
162
- }
163
-
164
- private matchDomainByPath(targetPath: string, config: ValidatedSeptumConfig): string | null {
165
- const absTargetPath = this.toAbsolutePath(targetPath);
166
-
167
- const sortedDomains = Object.entries(config.domains)
168
- .map(([domainName, domainCfg]) => ({
169
- domainName,
170
- domainCfg,
171
- absRoot: this.toAbsolutePath(domainCfg.root),
172
- }))
173
- .sort((a, b) => b.absRoot.length - a.absRoot.length);
174
-
175
- for (const { domainName, absRoot } of sortedDomains) {
176
- if (this.isSubPathOrSame(absTargetPath, absRoot)) {
177
- return domainName;
178
- }
179
- }
180
-
181
- return null;
182
- }
183
-
184
- private matchDomainByPackageName(target: string, config: ValidatedSeptumConfig): string | null {
185
- for (const [domainName, domainCfg] of Object.entries(config.domains)) {
186
- const pkgJsonPath = path.resolve(this.projectRoot, domainCfg.root, "package.json");
187
- if (fs.existsSync(pkgJsonPath)) {
188
- try {
189
- const raw = fs.readFileSync(pkgJsonPath, "utf8");
190
- const pkg = JSON.parse(raw);
191
- if (pkg.name && typeof pkg.name === "string") {
192
- if (target === pkg.name || target.startsWith(`${pkg.name}/`)) {
193
- return domainName;
194
- }
195
- }
196
- } catch {
197
- // Ignore unreadable or invalid package.json
198
- }
199
- }
200
- }
201
- return null;
202
- }
203
-
204
- public toCanonicalPath(filePath: string): string {
205
- let resolved = path.isAbsolute(filePath)
206
- ? path.normalize(filePath)
207
- : path.resolve(this.projectRoot, filePath);
208
-
209
- if (process.platform === "win32") {
210
- // Normalize drive letter to uppercase (e.g. c:\ -> C:\)
211
- resolved = resolved.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
212
- }
213
- return resolved;
214
- }
215
-
216
- private isSubPathOrSame(targetPath: string, rootPath: string): boolean {
217
- const canonicalTarget = this.toCanonicalPath(targetPath);
218
- const canonicalRoot = this.toCanonicalPath(rootPath);
219
-
220
- if (process.platform === "win32") {
221
- const lowerTarget = canonicalTarget.toLowerCase();
222
- const lowerRoot = canonicalRoot.toLowerCase();
223
- if (lowerTarget === lowerRoot) return true;
224
- const rootWithSep = lowerRoot.endsWith(path.sep)
225
- ? lowerRoot
226
- : lowerRoot + path.sep;
227
- return lowerTarget.startsWith(rootWithSep);
228
- }
229
-
230
- if (canonicalTarget === canonicalRoot) return true;
231
- const rootWithSep = canonicalRoot.endsWith(path.sep)
232
- ? canonicalRoot
233
- : canonicalRoot + path.sep;
234
-
235
- return canonicalTarget.startsWith(rootWithSep);
236
- }
237
-
238
- private toAbsolutePath(filePath: string): string {
239
- return this.toCanonicalPath(filePath);
240
- }
241
-
242
- private extractSymbolName(target: string): string | null {
243
- // PHP namespace or TS qualified name: Foo\Bar\Baz or Foo/Bar/Baz or bare Baz
244
- const cleaned = target.replace(/\\/g, "/");
245
- const parts = cleaned.split("/").filter(Boolean);
246
- const lastPart = parts[parts.length - 1];
247
- if (lastPart && /^[A-Z][a-zA-Z0-9_]*$/.test(lastPart)) {
248
- return lastPart;
249
- }
250
- return null;
251
- }
252
-
253
- private resolveTsPathAlias(target: string): string | null {
254
- for (const rule of this.tsPaths) {
255
- if (rule.prefix.endsWith("*")) {
256
- const basePrefix = rule.prefix.slice(0, -1);
257
- if (target.startsWith(basePrefix)) {
258
- const suffix = target.slice(basePrefix.length);
259
- for (const targetPattern of rule.targets) {
260
- const candidate = targetPattern.replace("*", suffix);
261
- const baseDir = path.resolve(this.projectRoot, this.tsBaseUrl);
262
- return path.resolve(baseDir, candidate);
263
- }
264
- }
265
- } else if (target === rule.prefix) {
266
- for (const targetPattern of rule.targets) {
267
- const baseDir = path.resolve(this.projectRoot, this.tsBaseUrl);
268
- return path.resolve(baseDir, targetPattern);
269
- }
270
- }
271
- }
272
- return null;
273
- }
274
-
275
- private resolvePsr4Namespace(target: string): string | null {
276
- // PHP PSR-4: App\Domain\Billing\Service -> app/Domain/Billing/Service.php
277
- const normalizedTarget = target.replace(/\//g, "\\");
278
-
279
- for (const rule of this.psr4Rules) {
280
- if (normalizedTarget.startsWith(rule.prefix)) {
281
- const subNamespace = normalizedTarget.slice(rule.prefix.length);
282
- const subPath = subNamespace.replace(/\\/g, path.sep);
283
- const resolved = path.resolve(this.projectRoot, rule.baseDir, subPath);
284
- return resolved;
285
- }
286
- }
287
- return null;
288
- }
289
-
290
- private loadTsConfig(): void {
291
- const tsconfigPath = path.join(this.projectRoot, "tsconfig.json");
292
- if (!fs.existsSync(tsconfigPath)) return;
293
-
294
- try {
295
- const raw = fs.readFileSync(tsconfigPath, "utf-8");
296
- // Strip comments (simple JSON with comments regex)
297
- const cleanJson = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "");
298
- const parsed = JSON.parse(cleanJson);
299
- const compilerOptions = parsed.compilerOptions ?? {};
300
-
301
- this.tsBaseUrl = compilerOptions.baseUrl ?? ".";
302
-
303
- if (compilerOptions.paths && typeof compilerOptions.paths === "object") {
304
- for (const [key, val] of Object.entries(compilerOptions.paths)) {
305
- const targets = Array.isArray(val) ? (val as string[]) : [String(val)];
306
- this.tsPaths.push({
307
- prefix: key,
308
- targets,
309
- });
310
- }
311
- }
312
- } catch {
313
- // Graceful fallback if tsconfig is malformed
314
- }
315
- }
316
-
317
- private loadComposerJson(): void {
318
- const composerPath = path.join(this.projectRoot, "composer.json");
319
- if (!fs.existsSync(composerPath)) return;
320
-
321
- try {
322
- const raw = fs.readFileSync(composerPath, "utf-8");
323
- const parsed = JSON.parse(raw);
324
-
325
- const extractPsr4 = (section: Record<string, unknown> | undefined) => {
326
- if (section && typeof section["psr-4"] === "object" && section["psr-4"] !== null) {
327
- for (const [ns, dir] of Object.entries(section["psr-4"] as Record<string, string>)) {
328
- this.psr4Rules.push({
329
- prefix: ns,
330
- baseDir: String(dir),
331
- });
332
- }
333
- }
334
- };
335
-
336
- extractPsr4(parsed.autoload);
337
- extractPsr4(parsed["autoload-dev"]);
338
- } catch {
339
- // Graceful fallback if composer.json is malformed
340
- }
341
- }
342
- }
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ValidatedSeptumConfig } from "../config/schema.ts";
4
+ import type { SeptumRepository } from "../database/repository.ts";
5
+
6
+ export interface PathAliasRule {
7
+ prefix: string;
8
+ targets: string[];
9
+ }
10
+
11
+ export interface Psr4Rule {
12
+ prefix: string;
13
+ baseDir: string;
14
+ }
15
+
16
+ export class ModuleResolver {
17
+ private projectRoot: string;
18
+ private repo?: SeptumRepository;
19
+ private tsPaths: PathAliasRule[] = [];
20
+ private tsBaseUrl: string = ".";
21
+ private psr4Rules: Psr4Rule[] = [];
22
+ private configsLoaded = false;
23
+ private fileDomainCache?: Map<string, string>;
24
+ private symbolDomainCache?: Map<string, string>;
25
+ private packageNameCache?: Map<string, string | null>;
26
+
27
+ constructor(projectRoot: string = process.cwd(), repo?: SeptumRepository) {
28
+ this.projectRoot = path.resolve(projectRoot);
29
+ this.repo = repo;
30
+ }
31
+
32
+ public setRepository(repo: SeptumRepository): void {
33
+ this.repo = repo;
34
+ this.fileDomainCache = undefined;
35
+ this.symbolDomainCache = undefined;
36
+ }
37
+
38
+ public getProjectRoot(): string {
39
+ return this.projectRoot;
40
+ }
41
+
42
+ /**
43
+ * Lazily loads project configuration files (tsconfig.json, composer.json)
44
+ */
45
+ public ensureConfigLoaded(): void {
46
+ if (this.configsLoaded) return;
47
+ this.loadTsConfig();
48
+ this.loadComposerJson();
49
+ this.configsLoaded = true;
50
+ }
51
+
52
+ private ensureRepoCachesLoaded(): void {
53
+ if (!this.repo) return;
54
+ if (this.fileDomainCache && this.symbolDomainCache) return;
55
+
56
+ this.fileDomainCache = new Map<string, string>();
57
+ this.symbolDomainCache = new Map<string, string>();
58
+
59
+ if (
60
+ typeof this.repo.getAllDomains === "function" &&
61
+ typeof this.repo.getAllFiles === "function"
62
+ ) {
63
+ const domains = this.repo.getAllDomains();
64
+ const domainById = new Map<number, string>();
65
+ for (const d of domains) {
66
+ domainById.set(d.id, d.name);
67
+ }
68
+
69
+ const files = this.repo.getAllFiles();
70
+ const domainByFilePath = new Map<string, string>();
71
+ for (const f of files) {
72
+ const domName = domainById.get(f.domain_id);
73
+ if (domName) {
74
+ this.fileDomainCache.set(f.path, domName);
75
+ const norm = f.path.replace(/\\/g, "/");
76
+ this.fileDomainCache.set(norm, domName);
77
+ domainByFilePath.set(f.path, domName);
78
+ }
79
+ }
80
+
81
+ if (typeof this.repo.getAllSymbolsWithFiles === "function") {
82
+ const symbols = this.repo.getAllSymbolsWithFiles();
83
+ for (const s of symbols) {
84
+ const domName = domainByFilePath.get(s.file_path) ?? this.fileDomainCache.get(s.file_path);
85
+ if (domName && !this.symbolDomainCache.has(s.name)) {
86
+ this.symbolDomainCache.set(s.name, domName);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Resolves the domain that owns the given source file.
95
+ */
96
+ public resolveSourceDomain(
97
+ filePath: string,
98
+ config: ValidatedSeptumConfig
99
+ ): string | null {
100
+ const absPath = this.toAbsolutePath(filePath);
101
+
102
+ for (const [domainName, domainCfg] of Object.entries(config.domains)) {
103
+ const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
104
+ if (this.isSubPathOrSame(absPath, domainAbsRoot)) {
105
+ return domainName;
106
+ }
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Deterministically resolves an imported module/target to its target domain.
114
+ */
115
+ public resolveTargetDomain(
116
+ targetStatement: string,
117
+ sourceFilePath: string | undefined,
118
+ config: ValidatedSeptumConfig
119
+ ): string | null {
120
+ this.ensureConfigLoaded();
121
+
122
+ const normalizedTarget = targetStatement.trim().replace(/^['"]|['"]$/g, "");
123
+ if (!normalizedTarget) return null;
124
+
125
+ // 1. Relative imports: ./ or ../ or Python dot relative . or ..
126
+ if (normalizedTarget.startsWith("./") || normalizedTarget.startsWith("../")) {
127
+ if (sourceFilePath) {
128
+ const sourceAbs = this.toAbsolutePath(sourceFilePath);
129
+ const sourceDir = path.dirname(sourceAbs);
130
+ const resolvedCandidate = path.resolve(sourceDir, normalizedTarget);
131
+ const domainFromPath = this.matchDomainByPath(resolvedCandidate, config);
132
+ if (domainFromPath) return domainFromPath;
133
+ }
134
+ } else if (normalizedTarget.startsWith(".")) {
135
+ // Python style relative import (e.g. .service, ..billing.service)
136
+ if (sourceFilePath) {
137
+ const sourceAbs = this.toAbsolutePath(sourceFilePath);
138
+ const sourceDir = path.dirname(sourceAbs);
139
+ const dotMatch = /^\.+/.exec(normalizedTarget);
140
+ if (dotMatch) {
141
+ const dotCount = dotMatch[0].length;
142
+ const remainder = normalizedTarget.slice(dotCount).replace(/\./g, path.sep);
143
+ const upSteps = "../".repeat(Math.max(0, dotCount - 1));
144
+ const resolvedCandidate = path.resolve(sourceDir, upSteps, remainder);
145
+ const domainFromPath = this.matchDomainByPath(resolvedCandidate, config);
146
+ if (domainFromPath) return domainFromPath;
147
+ }
148
+ }
149
+ }
150
+
151
+ // 2. Workspace Package Name Resolution (e.g. @repo/shared-types, @repo/design-system)
152
+ const domainFromPackage = this.matchDomainByPackageName(normalizedTarget, config);
153
+ if (domainFromPackage) return domainFromPackage;
154
+
155
+ // 3. Python Absolute Dot-notation: domains.billing.service or app.domains.billing
156
+ if (normalizedTarget.includes(".") && !normalizedTarget.includes("/")) {
157
+ const asPath = normalizedTarget.replace(/\./g, path.sep);
158
+ const candidatePath = path.resolve(this.projectRoot, asPath);
159
+ const domainFromPython = this.matchDomainByPath(candidatePath, config);
160
+ if (domainFromPython) return domainFromPython;
161
+ }
162
+
163
+ // 3. TypeScript Path Aliases (tsconfig.json paths)
164
+ const tsResolved = this.resolveTsPathAlias(normalizedTarget);
165
+ if (tsResolved) {
166
+ const domainFromTs = this.matchDomainByPath(tsResolved, config);
167
+ if (domainFromTs) return domainFromTs;
168
+ }
169
+
170
+ // 4. PHP PSR-4 Namespaces (composer.json autoload)
171
+ const phpResolved = this.resolvePsr4Namespace(normalizedTarget);
172
+ if (phpResolved) {
173
+ const domainFromPhp = this.matchDomainByPath(phpResolved, config);
174
+ if (domainFromPhp) return domainFromPhp;
175
+ }
176
+
177
+ // 4. Database-driven direct file & symbol lookup (pre-cached in memory to eliminate N+1)
178
+ if (this.repo) {
179
+ this.ensureRepoCachesLoaded();
180
+ const normKey = normalizedTarget.replace(/\\/g, "/");
181
+ const cachedFileDomain =
182
+ this.fileDomainCache?.get(normalizedTarget) ?? this.fileDomainCache?.get(normKey);
183
+ if (cachedFileDomain !== undefined) {
184
+ if (cachedFileDomain) return cachedFileDomain;
185
+ } else if (typeof this.repo.getFileWithDomain === "function") {
186
+ const fileRecord = this.repo.getFileWithDomain(normalizedTarget);
187
+ const domName = fileRecord?.domain_name ?? "";
188
+ this.fileDomainCache?.set(normalizedTarget, domName);
189
+ this.fileDomainCache?.set(normKey, domName);
190
+ if (domName) return domName;
191
+ }
192
+
193
+ // 5. Database-driven symbol lookup
194
+ const symbolName = this.extractSymbolName(normalizedTarget);
195
+ if (symbolName) {
196
+ const cachedSymDomain = this.symbolDomainCache?.get(symbolName);
197
+ if (cachedSymDomain !== undefined) {
198
+ if (cachedSymDomain) return cachedSymDomain;
199
+ } else if (typeof this.repo.findSymbolWithDomain === "function") {
200
+ const symbolRecord = this.repo.findSymbolWithDomain(symbolName);
201
+ const domName = symbolRecord?.domain_name ?? "";
202
+ this.symbolDomainCache?.set(symbolName, domName);
203
+ if (domName) return domName;
204
+ }
205
+ }
206
+ }
207
+
208
+ // 6. Root path segment matching (fallback for explicit domain root references)
209
+ // Only apply if the target explicitly references a domain root or is a path within the workspace
210
+ const normalizedSlash = normalizedTarget.replace(/\\/g, "/").toLowerCase();
211
+ const isBarePackage =
212
+ !normalizedTarget.startsWith(".") &&
213
+ !normalizedTarget.startsWith("/") &&
214
+ !normalizedTarget.includes(path.sep) &&
215
+ !normalizedTarget.includes("/");
216
+
217
+ if (!isBarePackage) {
218
+ for (const [domainName, domainCfg] of Object.entries(config.domains)) {
219
+ const domainRootSlash = domainCfg.root.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
220
+
221
+ if (
222
+ normalizedSlash === domainRootSlash ||
223
+ normalizedSlash.startsWith(`${domainRootSlash}/`) ||
224
+ normalizedSlash.includes(`/${domainRootSlash}/`)
225
+ ) {
226
+ return domainName;
227
+ }
228
+
229
+ const candidateAbs = path.resolve(this.projectRoot, normalizedTarget);
230
+ const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
231
+ if (this.isSubPathOrSame(candidateAbs, domainAbsRoot) && fs.existsSync(candidateAbs)) {
232
+ return domainName;
233
+ }
234
+ }
235
+ }
236
+
237
+ return null;
238
+ }
239
+
240
+ private matchDomainByPath(targetPath: string, config: ValidatedSeptumConfig): string | null {
241
+ const absTargetPath = this.toAbsolutePath(targetPath);
242
+
243
+ const sortedDomains = Object.entries(config.domains)
244
+ .map(([domainName, domainCfg]) => ({
245
+ domainName,
246
+ domainCfg,
247
+ absRoot: this.toAbsolutePath(domainCfg.root),
248
+ }))
249
+ .sort((a, b) => b.absRoot.length - a.absRoot.length);
250
+
251
+ for (const { domainName, absRoot } of sortedDomains) {
252
+ if (this.isSubPathOrSame(absTargetPath, absRoot)) {
253
+ return domainName;
254
+ }
255
+ }
256
+
257
+ return null;
258
+ }
259
+
260
+ private matchDomainByPackageName(target: string, config: ValidatedSeptumConfig): string | null {
261
+ if (!this.packageNameCache) {
262
+ this.packageNameCache = new Map<string, string | null>();
263
+ }
264
+
265
+ for (const [domainName, domainCfg] of Object.entries(config.domains)) {
266
+ const pkgJsonPath = path.resolve(this.projectRoot, domainCfg.root, "package.json");
267
+ const cached = this.packageNameCache.get(pkgJsonPath);
268
+ let pkgName: string | null = cached ?? null;
269
+ if (cached === undefined) {
270
+ if (fs.existsSync(pkgJsonPath)) {
271
+ try {
272
+ const raw = fs.readFileSync(pkgJsonPath, "utf8");
273
+ const pkg = JSON.parse(raw);
274
+ if (pkg.name && typeof pkg.name === "string") {
275
+ pkgName = pkg.name;
276
+ }
277
+ } catch {
278
+ // Ignore unreadable or invalid package.json
279
+ }
280
+ }
281
+ this.packageNameCache.set(pkgJsonPath, pkgName);
282
+ }
283
+
284
+ if (pkgName && (target === pkgName || target.startsWith(`${pkgName}/`))) {
285
+ return domainName;
286
+ }
287
+ }
288
+ return null;
289
+ }
290
+
291
+ public toCanonicalPath(filePath: string): string {
292
+ let resolved = path.isAbsolute(filePath)
293
+ ? path.normalize(filePath)
294
+ : path.resolve(this.projectRoot, filePath);
295
+
296
+ if (process.platform === "win32") {
297
+ // Normalize drive letter to uppercase (e.g. c:\ -> C:\)
298
+ resolved = resolved.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
299
+ }
300
+ return resolved;
301
+ }
302
+
303
+ private isSubPathOrSame(targetPath: string, rootPath: string): boolean {
304
+ const canonicalTarget = this.toCanonicalPath(targetPath);
305
+ const canonicalRoot = this.toCanonicalPath(rootPath);
306
+
307
+ if (process.platform === "win32") {
308
+ const lowerTarget = canonicalTarget.toLowerCase();
309
+ const lowerRoot = canonicalRoot.toLowerCase();
310
+ if (lowerTarget === lowerRoot) return true;
311
+ const rootWithSep = lowerRoot.endsWith(path.sep)
312
+ ? lowerRoot
313
+ : lowerRoot + path.sep;
314
+ return lowerTarget.startsWith(rootWithSep);
315
+ }
316
+
317
+ if (canonicalTarget === canonicalRoot) return true;
318
+ const rootWithSep = canonicalRoot.endsWith(path.sep)
319
+ ? canonicalRoot
320
+ : canonicalRoot + path.sep;
321
+
322
+ return canonicalTarget.startsWith(rootWithSep);
323
+ }
324
+
325
+ private toAbsolutePath(filePath: string): string {
326
+ return this.toCanonicalPath(filePath);
327
+ }
328
+
329
+ private extractSymbolName(target: string): string | null {
330
+ // PHP namespace or TS qualified name: Foo\Bar\Baz or Foo/Bar/Baz or bare Baz
331
+ const cleaned = target.replace(/\\/g, "/");
332
+ const parts = cleaned.split("/").filter(Boolean);
333
+ const lastPart = parts[parts.length - 1];
334
+ if (lastPart && /^[A-Z][a-zA-Z0-9_]*$/.test(lastPart)) {
335
+ return lastPart;
336
+ }
337
+ return null;
338
+ }
339
+
340
+ public fileExistsOnDisk(targetPath: string): boolean {
341
+ if (fs.existsSync(targetPath)) {
342
+ try {
343
+ const stat = fs.statSync(targetPath);
344
+ if (stat.isFile()) return true;
345
+ if (stat.isDirectory()) {
346
+ const indexExtensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
347
+ for (const ext of indexExtensions) {
348
+ if (fs.existsSync(path.join(targetPath, `index${ext}`))) {
349
+ return true;
350
+ }
351
+ }
352
+ }
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
358
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".json"];
359
+ for (const ext of extensions) {
360
+ if (fs.existsSync(targetPath + ext)) {
361
+ return true;
362
+ }
363
+ }
364
+
365
+ const indexExtensions = [".ts", ".tsx", ".js", ".jsx", ".d.ts"];
366
+ for (const ext of indexExtensions) {
367
+ if (fs.existsSync(path.join(targetPath, `index${ext}`))) {
368
+ return true;
369
+ }
370
+ }
371
+
372
+ return false;
373
+ }
374
+
375
+ private resolveTsPathAlias(target: string): string | null {
376
+ for (const rule of this.tsPaths) {
377
+ if (rule.prefix.endsWith("*")) {
378
+ const basePrefix = rule.prefix.slice(0, -1);
379
+ if (target.startsWith(basePrefix)) {
380
+ const suffix = target.slice(basePrefix.length);
381
+ for (const targetPattern of rule.targets) {
382
+ const candidate = targetPattern.replace("*", suffix);
383
+ const baseDir = path.resolve(this.projectRoot, this.tsBaseUrl);
384
+ const resolved = path.resolve(baseDir, candidate);
385
+ if (this.fileExistsOnDisk(resolved)) {
386
+ return resolved;
387
+ }
388
+ }
389
+ }
390
+ } else if (target === rule.prefix) {
391
+ for (const targetPattern of rule.targets) {
392
+ const baseDir = path.resolve(this.projectRoot, this.tsBaseUrl);
393
+ const resolved = path.resolve(baseDir, targetPattern);
394
+ if (this.fileExistsOnDisk(resolved)) {
395
+ return resolved;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+
403
+ private resolvePsr4Namespace(target: string): string | null {
404
+ // PHP PSR-4: App\Domain\Billing\Service -> app/Domain/Billing/Service.php
405
+ const normalizedTarget = target.replace(/\//g, "\\");
406
+
407
+ for (const rule of this.psr4Rules) {
408
+ if (normalizedTarget.startsWith(rule.prefix)) {
409
+ const subNamespace = normalizedTarget.slice(rule.prefix.length);
410
+ const subPath = subNamespace.replace(/\\/g, path.sep);
411
+ const resolved = path.resolve(this.projectRoot, rule.baseDir, subPath);
412
+ if (!resolved.endsWith(".php") && !resolved.endsWith(".inc")) {
413
+ const withPhp = `${resolved}.php`;
414
+ if (fs.existsSync(withPhp)) {
415
+ return withPhp;
416
+ }
417
+ }
418
+ return resolved;
419
+ }
420
+ }
421
+ return null;
422
+ }
423
+
424
+ private loadTsConfig(): void {
425
+ const tsconfigPath = path.join(this.projectRoot, "tsconfig.json");
426
+ if (!fs.existsSync(tsconfigPath)) return;
427
+
428
+ try {
429
+ const raw = fs.readFileSync(tsconfigPath, "utf-8");
430
+ // Strip comments (simple JSON with comments regex)
431
+ const cleanJson = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "");
432
+ const parsed = JSON.parse(cleanJson);
433
+ const compilerOptions = parsed.compilerOptions ?? {};
434
+
435
+ this.tsBaseUrl = compilerOptions.baseUrl ?? ".";
436
+
437
+ if (compilerOptions.paths && typeof compilerOptions.paths === "object") {
438
+ for (const [key, val] of Object.entries(compilerOptions.paths)) {
439
+ const targets = Array.isArray(val) ? (val as string[]) : [String(val)];
440
+ this.tsPaths.push({
441
+ prefix: key,
442
+ targets,
443
+ });
444
+ }
445
+ }
446
+ } catch {
447
+ // Graceful fallback if tsconfig is malformed
448
+ }
449
+ }
450
+
451
+ private loadComposerJson(): void {
452
+ const composerPath = path.join(this.projectRoot, "composer.json");
453
+ if (!fs.existsSync(composerPath)) return;
454
+
455
+ try {
456
+ const raw = fs.readFileSync(composerPath, "utf-8");
457
+ const parsed = JSON.parse(raw);
458
+
459
+ const extractPsr4 = (section: Record<string, unknown> | undefined) => {
460
+ if (section && typeof section["psr-4"] === "object" && section["psr-4"] !== null) {
461
+ for (const [ns, dir] of Object.entries(section["psr-4"] as Record<string, string>)) {
462
+ this.psr4Rules.push({
463
+ prefix: ns,
464
+ baseDir: String(dir),
465
+ });
466
+ }
467
+ }
468
+ };
469
+
470
+ extractPsr4(parsed.autoload);
471
+ extractPsr4(parsed["autoload-dev"]);
472
+
473
+ // Sort longest-prefix-first to prevent short prefixes from shadowing specific ones
474
+ this.psr4Rules.sort((a, b) => b.prefix.length - a.prefix.length);
475
+ } catch {
476
+ // Graceful fallback if composer.json is malformed
477
+ }
478
+ }
479
+ }