@mandujs/core 0.54.1 β†’ 0.54.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/scripts/postinstall-lock.ts +153 -0
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/brain/doctor/analyzer.ts +7 -7
  5. package/src/bundler/__tests__/cold-start.test.ts +35 -7
  6. package/src/bundler/analyzer.ts +15 -7
  7. package/src/bundler/build.test.ts +13 -6
  8. package/src/bundler/build.ts +429 -182
  9. package/src/bundler/manifest-schema.ts +21 -14
  10. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
  11. package/src/bundler/plugins/block-generated-imports.ts +13 -12
  12. package/src/bundler/types.ts +31 -14
  13. package/src/client/island.ts +79 -29
  14. package/src/config/validate.ts +1 -1
  15. package/src/deploy/inference/context.ts +82 -15
  16. package/src/filling/context.ts +17 -4
  17. package/src/guard/check.ts +9 -9
  18. package/src/guard/config-guard.ts +13 -7
  19. package/src/guard/fs-routes-policy.ts +51 -0
  20. package/src/guard/index.ts +11 -6
  21. package/src/kitchen/api/file-api.ts +11 -8
  22. package/src/resource/__tests__/schema.test.ts +14 -9
  23. package/src/resource/generators/slot.ts +72 -71
  24. package/src/resource/schema.ts +21 -13
  25. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
  26. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
  27. package/src/runtime/__tests__/page-render-response.test.ts +103 -0
  28. package/src/runtime/__tests__/request-middleware.test.ts +70 -0
  29. package/src/runtime/devtools-adapter.ts +68 -0
  30. package/src/runtime/escape.ts +34 -6
  31. package/src/runtime/observability-lifecycle.ts +290 -0
  32. package/src/runtime/page-render-response.ts +106 -0
  33. package/src/runtime/request-middleware.ts +31 -0
  34. package/src/runtime/server.ts +228 -944
  35. package/src/runtime/ssr.ts +59 -37
  36. package/src/runtime/static-files.ts +289 -0
  37. package/src/runtime/streaming-ssr.ts +22 -13
@@ -28,7 +28,8 @@
28
28
  *
29
29
  * URL safety model (applies to `shared.runtime`, `shared.vendor`,
30
30
  * `shared.router`, `shared.fastRefresh.glue`, `shared.fastRefresh.runtime`,
31
- * `bundles[].js`, `bundles[].css`, `islands[].js`, `importMap.imports[*]`):
31
+ * `bundles[].js`, `bundles[].css`, `islands[].js`, `partials[].js`,
32
+ * `importMap.imports[*]`):
32
33
  *
33
34
  * ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
34
35
  * The bundler itself only ever emits this shape.
@@ -161,15 +162,20 @@ const BundleEntrySchema = z.object({
161
162
  priority: PrioritySchema,
162
163
  });
163
164
 
