septum 0.1.4 → 0.1.5

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