@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.
@@ -0,0 +1,159 @@
1
+ import fg from "fast-glob";
2
+ import { relative, join } from "node:path";
3
+ import { createEmptyGraph, addEdge, addNode } from "./ir";
4
+ import { getAtePaths, writeJson } from "./fs";
5
+ import type { ExtractInput, InteractionGraph } from "./types";
6
+
7
+ const DEFAULT_ROUTE_GLOBS = ["app/**/page.tsx", "routes/**/page.tsx"]; // demo-first default
8
+
9
+ function isStringLiteral(node: any /* ts-morph Node */, SyntaxKind: any): boolean {
10
+ return node.getKind() === SyntaxKind.StringLiteral;
11
+ }
12
+
13
+ function tryExtractLiteralArg(callExpr: any /* ts-morph CallExpression */, argIndex = 0, SyntaxKind: any): string | null {
14
+ const args = callExpr.getArguments();
15
+ const arg = args[argIndex];
16
+ if (!arg) return null;
17
+ if (isStringLiteral(arg, SyntaxKind)) return arg.getLiteralValue();
18
+ return null;
19
+ }
20
+
21
+ export async function extract(input: ExtractInput): Promise<{ ok: true; graphPath: string; summary: { nodes: number; edges: number }; warnings: string[] }> {
22
+ const repoRoot = input.repoRoot;
23
+ const buildSalt = input.buildSalt ?? process.env.MANDU_BUILD_SALT ?? "dev";
24
+ const paths = getAtePaths(repoRoot);
25
+ const warnings: string[] = [];
26
+
27
+ const graph: InteractionGraph = createEmptyGraph(buildSalt);
28
+
29
+ // Validate input
30
+ if (!repoRoot) {
31
+ throw new Error("repoRoot는 필수입니다");
32
+ }
33
+
34
+ const routeGlobs = input.routeGlobs?.length ? input.routeGlobs : DEFAULT_ROUTE_GLOBS;
35
+
36
+ let routeFiles: string[];
37
+ try {
38
+ routeFiles = await fg(routeGlobs, {
39
+ cwd: repoRoot,
40
+ absolute: true,
41
+ onlyFiles: true,
42
+ ignore: ["**/node_modules/**", "**/.mandu/**"],
43
+ });
44
+ } catch (err: any) {
45
+ throw new Error(`파일 검색 실패: ${err.message}`);
46
+ }
47
+
48
+ if (routeFiles.length === 0) {
49
+ warnings.push(`경고: route 파일을 찾을 수 없습니다 (globs: ${routeGlobs.join(", ")})`);
50
+ }
51
+
52
+ // Lazy load ts-morph only when needed
53
+ const { Project, SyntaxKind } = await import("ts-morph");
54
+
55
+ let project: any; // ts-morph Project
56
+ try {
57
+ project = new Project({
58
+ tsConfigFilePath: input.tsconfigPath ? join(repoRoot, input.tsconfigPath) : undefined,
59
+ skipAddingFilesFromTsConfig: true,
60
+ });
61
+ } catch (err: any) {
62
+ throw new Error(`TypeScript 프로젝트 초기화 실패: ${err.message}`);
63
+ }
64
+
65
+ for (const filePath of routeFiles) {
66
+ try {
67
+ const sourceFile = project.addSourceFileAtPath(filePath);
68
+ const rel = relative(repoRoot, filePath);
69
+ const relNormalized = rel.replace(/\\/g, "/");
70
+
71
+ // route node id: normalize to path without trailing /page.tsx
72
+ const routePath = relNormalized
73
+ .replace(/^app\//, "/")
74
+ .replace(/^routes\//, "/")
75
+ .replace(/\/page\.tsx$/, "")
76
+ .replace(/\/index\.tsx$/, "")
77
+ .replace(/\/page$/, "")
78
+ .replace(/\\/g, "/");
79
+
80
+ addNode(graph, { kind: "route", id: routePath === "" ? "/" : routePath, file: relNormalized, path: routePath === "" ? "/" : routePath });
81
+
82
+ // ManduLink / Link literal extraction: <Link href="/x"> or <ManduLink to="/x">
83
+ try {
84
+ const jsxAttrs = sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute);
85
+ for (const attr of jsxAttrs) {
86
+ try {
87
+ const name = (attr as any).getNameNode?.().getText?.() ?? (attr as any).getName?.() ?? "";
88
+ if (name !== "to" && name !== "href") continue;
89
+ const init = (attr as any).getInitializer?.();
90
+ if (!init) continue;
91
+ if (init.getKind?.() === SyntaxKind.StringLiteral) {
92
+ const raw = (init as any).getLiteralValue?.() ?? init.getText?.();
93
+ const to = typeof raw === "string" ? raw.replace(/^"|"$/g, "") : null;
94
+ if (typeof to === "string" && to.startsWith("/")) {
95
+ addEdge(graph, { kind: "navigate", from: routePath || "/", to, file: relNormalized, source: `<jsx ${name}>` });
96
+ }
97
+ }
98
+ } catch (err: any) {
99
+ // Skip invalid JSX attributes
100
+ warnings.push(`JSX 속성 파싱 실패 (${relNormalized}): ${err.message}`);
101
+ }
102
+ }
103
+ } catch (err: any) {
104
+ warnings.push(`JSX 분석 실패 (${relNormalized}): ${err.message}`);
105
+ }
106
+
107
+ // mandu.navigate("/x") literal
108
+ try {
109
+ const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
110
+ for (const call of calls) {
111
+ try {
112
+ const exprText = call.getExpression().getText();
113
+ if (exprText === "mandu.navigate" || exprText.endsWith(".navigate")) {
114
+ const to = tryExtractLiteralArg(call, 0, SyntaxKind);
115
+ if (to && to.startsWith("/")) {
116
+ addEdge(graph, { kind: "navigate", from: routePath || "/", to, file: relNormalized, source: "mandu.navigate" });
117
+ }
118
+ }
119
+ if (exprText === "mandu.modal.open" || exprText.endsWith(".modal.open")) {
120
+ const modal = tryExtractLiteralArg(call, 0, SyntaxKind);
121
+ if (modal) {
122
+ addEdge(graph, { kind: "openModal", from: routePath || "/", modal, file: relNormalized, source: "mandu.modal.open" });
123
+ }
124
+ }
125
+ if (exprText === "mandu.action.run" || exprText.endsWith(".action.run")) {
126
+ const action = tryExtractLiteralArg(call, 0, SyntaxKind);
127
+ if (action) {
128
+ addEdge(graph, { kind: "runAction", from: routePath || "/", action, file: relNormalized, source: "mandu.action.run" });
129
+ }
130
+ }
131
+ } catch (err: any) {
132
+ // Skip invalid call expressions
133
+ warnings.push(`함수 호출 파싱 실패 (${relNormalized}): ${err.message}`);
134
+ }
135
+ }
136
+ } catch (err: any) {
137
+ warnings.push(`함수 호출 분석 실패 (${relNormalized}): ${err.message}`);
138
+ }
139
+ } catch (err: any) {
140
+ // Graceful degradation: skip this file and continue
141
+ warnings.push(`파일 파싱 실패 (${filePath}): ${err.message}`);
142
+ console.warn(`[ATE] 파일 스킵: ${filePath} - ${err.message}`);
143
+ continue;
144
+ }
145
+ }
146
+
147
+ try {
148
+ writeJson(paths.interactionGraphPath, graph);
149
+ } catch (err: any) {
150
+ throw new Error(`Interaction graph 저장 실패: ${err.message}`);
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ graphPath: paths.interactionGraphPath,
156
+ summary: { nodes: graph.nodes.length, edges: graph.edges.length },
157
+ warnings,
158
+ };
159
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { AtePaths } from "./types";
4
+
5
+ export class ATEFileError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly code: string,
9
+ public readonly path: string,
10
+ ) {
11
+ super(message);
12
+ this.name = "ATEFileError";
13
+ }
14
+ }
15
+
16
+ export function getAtePaths(repoRoot: string): AtePaths {
17
+ const manduDir = join(repoRoot, ".mandu");
18
+ return {
19
+ repoRoot,
20
+ manduDir,
21
+ interactionGraphPath: join(manduDir, "interaction-graph.json"),
22
+ selectorMapPath: join(manduDir, "selector-map.json"),
23
+ scenariosPath: join(manduDir, "scenarios", "generated.json"),
24
+ reportsDir: join(manduDir, "reports"),
25
+ autoE2eDir: join(repoRoot, "tests", "e2e", "auto"),
26
+ manualE2eDir: join(repoRoot, "tests", "e2e", "manual"),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * 디렉토리 생성 (재귀적)
32
+ * @throws ATEFileError - 권한 없음 또는 파일 시스템 에러
33
+ */
34
+ export function ensureDir(path: string): void {
35
+ try {
36
+ mkdirSync(path, { recursive: true });
37
+ } catch (err: any) {
38
+ if (err.code === "EACCES") {
39
+ throw new ATEFileError(
40
+ `디렉토리 생성 권한이 없습니다: ${path}`,
41
+ "PERMISSION_DENIED",
42
+ path,
43
+ );
44
+ }
45
+ if (err.code === "ENOSPC") {
46
+ throw new ATEFileError(
47
+ `디스크 공간이 부족합니다: ${path}`,
48
+ "NO_SPACE",
49
+ path,
50
+ );
51
+ }
52
+ throw new ATEFileError(
53
+ `디렉토리 생성 실패: ${path} (${err.message})`,
54
+ err.code || "UNKNOWN",
55
+ path,
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * JSON 파일 쓰기
62
+ * @throws ATEFileError - 파일 쓰기 실패
63
+ */
64
+ export function writeJson(path: string, data: unknown): void {
65
+ try {
66
+ ensureDir(dirname(path));
67
+ writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
68
+ } catch (err: any) {
69
+ if (err instanceof ATEFileError) {
70
+ throw err;
71
+ }
72
+ if (err.code === "EACCES") {
73
+ throw new ATEFileError(
74
+ `파일 쓰기 권한이 없습니다: ${path}`,
75
+ "PERMISSION_DENIED",
76
+ path,
77
+ );
78
+ }
79
+ if (err.code === "ENOSPC") {
80
+ throw new ATEFileError(
81
+ `디스크 공간이 부족합니다: ${path}`,
82
+ "NO_SPACE",
83
+ path,
84
+ );
85
+ }
86
+ throw new ATEFileError(
87
+ `JSON 파일 쓰기 실패: ${path} (${err.message})`,
88
+ err.code || "UNKNOWN",
89
+ path,
90
+ );
91
+ }
92
+ }
93
+
94
+ /**
95
+ * JSON 파일 읽기
96
+ * @throws ATEFileError - 파일 없음, 읽기 실패, JSON 파싱 실패
97
+ */
98
+ export function readJson<T>(path: string): T {
99
+ try {
100
+ if (!existsSync(path)) {
101
+ throw new ATEFileError(
102
+ `파일을 찾을 수 없습니다: ${path}`,
103
+ "FILE_NOT_FOUND",
104
+ path,
105
+ );
106
+ }
107
+
108
+ const content = readFileSync(path, "utf8");
109
+ return JSON.parse(content) as T;
110
+ } catch (err: any) {
111
+ if (err instanceof ATEFileError) {
112
+ throw err;
113
+ }
114
+ if (err.code === "EACCES") {
115
+ throw new ATEFileError(
116
+ `파일 읽기 권한이 없습니다: ${path}`,
117
+ "PERMISSION_DENIED",
118
+ path,
119
+ );
120
+ }
121
+ if (err instanceof SyntaxError) {
122
+ throw new ATEFileError(
123
+ `잘못된 JSON 형식입니다: ${path}`,
124
+ "INVALID_JSON",
125
+ path,
126
+ );
127
+ }
128
+ throw new ATEFileError(
129
+ `JSON 파일 읽기 실패: ${path} (${err.message})`,
130
+ err.code || "UNKNOWN",
131
+ path,
132
+ );
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 파일 존재 여부 확인 (안전)
138
+ */
139
+ export function fileExists(path: string): boolean {
140
+ try {
141
+ return existsSync(path);
142
+ } catch {
143
+ return false;
144
+ }
145
+ }