@mandujs/ate 0.17.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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@mandujs/ate",
3
+ "version": "0.17.0",
4
+ "description": "Mandu ATE (Automation Test Engine) - extract/generate/run/report/heal/impact in one package",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./extractor": "./src/extractor.ts",
11
+ "./ir": "./src/ir.ts",
12
+ "./scenario": "./src/scenario.ts",
13
+ "./codegen": "./src/codegen.ts",
14
+ "./runner": "./src/runner.ts",
15
+ "./oracle": "./src/oracle.ts",
16
+ "./report": "./src/report.ts",
17
+ "./heal": "./src/heal.ts",
18
+ "./trace-parser": "./src/trace-parser.ts",
19
+ "./impact": "./src/impact.ts",
20
+ "./fs": "./src/fs.ts",
21
+ "./types": "./src/types.ts"
22
+ },
23
+ "files": [
24
+ "src/**/*"
25
+ ],
26
+ "keywords": [
27
+ "mandu",
28
+ "ate",
29
+ "automation",
30
+ "e2e",
31
+ "playwright",
32
+ "ts-morph"
33
+ ],
34
+ "license": "MPL-2.0",
35
+ "dependencies": {
36
+ "fast-glob": "^3.3.2",
37
+ "ts-morph": "^26.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@playwright/test": ">=1.40.0",
41
+ "playwright": ">=1.40.0"
42
+ },
43
+ "engines": {
44
+ "bun": ">=1.0.0"
45
+ }
46
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { getAtePaths, ensureDir, readJson, writeJson } from "./fs";
4
+ import type { ScenarioBundle } from "./scenario";
5
+ import type { OracleLevel } from "./types";
6
+ import { readSelectorMap, buildPlaywrightLocatorChain } from "./selector-map";
7
+ import { generateL1Assertions } from "./oracle";
8
+ import { detectDomain } from "./domain-detector";
9
+
10
+ function specHeader(): string {
11
+ return `import { test, expect } from "@playwright/test";\n\n`;
12
+ }
13
+
14
+ function oracleTemplate(level: OracleLevel, routePath: string): { setup: string; assertions: string } {
15
+ const setup: string[] = [];
16
+ const assertions: string[] = [];
17
+
18
+ // L0 baseline always
19
+ setup.push(`// L0: no console.error / uncaught exception / 5xx`);
20
+ setup.push(`const errors: string[] = [];`);
21
+ setup.push(`page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); });`);
22
+ setup.push(`page.on("pageerror", (err) => errors.push(String(err)));`);
23
+
24
+ if (level === "L1" || level === "L2" || level === "L3") {
25
+ // Use domain-aware L1 assertions
26
+ const domain = detectDomain(routePath).domain;
27
+ const l1Assertions = generateL1Assertions(domain, routePath);
28
+ assertions.push(...l1Assertions);
29
+ }
30
+ if (level === "L2" || level === "L3") {
31
+ assertions.push(`// L2: behavior signals (placeholder - extend per app)`);
32
+ assertions.push(`await expect(page).toHaveURL(/.*/);`);
33
+ }
34
+ if (level === "L3") {
35
+ assertions.push(`// L3: domain hints (placeholder)`);
36
+ }
37
+
38
+ assertions.push(`expect(errors, "console/page errors").toEqual([]);`);
39
+
40
+ return { setup: setup.join("\n"), assertions: assertions.join("\n") };
41
+ }
42
+
43
+ export function generatePlaywrightSpecs(repoRoot: string, opts?: { onlyRoutes?: string[] }): { files: string[]; warnings: string[] } {
44
+ const paths = getAtePaths(repoRoot);
45
+ const warnings: string[] = [];
46
+
47
+ let bundle: ScenarioBundle;
48
+ try {
49
+ bundle = readJson<ScenarioBundle>(paths.scenariosPath);
50
+ } catch (err: any) {
51
+ throw new Error(`시나리오 번들 읽기 실패: ${err.message}`);
52
+ }
53
+
54
+ if (!bundle.scenarios || bundle.scenarios.length === 0) {
55
+ warnings.push("경고: 생성할 시나리오가 없습니다");
56
+ return { files: [], warnings };
57
+ }
58
+
59
+ let selectorMap;
60
+ try {
61
+ selectorMap = readSelectorMap(repoRoot);
62
+ } catch (err: any) {
63
+ // Selector map is optional
64
+ warnings.push(`Selector map 읽기 실패 (무시): ${err.message}`);
65
+ }
66
+
67
+ try {
68
+ ensureDir(paths.autoE2eDir);
69
+ } catch (err: any) {
70
+ throw new Error(`E2E 디렉토리 생성 실패: ${err.message}`);
71
+ }
72
+
73
+ const files: string[] = [];
74
+ for (const s of bundle.scenarios) {
75
+ if (opts?.onlyRoutes?.length && !opts.onlyRoutes.includes(s.route)) continue;
76
+
77
+ try {
78
+ const safeId = s.id.replace(/[^a-zA-Z0-9_-]/g, "_");
79
+ const filePath = join(paths.autoE2eDir, `${safeId}.spec.ts`);
80
+
81
+ const oracle = oracleTemplate(s.oracleLevel, s.route);
82
+
83
+ // Generate selector examples if selector map exists
84
+ let selectorExamples = "";
85
+ if (selectorMap && selectorMap.entries.length > 0) {
86
+ const exampleEntry = selectorMap.entries[0];
87
+ const locatorChain = buildPlaywrightLocatorChain(exampleEntry);
88
+ selectorExamples = ` // Example: Selector with fallback chain\n // const loginBtn = ${locatorChain};\n`;
89
+ }
90
+
91
+ const code = [
92
+ specHeader(),
93
+ `test.describe(${JSON.stringify(s.id)}, () => {`,
94
+ ` test(${JSON.stringify(`smoke ${s.route}`)}, async ({ page, baseURL }) => {`,
95
+ ` const url = (baseURL ?? "http://localhost:3333") + ${JSON.stringify(s.route === "/" ? "/" : s.route)};`,
96
+ ` ${oracle.setup.split("\n").join("\n ")}`,
97
+ ` await page.goto(url);`,
98
+ selectorExamples,
99
+ ` ${oracle.assertions.split("\n").join("\n ")}`,
100
+ ` });`,
101
+ `});`,
102
+ "",
103
+ ].join("\n");
104
+
105
+ try {
106
+ writeFileSync(filePath, code, "utf8");
107
+ files.push(filePath);
108
+ } catch (err: any) {
109
+ warnings.push(`Spec 파일 쓰기 실패 (${filePath}): ${err.message}`);
110
+ console.error(`[ATE] Spec 생성 실패: ${filePath} - ${err.message}`);
111
+ }
112
+ } catch (err: any) {
113
+ warnings.push(`Spec 생성 실패 (${s.id}): ${err.message}`);
114
+ console.error(`[ATE] Spec 생성 에러: ${s.id} - ${err.message}`);
115
+ // Continue with next scenario
116
+ }
117
+ }
118
+
119
+ // ensure playwright config exists (minimal)
120
+ try {
121
+ const configPath = join(repoRoot, "tests", "e2e", "playwright.config.ts");
122
+ ensureDir(join(repoRoot, "tests", "e2e"));
123
+ const desiredConfig = `import { defineConfig } from "@playwright/test";\n\nexport default defineConfig({\n // NOTE: resolved relative to this config file (tests/e2e).\n testDir: ".",\n timeout: 60_000,\n use: {\n baseURL: process.env.BASE_URL ?? "http://localhost:3333",\n trace: process.env.CI ? "on-first-retry" : "retain-on-failure",\n video: process.env.CI ? "retain-on-failure" : "off",\n screenshot: "only-on-failure",\n },\n reporter: [\n ["html", { outputFolder: "../../.mandu/reports/latest/playwright-html", open: "never" }],\n ["json", { outputFile: "../../.mandu/reports/latest/playwright-report.json" }],\n ["junit", { outputFile: "../../.mandu/reports/latest/junit.xml" }],\n ],\n});\n`;
124
+
125
+ if (!existsSync(configPath)) {
126
+ Bun.write(configPath, desiredConfig);
127
+ } else {
128
+ // migrate older auto-generated config that used testDir: "tests/e2e" (breaks because config is already under tests/e2e)
129
+ const current = readFileSync(configPath, "utf8");
130
+ if (current.includes('testDir: "tests/e2e"')) {
131
+ Bun.write(configPath, desiredConfig);
132
+ }
133
+ }
134
+ } catch (err: any) {
135
+ warnings.push(`Playwright config 생성 실패: ${err.message}`);
136
+ console.warn(`[ATE] Playwright config 생성 실패: ${err.message}`);
137
+ }
138
+
139
+ return { files, warnings };
140
+ }
@@ -0,0 +1,279 @@
1
+ import { normalize, resolve, relative, dirname, sep } from "node:path";
2
+
3
+ /**
4
+ * Dependency graph: file → Set of files it depends on
5
+ */
6
+ export interface DependencyGraph {
7
+ /** Map: normalized file path → Set of normalized dependency paths */
8
+ dependencies: Map<string, Set<string>>;
9
+ /** Map: normalized file path → Set of files that depend on it (reverse) */
10
+ dependents: Map<string, Set<string>>;
11
+ /** All files in the graph */
12
+ files: Set<string>;
13
+ }
14
+
15
+ /**
16
+ * Build options for dependency graph
17
+ */
18
+ export interface BuildGraphOptions {
19
+ /** Root directory to resolve relative paths */
20
+ rootDir: string;
21
+ /** tsconfig.json path for TypeScript configuration */
22
+ tsconfigPath?: string;
23
+ /** File glob patterns to include */
24
+ include?: string[];
25
+ /** File glob patterns to exclude */
26
+ exclude?: string[];
27
+ }
28
+
29
+ /**
30
+ * Normalize path to forward slashes and resolve to absolute
31
+ */
32
+ function normalizePath(path: string, rootDir: string): string {
33
+ const abs = resolve(rootDir, path);
34
+ return abs.replace(/\\/g, "/");
35
+ }
36
+
37
+ /**
38
+ * Resolve import specifier to file path
39
+ */
40
+ function resolveImport(
41
+ sourceFile: any, // ts-morph SourceFile (lazy loaded)
42
+ importSpecifier: string,
43
+ rootDir: string,
44
+ project: any // ts-morph Project (lazy loaded)
45
+ ): string | null {
46
+ // Skip external modules (no relative/absolute path)
47
+ if (!importSpecifier.startsWith(".") && !importSpecifier.startsWith("/")) {
48
+ return null;
49
+ }
50
+
51
+ const sourceDir = dirname(sourceFile.getFilePath());
52
+ let targetPath = resolve(sourceDir, importSpecifier);
53
+
54
+ // Try extensions: .ts, .tsx, .js, .jsx
55
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ""];
56
+ for (const ext of extensions) {
57
+ const candidate = targetPath + ext;
58
+ const sourceFileFound = project.getSourceFile(candidate);
59
+ if (sourceFileFound) {
60
+ return normalizePath(candidate, rootDir);
61
+ }
62
+ }
63
+
64
+ // Try index files
65
+ for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
66
+ const indexPath = resolve(targetPath, `index${ext}`);
67
+ const sourceFileFound = project.getSourceFile(indexPath);
68
+ if (sourceFileFound) {
69
+ return normalizePath(indexPath, rootDir);
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Build dependency graph from TypeScript project
78
+ */
79
+ function shouldExcludeFile(filePath: string, excludePatterns: string[]): boolean {
80
+ if (excludePatterns.length === 0) return false;
81
+
82
+ const normalizedPath = filePath.replace(/\\/g, "/");
83
+ return excludePatterns.some((pattern) => {
84
+ // **/*.spec.ts → remove **/ and * to get .spec.ts
85
+ let processedPattern = pattern;
86
+
87
+ // Remove **/ prefix
88
+ if (processedPattern.startsWith("**/")) {
89
+ processedPattern = processedPattern.substring(3);
90
+ }
91
+
92
+ // Remove * prefix
93
+ if (processedPattern.startsWith("*")) {
94
+ processedPattern = processedPattern.substring(1);
95
+ }
96
+
97
+ // Now check if file ends with the processed pattern
98
+ return normalizedPath.endsWith(processedPattern);
99
+ });
100
+ }
101
+
102
+ export async function buildDependencyGraph(options: BuildGraphOptions): Promise<DependencyGraph> {
103
+ const { rootDir, tsconfigPath, include, exclude = [] } = options;
104
+
105
+ // Lazy load ts-morph only when needed
106
+ const { Project } = await import("ts-morph");
107
+
108
+ const project = new Project({
109
+ tsConfigFilePath: tsconfigPath,
110
+ skipAddingFilesFromTsConfig: !tsconfigPath,
111
+ });
112
+
113
+ // Add files if no tsconfig
114
+ if (!tsconfigPath && include) {
115
+ project.addSourceFilesAtPaths(include);
116
+ }
117
+
118
+ const dependencies = new Map<string, Set<string>>();
119
+ const dependents = new Map<string, Set<string>>();
120
+ const files = new Set<string>();
121
+
122
+ const sourceFiles = project.getSourceFiles();
123
+
124
+ // First pass: collect all files (excluding filtered ones)
125
+ for (const sourceFile of sourceFiles) {
126
+ const filePath = normalizePath(sourceFile.getFilePath(), rootDir);
127
+
128
+ // Skip excluded files
129
+ if (shouldExcludeFile(filePath, exclude)) {
130
+ continue;
131
+ }
132
+
133
+ files.add(filePath);
134
+ dependencies.set(filePath, new Set());
135
+ dependents.set(filePath, new Set());
136
+ }
137
+
138
+ // Second pass: extract imports
139
+ for (const sourceFile of sourceFiles) {
140
+ const filePath = normalizePath(sourceFile.getFilePath(), rootDir);
141
+
142
+ // Skip excluded files
143
+ if (shouldExcludeFile(filePath, exclude)) {
144
+ continue;
145
+ }
146
+
147
+ const deps = dependencies.get(filePath)!;
148
+
149
+ // Process import declarations
150
+ for (const importDecl of sourceFile.getImportDeclarations()) {
151
+ const specifier = importDecl.getModuleSpecifierValue();
152
+ const resolvedPath = resolveImport(sourceFile, specifier, rootDir, project);
153
+
154
+ if (resolvedPath && files.has(resolvedPath)) {
155
+ deps.add(resolvedPath);
156
+ dependents.get(resolvedPath)?.add(filePath);
157
+ }
158
+ }
159
+
160
+ // Process export declarations with from clause
161
+ for (const exportDecl of sourceFile.getExportDeclarations()) {
162
+ const moduleSpecifier = exportDecl.getModuleSpecifier();
163
+ if (moduleSpecifier) {
164
+ const specifier = moduleSpecifier.getLiteralText();
165
+ const resolvedPath = resolveImport(sourceFile, specifier, rootDir, project);
166
+
167
+ if (resolvedPath && files.has(resolvedPath)) {
168
+ deps.add(resolvedPath);
169
+ dependents.get(resolvedPath)?.add(filePath);
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ return { dependencies, dependents, files };
176
+ }
177
+
178
+ /**
179
+ * Find all files that transitively depend on the given file
180
+ * Uses DFS to traverse the dependency graph in reverse
181
+ */
182
+ export function findDependents(
183
+ graph: DependencyGraph,
184
+ changedFile: string,
185
+ options?: { maxDepth?: number }
186
+ ): Set<string> {
187
+ const result = new Set<string>();
188
+ const visited = new Set<string>();
189
+ const maxDepth = options?.maxDepth ?? Infinity;
190
+
191
+ function dfs(file: string, depth: number) {
192
+ if (visited.has(file)) return; // Prevent infinite loop (circular deps)
193
+ visited.add(file);
194
+
195
+ const deps = graph.dependents.get(file);
196
+ if (!deps) return;
197
+
198
+ for (const dependent of deps) {
199
+ if (depth < maxDepth) {
200
+ result.add(dependent);
201
+ dfs(dependent, depth + 1);
202
+ }
203
+ }
204
+ }
205
+
206
+ dfs(changedFile, 0);
207
+ return result;
208
+ }
209
+
210
+ /**
211
+ * Find all files that the given file transitively depends on
212
+ * Uses DFS to traverse the dependency graph forward
213
+ */
214
+ export function findDependencies(
215
+ graph: DependencyGraph,
216
+ file: string,
217
+ options?: { maxDepth?: number }
218
+ ): Set<string> {
219
+ const result = new Set<string>();
220
+ const visited = new Set<string>();
221
+ const maxDepth = options?.maxDepth ?? Infinity;
222
+
223
+ function dfs(currentFile: string, depth: number) {
224
+ if (depth > maxDepth) return;
225
+ if (visited.has(currentFile)) return;
226
+ visited.add(currentFile);
227
+
228
+ const deps = graph.dependencies.get(currentFile);
229
+ if (!deps) return;
230
+
231
+ for (const dep of deps) {
232
+ result.add(dep);
233
+ dfs(dep, depth + 1);
234
+ }
235
+ }
236
+
237
+ dfs(file, 0);
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * Detect circular dependencies in the graph
243
+ */
244
+ export function detectCircularDependencies(graph: DependencyGraph): string[][] {
245
+ const cycles: string[][] = [];
246
+ const visited = new Set<string>();
247
+ const recursionStack = new Set<string>();
248
+
249
+ function dfs(file: string, path: string[]) {
250
+ if (recursionStack.has(file)) {
251
+ // Found a cycle
252
+ const cycleStart = path.indexOf(file);
253
+ const cycle = path.slice(cycleStart);
254
+ cycles.push([...cycle, file]);
255
+ return;
256
+ }
257
+
258
+ if (visited.has(file)) return;
259
+ visited.add(file);
260
+ recursionStack.add(file);
261
+
262
+ const deps = graph.dependencies.get(file);
263
+ if (deps) {
264
+ for (const dep of deps) {
265
+ dfs(dep, [...path, file]);
266
+ }
267
+ }
268
+
269
+ recursionStack.delete(file);
270
+ }
271
+
272
+ for (const file of graph.files) {
273
+ if (!visited.has(file)) {
274
+ dfs(file, []);
275
+ }
276
+ }
277
+
278
+ return cycles;
279
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Domain detection for context-aware test assertions
3
+ * Analyzes route paths, imports, and keywords to identify application domain
4
+ */
5
+
6
+ export type AppDomain = "ecommerce" | "blog" | "dashboard" | "auth" | "generic";
7
+
8
+ export interface DomainDetectionResult {
9
+ domain: AppDomain;
10
+ confidence: number; // 0-1
11
+ signals: string[];
12
+ }
13
+
14
+ interface DomainPattern {
15
+ domain: AppDomain;
16
+ routePatterns: RegExp[];
17
+ importKeywords: string[];
18
+ codeKeywords: string[];
19
+ }
20
+
21
+ const DOMAIN_PATTERNS: DomainPattern[] = [
22
+ {
23
+ domain: "ecommerce",
24
+ routePatterns: [
25
+ /\/shop/i,
26
+ /\/cart/i,
27
+ /\/checkout/i,
28
+ /\/product/i,
29
+ /\/order/i,
30
+ /\/payment/i,
31
+ ],
32
+ importKeywords: ["stripe", "commerce", "cart", "checkout", "payment"],
33
+ codeKeywords: ["addToCart", "removeFromCart", "checkout", "price", "product", "order"],
34
+ },
35
+ {
36
+ domain: "blog",
37
+ routePatterns: [
38
+ /\/blog/i,
39
+ /\/post/i,
40
+ /\/article/i,
41
+ /\/author/i,
42
+ /\/category/i,
43
+ ],
44
+ importKeywords: ["markdown", "mdx", "contentlayer", "sanity", "cms"],
45
+ codeKeywords: ["post", "article", "author", "publish", "content", "comment"],
46
+ },
47
+ {
48
+ domain: "dashboard",
49
+ routePatterns: [
50
+ /\/dashboard/i,
51
+ /\/admin/i,
52
+ /\/analytics/i,
53
+ /\/settings/i,
54
+ /\/profile/i,
55
+ ],
56
+ importKeywords: ["chart", "recharts", "d3", "analytics", "table", "zustand", "react-query"],
57
+ codeKeywords: ["chart", "table", "sidebar", "analytics", "report", "metric"],
58
+ },
59
+ {
60
+ domain: "auth",
61
+ routePatterns: [
62
+ /\/login/i,
63
+ /\/signup/i,
64
+ /\/register/i,
65
+ /\/auth/i,
66
+ /\/forgot-password/i,
67
+ ],
68
+ importKeywords: ["auth", "clerk", "nextauth", "supabase", "firebase"],
69
+ codeKeywords: ["login", "logout", "signup", "register", "authenticate", "session"],
70
+ },
71
+ ];
72
+
73
+ /**
74
+ * Detect application domain from route path
75
+ */
76
+ export function detectDomainFromRoute(routePath: string): DomainDetectionResult {
77
+ const results: Array<{ domain: AppDomain; score: number; signals: string[] }> = [];
78
+
79
+ for (const pattern of DOMAIN_PATTERNS) {
80
+ const signals: string[] = [];
81
+ let score = 0;
82
+
83
+ for (const regex of pattern.routePatterns) {
84
+ if (regex.test(routePath)) {
85
+ score += 1;
86
+ signals.push(`route pattern: ${regex.source}`);
87
+ }
88
+ }
89
+
90
+ if (score > 0) {
91
+ results.push({ domain: pattern.domain, score, signals });
92
+ }
93
+ }
94
+
95
+ // Sort by score descending
96
+ results.sort((a, b) => b.score - a.score);
97
+
98
+ if (results.length > 0 && results[0].score > 0) {
99
+ const best = results[0];
100
+ return {
101
+ domain: best.domain,
102
+ confidence: Math.min(best.score / 2, 1), // Normalize confidence
103
+ signals: best.signals,
104
+ };
105
+ }
106
+
107
+ return {
108
+ domain: "generic",
109
+ confidence: 1,
110
+ signals: ["no domain-specific patterns detected"],
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Detect application domain from source code content
116
+ */
117
+ export function detectDomainFromSource(sourceCode: string): DomainDetectionResult {
118
+ const results: Array<{ domain: AppDomain; score: number; signals: string[] }> = [];
119
+
120
+ for (const pattern of DOMAIN_PATTERNS) {
121
+ const signals: string[] = [];
122
+ let score = 0;
123
+
124
+ // Check import keywords
125
+ for (const keyword of pattern.importKeywords) {
126
+ const importRegex = new RegExp(`import\\s+.*from\\s+["'].*${keyword}.*["']`, "i");
127
+ if (importRegex.test(sourceCode)) {
128
+ score += 2;
129
+ signals.push(`import: ${keyword}`);
130
+ }
131
+ }
132
+
133
+ // Check code keywords
134
+ for (const keyword of pattern.codeKeywords) {
135
+ const codeRegex = new RegExp(`\\b${keyword}\\b`, "i");
136
+ if (codeRegex.test(sourceCode)) {
137
+ score += 1;
138
+ signals.push(`code keyword: ${keyword}`);
139
+ }
140
+ }
141
+
142
+ if (score > 0) {
143
+ results.push({ domain: pattern.domain, score, signals });
144
+ }
145
+ }
146
+
147
+ // Sort by score descending
148
+ results.sort((a, b) => b.score - a.score);
149
+
150
+ if (results.length > 0 && results[0].score >= 3) {
151
+ const best = results[0];
152
+ return {
153
+ domain: best.domain,
154
+ confidence: Math.min(best.score / 10, 1), // Normalize confidence
155
+ signals: best.signals.slice(0, 5), // Limit signals
156
+ };
157
+ }
158
+
159
+ return {
160
+ domain: "generic",
161
+ confidence: 1,
162
+ signals: ["no strong domain signals in source code"],
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Combine multiple detection strategies
168
+ */
169
+ export function detectDomain(routePath: string, sourceCode?: string): DomainDetectionResult {
170
+ const routeResult = detectDomainFromRoute(routePath);
171
+
172
+ if (!sourceCode) {
173
+ return routeResult;
174
+ }
175
+
176
+ const sourceResult = detectDomainFromSource(sourceCode);
177
+
178
+ // If both agree, combine confidence
179
+ if (routeResult.domain === sourceResult.domain) {
180
+ return {
181
+ domain: routeResult.domain,
182
+ confidence: Math.min((routeResult.confidence + sourceResult.confidence) / 2 + 0.2, 1),
183
+ signals: [...routeResult.signals, ...sourceResult.signals],
184
+ };
185
+ }
186
+
187
+ // If they disagree, prefer route result if it's not generic
188
+ if (routeResult.domain !== "generic") {
189
+ return routeResult;
190
+ }
191
+
192
+ // Otherwise use source result
193
+ return sourceResult;
194
+ }