164
- const IslandEntrySchema = z.object({
165
- js: safeManduUrl("islands[].js"),
166
- route: z.string().min(1),
167
- priority: PrioritySchema,
168
- });
169
-
170
- const FastRefreshSchema = z.object({
171
- runtime: safeManduUrl("shared.fastRefresh.runtime"),
172
- glue: safeManduUrl("shared.fastRefresh.glue"),
165
+ const IslandEntrySchema = z.object({
166
+ js: safeManduUrl("islands[].js"),
167
+ route: z.string().min(1),
168
+ priority: PrioritySchema,
169
+ });
170
+
171
+ const PartialEntrySchema = z.object({
172
+ js: safeManduUrl("partials[].js"),
173
+ priority: PrioritySchema,
174
+ });
175
+
176
+ const FastRefreshSchema = z.object({
177
+ runtime: safeManduUrl("shared.fastRefresh.runtime"),
178
+ glue: safeManduUrl("shared.fastRefresh.glue"),
173
179
  });
174
180
 
175
181
  const SharedSchema = z.object({
@@ -219,10 +225,11 @@ export const BundleManifestSchema = z
219
225
  .object({
220
226
  version: z.number().int().min(1),
221
227
  buildTime: z.string().min(1),
222
- env: z.enum(["development", "production"]),
223
- bundles: z.record(z.string(), BundleEntrySchema),
224
- islands: z.record(z.string(), IslandEntrySchema).optional(),
225
- shared: SharedSchema,
228
+ env: z.enum(["development", "production"]),
229
+ bundles: z.record(z.string(), BundleEntrySchema),
230
+ islands: z.record(z.string(), IslandEntrySchema).optional(),
231
+ partials: z.record(z.string(), PartialEntrySchema).optional(),
232
+ shared: SharedSchema,
226
233
  importMap: ImportMapSchema.optional(),
227
234
  })
228
235
  .strict();
@@ -80,15 +80,19 @@ describe("defaultAllowImporter", () => {
80
80
  });
81
81
 
82
82
  describe("DEFAULT_BLOCK_FILTER", () => {
83
- test("matches __generated__ specifiers", () => {
84
- expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
85
- expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
86
- });
87
- test("does NOT match look-alikes without double underscores", () => {
88
- expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
89
- expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
90
- });
91
- });
83
+ test("matches __generated__ specifiers", () => {
84
+ expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
85
+ expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
86
+ });
87
+ test("matches direct .mandu/generated relative specifiers", () => {
88
+ expect("../.mandu/generated/routes").toMatch(DEFAULT_BLOCK_FILTER);
89
+ expect("../../../.mandu/generated/server/repos/party.repo").toMatch(DEFAULT_BLOCK_FILTER);
90
+ });
91
+ test("does NOT match look-alikes without double underscores", () => {
92
+ expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
93
+ expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
94
+ });
95
+ });
92
96
 
93
97
  describe("defaultBundlerPlugins", () => {
94
98
  test("installs block-generated-imports by default", () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Bun bundler plugin β€” hard-fail on direct `__generated__/` imports.
2
+ * Bun bundler plugin β€” hard-fail on direct generated-artifact imports.
3
3
  *
4
4
  * Background
5
5
  * ──────────
@@ -8,14 +8,15 @@
8
8
  * but it only runs when the user (or CI) invokes `mandu guard check`.
9
9
  * Autonomous coding agents routinely bypass that step. This plugin closes
10
10
  * the gap at the bundler level: every `mandu dev` / `mandu build` pass
11
- * installs it by default, and any import whose specifier contains
12
- * `__generated__` fails the build with a structured, actionable error.
11
+ * installs it by default, and any import whose specifier targets
12
+ * `__generated__` or `.mandu/generated` fails the build with a structured,
13
+ * actionable error.
13
14
  *
14
15
  * Design
15
16
  * ──────
16
- * - `onResolve({ filter: /__generated__/ })` β€” Bun hands us every import
17
- * whose *specifier* matches the regex, along with the importer's path
18
- * (`args.importer`). We never return a result; we always throw.
17
+ * - `onResolve({ filter: DEFAULT_BLOCK_FILTER })` β€” Bun hands us every
18
+ * import whose *specifier* matches the regex, along with the importer's
19
+ * path (`args.importer`). We never return a result; we always throw.
19
20
  * - The error is `ForbiddenGeneratedImportError`, a named subclass of
20
21
  * `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
21
22
  * its `result.logs` output for CLI display.
@@ -90,8 +91,8 @@ export interface BlockGeneratedImportsOptions {
90
91
  allowImporter?: (importerPath: string) => boolean;
91
92
  /**
92
93
  * Custom filter regex applied to the import specifier. Defaults to
93
- * `/__generated__/`. Mandu ships a single default β€” exposing this for
94
- * test harnesses that want to narrow or broaden the filter.
94
+ * generated-artifact paths. Mandu ships a single default β€” exposing this
95
+ * for test harnesses that want to narrow or broaden the filter.
95
96
  */
96
97
  filter?: RegExp;
97
98
  }
@@ -114,7 +115,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
114
115
  }
115
116
 
116
117
  /**
117
- * Build a `BunPlugin` that blocks direct `__generated__/` imports.
118
+ * Build a `BunPlugin` that blocks direct generated-artifact imports.
118
119
  *
119
120
  * Usage β€” call from `defaultBundlerPlugins(config)` (see `./index.ts`).
120
121
  * Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
@@ -123,7 +124,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
123
124
  export function blockGeneratedImports(
124
125
  options: BlockGeneratedImportsOptions = {},
125
126
  ): BunPlugin {
126
- const filter = options.filter ?? /__generated__/;
127
+ const filter = options.filter ?? DEFAULT_BLOCK_FILTER;
127
128
  const allowImporter = options.allowImporter ?? defaultAllowImporter;
128
129
 
129
130
  return {
@@ -151,5 +152,5 @@ export function blockGeneratedImports(
151
152
  };
152
153
  }
153
154
 
154
- /** Exported for unit-test convenience β€” keep the filter text assertable. */
155
- export const DEFAULT_BLOCK_FILTER = /__generated__/;
155
+ /** Exported for unit-test convenience β€” keep the filter text assertable. */
156
+ export const DEFAULT_BLOCK_FILTER = /__generated__|(?:^|[\/\\])\.mandu[\/\\]generated(?:[\/\\]|$)/;
@@ -55,18 +55,28 @@ export interface BundleManifest {
55
55
  priority: "immediate" | "visible" | "idle" | "interaction";
56
56
  }
57
57
  >;
58
- /** Per-island bundles (code splitting: each island file gets its own JS bundle) */
59
- islands?: Record<
60
- string,
61
- {
62
- /** JavaScript bundle path */
58
+ /** Per-island bundles (code splitting: each island file gets its own JS bundle) */
59
+ islands?: Record<
60
+ string,
61
+ {
62
+ /** JavaScript bundle path */
63
63
  js: string;
64
64
  /** Route that owns this island */
65
65
  route: string;
66
66
  /** Hydration priority */
67
- priority: "immediate" | "visible" | "idle" | "interaction";
68
- }
69
- >;
67
+ priority: "immediate" | "visible" | "idle" | "interaction";
68
+ }
69
+ >;
70
+ /** Inline partial bundles (from *.partial.tsx / *.partial.ts files) */
71
+ partials?: Record<
72
+ string,
73
+ {
74
+ /** JavaScript bundle path */
75
+ js: string;
76
+ /** Hydration priority */
77
+ priority: "immediate" | "visible" | "idle" | "interaction";
78
+ }
79
+ >;
70
80
  /** 곡유 청크 */
71
81
  shared: {
72
82
  /** Hydration λŸ°νƒ€μž„ */
@@ -113,12 +123,19 @@ export interface BundleStats {
113
123
  }
114
124
 
115
125
  /** Per-island code splitting entry (used by scanIslandFiles) */
116
- export interface IslandFileEntry {
117
- name: string;
118
- filePath: string;
119
- routeId: string;
120
- priority: "immediate" | "visible" | "idle" | "interaction";
121
- }
126
+ export interface IslandFileEntry {
127
+ name: string;
128
+ filePath: string;
129
+ routeId: string;
130
+ priority: "immediate" | "visible" | "idle" | "interaction";
131
+ }
132
+
133
+ /** Inline partial bundle entry (used by scanPartialFiles) */
134
+ export interface PartialFileEntry {
135
+ name: string;
136
+ filePath: string;
137
+ priority: "immediate" | "visible" | "idle" | "interaction";
138
+ }
122
139
 
123
140
  /**
124
141
  * λ²ˆλ“€λŸ¬ μ˜΅μ…˜
@@ -3,8 +3,9 @@
3
3
  * Hydration을 μœ„ν•œ ν΄λΌμ΄μ–ΈνŠΈ μ‚¬μ΄λ“œ μ»΄ν¬λ„ŒνŠΈ μ •μ˜
4
4
  */
5
5
 
6
- import type { ReactNode } from "react";
7
- import { getServerData as getGlobalServerData } from "./window-state";
6
+ import type { ReactNode } from "react";
7
+ import { serializeProps } from "./serialize";
8
+ import { getServerData as getGlobalServerData } from "./window-state";
8
9
 
9
10
  /**
10
11
  * Island μ •μ˜ νƒ€μž…
@@ -335,26 +336,45 @@ export interface PartialConfig {
335
336
  /**
336
337
  * Partial Island μ •μ˜ νƒ€μž…
337
338
  */
338
- export interface PartialDefinition<TProps> {
339
- /** Partial μ»΄ν¬λ„ŒνŠΈ */
340
- component: React.ComponentType<TProps>;
341
- /** 초기 props (SSRμ—μ„œ 전달) */
342
- initialProps?: TProps;
343
- /** ν•˜μ΄λ“œλ ˆμ΄μ…˜ μš°μ„ μˆœμœ„ */
344
- priority?: "immediate" | "visible" | "idle" | "interaction";
345
- }
339
+ export interface PartialDefinition<TProps> {
340
+ /** Partial 고유 ID. 기본값은 component displayName/name μž…λ‹ˆλ‹€. */
341
+ id?: string;
342
+ /** Partial μ»΄ν¬λ„ŒνŠΈ */
343
+ component: React.ComponentType<TProps>;
344
+ /** 초기 props (SSRμ—μ„œ 전달) */
345
+ initialProps?: TProps;
346
+ /** ν•˜μ΄λ“œλ ˆμ΄μ…˜ μš°μ„ μˆœμœ„ */
347
+ priority?: "immediate" | "visible" | "idle" | "interaction";
348
+ /** λͺ…μ‹œμ  λ²ˆλ“€ URL. 기본값은 `/.mandu/client/{id}.partial.js` μž…λ‹ˆλ‹€. */
349
+ src?: string;
350
+ /** μ—λŸ¬ μ‹œ ν‘œμ‹œν•  UI */
351
+ errorBoundary?: (error: Error, reset: () => void) => ReactNode;
352
+ /** λ‘œλ”© 쀑 ν‘œμ‹œν•  UI */
353
+ loading?: () => ReactNode;
354
+ }
346
355
 
347
356
  /**
348
357
  * 컴파일된 Partial
349
358
  */
350
- export interface CompiledPartial<TProps> {
359
+ export interface CompiledPartial<TProps> {
351
360
  /** Partial μ •μ˜ */
352
361
  definition: PartialDefinition<TProps>;
353
362
  /** Mandu Partial 마컀 */
354
363
  __mandu_partial: true;
355
364
  /** Partial ID */
356
- __mandu_partial_id?: string;
357
- }
365
+ __mandu_partial_id?: string;
366
+ }
367
+
368
+ function normalizePartialId(id: string): string {
369
+ return id
370
+ .trim()
371
+ .replace(/[^A-Za-z0-9_-]/g, "-")
372
+ .replace(/^-+|-+$/g, "") || "partial";
373
+ }
374
+
375
+ function priorityToHydrate(priority: NonNullable<PartialDefinition<unknown>["priority"]>): string {
376
+ return priority === "immediate" ? "load" : priority;
377
+ }
358
378
 
359
379
  /**
360
380
  * Partial Island 생성
@@ -379,26 +399,56 @@ export interface CompiledPartial<TProps> {
379
399
  * }
380
400
  * ```
381
401
  */
382
- export function partial<TProps extends Record<string, unknown>>(
383
- definition: PartialDefinition<TProps>
384
- ): CompiledPartial<TProps> & {
385
- Render: React.ComponentType<TProps>;
386
- } {
402
+ export function partial<TProps extends Record<string, unknown>>(
403
+ definition: PartialDefinition<TProps>
404
+ ): CompiledPartial<TProps> & {
405
+ Render: React.ComponentType<TProps>;
406
+ } {
387
407
  if (!definition.component) {
388
408
  throw new Error("[Mandu Partial] component is required");
389
409
  }
390
410
 
391
- const compiled: CompiledPartial<TProps> = {
392
- definition,
393
- __mandu_partial: true,
394
- };
395
-
396
- // Render μ»΄ν¬λ„ŒνŠΈ 생성
397
- const React = require("react");
398
-
399
- const RenderComponent: React.FC<TProps> = (props) => {
400
- return React.createElement(definition.component, props);
401
- };
411
+ const partialId = normalizePartialId(
412
+ definition.id ||
413
+ definition.component.displayName ||
414
+ definition.component.name ||
415
+ "partial",
416
+ );
417
+ const normalizedDefinition: PartialDefinition<TProps> = {
418
+ ...definition,
419
+ id: partialId,
420
+ };
421
+
422
+ const compiled: CompiledPartial<TProps> = {
423
+ definition: normalizedDefinition,
424
+ __mandu_partial: true,
425
+ __mandu_partial_id: partialId,
426
+ };
427
+
428
+ // Render μ»΄ν¬λ„ŒνŠΈ 생성
429
+ const React = require("react");
430
+
431
+ const RenderComponent: React.FC<TProps> = (props) => {
432
+ const renderProps = Object.keys(props).length > 0
433
+ ? props
434
+ : (normalizedDefinition.initialProps ?? props);
435
+ const priority = normalizedDefinition.priority ?? "visible";
436
+ const bundleSrc = normalizedDefinition.src ?? `/.mandu/client/${partialId}.partial.js`;
437
+
438
+ return React.createElement(
439
+ "div",
440
+ {
441
+ "data-mandu-island": partialId,
442
+ "data-mandu-partial": partialId,
443
+ "data-mandu-src": bundleSrc,
444
+ "data-mandu-priority": priority,
445
+ "data-hydrate": priorityToHydrate(priority),
446
+ "data-props": serializeProps(renderProps),
447
+ style: { display: "contents" },
448
+ },
449
+ React.createElement(normalizedDefinition.component, renderProps),
450
+ );
451
+ };
402
452
 
403
453
  return Object.assign(compiled, { Render: RenderComponent });
404
454
  }
@@ -43,7 +43,7 @@ function _strictWithWarnings<T extends z.ZodRawShape>(
43
43
  */
44
44
  const ServerConfigSchema = z
45
45
  .object({
46
- port: z.number().min(1).max(65535).default(3000),
46
+ port: z.number().min(1).max(65535).default(3333),
47
47
  // Default `"::"` (IPv6 wildcard, dual-stack): accepts both IPv4 and
48
48
  // IPv6 clients on one socket. Fixes Windows Node 17+ fetch failing
49
49
  // with `ECONNREFUSED ::1:PORT` because `localhost` resolves to `::1`
@@ -78,8 +78,8 @@ export async function buildDeployInferenceContext(
78
78
  // still gets the manifest metadata and falls back to defaults.
79
79
  }
80
80
 
81
- const imports = extractImports(source);
82
- const dependencyClasses = classifyImports(imports);
81
+ const imports = extractImports(source);
82
+ const dependencyClasses = classifySourceDependencies(source, imports);
83
83
  // Mandu's manifest patterns use `:param` / `*` (path-pattern style),
84
84
  // not bracket-form. Detect both shapes so the heuristic doesn't
85
85
  // misclassify dynamic routes as prerenderable. Examples:
@@ -139,18 +139,82 @@ export function extractImports(source: string): string[] {
139
139
  }
140
140
 
141
141
  /** Map import specifiers to coarse dependency classes. */
142
- export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
143
- const classes = new Set<DependencyClass>();
144
- for (const spec of imports) {
142
+ export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
143
+ const classes = new Set<DependencyClass>();
144
+ for (const spec of imports) {
145
145
  const cls = classifyOne(spec);
146
146
  if (cls) classes.add(cls);
147
147
  }
148
148
  if (classes.size === 0) classes.add("fetch-only");
149
- return classes;
150
- }
151
-
152
- function classifyOne(spec: string): DependencyClass | null {
153
- const s = spec.toLowerCase();
149
+ return classes;
150
+ }
151
+
152
+ /**
153
+ * Classify dependency signals that do not always appear as bare imports.
154
+ *
155
+ * Dogfooding surfaced Mandu API routes importing project-local
156
+ * `src/server/infra/db` helpers and using `db` tagged templates. Those
157
+ * are server-only even when the bare imports look edge-safe.
158
+ */
159
+ export function classifySourceDependencies(
160
+ source: string,
161
+ imports: string[] = extractImports(source),
162
+ ): ReadonlySet<DependencyClass> {
163
+ const classes = new Set<DependencyClass>(classifyImports(imports));
164
+ if (classes.size === 1 && classes.has("fetch-only")) {
165
+ classes.delete("fetch-only");
166
+ }
167
+
168
+ const allImports = extractAllImportSpecifiers(source);
169
+ if (allImports.some(isServerInfraImport)) {
170
+ classes.add("db");
171
+ }
172
+
173
+ if (/\bBun\.SQL\b|\bBun\.sqlite\b/i.test(source)) {
174
+ classes.add("bun-native");
175
+ }
176
+
177
+ if (
178
+ /(?:^|[^\w$])db\s*`/.test(source) ||
179
+ /\bctx\.deps\.db\b/.test(source) ||
180
+ /\bdb\.(?:query|execute|select|insert|update|delete)\b/.test(source)
181
+ ) {
182
+ classes.add("db");
183
+ }
184
+
185
+ if (classes.size === 0) {
186
+ classes.add("fetch-only");
187
+ }
188
+ return classes;
189
+ }
190
+
191
+ function extractAllImportSpecifiers(source: string): string[] {
192
+ const out = new Set<string>();
193
+ const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
194
+ const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
195
+ for (const re of [staticImport, dynamicImport]) {
196
+ let m: RegExpExecArray | null;
197
+ while ((m = re.exec(source)) !== null) {
198
+ out.add(m[1]!);
199
+ }
200
+ }
201
+ return [...out].sort();
202
+ }
203
+
204
+ function isServerInfraImport(specifier: string): boolean {
205
+ const s = specifier.replace(/\\/g, "/").toLowerCase();
206
+ return (
207
+ s === "@/server/infra" ||
208
+ s.startsWith("@/server/infra/") ||
209
+ s === "src/server/infra" ||
210
+ s.startsWith("src/server/infra/") ||
211
+ s.endsWith("/server/infra") ||
212
+ s.includes("/server/infra/")
213
+ );
214
+ }
215
+
216
+ function classifyOne(spec: string): DependencyClass | null {
217
+ const s = spec.toLowerCase();
154
218
  if (
155
219
  s === "bun:sqlite" ||
156
220
  s === "bun:ffi" ||
@@ -167,11 +231,14 @@ function classifyOne(spec: string): DependencyClass | null {
167
231
  if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
168
232
  return "node-child";
169
233
  }
170
- if (
171
- /^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
172
- ) {
173
- return "db";
174
- }
234
+ if (
235
+ /^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
236
+ ) {
237
+ return "db";
238
+ }
239
+ if (s === "@mandujs/core/db" || s.startsWith("@mandujs/core/db/")) {
240
+ return "db";
241
+ }
175
242
  if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
176
243
  return "ai-sdk";
177
244
  }
@@ -628,10 +628,23 @@ export class ManduContext {
628
628
  return this.withCookies(new Response(null, { status: 204 }));
629
629
  }
630
630
 
631
- /** 400 Bad Request */
632
- error(message: string, details?: unknown): Response {
633
- return this.json({ status: "error", message, details }, 400);
634
- }
631
+ /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
632
+ error(message: string, details?: unknown): Response;
633
+ error(status: number, message: string, details?: unknown): Response;
634
+ error(
635
+ statusOrMessage: number | string,
636
+ messageOrDetails?: string | unknown,
637
+ maybeDetails?: unknown
638
+ ): Response {
639
+ if (typeof statusOrMessage === "number") {
640
+ const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
641
+ ? statusOrMessage
642
+ : 400;
643
+ const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
644
+ return this.json({ status: "error", message, details: maybeDetails }, status);
645
+ }
646
+ return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
647
+ }
635
648
 
636
649
  /** 401 Unauthorized */
637
650
  unauthorized(message: string = "Unauthorized"): Response {
@@ -30,21 +30,21 @@ export const GENERATED_IMPORT_DOCS_URL =
30
30
  "https://mandujs.com/docs/architect/generated-access";
31
31
 
32
32
  /**
33
- * Build the user-facing message for a detected direct `__generated__/`
34
- * import. `specifier` is the literal import string that tripped the
35
- * guard (not the resolved path).
33
+ * Build the user-facing message for a detected direct generated-artifact
34
+ * import. `specifier` is the literal import string that tripped the guard
35
+ * (not the resolved path).
36
36
  *
37
37
  * This helper is the single source of truth for the message text β€” both
38
38
  * the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
39
39
  * plugin (`blockGeneratedImports`) call through it so the two paths
40
40
  * cannot drift.
41
41
  */
42
- export function buildForbiddenGeneratedImportMessage(specifier: string): string {
43
- return (
44
- `Direct __generated__/ imports are forbidden: ${specifier}. ` +
45
- `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
46
- );
47
- }
42
+ export function buildForbiddenGeneratedImportMessage(specifier: string): string {
43
+ return (
44
+ `Direct generated artifact imports are forbidden: ${specifier}. ` +
45
+ `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
46
+ );
47
+ }
48
48
 
49
49
  /**
50
50
  * Shared remediation hint. Points at `getGenerated()` from
@@ -187,13 +187,19 @@ export function formatConfigGuardResult(result: ConfigGuardResult): string {
187
187
  } else if (!result.lockfileExists) {
188
188
  lines.push("πŸ’‘ Lockfile μ—†μŒ");
189
189
  lines.push(" 'mandu lock'으둜 생성 ꢌμž₯");
190
- } else {
191
- lines.push("❌ μ„€μ • 무결성 검증 μ‹€νŒ¨");
192
-
193
- for (const error of result.errors) {
194
- lines.push(` πŸ”΄ ${error.message}`);
195
- }
196
- }
190
+ } else {
191
+ lines.push("❌ μ„€μ • 무결성 검증 μ‹€νŒ¨");
192
+
193
+ for (const error of result.errors) {
194
+ lines.push(` πŸ”΄ ${error.message}`);
195
+ }
196
+
197
+ lines.push("");
198
+ lines.push(" λ‹€μŒ 단계:");
199
+ lines.push(" ↳ λ³€κ²½ 확인: mandu lock --diff");
200
+ lines.push(" ↳ μ˜λ„ν•œ 변경이면: mandu lock");
201
+ lines.push(" ↳ νŒ¨ν‚€μ§€ μ—…λ°μ΄νŠΈ 직후라면 μƒˆ Mandu κΈ°λ³Έκ°’μœΌλ‘œ lockfile을 κ°±μ‹ ν•˜μ„Έμš”.");
202
+ }
197
203
 
198
204
  if (result.warnings.length > 0 && result.lockfileExists) {
199
205
  lines.push("");
@@ -0,0 +1,51 @@
1
+ import type { FSRoutesGuardConfig } from "./types";
2
+
3
+ export const DEFAULT_FS_ROUTES_GUARD_POLICY: FSRoutesGuardConfig = {
4
+ noPageToPage: true,
5
+ pageCanImport: [
6
+ "client/pages",
7
+ "client/widgets",
8
+ "client/features",
9
+ "client/entities",
10
+ "client/shared",
11
+ "shared/contracts",
12
+ "shared/types",
13
+ "shared/utils/client",
14
+ ],
15
+ layoutCanImport: [
16
+ "client/app",
17
+ "client/widgets",
18
+ "client/shared",
19
+ "shared/contracts",
20
+ "shared/types",
21
+ "shared/utils/client",
22
+ ],
23
+ routeCanImport: [
24
+ "server/api",
25
+ "server/application",
26
+ "server/domain",
27
+ "server/infra",
28
+ "server/core",
29
+ "shared/contracts",
30
+ "shared/schema",
31
+ "shared/types",
32
+ "shared/utils/client",
33
+ "shared/utils/server",
34
+ "shared/env",
35
+ ],
36
+ };
37
+
38
+ function clonePolicy(policy: FSRoutesGuardConfig): FSRoutesGuardConfig {
39
+ return {
40
+ noPageToPage: policy.noPageToPage,
41
+ pageCanImport: policy.pageCanImport ? [...policy.pageCanImport] : undefined,
42
+ layoutCanImport: policy.layoutCanImport ? [...policy.layoutCanImport] : undefined,
43
+ routeCanImport: policy.routeCanImport ? [...policy.routeCanImport] : undefined,
44
+ };
45
+ }
46
+
47
+ export function getDefaultFsRoutesGuardPolicy(
48
+ enabled: boolean
49
+ ): FSRoutesGuardConfig | undefined {
50
+ return enabled ? clonePolicy(DEFAULT_FS_ROUTES_GUARD_POLICY) : undefined;
51
+ }
@@ -120,12 +120,17 @@ export {
120
120
  // Architecture Guard - Watcher
121
121
  // ═══════════════════════════════════════════════════════════════════════════
122
122
 
123
- export {
124
- createGuardWatcher,
125
- checkFile,
126
- checkDirectory,
127
- clearAnalysisCache,
128
- } from "./watcher";
123
+ export {
124
+ createGuardWatcher,
125
+ checkFile,
126
+ checkDirectory,
127
+ clearAnalysisCache,
128
+ } from "./watcher";
129
+
130
+ export {
131
+ DEFAULT_FS_ROUTES_GUARD_POLICY,
132
+ getDefaultFsRoutesGuardPolicy,
133
+ } from "./fs-routes-policy";
129
134
 
130
135
  // ═══════════════════════════════════════════════════════════════════════════
131
136
  // Architecture Guard - Presets