@mandujs/core 0.54.9 → 0.54.10

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,200 +1,200 @@
1
- {
2
- "name": "@mandujs/core",
3
- "version": "0.54.9",
4
- "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts",
10
- "./a11y": "./src/a11y/index.ts",
11
- "./agent": "./src/agent/index.ts",
12
- "./auth": "./src/auth/index.ts",
13
- "./auth/login": "./src/auth/login.ts",
14
- "./auth/password": "./src/auth/password.ts",
15
- "./auth/reset": "./src/auth/reset.ts",
16
- "./auth/verification": "./src/auth/verification.ts",
17
- "./client": "./src/client/index.ts",
18
- "./client/rpc": "./src/client/rpc.ts",
19
- "./contract": "./src/contract/index.ts",
20
- "./contract/rpc": "./src/contract/rpc.ts",
21
- "./content": "./src/content/index.ts",
22
- "./content/prebuild": "./src/content/prebuild.ts",
23
- "./content/collection": "./src/content/collection.ts",
24
- "./content/sidebar": "./src/content/sidebar.ts",
25
- "./content/slug": "./src/content/slug.ts",
26
- "./content/llms-txt": "./src/content/llms-txt.ts",
27
- "./content/schema": "./src/content/schema.ts",
28
- "./config": "./src/config/index.ts",
29
- "./config/mandu": "./src/config/mandu.ts",
30
- "./config/validate": "./src/config/validate.ts",
31
- "./db": "./src/db/index.ts",
32
- "./db/migrations": "./src/db/migrations/index.ts",
33
- "./db/migrations/history-table": "./src/db/migrations/history-table.ts",
34
- "./db/migrations/runner": "./src/db/migrations/runner.ts",
35
- "./deploy": "./src/deploy/index.ts",
36
- "./design": "./src/design/index.ts",
37
- "./desktop": "./src/desktop/index.ts",
38
- "./desktop/worker": "./src/desktop/worker.ts",
39
- "./email": "./src/email/index.ts",
40
- "./filling": "./src/filling/index.ts",
41
- "./filling/cookie-codec": "./src/filling/cookie-codec.ts",
42
- "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
43
- "./kitchen": "./src/kitchen/index.ts",
44
- "./logging": "./src/logging/index.ts",
45
- "./middleware": "./src/middleware/index.ts",
46
- "./middleware/csrf": "./src/middleware/csrf.ts",
47
- "./middleware/oauth": "./src/middleware/oauth/index.ts",
48
- "./middleware/secure": "./src/middleware/secure/index.ts",
49
- "./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
50
- "./middleware/session": "./src/middleware/session.ts",
51
- "./testing": "./src/testing/index.ts",
52
- "./testing/db": "./src/testing/db.ts",
53
- "./testing/mocks": "./src/testing/mocks.ts",
54
- "./testing/reporter": "./src/testing/reporter.ts",
55
- "./testing/server": "./src/testing/server.ts",
56
- "./testing/session": "./src/testing/session.ts",
57
- "./testing/snapshot": "./src/testing/snapshot.ts",
58
- "./plugins": "./src/plugins/index.ts",
59
- "./plugins/define": "./src/plugins/define.ts",
60
- "./plugins/runner": "./src/plugins/runner.ts",
61
- "./error": "./src/error/index.ts",
62
- "./i18n": "./src/i18n/index.ts",
63
- "./id": "./src/id/index.ts",
64
- "./observability": "./src/observability/index.ts",
65
- "./openapi/generator": "./src/openapi/generator.ts",
66
- "./perf": "./src/perf/index.ts",
67
- "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
68
- "./perf/user-marks": "./src/perf/user-marks.ts",
69
- "./resource": "./src/resource/index.ts",
70
- "./resource/ddl/diff": "./src/resource/ddl/diff.ts",
71
- "./resource/ddl/emit": "./src/resource/ddl/emit.ts",
72
- "./resource/ddl/snapshot": "./src/resource/ddl/snapshot.ts",
73
- "./resource/ddl/type-map": "./src/resource/ddl/type-map.ts",
74
- "./resource/ddl/types": "./src/resource/ddl/types.ts",
75
- "./resource/generator-repo": "./src/resource/generator-repo.ts",
76
- "./resource/parser": "./src/resource/parser.ts",
77
- "./resource/schema": "./src/resource/schema.ts",
78
- "./router": "./src/router/index.ts",
79
- "./routes": "./src/routes/index.ts",
80
- "./runtime": "./src/runtime/index.ts",
81
- "./runtime/cache": "./src/runtime/cache.ts",
82
- "./runtime/router": "./src/runtime/router.ts",
83
- "./runtime/server": "./src/runtime/server.ts",
84
- "./scheduler": "./src/scheduler/index.ts",
85
- "./storage/s3": "./src/storage/s3/index.ts",
86
- "./guard": "./src/guard/index.ts",
87
- "./guard/define-rule": "./src/guard/define-rule.ts",
88
- "./guard/design-inline-class": "./src/guard/design-inline-class.ts",
89
- "./guard/rule-presets": "./src/guard/rule-presets.ts",
90
- "./guard/tsgolint-bridge": "./src/guard/tsgolint-bridge.ts",
91
- "./bundler": "./src/bundler/index.ts",
92
- "./bundler/prerender": "./src/bundler/prerender.ts",
93
- "./bundler/safe-build": "./src/bundler/safe-build.ts",
94
- "./bundler/types": "./src/bundler/types.ts",
95
- "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
96
- "./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
97
- "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
98
- "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
99
- "./bundler/analyzer": "./src/bundler/analyzer.ts",
100
- "./bundler/budget": "./src/bundler/budget.ts",
101
- "./bundler/plugins": "./src/bundler/plugins/index.ts",
102
- "./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
103
- "./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
104
- "./diagnose": "./src/diagnose/index.ts",
105
- "./dev-error-overlay": "./src/dev-error-overlay/index.ts",
106
- "./middleware/compose": "./src/middleware/compose.ts",
107
- "./middleware/define": "./src/middleware/define.ts",
108
- "./middleware/bridge": "./src/middleware/bridge.ts",
109
- "./client/hydrate": "./src/client/hydrate.ts",
110
- "./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
111
- "./components/Image": "./src/components/Image.tsx"
112
- },
113
- "files": [
114
- "src/**/*",
115
- "scripts/postinstall-lock.ts"
116
- ],
117
- "scripts": {
118
- "postinstall": "bun ./scripts/postinstall-lock.ts",
119
- "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
120
- "test:hydration": "bun test tests/hydration",
121
- "test:streaming": "bun test tests/streaming-ssr",
122
- "test:watch": "bun test --watch"
123
- },
124
- "devDependencies": {
125
- "@happy-dom/global-registrator": "^15.0.0"
126
- },
127
- "keywords": [
128
- "mandu",
129
- "framework",
130
- "agent",
131
- "ai",
132
- "code-generation"
133
- ],
134
- "repository": {
135
- "type": "git",
136
- "url": "git+https://github.com/konamgil/mandu.git",
137
- "directory": "packages/core"
138
- },
139
- "author": "konamgil",
140
- "license": "MPL-2.0",
141
- "publishConfig": {
142
- "access": "public"
143
- },
144
- "engines": {
145
- "bun": ">=1.3.12"
146
- },
147
- "peerDependencies": {
148
- "react": "^19.2.0",
149
- "react-dom": "^19.2.0",
150
- "react-refresh": ">=0.18.0",
151
- "@tailwindcss/cli": ">=4.0.0",
152
- "webview-bun": "^2.4.0",
153
- "axe-core": ">=4.8.0",
154
- "jsdom": ">=24.0.0",
155
- "happy-dom": ">=15.0.0",
156
- "@babel/core": "^7.29.0",
157
- "babel-plugin-react-compiler": "^1.0.0",
158
- "eslint": "^9.0.0",
159
- "eslint-plugin-react-compiler": "^19.0.0 || ^1.0.0"
160
- },
161
- "peerDependenciesMeta": {
162
- "@tailwindcss/cli": {
163
- "optional": true
164
- },
165
- "react-refresh": {
166
- "optional": true
167
- },
168
- "webview-bun": {
169
- "optional": true
170
- },
171
- "axe-core": {
172
- "optional": true
173
- },
174
- "jsdom": {
175
- "optional": true
176
- },
177
- "happy-dom": {
178
- "optional": true
179
- },
180
- "@babel/core": {
181
- "optional": true
182
- },
183
- "babel-plugin-react-compiler": {
184
- "optional": true
185
- },
186
- "eslint": {
187
- "optional": true
188
- },
189
- "eslint-plugin-react-compiler": {
190
- "optional": true
191
- }
192
- },
193
- "dependencies": {
194
- "chokidar": "^5.0.0",
195
- "fast-glob": "^3.3.2",
196
- "glob": "^13.0.0",
197
- "minimatch": "^10.1.1",
198
- "zod": "^3.23.8"
199
- }
200
- }
1
+ {
2
+ "name": "@mandujs/core",
3
+ "version": "0.54.10",
4
+ "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./a11y": "./src/a11y/index.ts",
11
+ "./agent": "./src/agent/index.ts",
12
+ "./auth": "./src/auth/index.ts",
13
+ "./auth/login": "./src/auth/login.ts",
14
+ "./auth/password": "./src/auth/password.ts",
15
+ "./auth/reset": "./src/auth/reset.ts",
16
+ "./auth/verification": "./src/auth/verification.ts",
17
+ "./client": "./src/client/index.ts",
18
+ "./client/rpc": "./src/client/rpc.ts",
19
+ "./contract": "./src/contract/index.ts",
20
+ "./contract/rpc": "./src/contract/rpc.ts",
21
+ "./content": "./src/content/index.ts",
22
+ "./content/prebuild": "./src/content/prebuild.ts",
23
+ "./content/collection": "./src/content/collection.ts",
24
+ "./content/sidebar": "./src/content/sidebar.ts",
25
+ "./content/slug": "./src/content/slug.ts",
26
+ "./content/llms-txt": "./src/content/llms-txt.ts",
27
+ "./content/schema": "./src/content/schema.ts",
28
+ "./config": "./src/config/index.ts",
29
+ "./config/mandu": "./src/config/mandu.ts",
30
+ "./config/validate": "./src/config/validate.ts",
31
+ "./db": "./src/db/index.ts",
32
+ "./db/migrations": "./src/db/migrations/index.ts",
33
+ "./db/migrations/history-table": "./src/db/migrations/history-table.ts",
34
+ "./db/migrations/runner": "./src/db/migrations/runner.ts",
35
+ "./deploy": "./src/deploy/index.ts",
36
+ "./design": "./src/design/index.ts",
37
+ "./desktop": "./src/desktop/index.ts",
38
+ "./desktop/worker": "./src/desktop/worker.ts",
39
+ "./email": "./src/email/index.ts",
40
+ "./filling": "./src/filling/index.ts",
41
+ "./filling/cookie-codec": "./src/filling/cookie-codec.ts",
42
+ "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
43
+ "./kitchen": "./src/kitchen/index.ts",
44
+ "./logging": "./src/logging/index.ts",
45
+ "./middleware": "./src/middleware/index.ts",
46
+ "./middleware/csrf": "./src/middleware/csrf.ts",
47
+ "./middleware/oauth": "./src/middleware/oauth/index.ts",
48
+ "./middleware/secure": "./src/middleware/secure/index.ts",
49
+ "./middleware/rate-limit": "./src/middleware/rate-limit/index.ts",
50
+ "./middleware/session": "./src/middleware/session.ts",
51
+ "./testing": "./src/testing/index.ts",
52
+ "./testing/db": "./src/testing/db.ts",
53
+ "./testing/mocks": "./src/testing/mocks.ts",
54
+ "./testing/reporter": "./src/testing/reporter.ts",
55
+ "./testing/server": "./src/testing/server.ts",
56
+ "./testing/session": "./src/testing/session.ts",
57
+ "./testing/snapshot": "./src/testing/snapshot.ts",
58
+ "./plugins": "./src/plugins/index.ts",
59
+ "./plugins/define": "./src/plugins/define.ts",
60
+ "./plugins/runner": "./src/plugins/runner.ts",
61
+ "./error": "./src/error/index.ts",
62
+ "./i18n": "./src/i18n/index.ts",
63
+ "./id": "./src/id/index.ts",
64
+ "./observability": "./src/observability/index.ts",
65
+ "./openapi/generator": "./src/openapi/generator.ts",
66
+ "./perf": "./src/perf/index.ts",
67
+ "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
68
+ "./perf/user-marks": "./src/perf/user-marks.ts",
69
+ "./resource": "./src/resource/index.ts",
70
+ "./resource/ddl/diff": "./src/resource/ddl/diff.ts",
71
+ "./resource/ddl/emit": "./src/resource/ddl/emit.ts",
72
+ "./resource/ddl/snapshot": "./src/resource/ddl/snapshot.ts",
73
+ "./resource/ddl/type-map": "./src/resource/ddl/type-map.ts",
74
+ "./resource/ddl/types": "./src/resource/ddl/types.ts",
75
+ "./resource/generator-repo": "./src/resource/generator-repo.ts",
76
+ "./resource/parser": "./src/resource/parser.ts",
77
+ "./resource/schema": "./src/resource/schema.ts",
78
+ "./router": "./src/router/index.ts",
79
+ "./routes": "./src/routes/index.ts",
80
+ "./runtime": "./src/runtime/index.ts",
81
+ "./runtime/cache": "./src/runtime/cache.ts",
82
+ "./runtime/router": "./src/runtime/router.ts",
83
+ "./runtime/server": "./src/runtime/server.ts",
84
+ "./scheduler": "./src/scheduler/index.ts",
85
+ "./storage/s3": "./src/storage/s3/index.ts",
86
+ "./guard": "./src/guard/index.ts",
87
+ "./guard/define-rule": "./src/guard/define-rule.ts",
88
+ "./guard/design-inline-class": "./src/guard/design-inline-class.ts",
89
+ "./guard/rule-presets": "./src/guard/rule-presets.ts",
90
+ "./guard/tsgolint-bridge": "./src/guard/tsgolint-bridge.ts",
91
+ "./bundler": "./src/bundler/index.ts",
92
+ "./bundler/prerender": "./src/bundler/prerender.ts",
93
+ "./bundler/safe-build": "./src/bundler/safe-build.ts",
94
+ "./bundler/types": "./src/bundler/types.ts",
95
+ "./bundler/hmr-types": "./src/bundler/hmr-types.ts",
96
+ "./bundler/scenario-matrix": "./src/bundler/scenario-matrix.ts",
97
+ "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
98
+ "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
99
+ "./bundler/analyzer": "./src/bundler/analyzer.ts",
100
+ "./bundler/budget": "./src/bundler/budget.ts",
101
+ "./bundler/plugins": "./src/bundler/plugins/index.ts",
102
+ "./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
103
+ "./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
104
+ "./diagnose": "./src/diagnose/index.ts",
105
+ "./dev-error-overlay": "./src/dev-error-overlay/index.ts",
106
+ "./middleware/compose": "./src/middleware/compose.ts",
107
+ "./middleware/define": "./src/middleware/define.ts",
108
+ "./middleware/bridge": "./src/middleware/bridge.ts",
109
+ "./client/hydrate": "./src/client/hydrate.ts",
110
+ "./runtime/fast-refresh-types": "./src/runtime/fast-refresh-types.ts",
111
+ "./components/Image": "./src/components/Image.tsx"
112
+ },
113
+ "files": [
114
+ "src/**/*",
115
+ "scripts/postinstall-lock.ts"
116
+ ],
117
+ "scripts": {
118
+ "postinstall": "bun ./scripts/postinstall-lock.ts",
119
+ "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
120
+ "test:hydration": "bun test tests/hydration",
121
+ "test:streaming": "bun test tests/streaming-ssr",
122
+ "test:watch": "bun test --watch"
123
+ },
124
+ "devDependencies": {
125
+ "@happy-dom/global-registrator": "^15.0.0"
126
+ },
127
+ "keywords": [
128
+ "mandu",
129
+ "framework",
130
+ "agent",
131
+ "ai",
132
+ "code-generation"
133
+ ],
134
+ "repository": {
135
+ "type": "git",
136
+ "url": "git+https://github.com/konamgil/mandu.git",
137
+ "directory": "packages/core"
138
+ },
139
+ "author": "konamgil",
140
+ "license": "MPL-2.0",
141
+ "publishConfig": {
142
+ "access": "public"
143
+ },
144
+ "engines": {
145
+ "bun": ">=1.3.12"
146
+ },
147
+ "peerDependencies": {
148
+ "react": "^19.2.0",
149
+ "react-dom": "^19.2.0",
150
+ "react-refresh": ">=0.18.0",
151
+ "@tailwindcss/cli": ">=4.0.0",
152
+ "webview-bun": "^2.4.0",
153
+ "axe-core": ">=4.8.0",
154
+ "jsdom": ">=24.0.0",
155
+ "happy-dom": ">=15.0.0",
156
+ "@babel/core": "^7.29.0",
157
+ "babel-plugin-react-compiler": "^1.0.0",
158
+ "eslint": "^9.0.0",
159
+ "eslint-plugin-react-compiler": "^19.0.0 || ^1.0.0"
160
+ },
161
+ "peerDependenciesMeta": {
162
+ "@tailwindcss/cli": {
163
+ "optional": true
164
+ },
165
+ "react-refresh": {
166
+ "optional": true
167
+ },
168
+ "webview-bun": {
169
+ "optional": true
170
+ },
171
+ "axe-core": {
172
+ "optional": true
173
+ },
174
+ "jsdom": {
175
+ "optional": true
176
+ },
177
+ "happy-dom": {
178
+ "optional": true
179
+ },
180
+ "@babel/core": {
181
+ "optional": true
182
+ },
183
+ "babel-plugin-react-compiler": {
184
+ "optional": true
185
+ },
186
+ "eslint": {
187
+ "optional": true
188
+ },
189
+ "eslint-plugin-react-compiler": {
190
+ "optional": true
191
+ }
192
+ },
193
+ "dependencies": {
194
+ "chokidar": "^5.0.0",
195
+ "fast-glob": "^3.3.2",
196
+ "glob": "^13.0.0",
197
+ "minimatch": "^10.1.1",
198
+ "zod": "^3.23.8"
199
+ }
200
+ }
@@ -27,13 +27,31 @@ import {
27
27
  writeAgentVerifyReport,
28
28
  } from "../verify";
