@mandujs/core 0.9.0 → 0.9.2
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -45,5 +45,8 @@
|
|
|
45
45
|
"react": ">=18.0.0",
|
|
46
46
|
"react-dom": ">=18.0.0",
|
|
47
47
|
"zod": ">=3.0.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"ollama": "^0.6.3"
|
|
48
51
|
}
|
|
49
52
|
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* Brain v0.1 - Ollama LLM Adapter
|
|
3
3
|
*
|
|
4
4
|
* Default adapter for local sLLM via Ollama.
|
|
5
|
-
*
|
|
5
|
+
* Uses official ollama npm package for reliable API integration.
|
|
6
|
+
* Recommended models: ministral-3:3b, llama3.2, codellama, mistral
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import { Ollama } from "ollama";
|
|
8
10
|
import { BaseLLMAdapter } from "./base";
|
|
9
11
|
import type {
|
|
10
12
|
AdapterConfig,
|
|
@@ -23,32 +25,10 @@ import type {
|
|
|
23
25
|
*/
|
|
24
26
|
export const DEFAULT_OLLAMA_CONFIG: AdapterConfig = {
|
|
25
27
|
baseUrl: "http://localhost:11434",
|
|
26
|
-
model: "ministral-3:3b",
|
|
28
|
+
model: "ministral-3:3b", // Mistral's lightweight 3B model (3.0GB)
|
|
27
29
|
timeout: 30000, // 30 seconds
|
|
28
30
|
};
|
|
29
31
|
|
|
30
|
-
/**
|
|
31
|
-
* Ollama API response types
|
|
32
|
-
*/
|
|
33
|
-
interface OllamaTagsResponse {
|
|
34
|
-
models: Array<{
|
|
35
|
-
name: string;
|
|
36
|
-
size: number;
|
|
37
|
-
modified_at: string;
|
|
38
|
-
}>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface OllamaChatResponse {
|
|
42
|
-
model: string;
|
|
43
|
-
message: {
|
|
44
|
-
role: string;
|
|
45
|
-
content: string;
|
|
46
|
-
};
|
|
47
|
-
done: boolean;
|
|
48
|
-
eval_count?: number;
|
|
49
|
-
prompt_eval_count?: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
32
|
/**
|
|
53
33
|
* Ollama LLM Adapter
|
|
54
34
|
*
|
|
@@ -57,12 +37,17 @@ interface OllamaChatResponse {
|
|
|
57
37
|
*/
|
|
58
38
|
export class OllamaAdapter extends BaseLLMAdapter {
|
|
59
39
|
readonly name = "ollama";
|
|
40
|
+
private client: Ollama;
|
|
60
41
|
|
|
61
42
|
constructor(config: Partial<AdapterConfig> = {}) {
|
|
62
43
|
super({
|
|
63
44
|
...DEFAULT_OLLAMA_CONFIG,
|
|
64
45
|
...config,
|
|
65
46
|
});
|
|
47
|
+
|
|
48
|
+
this.client = new Ollama({
|
|
49
|
+
host: this.baseUrl,
|
|
50
|
+
});
|
|
66
51
|
}
|
|
67
52
|
|
|
68
53
|
/**
|
|
@@ -70,28 +55,8 @@ export class OllamaAdapter extends BaseLLMAdapter {
|
|
|
70
55
|
*/
|
|
71
56
|
async checkStatus(): Promise<AdapterStatus> {
|
|
72
57
|
try {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
() => controller.abort(),
|
|
76
|
-
this.config.timeout ?? 5000
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
|
80
|
-
signal: controller.signal,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
clearTimeout(timeoutId);
|
|
84
|
-
|
|
85
|
-
if (!response.ok) {
|
|
86
|
-
return {
|
|
87
|
-
available: false,
|
|
88
|
-
model: null,
|
|
89
|
-
error: `Ollama API error: ${response.status}`,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const data = (await response.json()) as OllamaTagsResponse;
|
|
94
|
-
const models = data.models || [];
|
|
58
|
+
const response = await this.client.list();
|
|
59
|
+
const models = response.models || [];
|
|
95
60
|
|
|
96
61
|
// Check if configured model is available
|
|
97
62
|
const modelAvailable = models.some(
|
|
@@ -122,21 +87,14 @@ export class OllamaAdapter extends BaseLLMAdapter {
|
|
|
122
87
|
model: this.config.model,
|
|
123
88
|
};
|
|
124
89
|
} catch (error) {
|
|
125
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
126
|
-
return {
|
|
127
|
-
available: false,
|
|
128
|
-
model: null,
|
|
129
|
-
error: "Ollama connection timeout",
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
90
|
const errorMessage =
|
|
134
91
|
error instanceof Error ? error.message : "Unknown error";
|
|
135
92
|
|
|
136
93
|
// Check for common connection errors
|
|
137
94
|
if (
|
|
138
95
|
errorMessage.includes("ECONNREFUSED") ||
|
|
139
|
-
errorMessage.includes("fetch failed")
|
|
96
|
+
errorMessage.includes("fetch failed") ||
|
|
97
|
+
errorMessage.includes("Unable to connect")
|
|
140
98
|
) {
|
|
141
99
|
return {
|
|
142
100
|
available: false,
|
|
@@ -163,47 +121,26 @@ export class OllamaAdapter extends BaseLLMAdapter {
|
|
|
163
121
|
const { temperature = 0.7, maxTokens = 2048 } = options;
|
|
164
122
|
|
|
165
123
|
try {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
() =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
124
|
+
const response = await this.client.chat({
|
|
125
|
+
model: this.config.model,
|
|
126
|
+
messages: messages.map((m) => ({
|
|
127
|
+
role: m.role,
|
|
128
|
+
content: m.content,
|
|
129
|
+
})),
|
|
130
|
+
stream: false,
|
|
131
|
+
options: {
|
|
132
|
+
temperature,
|
|
133
|
+
num_predict: maxTokens,
|
|
176
134
|
},
|
|
177
|
-
body: JSON.stringify({
|
|
178
|
-
model: this.config.model,
|
|
179
|
-
messages: messages.map((m) => ({
|
|
180
|
-
role: m.role,
|
|
181
|
-
content: m.content,
|
|
182
|
-
})),
|
|
183
|
-
stream: false,
|
|
184
|
-
options: {
|
|
185
|
-
temperature,
|
|
186
|
-
num_predict: maxTokens,
|
|
187
|
-
},
|
|
188
|
-
}),
|
|
189
|
-
signal: controller.signal,
|
|
190
135
|
});
|
|
191
136
|
|
|
192
|
-
clearTimeout(timeoutId);
|
|
193
|
-
|
|
194
|
-
if (!response.ok) {
|
|
195
|
-
const errorText = await response.text();
|
|
196
|
-
throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const data = (await response.json()) as OllamaChatResponse;
|
|
200
|
-
|
|
201
137
|
return {
|
|
202
|
-
content:
|
|
138
|
+
content: response.message?.content || "",
|
|
203
139
|
usage: {
|
|
204
|
-
promptTokens:
|
|
205
|
-
completionTokens:
|
|
206
|
-
totalTokens:
|
|
140
|
+
promptTokens: response.prompt_eval_count || 0,
|
|
141
|
+
completionTokens: response.eval_count || 0,
|
|
142
|
+
totalTokens:
|
|
143
|
+
(response.prompt_eval_count || 0) + (response.eval_count || 0),
|
|
207
144
|
},
|
|
208
145
|
};
|
|
209
146
|
} catch (error) {
|
|
@@ -215,28 +152,77 @@ export class OllamaAdapter extends BaseLLMAdapter {
|
|
|
215
152
|
}
|
|
216
153
|
|
|
217
154
|
/**
|
|
218
|
-
* Pull a model from Ollama registry
|
|
155
|
+
* Pull a model from Ollama registry with progress callback
|
|
219
156
|
*/
|
|
220
|
-
async pullModel(
|
|
157
|
+
async pullModel(
|
|
158
|
+
modelName?: string,
|
|
159
|
+
onProgress?: (status: string, completed?: number, total?: number) => void
|
|
160
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
221
161
|
const model = modelName ?? this.config.model;
|
|
222
162
|
|
|
223
163
|
try {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"Content-Type": "application/json",
|
|
228
|
-
},
|
|
229
|
-
body: JSON.stringify({
|
|
230
|
-
name: model,
|
|
231
|
-
stream: false,
|
|
232
|
-
}),
|
|
164
|
+
const stream = await this.client.pull({
|
|
165
|
+
model,
|
|
166
|
+
stream: true,
|
|
233
167
|
});
|
|
234
168
|
|
|
235
|
-
|
|
169
|
+
for await (const progress of stream) {
|
|
170
|
+
if (onProgress && progress.status) {
|
|
171
|
+
onProgress(
|
|
172
|
+
progress.status,
|
|
173
|
+
progress.completed,
|
|
174
|
+
progress.total
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { success: true };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if Ollama server is reachable
|
|
190
|
+
*/
|
|
191
|
+
async isServerRunning(): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await this.client.list();
|
|
194
|
+
return true;
|
|
236
195
|
} catch {
|
|
237
196
|
return false;
|
|
238
197
|
}
|
|
239
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* List all available models
|
|
202
|
+
*/
|
|
203
|
+
async listModels(): Promise<string[]> {
|
|
204
|
+
try {
|
|
205
|
+
const response = await this.client.list();
|
|
206
|
+
return (response.models || []).map((m) => m.name);
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate embeddings for text
|
|
214
|
+
*/
|
|
215
|
+
async embed(text: string, model?: string): Promise<number[] | null> {
|
|
216
|
+
try {
|
|
217
|
+
const response = await this.client.embed({
|
|
218
|
+
model: model ?? this.config.model,
|
|
219
|
+
input: text,
|
|
220
|
+
});
|
|
221
|
+
return response.embeddings?.[0] ?? null;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
240
226
|
}
|
|
241
227
|
|
|
242
228
|
/**
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain v0.2 - Architecture Analyzer
|
|
3
|
+
*
|
|
4
|
+
* 프로젝트 아키텍처 규칙을 분석하고 위반을 감지
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ArchitectureConfig,
|
|
9
|
+
ArchitectureViolation,
|
|
10
|
+
CheckLocationRequest,
|
|
11
|
+
CheckLocationResult,
|
|
12
|
+
CheckImportRequest,
|
|
13
|
+
CheckImportResult,
|
|
14
|
+
ProjectStructure,
|
|
15
|
+
FolderInfo,
|
|
16
|
+
FolderRule,
|
|
17
|
+
ImportRule,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import { getBrain } from "../brain";
|
|
20
|
+
import path from "path";
|
|
21
|
+
import fs from "fs/promises";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mandu 기본 아키텍처 규칙
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
|
|
27
|
+
folders: {
|
|
28
|
+
"spec/": {
|
|
29
|
+
pattern: "spec/**",
|
|
30
|
+
description: "스펙 정의 전용. 구현 코드 금지",
|
|
31
|
+
allowedFiles: ["*.ts", "*.json"],
|
|
32
|
+
readonly: false,
|
|
33
|
+
},
|
|
34
|
+
"spec/slots/": {
|
|
35
|
+
pattern: "spec/slots/**",
|
|
36
|
+
description: "Slot 파일 전용",
|
|
37
|
+
allowedFiles: ["*.slot.ts", "*.client.ts"],
|
|
38
|
+
},
|
|
39
|
+
"spec/contracts/": {
|
|
40
|
+
pattern: "spec/contracts/**",
|
|
41
|
+
description: "Contract 파일 전용",
|
|
42
|
+
allowedFiles: ["*.contract.ts"],
|
|
43
|
+
},
|
|
44
|
+
"generated/": {
|
|
45
|
+
pattern: "**/generated/**",
|
|
46
|
+
description: "자동 생성 파일. 직접 수정 금지",
|
|
47
|
+
readonly: true,
|
|
48
|
+
},
|
|
49
|
+
"apps/server/": {
|
|
50
|
+
pattern: "apps/server/**",
|
|
51
|
+
description: "백엔드 로직",
|
|
52
|
+
allowedFiles: ["*.ts"],
|
|
53
|
+
},
|
|
54
|
+
"apps/web/": {
|
|
55
|
+
pattern: "apps/web/**",
|
|
56
|
+
description: "프론트엔드 컴포넌트",
|
|
57
|
+
allowedFiles: ["*.ts", "*.tsx"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
imports: [
|
|
61
|
+
{
|
|
62
|
+
source: "apps/web/**",
|
|
63
|
+
forbid: ["fs", "child_process", "path", "crypto"],
|
|
64
|
+
reason: "프론트엔드에서 Node.js 내장 모듈 사용 금지",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
source: "**/generated/**",
|
|
68
|
+
forbid: ["fs", "child_process"],
|
|
69
|
+
reason: "Generated 파일에서 시스템 모듈 사용 금지",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
source: "spec/**",
|
|
73
|
+
forbid: ["react", "react-dom"],
|
|
74
|
+
reason: "Spec 파일에서 React 사용 금지",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
layers: [
|
|
78
|
+
{
|
|
79
|
+
name: "spec",
|
|
80
|
+
folders: ["spec/**"],
|
|
81
|
+
canDependOn: [],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "generated",
|
|
85
|
+
folders: ["**/generated/**"],
|
|
86
|
+
canDependOn: ["spec"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "app",
|
|
90
|
+
folders: ["apps/**"],
|
|
91
|
+
canDependOn: ["spec", "generated"],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
naming: [
|
|
95
|
+
{
|
|
96
|
+
folder: "spec/slots/",
|
|
97
|
+
filePattern: "^[a-z][a-z0-9-]*\\.(slot|client)\\.ts$",
|
|
98
|
+
description: "Slot 파일은 kebab-case.slot.ts 또는 kebab-case.client.ts",
|
|
99
|
+
examples: ["users-list.slot.ts", "counter.client.ts"],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
folder: "spec/contracts/",
|
|
103
|
+
filePattern: "^[a-z][a-z0-9-]*\\.contract\\.ts$",
|
|
104
|
+
description: "Contract 파일은 kebab-case.contract.ts",
|
|
105
|
+
examples: ["users.contract.ts", "auth.contract.ts"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 글로브 패턴을 정규식으로 변환
|
|
112
|
+
*/
|
|
113
|
+
function globToRegex(glob: string): RegExp {
|
|
114
|
+
const escaped = glob
|
|
115
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
116
|
+
.replace(/\*\*/g, "§§")
|
|
117
|
+
.replace(/\*/g, "[^/]*")
|
|
118
|
+
.replace(/§§/g, ".*")
|
|
119
|
+
.replace(/\?/g, ".");
|
|
120
|
+
return new RegExp(`^${escaped}$`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 경로가 패턴과 매칭되는지 확인
|
|
125
|
+
*/
|
|
126
|
+
function matchesPattern(filePath: string, pattern: string): boolean {
|
|
127
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
128
|
+
const regex = globToRegex(pattern);
|
|
129
|
+
return regex.test(normalizedPath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Architecture Analyzer 클래스
|
|
134
|
+
*/
|
|
135
|
+
export class ArchitectureAnalyzer {
|
|
136
|
+
private config: ArchitectureConfig;
|
|
137
|
+
private rootDir: string;
|
|
138
|
+
private projectStructure: ProjectStructure | null = null;
|
|
139
|
+
|
|
140
|
+
constructor(rootDir: string, config?: Partial<ArchitectureConfig>) {
|
|
141
|
+
this.rootDir = rootDir;
|
|
142
|
+
this.config = {
|
|
143
|
+
...DEFAULT_ARCHITECTURE_CONFIG,
|
|
144
|
+
...config,
|
|
145
|
+
folders: {
|
|
146
|
+
...DEFAULT_ARCHITECTURE_CONFIG.folders,
|
|
147
|
+
...config?.folders,
|
|
148
|
+
},
|
|
149
|
+
imports: [
|
|
150
|
+
...(DEFAULT_ARCHITECTURE_CONFIG.imports || []),
|
|
151
|
+
...(config?.imports || []),
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 설정 업데이트
|
|
158
|
+
*/
|
|
159
|
+
updateConfig(config: Partial<ArchitectureConfig>): void {
|
|
160
|
+
this.config = {
|
|
161
|
+
...this.config,
|
|
162
|
+
...config,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 현재 설정 반환
|
|
168
|
+
*/
|
|
169
|
+
getConfig(): ArchitectureConfig {
|
|
170
|
+
return this.config;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 파일 위치 검증
|
|
175
|
+
*/
|
|
176
|
+
async checkLocation(request: CheckLocationRequest): Promise<CheckLocationResult> {
|
|
177
|
+
const violations: ArchitectureViolation[] = [];
|
|
178
|
+
const normalizedPath = request.path.replace(/\\/g, "/");
|
|
179
|
+
|
|
180
|
+
// 1. readonly 폴더 검사
|
|
181
|
+
for (const [key, rule] of Object.entries(this.config.folders || {})) {
|
|
182
|
+
const folderRule = typeof rule === "string"
|
|
183
|
+
? { pattern: key, description: rule }
|
|
184
|
+
: rule;
|
|
185
|
+
|
|
186
|
+
if (folderRule.readonly && matchesPattern(normalizedPath, folderRule.pattern)) {
|
|
187
|
+
violations.push({
|
|
188
|
+
ruleId: "READONLY_FOLDER",
|
|
189
|
+
ruleType: "folder",
|
|
190
|
+
file: request.path,
|
|
191
|
+
message: `이 폴더는 수정 금지입니다: ${folderRule.description}`,
|
|
192
|
+
suggestion: "이 파일은 자동 생성됩니다. bunx mandu generate를 사용하세요.",
|
|
193
|
+
severity: "error",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. 네이밍 규칙 검사
|
|
199
|
+
for (const rule of this.config.naming || []) {
|
|
200
|
+
if (normalizedPath.startsWith(rule.folder.replace(/\\/g, "/"))) {
|
|
201
|
+
const fileName = path.basename(normalizedPath);
|
|
202
|
+
const regex = new RegExp(rule.filePattern);
|
|
203
|
+
|
|
204
|
+
if (!regex.test(fileName)) {
|
|
205
|
+
violations.push({
|
|
206
|
+
ruleId: "NAMING_CONVENTION",
|
|
207
|
+
ruleType: "naming",
|
|
208
|
+
file: request.path,
|
|
209
|
+
message: `네이밍 규칙 위반: ${rule.description}`,
|
|
210
|
+
suggestion: `예시: ${rule.examples?.join(", ") || "N/A"}`,
|
|
211
|
+
severity: "error",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 3. 허용된 파일 타입 검사
|
|
218
|
+
for (const [key, rule] of Object.entries(this.config.folders || {})) {
|
|
219
|
+
const folderRule = typeof rule === "string"
|
|
220
|
+
? { pattern: key, description: rule }
|
|
221
|
+
: rule;
|
|
222
|
+
|
|
223
|
+
if (matchesPattern(normalizedPath, folderRule.pattern)) {
|
|
224
|
+
if (folderRule.allowedFiles && folderRule.allowedFiles.length > 0) {
|
|
225
|
+
const fileName = path.basename(normalizedPath);
|
|
226
|
+
const isAllowed = folderRule.allowedFiles.some((pattern) => {
|
|
227
|
+
const regex = globToRegex(pattern);
|
|
228
|
+
return regex.test(fileName);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!isAllowed) {
|
|
232
|
+
violations.push({
|
|
233
|
+
ruleId: "DISALLOWED_FILE_TYPE",
|
|
234
|
+
ruleType: "folder",
|
|
235
|
+
file: request.path,
|
|
236
|
+
message: `이 폴더에서 허용되지 않는 파일 타입입니다`,
|
|
237
|
+
suggestion: `허용: ${folderRule.allowedFiles.join(", ")}`,
|
|
238
|
+
severity: "warning",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. 내용 기반 검사 (content가 제공된 경우)
|
|
246
|
+
if (request.content) {
|
|
247
|
+
const importViolations = await this.checkImports({
|
|
248
|
+
sourceFile: request.path,
|
|
249
|
+
imports: this.extractImports(request.content),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
for (const v of importViolations.violations) {
|
|
253
|
+
violations.push({
|
|
254
|
+
ruleId: "FORBIDDEN_IMPORT",
|
|
255
|
+
ruleType: "import",
|
|
256
|
+
file: request.path,
|
|
257
|
+
message: v.reason,
|
|
258
|
+
suggestion: v.suggestion,
|
|
259
|
+
severity: "error",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 5. LLM 제안 (위반이 있는 경우)
|
|
265
|
+
let suggestion: string | undefined;
|
|
266
|
+
let recommendedPath: string | undefined;
|
|
267
|
+
|
|
268
|
+
if (violations.length > 0) {
|
|
269
|
+
const brain = getBrain();
|
|
270
|
+
if (await brain.isLLMAvailable()) {
|
|
271
|
+
const llmSuggestion = await this.getLLMSuggestion(request, violations);
|
|
272
|
+
suggestion = llmSuggestion.suggestion;
|
|
273
|
+
recommendedPath = llmSuggestion.recommendedPath;
|
|
274
|
+
} else {
|
|
275
|
+
// 템플릿 기반 제안
|
|
276
|
+
recommendedPath = this.getTemplateRecommendedPath(request.path);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
allowed: violations.length === 0,
|
|
282
|
+
violations,
|
|
283
|
+
suggestion,
|
|
284
|
+
recommendedPath,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Import 검증
|
|
290
|
+
*/
|
|
291
|
+
async checkImports(request: CheckImportRequest): Promise<CheckImportResult> {
|
|
292
|
+
const violations: Array<{
|
|
293
|
+
import: string;
|
|
294
|
+
reason: string;
|
|
295
|
+
suggestion?: string;
|
|
296
|
+
}> = [];
|
|
297
|
+
|
|
298
|
+
const normalizedSource = request.sourceFile.replace(/\\/g, "/");
|
|
299
|
+
|
|
300
|
+
for (const importPath of request.imports) {
|
|
301
|
+
for (const rule of this.config.imports || []) {
|
|
302
|
+
if (matchesPattern(normalizedSource, rule.source)) {
|
|
303
|
+
// 금지된 import 검사
|
|
304
|
+
if (rule.forbid) {
|
|
305
|
+
for (const forbidden of rule.forbid) {
|
|
306
|
+
if (
|
|
307
|
+
importPath === forbidden ||
|
|
308
|
+
importPath.startsWith(`${forbidden}/`)
|
|
309
|
+
) {
|
|
310
|
+
violations.push({
|
|
311
|
+
import: importPath,
|
|
312
|
+
reason: rule.reason || `'${importPath}' import 금지`,
|
|
313
|
+
suggestion: this.getImportSuggestion(importPath, normalizedSource),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 허용된 import만 검사 (allow가 정의된 경우)
|
|
320
|
+
if (rule.allow && rule.allow.length > 0) {
|
|
321
|
+
const isAllowed = rule.allow.some((allowed) =>
|
|
322
|
+
matchesPattern(importPath, allowed)
|
|
323
|
+
);
|
|
324
|
+
if (!isAllowed) {
|
|
325
|
+
violations.push({
|
|
326
|
+
import: importPath,
|
|
327
|
+
reason: `'${importPath}'는 허용되지 않은 import입니다`,
|
|
328
|
+
suggestion: `허용된 패턴: ${rule.allow.join(", ")}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
allowed: violations.length === 0,
|
|
338
|
+
violations,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 프로젝트 구조 인덱싱
|
|
344
|
+
*/
|
|
345
|
+
async indexProject(): Promise<ProjectStructure> {
|
|
346
|
+
const folders = await this.scanFolders(this.rootDir, 0, 3);
|
|
347
|
+
|
|
348
|
+
this.projectStructure = {
|
|
349
|
+
rootDir: this.rootDir,
|
|
350
|
+
folders,
|
|
351
|
+
config: this.config,
|
|
352
|
+
indexedAt: new Date().toISOString(),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return this.projectStructure;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 프로젝트 구조 반환
|
|
360
|
+
*/
|
|
361
|
+
async getProjectStructure(): Promise<ProjectStructure> {
|
|
362
|
+
if (!this.projectStructure) {
|
|
363
|
+
return this.indexProject();
|
|
364
|
+
}
|
|
365
|
+
return this.projectStructure;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 코드에서 import 문 추출
|
|
370
|
+
*/
|
|
371
|
+
private extractImports(content: string): string[] {
|
|
372
|
+
const imports: string[] = [];
|
|
373
|
+
|
|
374
|
+
// ES6 import
|
|
375
|
+
const importRegex = /import\s+(?:.*\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
376
|
+
let match;
|
|
377
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
378
|
+
imports.push(match[1]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// require
|
|
382
|
+
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
383
|
+
while ((match = requireRegex.exec(content)) !== null) {
|
|
384
|
+
imports.push(match[1]);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return imports;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 폴더 스캔
|
|
392
|
+
*/
|
|
393
|
+
private async scanFolders(
|
|
394
|
+
dir: string,
|
|
395
|
+
depth: number,
|
|
396
|
+
maxDepth: number
|
|
397
|
+
): Promise<FolderInfo[]> {
|
|
398
|
+
if (depth >= maxDepth) return [];
|
|
399
|
+
|
|
400
|
+
const folders: FolderInfo[] = [];
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
404
|
+
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (!entry.isDirectory()) continue;
|
|
407
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
408
|
+
|
|
409
|
+
const fullPath = path.join(dir, entry.name);
|
|
410
|
+
const relativePath = path.relative(this.rootDir, fullPath).replace(/\\/g, "/");
|
|
411
|
+
|
|
412
|
+
// 폴더 설명 찾기
|
|
413
|
+
let description: string | undefined;
|
|
414
|
+
for (const [key, rule] of Object.entries(this.config.folders || {})) {
|
|
415
|
+
const folderRule = typeof rule === "string" ? { pattern: key, description: rule } : rule;
|
|
416
|
+
if (matchesPattern(relativePath + "/", folderRule.pattern)) {
|
|
417
|
+
description = folderRule.description;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 파일 수 계산
|
|
423
|
+
const files = entries.filter((e) => e.isFile());
|
|
424
|
+
|
|
425
|
+
folders.push({
|
|
426
|
+
path: relativePath,
|
|
427
|
+
description,
|
|
428
|
+
fileCount: files.length,
|
|
429
|
+
children: await this.scanFolders(fullPath, depth + 1, maxDepth),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// 권한 없는 폴더 무시
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return folders;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Import에 대한 템플릿 제안
|
|
441
|
+
*/
|
|
442
|
+
private getImportSuggestion(importPath: string, sourceFile: string): string {
|
|
443
|
+
if (importPath === "fs") {
|
|
444
|
+
if (sourceFile.includes("apps/web")) {
|
|
445
|
+
return "프론트엔드에서는 fetch API를 사용하세요";
|
|
446
|
+
}
|
|
447
|
+
return "Bun.file() 또는 Bun.write()를 사용하세요";
|
|
448
|
+
}
|
|
449
|
+
if (importPath === "child_process") {
|
|
450
|
+
return "Bun.spawn() 또는 Bun.$를 사용하세요";
|
|
451
|
+
}
|
|
452
|
+
if (importPath === "path") {
|
|
453
|
+
return "import.meta.dir 또는 Bun.pathToFileURL을 사용하세요";
|
|
454
|
+
}
|
|
455
|
+
return "다른 모듈을 사용하세요";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 템플릿 기반 권장 경로
|
|
460
|
+
*/
|
|
461
|
+
private getTemplateRecommendedPath(filePath: string): string | undefined {
|
|
462
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
463
|
+
const fileName = path.basename(normalized);
|
|
464
|
+
|
|
465
|
+
if (fileName.endsWith(".slot.ts")) {
|
|
466
|
+
return `spec/slots/${fileName}`;
|
|
467
|
+
}
|
|
468
|
+
if (fileName.endsWith(".contract.ts")) {
|
|
469
|
+
return `spec/contracts/${fileName}`;
|
|
470
|
+
}
|
|
471
|
+
if (normalized.includes("generated/")) {
|
|
472
|
+
return undefined; // generated는 이동 불가
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* LLM 기반 제안
|
|
480
|
+
*/
|
|
481
|
+
private async getLLMSuggestion(
|
|
482
|
+
request: CheckLocationRequest,
|
|
483
|
+
violations: ArchitectureViolation[]
|
|
484
|
+
): Promise<{ suggestion?: string; recommendedPath?: string }> {
|
|
485
|
+
const brain = getBrain();
|
|
486
|
+
|
|
487
|
+
const prompt = `Mandu Framework 아키텍처 분석:
|
|
488
|
+
|
|
489
|
+
파일: ${request.path}
|
|
490
|
+
위반 사항:
|
|
491
|
+
${violations.map((v) => `- ${v.message}`).join("\n")}
|
|
492
|
+
|
|
493
|
+
프로젝트 구조 규칙:
|
|
494
|
+
${JSON.stringify(this.config.folders, null, 2)}
|
|
495
|
+
|
|
496
|
+
질문:
|
|
497
|
+
1. 이 파일의 올바른 위치는 어디인가요?
|
|
498
|
+
2. 어떻게 수정해야 하나요?
|
|
499
|
+
|
|
500
|
+
짧고 명확하게 답변하세요 (3줄 이내).`;
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const result = await brain.complete([
|
|
504
|
+
{ role: "user", content: prompt },
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
// 응답에서 경로 추출 시도
|
|
508
|
+
const pathMatch = result.content.match(/(?:spec\/|apps\/|packages\/)[^\s,)]+/);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
suggestion: result.content,
|
|
512
|
+
recommendedPath: pathMatch?.[0],
|
|
513
|
+
};
|
|
514
|
+
} catch {
|
|
515
|
+
return {};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 글로벌 analyzer 인스턴스
|
|
522
|
+
*/
|
|
523
|
+
let globalAnalyzer: ArchitectureAnalyzer | null = null;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Architecture Analyzer 초기화
|
|
527
|
+
*/
|
|
528
|
+
export function initializeArchitectureAnalyzer(
|
|
529
|
+
rootDir: string,
|
|
530
|
+
config?: Partial<ArchitectureConfig>
|
|
531
|
+
): ArchitectureAnalyzer {
|
|
532
|
+
globalAnalyzer = new ArchitectureAnalyzer(rootDir, config);
|
|
533
|
+
return globalAnalyzer;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 글로벌 analyzer 반환
|
|
538
|
+
*/
|
|
539
|
+
export function getArchitectureAnalyzer(): ArchitectureAnalyzer | null {
|
|
540
|
+
return globalAnalyzer;
|
|
541
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain v0.2 - Architecture Guard Types
|
|
3
|
+
*
|
|
4
|
+
* 프로젝트 아키텍처 규칙 정의 및 검증을 위한 타입
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 폴더 역할 정의
|
|
9
|
+
*/
|
|
10
|
+
export interface FolderRule {
|
|
11
|
+
/** 폴더 경로 패턴 (glob) */
|
|
12
|
+
pattern: string;
|
|
13
|
+
/** 폴더 설명/역할 */
|
|
14
|
+
description: string;
|
|
15
|
+
/** 허용되는 파일 패턴 */
|
|
16
|
+
allowedFiles?: string[];
|
|
17
|
+
/** 금지되는 파일 패턴 */
|
|
18
|
+
forbiddenFiles?: string[];
|
|
19
|
+
/** 이 폴더에서 허용되는 export */
|
|
20
|
+
allowedExports?: string[];
|
|
21
|
+
/** 수정 금지 여부 */
|
|
22
|
+
readonly?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Import 규칙 정의
|
|
27
|
+
*/
|
|
28
|
+
export interface ImportRule {
|
|
29
|
+
/** 적용 대상 파일 패턴 (glob) */
|
|
30
|
+
source: string;
|
|
31
|
+
/** 허용되는 import 패턴 */
|
|
32
|
+
allow?: string[];
|
|
33
|
+
/** 금지되는 import 패턴 */
|
|
34
|
+
forbid?: string[];
|
|
35
|
+
/** 규칙 설명 */
|
|
36
|
+
reason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 레이어 의존성 규칙
|
|
41
|
+
*/
|
|
42
|
+
export interface LayerRule {
|
|
43
|
+
/** 레이어 이름 */
|
|
44
|
+
name: string;
|
|
45
|
+
/** 레이어에 속하는 폴더 패턴 */
|
|
46
|
+
folders: string[];
|
|
47
|
+
/** 의존 가능한 레이어 */
|
|
48
|
+
canDependOn: string[];
|
|
49
|
+
/** 의존 불가 레이어 */
|
|
50
|
+
cannotDependOn?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 네이밍 규칙
|
|
55
|
+
*/
|
|
56
|
+
export interface NamingRule {
|
|
57
|
+
/** 적용 대상 폴더 패턴 */
|
|
58
|
+
folder: string;
|
|
59
|
+
/** 파일명 패턴 (정규식) */
|
|
60
|
+
filePattern: string;
|
|
61
|
+
/** 규칙 설명 */
|
|
62
|
+
description: string;
|
|
63
|
+
/** 예시 */
|
|
64
|
+
examples?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 아키텍처 설정
|
|
69
|
+
*/
|
|
70
|
+
export interface ArchitectureConfig {
|
|
71
|
+
/** 폴더 규칙 */
|
|
72
|
+
folders?: Record<string, FolderRule | string>;
|
|
73
|
+
/** Import 규칙 */
|
|
74
|
+
imports?: ImportRule[];
|
|
75
|
+
/** 레이어 규칙 */
|
|
76
|
+
layers?: LayerRule[];
|
|
77
|
+
/** 네이밍 규칙 */
|
|
78
|
+
naming?: NamingRule[];
|
|
79
|
+
/** 커스텀 규칙 */
|
|
80
|
+
custom?: CustomRule[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 커스텀 규칙
|
|
85
|
+
*/
|
|
86
|
+
export interface CustomRule {
|
|
87
|
+
/** 규칙 ID */
|
|
88
|
+
id: string;
|
|
89
|
+
/** 규칙 설명 */
|
|
90
|
+
description: string;
|
|
91
|
+
/** 파일 패턴 */
|
|
92
|
+
pattern: string;
|
|
93
|
+
/** 검증 조건 (코드에 포함되어야 하는 패턴) */
|
|
94
|
+
mustContain?: string[];
|
|
95
|
+
/** 금지 조건 (코드에 포함되면 안 되는 패턴) */
|
|
96
|
+
mustNotContain?: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 아키텍처 위반
|
|
101
|
+
*/
|
|
102
|
+
export interface ArchitectureViolation {
|
|
103
|
+
/** 규칙 ID */
|
|
104
|
+
ruleId: string;
|
|
105
|
+
/** 규칙 타입 */
|
|
106
|
+
ruleType: "folder" | "import" | "layer" | "naming" | "custom";
|
|
107
|
+
/** 위반 파일 경로 */
|
|
108
|
+
file: string;
|
|
109
|
+
/** 위반 메시지 */
|
|
110
|
+
message: string;
|
|
111
|
+
/** 수정 제안 */
|
|
112
|
+
suggestion?: string;
|
|
113
|
+
/** 심각도 */
|
|
114
|
+
severity: "error" | "warning" | "info";
|
|
115
|
+
/** 위반 라인 (해당시) */
|
|
116
|
+
line?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 위치 검증 요청
|
|
121
|
+
*/
|
|
122
|
+
export interface CheckLocationRequest {
|
|
123
|
+
/** 검사할 파일 경로 */
|
|
124
|
+
path: string;
|
|
125
|
+
/** 파일 내용 (선택) */
|
|
126
|
+
content?: string;
|
|
127
|
+
/** 파일 타입 */
|
|
128
|
+
fileType?: "ts" | "tsx" | "js" | "jsx" | "json" | "other";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 위치 검증 결과
|
|
133
|
+
*/
|
|
134
|
+
export interface CheckLocationResult {
|
|
135
|
+
/** 허용 여부 */
|
|
136
|
+
allowed: boolean;
|
|
137
|
+
/** 위반 목록 */
|
|
138
|
+
violations: ArchitectureViolation[];
|
|
139
|
+
/** LLM 제안 (활성화시) */
|
|
140
|
+
suggestion?: string;
|
|
141
|
+
/** 권장 경로 */
|
|
142
|
+
recommendedPath?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Import 검증 요청
|
|
147
|
+
*/
|
|
148
|
+
export interface CheckImportRequest {
|
|
149
|
+
/** 소스 파일 경로 */
|
|
150
|
+
sourceFile: string;
|
|
151
|
+
/** 검사할 import 문 */
|
|
152
|
+
imports: string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Import 검증 결과
|
|
157
|
+
*/
|
|
158
|
+
export interface CheckImportResult {
|
|
159
|
+
/** 모든 import 허용 여부 */
|
|
160
|
+
allowed: boolean;
|
|
161
|
+
/** 위반된 import 목록 */
|
|
162
|
+
violations: Array<{
|
|
163
|
+
import: string;
|
|
164
|
+
reason: string;
|
|
165
|
+
suggestion?: string;
|
|
166
|
+
}>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 프로젝트 구조 정보
|
|
171
|
+
*/
|
|
172
|
+
export interface ProjectStructure {
|
|
173
|
+
/** 루트 디렉토리 */
|
|
174
|
+
rootDir: string;
|
|
175
|
+
/** 폴더 트리 */
|
|
176
|
+
folders: FolderInfo[];
|
|
177
|
+
/** 아키텍처 설정 */
|
|
178
|
+
config: ArchitectureConfig;
|
|
179
|
+
/** 인덱싱 시간 */
|
|
180
|
+
indexedAt: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 폴더 정보
|
|
185
|
+
*/
|
|
186
|
+
export interface FolderInfo {
|
|
187
|
+
/** 경로 */
|
|
188
|
+
path: string;
|
|
189
|
+
/** 역할 설명 */
|
|
190
|
+
description?: string;
|
|
191
|
+
/** 파일 수 */
|
|
192
|
+
fileCount: number;
|
|
193
|
+
/** 하위 폴더 */
|
|
194
|
+
children?: FolderInfo[];
|
|
195
|
+
}
|
package/src/brain/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Brain v0.
|
|
2
|
+
* Brain v0.2 - Module Exports
|
|
3
3
|
*
|
|
4
|
-
* Brain handles
|
|
4
|
+
* Brain handles three responsibilities:
|
|
5
5
|
* 1. Doctor (error recovery): Guard failure analysis + minimal patch suggestions
|
|
6
6
|
* 2. Watch (error prevention): File change warnings (no blocking)
|
|
7
|
+
* 3. Architecture (structure enforcement): Project structure validation for coding agents
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
// Types
|
|
@@ -43,3 +44,6 @@ export {
|
|
|
43
44
|
|
|
44
45
|
// Doctor
|
|
45
46
|
export * from "./doctor";
|
|
47
|
+
|
|
48
|
+
// Architecture (v0.2)
|
|
49
|
+
export * from "./architecture";
|