@mandujs/core 0.54.1 → 0.54.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.54.1",
3
+ "version": "0.54.2",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -69,8 +69,11 @@ interface DomProvider {
69
69
  fromHtml(html: string, url: string): Promise<{ window: unknown; dispose: () => Promise<void> }>;
70
70
  }
71
71
 
72
- const DEFAULT_MAX_FILES = 500;
73
- const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
72
+ const DEFAULT_MAX_FILES = 500;
73
+ const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
74
+ const AXE_CORE_MODULE = "axe-core";
75
+ const JSDOM_MODULE = "jsdom";
76
+ const HAPPY_DOM_MODULE = "happy-dom";
74
77
 
75
78
  /**
76
79
  * Zero every entry in an impact-count record. Returned by value so
@@ -100,11 +103,10 @@ async function resolveAxe(options: RunAuditOptions): Promise<AxeLike | null> {
100
103
  return null;
101
104
  }
102
105
  };
103
-
104
- if (options.axeLoader) return tryLoad(options.axeLoader);
105
- // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
106
- return tryLoad(() => import("axe-core"));
107
- }
106
+
107
+ if (options.axeLoader) return tryLoad(options.axeLoader);
108
+ return tryLoad(() => import(AXE_CORE_MODULE));
109
+ }
108
110
 
109
111
  /**
110
112
  * Resolve a DOM provider. Prefers jsdom; falls back to HappyDOM via
@@ -126,10 +128,9 @@ async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider
126
128
  return null;
127
129
  }
128
130
 
129
- // Preferred path — jsdom.
130
- try {
131
- // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
132
- const jsdom = await import("jsdom");
131
+ // Preferred path — jsdom.
132
+ try {
133
+ const jsdom = await import(JSDOM_MODULE);
133
134
  const JSDOMCtor = (jsdom as { JSDOM?: new (html: string, opts?: unknown) => unknown }).JSDOM;
134
135
  if (JSDOMCtor) {
135
136
  return {
@@ -153,10 +154,9 @@ async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider
153
154
  // jsdom not installed — fall through to HappyDOM.
154
155
  }
155
156
 
156
- // Fallback path — HappyDOM.
157
- try {
158
- // @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
159
- const happy = await import("happy-dom");
157
+ // Fallback path — HappyDOM.
158
+ try {
159
+ const happy = await import(HAPPY_DOM_MODULE);
160
160
  const WindowCtor = (happy as { Window?: new (opts?: { url?: string; innerWidth?: number }) => unknown }).Window;
161
161
  if (WindowCtor) {
162
162
  return {
@@ -206,13 +206,13 @@ export function generateTemplatePatches(
206
206
  "Do NOT import or re-export the island in page.tsx — island() returns " +
207
207
  "a config object, not a React component. Use data-island attributes instead.",
208
208
  type: "modify",
209
- content:
210
- `// Example: app/my-feature.island.tsx\n` +
211
- `import { island } from "@mandujs/core/client";\n\n` +
212
- `export default island("visible", MyComponent);\n\n` +
213
- `// In page.tsx, reference via: <div data-island="my-feature">...</div>`,
214
- confidence: 0.9,
215
- });
209
+ content:
210
+ `// Example: app/my-feature.island.tsx\n` +
211
+ `import { wrapComponent } from "@mandujs/core/client";\n\n` +
212
+ `export default wrapComponent(MyComponent);\n\n` +
213
+ `// In page.tsx, reference via: <div data-island="my-feature">...</div>`,
214
+ confidence: 0.9,
215
+ });
216
216
  break;
217
217
 
218
218
  default:
@@ -1607,9 +1607,9 @@ async function buildVendorShims(
1607
1607
  }
1608
1608
  }
1609
1609
 
1610
- const buildShim = async (
1611
- shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
1612
- ): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
1610
+ const buildShim = async (
1611
+ shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
1612
+ ): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
1613
1613
  const srcPath = path.join(outDir, `${shim.name}.src.js`);
1614
1614
  const outputName = `${shim.name}.js`;
1615
1615
 
@@ -1645,14 +1645,14 @@ async function buildVendorShims(
1645
1645
 
1646
1646
  await fs.unlink(srcPath).catch(() => {});
1647
1647
 
1648
- if (!result.success) {
1649
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1650
- return {
1651
- key: shim.key,
1652
- cacheId: shim.cacheId,
1653
- error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n Hint: Check the import paths and ensure the vendor package is installed.`,
1654
- };
1655
- }
1648
+ if (!result.success) {
1649
+ const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1650
+ return {
1651
+ key: shim.key,
1652
+ cacheId: shim.cacheId,
1653
+ error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n ${vendorShimFailureHint(shim.name)}`,
1654
+ };
1655
+ }
1656
1656
 
1657
1657
  return {
1658
1658
  key: shim.key,
@@ -1662,13 +1662,13 @@ async function buildVendorShims(
1662
1662
  };
1663
1663
  } catch (error) {
1664
1664
  await fs.unlink(srcPath).catch(() => {});
1665
- return {
1666
- key: shim.key,
1667
- cacheId: shim.cacheId,
1668
- error: `[${shim.name}] ${String(error)}`,
1669
- };
1670
- }
1671
- };
1665
+ return {
1666
+ key: shim.key,
1667
+ cacheId: shim.cacheId,
1668
+ error: `[${shim.name}] ${String(error)}\n ${vendorShimFailureHint(shim.name)}`,
1669
+ };
1670
+ }
1671
+ };
1672
1672
 
1673
1673
  const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
1674
1674
  const writeEntries: VendorCacheWriteEntry[] = [];
@@ -1711,7 +1711,14 @@ async function buildVendorShims(
1711
1711
  fastRefreshRuntime: results.fastRefreshRuntime,
1712
1712
  errors,
1713
1713
  };
1714
- }
1714
+ }
1715
+
1716
+ function vendorShimFailureHint(shimName: string): string {
1717
+ if (shimName.includes("react-refresh")) {
1718
+ return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
1719
+ }
1720
+ return "Hint: check the import paths and ensure the vendor package is installed.";
1721
+ }
1715
1722
 
1716
1723
  /**
1717
1724
  * 단일 Island 번들 빌드
@@ -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(?:[\/\\]|$)/;
@@ -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
@@ -229,14 +229,17 @@ export class FileAPI {
229
229
  for (const line of stdout.split("\n")) {
230
230
  if (!line.trim()) continue;
231
231
 
232
- const statusCode = line.substring(0, 2);
233
- const gitFilePath = line.substring(3).trim();
234
- const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
235
-
236
- changes.push({
237
- filePath,
238
- status: parseGitStatus(statusCode),
239
- });
232
+ const statusCode = line.substring(0, 2);
233
+ const gitFilePath = line.substring(3).trim();
234
+ const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
235
+ if (!filePath || filePath === ".") {
236
+ continue;
237
+ }
238
+
239
+ changes.push({
240
+ filePath,
241
+ status: parseGitStatus(statusCode),
242
+ });
240
243
  }
241
244
 
242
245
  return changes;
@@ -108,15 +108,20 @@ describe("validateResourceDefinition", () => {
108
108
  });
109
109
 
110
110
  describe("getPluralName", () => {
111
- test("should add 's' for simple pluralization", () => {
112
- const result = getPluralName(userResourceFixture);
113
- expect(result).toBe("users");
114
- });
115
-
116
- test("should use custom plural name if provided", () => {
117
- const result = getPluralName(productResourceFixture);
118
- expect(result).toBe("inventory");
119
- });
111
+ test("should add 's' for simple pluralization", () => {
112
+ const result = getPluralName(userResourceFixture);
113
+ expect(result).toBe("users");
114
+ });
115
+
116
+ test("should use conservative English pluralization for common resource names", () => {
117
+ expect(getPluralName({ ...minimalResourceFixture, name: "party" })).toBe("parties");
118
+ expect(getPluralName({ ...minimalResourceFixture, name: "box" })).toBe("boxes");
119
+ });
120
+
121
+ test("should use custom plural name if provided", () => {
122
+ const result = getPluralName(productResourceFixture);
123
+ expect(result).toBe("inventory");
124
+ });
120
125
 
121
126
  test("should respect autoPlural: false", () => {
122
127
  const definition = {