29
29
 
30
- async function writeFile(root: string, rel: string, content: string): Promise<void> {
31
- const abs = path.join(root, rel);
32
- await fs.mkdir(path.dirname(abs), { recursive: true });
33
- await fs.writeFile(abs, content, "utf8");
34
- }
35
-
36
- describe("agent context", () => {
30
+ async function writeFile(root: string, rel: string, content: string): Promise<void> {
31
+ const abs = path.join(root, rel);
32
+ await fs.mkdir(path.dirname(abs), { recursive: true });
33
+ await fs.writeFile(abs, content, "utf8");
34
+ }
35
+
36
+ async function runGit(root: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
37
+ try {
38
+ const proc = Bun.spawn(["git", ...args], {
39
+ cwd: root,
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ });
43
+ const [stdout, stderr, exitCode] = await Promise.all([
44
+ new Response(proc.stdout).text(),
45
+ new Response(proc.stderr).text(),
46
+ proc.exited,
47
+ ]);
48
+ return { ok: exitCode === 0, stdout, stderr };
49
+ } catch (error) {
50
+ return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
51
+ }
52
+ }
53
+
54
+ describe("agent context", () => {
37
55
  let root: string;
38
56
 
39
57
  beforeEach(async () => {
@@ -172,19 +190,48 @@ describe("agent context", () => {
172
190
  includeContract: false,
173
191
  });
174
192
 
175
- expect(report.framework).toBe("mandu");
176
- expect(report.ok).toBe(true);
177
- expect(report.checks.map((check) => check.id)).toEqual(["manifest"]);
193
+ expect(report.framework).toBe("mandu");
194
+ expect(report.ok).toBe(true);
195
+ expect(report.checks.map((check) => check.id)).toEqual(["internal-api", "manifest"]);
178
196
  expect(report.suggestedCommands.map((cmd) => cmd.command)).toContain("bun run typecheck");
179
197
  expect(report.nextRepairInput).toBe(".mandu/agent-verify.json");
180
198
 
181
199
  const result = await writeAgentVerifyReport(root, report);
182
200
  expect(result.path).toBe(agentVerifyReportPath(root));
183
201
  const parsed = JSON.parse(await fs.readFile(result.path, "utf8"));
184
- expect(parsed.project.name).toBe("agent-app");
185
- });
186
-
187
- it("turns a verify report into repair actions", async () => {
202
+ expect(parsed.project.name).toBe("agent-app");
203
+ });
204
+
205
+ it("records changed file reasons and warns on internal API edits", async () => {
206
+ const version = await runGit(root, ["--version"]);
207
+ if (!version.ok) return;
208
+
209
+ expect((await runGit(root, ["init"])).ok).toBe(true);
210
+ await runGit(root, ["config", "user.email", "agent@example.com"]);
211
+ await runGit(root, ["config", "user.name", "Agent"]);
212
+ expect((await runGit(root, ["add", "."])).ok).toBe(true);
213
+ expect((await runGit(root, ["commit", "-m", "initial"])).ok).toBe(true);
214
+
215
+ await writeFile(root, "packages/core/src/runtime/internal-change.ts", "export const value = 1;\n");
216
+
217
+ const report = await buildAgentVerifyReport(root, {
218
+ includeDiagnose: false,
219
+ includeGuard: false,
220
+ includeContract: false,
221
+ });
222
+
223
+ const reason = report.changedFileReasons.find(
224
+ (entry) => entry.file === "packages/core/src/runtime/internal-change.ts",
225
+ );
226
+ expect(reason?.internalApi).toBe(true);
227
+ expect(reason?.recommendedChecks).toContain("bun run check:public-api && bun run check:target-boundaries");
228
+ expect(report.diagnostics.some((diag) => diag.code === "MANDU_VERIFY_INTERNAL_API_EDIT")).toBe(true);
229
+ expect(report.suggestedCommands.map((cmd) => cmd.command)).toContain(
230
+ "bun run check:public-api && bun run check:target-boundaries",
231
+ );
232
+ });
233
+
234
+ it("turns a verify report into repair actions", async () => {
188
235
  await writeAgentVerifyReport(root, {
189
236
  schemaVersion: 1,
190
237
  framework: "mandu",
@@ -195,9 +242,10 @@ describe("agent context", () => {
195
242
  root,
196
243
  packageManager: "bun",
197
244
  configFile: null,
198
- },
199
- changedFiles: [],
200
- gitAvailable: false,
245
+ },
246
+ changedFiles: [],
247
+ changedFileReasons: [],
248
+ gitAvailable: false,
201
249
  notes: [],
202
250
  ok: false,
203
251
  checks: [],
@@ -153,19 +153,27 @@ export interface AgentVerifyCheck {
153
153
  details?: Record<string, unknown>;
154
154
  }
155
155
 
156
- export interface AgentSuggestedCommand {
157
- command: string;
158
- reason: string;
159
- required: boolean;
160
- }
161
-
162
- export interface AgentVerifyReport {
156
+ export interface AgentSuggestedCommand {
157
+ command: string;
158
+ reason: string;
159
+ required: boolean;
160
+ }
161
+
162
+ export interface AgentChangedFileReason {
163
+ file: string;
164
+ reasons: string[];
165
+ recommendedChecks: string[];
166
+ internalApi: boolean;
167
+ }
168
+
169
+ export interface AgentVerifyReport {
163
170
  schemaVersion: 1;
164
171
  framework: "mandu";
165
172
  generatedAt: string;
166
- project: AgentProjectSummary;
167
- changedFiles: string[];
168
- gitAvailable: boolean;
173
+ project: AgentProjectSummary;
174
+ changedFiles: string[];
175
+ changedFileReasons: AgentChangedFileReason[];
176
+ gitAvailable: boolean;
169
177
  notes: string[];
170
178
  ok: boolean;
171
179
  checks: AgentVerifyCheck[];
@@ -7,8 +7,9 @@ import type { ContractViolation } from "../guard/contract-guard";
7
7
  import {
8
8
  buildAgentContext,
9
9
  } from "./context";
10
- import type {
11
- AgentDiagnostic,
10
+ import type {
11
+ AgentChangedFileReason,
12
+ AgentDiagnostic,
12
13
  AgentDiagnosticSeverity,
13
14
  AgentSuggestedCommand,
14
15
  AgentVerifyCheck,
@@ -208,7 +209,11 @@ function matchesChanged(
208
209
  return candidates.some((file) => changed.has(file));
209
210
  }
210
211
 
211
- function commandSuggestions(changedFiles: string[], diagnostics: AgentDiagnostic[]): AgentSuggestedCommand[] {
212
+ function commandSuggestions(
213
+ changedFiles: string[],
214
+ diagnostics: AgentDiagnostic[],
215
+ changedFileReasons: AgentChangedFileReason[] = [],
216
+ ): AgentSuggestedCommand[] {
212
217
  const files = changedFiles.map(normalizePath).filter((value): value is string => Boolean(value));
213
218
  const out: AgentSuggestedCommand[] = [];
214
219
  const add = (command: string, reason: string, required: boolean) => {
@@ -240,15 +245,101 @@ function commandSuggestions(changedFiles: string[], diagnostics: AgentDiagnostic
240
245
  if (files.some((file) => file.startsWith("packages/mcp/"))) {
241
246
  add("bun test packages/mcp/tests", "MCP package files changed.", true);
242
247
  }
243
- if (files.some((file) => file.includes("package.json") || file === "bun.lock")) {
244
- add("bun run check:publish", "Package metadata or lockfile changed.", true);
245
- }
246
- if (diagnostics.some((d) => d.severity === "error" || d.severity === "fatal")) {
248
+ if (files.some((file) => file.includes("package.json") || file === "bun.lock")) {
249
+ add("bun run check:publish", "Package metadata or lockfile changed.", true);
250
+ }
251
+ if (changedFileReasons.some((entry) => entry.internalApi)) {
252
+ add("bun run check:public-api && bun run check:target-boundaries", "Internal framework boundaries changed.", true);
253
+ }
254
+ if (diagnostics.some((d) => d.severity === "error" || d.severity === "fatal")) {
247
255
  add("mandu agent repair --from .mandu/agent-verify.json", "Verification produced repairable diagnostics.", false);
248
256
  }
249
257
 
250
258
  return out;
251
- }
259
+ }
260
+
261
+ function isInternalApiEdit(file: string): boolean {
262
+ return [
263
+ "packages/core/src/runtime/",
264
+ "packages/core/src/bundler/",
265
+ "packages/core/src/server/",
266
+ "packages/core/src/guard/",
267
+ "packages/core/src/spec/",
268
+ "packages/core/src/router/",
269
+ "packages/core/src/internal/",
270
+ ].some((prefix) => file.startsWith(prefix));
271
+ }
272
+
273
+ function changedFileReason(file: string): AgentChangedFileReason {
274
+ const normalized = normalizePath(file) ?? file;
275
+ const reasons: string[] = [];
276
+ const recommendedChecks: string[] = [];
277
+
278
+ if (/\.(ts|tsx|js|jsx)$/.test(normalized)) {
279
+ reasons.push("Source code changed.");
280
+ recommendedChecks.push("bun run typecheck");
281
+ }
282
+ if (/\.test\.(ts|tsx|js|jsx)$/.test(normalized)) {
283
+ reasons.push("Test code changed.");
284
+ recommendedChecks.push(`bun test ${normalized}`);
285
+ }
286
+ if (normalized.startsWith("packages/cli/")) {
287
+ reasons.push("CLI behavior or documentation changed.");
288
+ recommendedChecks.push("bun test packages/cli/src");
289
+ }
290
+ if (normalized.startsWith("packages/mcp/")) {
291
+ reasons.push("MCP tool surface changed.");
292
+ recommendedChecks.push("bun test packages/mcp/tests");
293
+ }
294
+ if (normalized.startsWith("docs/") || normalized.endsWith("README.md") || normalized.endsWith("README.ko.md")) {
295
+ reasons.push("User-facing documentation changed.");
296
+ recommendedChecks.push("bun run check:docs-drift");
297
+ }
298
+ if (normalized === "package.json" || normalized === "bun.lock" || normalized.endsWith("/package.json")) {
299
+ reasons.push("Package metadata or dependency graph changed.");
300
+ recommendedChecks.push("bun run check:publish");
301
+ }
302
+
303
+ const internalApi = isInternalApiEdit(normalized);
304
+ if (internalApi) {
305
+ reasons.push("Framework internal API changed.");
306
+ recommendedChecks.push("bun run check:public-api && bun run check:target-boundaries");
307
+ }
308
+
309
+ return {
310
+ file: normalized,
311
+ reasons: reasons.length > 0 ? reasons : ["Changed file requires standard verification."],
312
+ recommendedChecks: [...new Set(recommendedChecks)],
313
+ internalApi,
314
+ };
315
+ }
316
+
317
+ function buildChangedFileReasons(changedFiles: string[]): AgentChangedFileReason[] {
318
+ return changedFiles
319
+ .map(normalizePath)
320
+ .filter((file): file is string => Boolean(file))
321
+ .map(changedFileReason);
322
+ }
323
+
324
+ function internalApiDiagnostics(changedFileReasons: AgentChangedFileReason[]): AgentDiagnostic[] {
325
+ return changedFileReasons
326
+ .filter((entry) => entry.internalApi)
327
+ .map((entry) => ({
328
+ code: "MANDU_VERIFY_INTERNAL_API_EDIT",
329
+ severity: "warning" as const,
330
+ title: "Internal framework API changed",
331
+ file: entry.file,
332
+ cause: "This file is part of Mandu's internal runtime/bundler/guard surface and can affect public behavior indirectly.",
333
+ suggestedFix: {
334
+ type: "run_command" as const,
335
+ command: "bun run check:public-api && bun run check:target-boundaries",
336
+ description: "Verify public API classification and target-safe import boundaries.",
337
+ },
338
+ docs: "docs/architect/public-api-boundary.md",
339
+ repairable: false,
340
+ source: "agent.verify",
341
+ }));
342
+ }
252
343
 
253
344
  function check(
254
345
  id: string,
@@ -281,16 +372,22 @@ export async function buildAgentVerifyReport(
281
372
  rootDir: string = process.cwd(),
282
373
  options: BuildAgentVerifyOptions = {},
283
374
  ): Promise<AgentVerifyReport> {
284
- const root = path.resolve(rootDir);
285
- const changed = await collectChangedFiles(root, options);
286
- const changedSet = new Set(changed.files.map(normalizePath).filter((value): value is string => Boolean(value)));
375
+ const root = path.resolve(rootDir);
376
+ const changed = await collectChangedFiles(root, options);
377
+ const changedSet = new Set(changed.files.map(normalizePath).filter((value): value is string => Boolean(value)));
378
+ const changedFileReasons = buildChangedFileReasons(changed.files);
287
379
  const context = await buildAgentContext(root, {
288
380
  includeDiagnose: false,
289
381
  includeGit: false,
290
382
  });
291
383
  const notes = [...changed.notes];
292
- const checks: AgentVerifyCheck[] = [];
293
- const diagnostics: AgentDiagnostic[] = [];
384
+ const checks: AgentVerifyCheck[] = [];
385
+ const diagnostics: AgentDiagnostic[] = [];
386
+ const internalDiagnostics = internalApiDiagnostics(changedFileReasons);
387
+ diagnostics.push(...internalDiagnostics);
388
+ checks.push(check("internal-api", "Internal API boundary", internalDiagnostics, {
389
+ changedFiles: changedFileReasons.filter((entry) => entry.internalApi).length,
390
+ }));
294
391
 
295
392
  if (options.includeDiagnose !== false) {
296
393
  try {
@@ -382,14 +479,15 @@ export async function buildAgentVerifyReport(
382
479
  schemaVersion: 1,
383
480
  framework: "mandu",
384
481
  generatedAt: new Date().toISOString(),
385
- project: context.project,
386
- changedFiles: changed.files,
387
- gitAvailable: changed.gitAvailable,
482
+ project: context.project,
483
+ changedFiles: changed.files,
484
+ changedFileReasons,
485
+ gitAvailable: changed.gitAvailable,
388
486
  notes,
389
487
  ok,
390
488
  checks,
391
489
  diagnostics,
392
- suggestedCommands: commandSuggestions(changed.files, diagnostics),
490
+ suggestedCommands: commandSuggestions(changed.files, diagnostics, changedFileReasons),
393
491
  nextRepairInput: AGENT_VERIFY_RELATIVE_PATH,
394
492
  };
395
493
  }
@@ -248,7 +248,7 @@ describe("buildClientBundles vendor shims", () => {
248
248
  expect(noClientResult.success).toBe(false);
249
249
  expect(errors).toContain("no clientModule could be resolved");
250
250
  expect(errors).toContain("LoginForm.client");
251
- expect(errors).toContain("partial({ component }).Render");
251
+ expect(errors).toContain("run mandu generate");
252
252
  } finally {
253
253
  await rm(missingRoot, { recursive: true, force: true });
254
254
  }
@@ -21,15 +21,20 @@ export function formatErrorResponse(error: ManduError, options: FormatOptions =
21
21
 
22
22
  const response: Record<string, unknown> = {
23
23
  errorType: error.errorType,
24
- code: error.code,
25
- message: error.message,
26
- summary: error.summary,
27
- fix: error.fix,
28
- };
29
-
30
- if (error.route) {
31
- response.route = error.route;
32
- }
24
+ code: error.code,
25
+ message: error.message,
26
+ summary: error.summary,
27
+ cause: error.summary,
28
+ fix: error.fix,
29
+ filePath: error.fix.file,
30
+ solution: error.fix.suggestion,
31
+ };
32
+
33
+ if (error.route) {
34
+ response.route = error.route;
35
+ response.routeId = error.route.id;
36
+ response.routePattern = error.route.pattern;
37
+ }
33
38
 
34
39
  // 개발 모드에서만 디버그 정보 포함
35
40
  if (isDev && error.debug) {
@@ -60,27 +65,31 @@ export function formatErrorForConsole(error: ManduError, options: FormatOptions
60
65
  lines.push(` ${error.message}`);
61
66
 
62
67
  // 요약
63
- if (useColors) {
64
- lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
65
- } else {
66
- lines.push(` → ${error.summary}`);
67
- }
68
+ if (useColors) {
69
+ lines.push(` ${CYAN}→ ${error.summary}${RESET}`);
70
+ } else {
71
+ lines.push(` → ${error.summary}`);
72
+ }
73
+ lines.push(` Cause: ${error.summary}`);
68
74
 
69
75
  // 수정 안내
70
76
  lines.push("");
71
77
  if (useColors) {
72
78
  lines.push(` ${YELLOW}Fix:${RESET} ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
73
79
  lines.push(` ${error.fix.suggestion}`);
74
- } else {
75
- lines.push(` Fix: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
76
- lines.push(` ${error.fix.suggestion}`);
77
- }
80
+ } else {
81
+ lines.push(` Fix: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
82
+ lines.push(` ${error.fix.suggestion}`);
83
+ }
84
+ lines.push(` File: ${error.fix.file}${error.fix.line ? `:${error.fix.line}` : ""}`);
85
+ lines.push(` Solution: ${error.fix.suggestion}`);
78
86
 
79
87
  // 라우트 컨텍스트
80
- if (error.route) {
81
- lines.push("");
82
- lines.push(` Route: ${error.route.id} (${error.route.pattern})`);
83
- }
88
+ if (error.route) {
89
+ lines.push("");
90
+ lines.push(` Route ID: ${error.route.id}`);
91
+ lines.push(` Route: ${error.route.pattern}`);
92
+ }
84
93
 
85
94
  // 디버그 정보 (개발 모드)
86
95
  if (isDev && includeStack && error.debug?.stack) {
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  findClientComponentImports,
4
4
  findRouteLevelClientComponentImport,
5
+ findRouteLevelClientComponentImports,
5
6
  } from "./client-entry";
6
7
 
7
8
  describe("findClientComponentImports", () => {
@@ -15,7 +16,7 @@ describe("findClientComponentImports", () => {
15
16
  {
16
17
  module: "@/client/widgets/login-form/LoginForm.client",
17
18
  kind: "named",
18
- names: ["LoginForm", "SubmitButton"],
19
+ names: ["LoginForm", "Button"],
19
20
  },
20
21
  {
21
22
  module: "./Header.client.tsx",
@@ -60,7 +61,22 @@ describe("findClientComponentImports", () => {
60
61
  });
61
62
  });
62
63
 
63
- it("does not promote embedded client imports inside a larger server page", () => {
64
+ it("detects a named-imported client component when the page returns only that component", () => {
65
+ const routeClient = findRouteLevelClientComponentImport(`
66
+ import { NotificationsPage } from "@/client/pages/notifications/NotificationsPage.client";
67
+
68
+ export default function Page() {
69
+ return <NotificationsPage />;
70
+ }
71
+ `);
72
+
73
+ expect(routeClient).toEqual({
74
+ module: "@/client/pages/notifications/NotificationsPage.client",
75
+ localName: "NotificationsPage",
76
+ });
77
+ });
78
+
79
+ it("promotes embedded client imports inside a larger server page", () => {
64
80
  const routeClient = findRouteLevelClientComponentImport(`
65
81
  import HomeApp from "@/client/pages/home/HomeApp.client";
66
82
 
@@ -72,10 +88,13 @@ describe("findClientComponentImports", () => {
72
88
  }
73
89
  `);
74
90
 
75
- expect(routeClient).toBeNull();
91
+ expect(routeClient).toEqual({
92
+ module: "@/client/pages/home/HomeApp.client",
93
+ localName: "HomeApp",
94
+ });
76
95
  });
77
96
 
78
- it("does not promote client imports when the page wrapper passes props", () => {
97
+ it("promotes client imports when the page wrapper passes props", () => {
79
98
  const routeClient = findRouteLevelClientComponentImport(`
80
99
  import PledgePage from "@/client/pages/pledges/PledgePage.client";
81
100
 
@@ -84,6 +103,38 @@ describe("findClientComponentImports", () => {
84
103
  }
85
104
  `);
86
105
 
87
- expect(routeClient).toBeNull();
106
+ expect(routeClient).toEqual({
107
+ module: "@/client/pages/pledges/PledgePage.client",
108
+ localName: "PledgePage",
109
+ });
110
+ });
111
+
112
+ it("detects multiple rendered client imports in a server shell", () => {
113
+ const routeClients = findRouteLevelClientComponentImports(`
114
+ import { CommentsSection } from "@/client/widgets/comments-section/CommentsSection.client";
115
+ import { PledgeActions } from "@/client/widgets/pledge-actions/PledgeActions.client";
116
+
117
+ export default async function PledgePage({ params }) {
118
+ const pledge = await Promise.resolve(params.id);
119
+ return (
120
+ <main>
121
+ <article>{pledge}</article>
122
+ <PledgeActions pledgeId={params.id} />
123
+ <CommentsSection pledgeId={params.id} />
124
+ </main>
125
+ );
126
+ }
127
+ `);
128
+
129
+ expect(routeClients).toEqual([
130
+ {
131
+ module: "@/client/widgets/comments-section/CommentsSection.client",
132
+ localName: "CommentsSection",
133
+ },
134
+ {
135
+ module: "@/client/widgets/pledge-actions/PledgeActions.client",
136
+ localName: "PledgeActions",
137
+ },
138
+ ]);
88
139
  });
89
140
  });
@@ -62,7 +62,8 @@ export function findClientComponentImports(source: string): ClientComponentImpor
62
62
  for (const rawName of namedMatch[1].split(",")) {
63
63
  const name = rawName.trim();
64
64
  if (!name) continue;
65
- names.push(name.split(/\s+as\s+/i)[0].trim());
65
+ const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
66
+ names.push(parts[1] ?? parts[0]);
66
67
  }
67
68
  }
68
69
 
@@ -100,19 +101,18 @@ export function findClientComponentImports(source: string): ClientComponentImpor
100
101
  }
101
102
 
102
103
  export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
103
- const defaultImports = findClientComponentImports(source).filter((entry) => {
104
- const localName = entry.names[0] ?? "";
105
- return entry.kind === "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName);
106
- });
107
-
108
- if (defaultImports.length !== 1) return null;
104
+ return findRouteLevelClientComponentImports(source)[0] ?? null;
105
+ }
109
106
 
110
- const entry = defaultImports[0];
111
- const localName = entry.names[0];
112
- if (!entry.module || !localName) return null;
113
- if (!defaultExportReturnsOnlyClientComponent(source, localName)) return null;
107
+ export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
108
+ const candidates = findClientComponentImports(source).flatMap((entry) =>
109
+ entry.names
110
+ .filter((localName) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName))
111
+ .map((localName) => ({ module: entry.module, localName }))
112
+ );
114
113
 
115
- return { module: entry.module, localName };
114
+ if (candidates.length === 0) return [];
115
+ return defaultExportRendersClientComponents(source, candidates);
116
116
  }
117
117
 
118
118
  export async function resolveClientImportModulePath(
@@ -168,15 +168,28 @@ function expandClientModuleCandidates(basePath: string): string[] {
168
168
  ];
169
169
  }
170
170
 
171
- function defaultExportReturnsOnlyClientComponent(source: string, localName: string): boolean {
171
+ function defaultExportRendersClientComponents(
172
+ source: string,
173
+ candidates: RouteLevelClientComponentImport[],
174
+ ): RouteLevelClientComponentImport[] {
172
175
  const functionBody = extractDefaultExportFunctionBody(source);
173
176
  if (functionBody !== null) {
174
- const returned = extractOnlyReturnExpression(functionBody);
175
- return returned !== null && jsxExpressionRendersClientComponent(returned, localName);
177
+ const returned = extractTopLevelReturnExpression(functionBody);
178
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
176
179
  }
177
180
 
178
181
  const arrowExpression = extractDefaultExportArrowExpression(source);
179
- return arrowExpression !== null && jsxExpressionRendersClientComponent(arrowExpression, localName);
182
+ if (arrowExpression !== null) {
183
+ return jsxExpressionRendersClientComponents(arrowExpression, candidates);
184
+ }
185
+
186
+ const arrowBody = extractDefaultExportArrowFunctionBody(source);
187
+ if (arrowBody !== null) {
188
+ const returned = extractTopLevelReturnExpression(arrowBody);
189
+ return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
190
+ }
191
+
192
+ return [];
180
193
  }
181
194
 
182
195
  function extractDefaultExportFunctionBody(source: string): string | null {
@@ -201,44 +214,109 @@ function extractDefaultExportArrowExpression(source: string): string | null {
201
214
  return semicolon === -1 ? rest : rest.slice(0, semicolon);
202
215
  }
203
216
 
204
- function extractOnlyReturnExpression(body: string): string | null {
205
- const match = /^\s*return\s+([\s\S]*?)\s*;?\s*$/.exec(body);
206
- return match?.[1]?.trim() ?? null;
217
+ function extractDefaultExportArrowFunctionBody(source: string): string | null {
218
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
219
+ if (!match) return null;
220
+
221
+ const openBrace = match.index + match[0].lastIndexOf("{");
222
+ const closeBrace = findMatchingBrace(source, openBrace);
223
+ if (closeBrace === -1) return null;
224
+ return source.slice(openBrace + 1, closeBrace);
207
225
  }
208
226
 
209
- function jsxExpressionRendersClientComponent(expression: string, localName: string): boolean {
210
- const expr = stripWrappingParentheses(expression.trim());
211
- return isBareClientElement(expr, localName) || isHeadOnlyFragmentWrapper(expr, localName);
227
+ function extractTopLevelReturnExpression(body: string): string | null {
228
+ let quote: '"' | "'" | "`" | null = null;
229
+ let lineComment = false;
230
+ let blockComment = false;
231
+ let braceDepth = 0;
232
+ let parenDepth = 0;
233
+ let bracketDepth = 0;
234
+
235
+ for (let i = 0; i < body.length; i++) {
236
+ const char = body[i];
237
+ const next = body[i + 1];
238
+ const prev = body[i - 1];
239
+
240
+ if (lineComment) {
241
+ if (char === "\n" || char === "\r") lineComment = false;
242
+ continue;
243
+ }
244
+
245
+ if (blockComment) {
246
+ if (char === "*" && next === "/") {
247
+ blockComment = false;
248
+ i++;
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if (quote) {
254
+ if (char === quote && prev !== "\\") quote = null;
255
+ continue;
256
+ }
257
+
258
+ if (char === "/" && next === "/") {
259
+ lineComment = true;
260
+ i++;
261
+ continue;
262
+ }
263
+ if (char === "/" && next === "*") {
264
+ blockComment = true;
265
+ i++;
266
+ continue;
267
+ }
268
+ if (char === '"' || char === "'" || char === "`") {
269
+ quote = char;
270
+ continue;
271
+ }
272
+
273
+ if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
274
+ const before = body[i - 1] ?? "";
275
+ const after = body[i + "return".length] ?? "";
276
+ if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
277
+ const expr = body.slice(i + "return".length).trim();
278
+ return trimTrailingSemicolon(expr);
279
+ }
280
+ }
281
+
282
+ if (char === "{") braceDepth++;
283
+ if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
284
+ if (char === "(") parenDepth++;
285
+ if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
286
+ if (char === "[") bracketDepth++;
287
+ if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
288
+ }
289
+
290
+ return null;
212
291
  }
213
292
 
214
- function isBareClientElement(expression: string, localName: string): boolean {
215
- const expr = stripWrappingParentheses(expression.trim());
216
- const escaped = escapeRegExp(localName);
217
- return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
293
+ function trimTrailingSemicolon(value: string): string {
294
+ const trimmed = value.trim();
295
+ return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
218
296
  }
219
297
 
220
- function isHeadOnlyFragmentWrapper(expression: string, localName: string): boolean {
298
+ function jsxExpressionRendersClientComponents(
299
+ expression: string,
300
+ candidates: RouteLevelClientComponentImport[],
301
+ ): RouteLevelClientComponentImport[] {
221
302
  const expr = stripWrappingParentheses(expression.trim());
222
- const fragmentMatch = /^<>\s*([\s\S]*?)\s*<\/>$/.exec(expr);
223
- if (!fragmentMatch) return false;
303
+ const seen = new Set<string>();
304
+ const rendered: RouteLevelClientComponentImport[] = [];
305
+
306
+ for (const candidate of candidates) {
307
+ const key = `${candidate.module}\0${candidate.localName}`;
308
+ if (seen.has(key)) continue;
309
+ if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
310
+ seen.add(key);
311
+ rendered.push(candidate);
312
+ }
313
+
314
+ return rendered;
315
+ }
224
316
 
317
+ function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
225
318
  const escaped = escapeRegExp(localName);
226
- const clientElementPattern = new RegExp(`<${escaped}(?:\\s[^>]*)?(?:/>|>)`, "g");
227
- const matches = [...(fragmentMatch[1] ?? "").matchAll(clientElementPattern)];
228
- if (matches.length !== 1) return false;
229
- const clientElement = matches[0][0];
230
- if (!isBareClientElement(clientElement, localName)) return false;
231
-
232
- const rest = (fragmentMatch[1] ?? "")
233
- .replace(clientElement, "")
234
- .replace(/<meta\b[^>]*\/>/gi, "")
235
- .replace(/<link\b[^>]*\/>/gi, "")
236
- .replace(/<base\b[^>]*\/>/gi, "")
237
- .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
238
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
239
- .trim();
240
-
241
- return rest.length === 0;
319
+ return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
242
320
  }
243
321
 
244
322
  function stripWrappingParentheses(value: string): string {
@@ -317,6 +395,10 @@ function escapeRegExp(value: string): string {
317
395
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
318
396
  }
319
397
 
398
+ function isIdentifierChar(value: string): boolean {
399
+ return /[A-Za-z0-9_$]/.test(value);
400
+ }
401
+
320
402
  export async function validateClientModuleForBrowserBundle(
321
403
  route: RouteSpec,
322
404
  rootDir: string,
@@ -349,9 +431,13 @@ async function routeComponentHasResolvableClientEntry(
349
431
  routeModule: string,
350
432
  source: string,
351
433
  ): Promise<boolean> {
352
- const routeLevelClientImport = findRouteLevelClientComponentImport(source);
353
- if (!routeLevelClientImport) return false;
354
- return (await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module)) !== null;
434
+ const routeLevelClientImports = findRouteLevelClientComponentImports(source);
435
+ for (const routeLevelClientImport of routeLevelClientImports) {
436
+ if ((await resolveClientImportModulePath(rootDir, routeModule, routeLevelClientImport.module)) !== null) {
437
+ return true;
438
+ }
439
+ }
440
+ return false;
355
441
  }
356
442
 
357
443
  export async function describeMissingHydrationClientModule(
@@ -393,8 +479,8 @@ export async function describeMissingHydrationClientModule(
393
479
 
394
480
  return (
395
481
  `${base}\n` +
396
- ` The page imports client-looking modules, but inline .client.tsx imports are not route bundles:\n` +
482
+ ` The page imports client-looking modules, but none was linked into the route manifest:\n` +
397
483
  `${importList}\n` +
398
- ` Fix: use partial({ component }).Render for embedded client regions, or expose a route-level client module.`
484
+ ` Fix: run mandu generate with the current source, or set an explicit route-level clientModule.`
399
485
  );
400
486
  }
@@ -31,7 +31,7 @@ import {
31
31
  import { mark, measure } from "../perf";
32
32
  import { METADATA_ROUTES } from "../routes/types";
33
33
  import {
34
- findRouteLevelClientComponentImport,
34
+ findRouteLevelClientComponentImports,
35
35
  hasUseClientDirective,
36
36
  resolveClientImportModulePath,
37
37
  } from "./client-entry";
@@ -399,8 +399,8 @@ export class FSScanner {
399
399
  if (hasUseClient) {
400
400
  clientModule = modulePath;
401
401
  } else {
402
- const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
403
- if (routeLevelClientImport) {
402
+ const routeLevelClientImports = findRouteLevelClientComponentImports(pageFileContent);
403
+ for (const routeLevelClientImport of routeLevelClientImports) {
404
404
  const resolvedClientImport = await resolveClientImportModulePath(
405
405
  rootDir,
406
406
  modulePath,
@@ -408,6 +408,7 @@ export class FSScanner {
408
408
  );
409
409
  if (resolvedClientImport) {
410
410
  clientModule = modulePath;
411
+ break;
411
412
  }
412
413
  }
413
414
  }