@mandujs/core 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -16,6 +16,8 @@ import type {
16
16
  import { HYDRATION } from "../constants";
17
17
  import { safeBuild } from "./safe-build";
18
18
  import { fastRefreshPlugin } from "./fast-refresh-plugin";
19
+ import { defaultBundlerPlugins } from "./plugins";
20
+ import type { BunPlugin } from "bun";
19
21
  import { mark, measure } from "../perf";
20
22
  import { HMR_PERF } from "../perf/hmr-markers";
21
23
  import {
@@ -29,6 +31,24 @@ import {
29
31
  import path from "path";
30
32
  import fs from "fs/promises";
31
33
 
34
+ /**
35
+ * Resolve Mandu's default bundler plugin set from a `BundlerOptions`
36
+ * object. Currently returns the `mandu:block-generated-imports` plugin
37
+ * unless `options.blockGeneratedImport === false`. Centralised here so
38
+ * every `safeBuild(...)` call-site can compose
39
+ * `[...manduDefaultPlugins(options), ...buildLocalPlugins]` and stay in
40
+ * sync as the default set grows.
41
+ */
42
+ function manduDefaultPlugins(options: BundlerOptions): BunPlugin[] {
43
+ return defaultBundlerPlugins({
44
+ config: {
45
+ guard: {
46
+ blockGeneratedImport: options.blockGeneratedImport,
47
+ },
48
+ },
49
+ });
50
+ }
51
+
32
52
  /**
33
53
  * Scan for *.island.tsx / *.island.ts files across hydrated route directories.
34
54
  *
@@ -121,7 +141,7 @@ async function buildPerIslandBundle(
121
141
  sourcemap: options.sourcemap ? "external" : "none",
122
142
  target: "browser",
123
143
  ...(isDev ? { reactFastRefresh: true } : {}),
124
- plugins: isDev ? [fastRefreshPlugin()] : [],
144
+ plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
125
145
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
126
146
  define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
127
147
  });
@@ -1063,6 +1083,7 @@ if (typeof window !== 'undefined') {
1063
1083
  minify: false, // dev only
1064
1084
  sourcemap: options.sourcemap ? "external" : "none",
1065
1085
  target: "browser",
1086
+ plugins: manduDefaultPlugins(options),
1066
1087
  // React를 인라인 번들링 (import map 없이도 독립 동작)
1067
1088
  // DevTools는 Shadow DOM 격리 → 앱 React와 충돌 없음
1068
1089
  define: {
@@ -1117,6 +1138,7 @@ async function buildRouterRuntime(
1117
1138
  minify: options.minify ?? process.env.NODE_ENV === "production",
1118
1139
  sourcemap: options.sourcemap ? "external" : "none",
1119
1140
  target: "browser",
1141
+ plugins: manduDefaultPlugins(options),
1120
1142
  define: {
1121
1143
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
1122
1144
  ...options.define,
@@ -1193,6 +1215,7 @@ async function buildRuntime(
1193
1215
  sourcemap: options.sourcemap ? "external" : "none",
1194
1216
  target: "browser",
1195
1217
  external: ["react", "react-dom", "react-dom/client"],
1218
+ plugins: manduDefaultPlugins(options),
1196
1219
  define: {
1197
1220
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
1198
1221
  ...options.define,
@@ -1482,6 +1505,7 @@ async function buildVendorShims(
1482
1505
  sourcemap: options.sourcemap ? "external" : "none",
1483
1506
  target: "browser",
1484
1507
  external: shimExternal,
1508
+ plugins: manduDefaultPlugins(options),
1485
1509
  define: {
1486
1510
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
1487
1511
  ...options.define,
@@ -1590,7 +1614,7 @@ async function buildIsland(
1590
1614
  target: "browser",
1591
1615
  splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
1592
1616
  ...(isDev ? { reactFastRefresh: true } : {}),
1593
- plugins: isDev ? [fastRefreshPlugin()] : [],
1617
+ plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
1594
1618
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1595
1619
  define: {
1596
1620
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
@@ -7,3 +7,4 @@ export * from "./types";
7
7
  export * from "./build";
8
8
  export * from "./dev";
9
9
  export * from "./css";
10
+ export * from "./plugins";
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Regression tests for `mandu:block-generated-imports` (issue #207).
3
+ *
4
+ * Split into two sections:
5
+ * A — pure unit tests on `ForbiddenGeneratedImportError` + helpers.
6
+ * B — integration tests driving a real `Bun.build` with a synthetic
7
+ * `__generated__/` import in a tmpdir, asserting the build fails
8
+ * with the expected error text.
9
+ */
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
13
+ import { tmpdir } from "os";
14
+ import path from "path";
15
+
16
+ import {
17
+ ForbiddenGeneratedImportError,
18
+ blockGeneratedImports,
19
+ defaultAllowImporter,
20
+ DEFAULT_BLOCK_FILTER,
21
+ defaultBundlerPlugins,
22
+ } from "../index";
23
+ import { GENERATED_IMPORT_DOCS_URL } from "../../../guard/check";
24
+
25
+ // ────────────────────────────────────────────────────────────────────
26
+ // Section A — unit
27
+ // ────────────────────────────────────────────────────────────────────
28
+
29
+ describe("ForbiddenGeneratedImportError", () => {
30
+ test("captures specifier and importer fields", () => {
31
+ const err = new ForbiddenGeneratedImportError(
32
+ "./__generated__/routes",
33
+ "/repo/src/app.ts",
34
+ );
35
+ expect(err).toBeInstanceOf(Error);
36
+ expect(err).toBeInstanceOf(ForbiddenGeneratedImportError);
37
+ expect(err.specifier).toBe("./__generated__/routes");
38
+ expect(err.importer).toBe("/repo/src/app.ts");
39
+ expect(err.docsUrl).toBe(GENERATED_IMPORT_DOCS_URL);
40
+ expect(err.name).toBe("ForbiddenGeneratedImportError");
41
+ });
42
+
43
+ test("message includes specifier, importer, docs URL, and getGenerated() hint", () => {
44
+ const err = new ForbiddenGeneratedImportError(
45
+ "./__generated__/data",
46
+ "/repo/src/page.tsx",
47
+ );
48
+ expect(err.message).toContain("./__generated__/data");
49
+ expect(err.message).toContain("/repo/src/page.tsx");
50
+ expect(err.message).toContain(GENERATED_IMPORT_DOCS_URL);
51
+ expect(err.message).toContain("getGenerated");
52
+ expect(err.message).toContain('@mandujs/core/runtime');
53
+ });
54
+
55
+ test("message falls back to <unknown> importer when empty", () => {
56
+ const err = new ForbiddenGeneratedImportError("./__generated__/x", "");
57
+ expect(err.message).toContain("<unknown>");
58
+ });
59
+ });
60
+
61
+ describe("defaultAllowImporter", () => {
62
+ test("exempts packages/core/src/runtime/** paths", () => {
63
+ expect(
64
+ defaultAllowImporter("/repo/packages/core/src/runtime/registry.ts"),
65
+ ).toBe(true);
66
+ });
67
+
68
+ test("normalises Windows backslashes", () => {
69
+ expect(
70
+ defaultAllowImporter(
71
+ "C:\\repo\\packages\\core\\src\\runtime\\registry.ts",
72
+ ),
73
+ ).toBe(true);
74
+ });
75
+
76
+ test("does NOT exempt regular user code", () => {
77
+ expect(defaultAllowImporter("/repo/src/app.ts")).toBe(false);
78
+ expect(defaultAllowImporter("")).toBe(false);
79
+ });
80
+ });
81
+
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
+ });
92
+
93
+ describe("defaultBundlerPlugins", () => {
94
+ test("installs block-generated-imports by default", () => {
95
+ const plugins = defaultBundlerPlugins();
96
+ expect(plugins).toHaveLength(1);
97
+ expect(plugins[0]?.name).toBe("mandu:block-generated-imports");
98
+ });
99
+
100
+ test("respects opt-out via guard.blockGeneratedImport === false", () => {
101
+ const plugins = defaultBundlerPlugins({
102
+ config: { guard: { blockGeneratedImport: false } },
103
+ });
104
+ expect(plugins).toHaveLength(0);
105
+ });
106
+
107
+ test("treats undefined as default-on", () => {
108
+ const plugins = defaultBundlerPlugins({ config: { guard: {} } });
109
+ expect(plugins).toHaveLength(1);
110
+ });
111
+ });
112
+
113
+ // ────────────────────────────────────────────────────────────────────
114
+ // Section B — integration against real Bun.build
115
+ // ────────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Integration tests invoke real `Bun.build`. On shards where the
119
+ * Windows `onResolve` panic is flaky, set `MANDU_SKIP_BUNDLER_TESTS=1`
120
+ * to skip — matches the convention used by `fast-refresh.test.ts`.
121
+ */
122
+ const SKIP_BUNDLER = process.env.MANDU_SKIP_BUNDLER_TESTS === "1";
123
+
124
+ describe.skipIf(SKIP_BUNDLER)("blockGeneratedImports — Bun.build integration", () => {
125
+ let tmpRoot: string;
126
+
127
+ beforeAll(async () => {
128
+ tmpRoot = await mkdtemp(path.join(tmpdir(), "mandu-block-gen-"));
129
+ });
130
+
131
+ afterAll(async () => {
132
+ await rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
133
+ });
134
+
135
+ test("Bun.build fails when source imports a __generated__ file", async () => {
136
+ const dir = path.join(tmpRoot, "scenario-block");
137
+ await mkdir(path.join(dir, "__generated__"), { recursive: true });
138
+ await writeFile(
139
+ path.join(dir, "__generated__/data.ts"),
140
+ "export const routes = [];\n",
141
+ );
142
+ await writeFile(
143
+ path.join(dir, "entry.ts"),
144
+ `import { routes } from "./__generated__/data";\nconsole.log(routes);\n`,
145
+ );
146
+
147
+ // Bun's plugin host surfaces a thrown onResolve error either as an
148
+ // exception from `Bun.build(...)` OR as an unsuccessful result whose
149
+ // logs contain the message. Accept either shape so the assertion is
150
+ // robust across Bun patch releases.
151
+ // Bun's plugin host surfaces a thrown onResolve error via multiple
152
+ // channels across patch releases: an exception from `Bun.build()`
153
+ // (with `AggregateError.errors[]` on recent versions), or an
154
+ // unsuccessful `result.logs[]`. Collect from every channel and assert
155
+ // the aggregated text.
156
+ const collected: string[] = [];
157
+ try {
158
+ const result = await Bun.build({
159
+ entrypoints: [path.join(dir, "entry.ts")],
160
+ outdir: path.join(dir, "out"),
161
+ target: "browser",
162
+ plugins: [blockGeneratedImports()],
163
+ });
164
+ expect(result.success).toBe(false);
165
+ for (const log of result.logs) {
166
+ collected.push(String(log?.message ?? log));
167
+ }
168
+ } catch (err) {
169
+ if (err instanceof Error) collected.push(err.message);
170
+ const maybeAgg = err as { errors?: unknown[] };
171
+ if (Array.isArray(maybeAgg.errors)) {
172
+ for (const inner of maybeAgg.errors) {
173
+ if (inner instanceof Error) collected.push(inner.message);
174
+ else collected.push(String(inner));
175
+ }
176
+ }
177
+ }
178
+
179
+ const message = collected.join("\n");
180
+ expect(message).toContain("__generated__");
181
+ expect(message).toContain(GENERATED_IMPORT_DOCS_URL);
182
+ expect(message).toContain("getGenerated");
183
+ });
184
+
185
+ test("Bun.build succeeds when opt-out is set (no plugin installed)", async () => {
186
+ const dir = path.join(tmpRoot, "scenario-optout");
187
+ await mkdir(path.join(dir, "__generated__"), { recursive: true });
188
+ await writeFile(
189
+ path.join(dir, "__generated__/data.ts"),
190
+ "export const routes = [];\n",
191
+ );
192
+ await writeFile(
193
+ path.join(dir, "entry.ts"),
194
+ `import { routes } from "./__generated__/data";\nconsole.log(routes);\n`,
195
+ );
196
+
197
+ // Simulate the opt-out path: defaultBundlerPlugins with the flag off
198
+ // returns an empty plugin list, so the build has nothing to reject it.
199
+ const plugins = defaultBundlerPlugins({
200
+ config: { guard: { blockGeneratedImport: false } },
201
+ });
202
+
203
+ const result = await Bun.build({
204
+ entrypoints: [path.join(dir, "entry.ts")],
205
+ outdir: path.join(dir, "out"),
206
+ target: "browser",
207
+ plugins,
208
+ });
209
+
210
+ expect(result.success).toBe(true);
211
+ });
212
+
213
+ test("Bun.build succeeds when source has no __generated__ imports", async () => {
214
+ const dir = path.join(tmpRoot, "scenario-clean");
215
+ await mkdir(dir, { recursive: true });
216
+ await writeFile(
217
+ path.join(dir, "clean.ts"),
218
+ `export const value = 42;\n`,
219
+ );
220
+ await writeFile(
221
+ path.join(dir, "entry.ts"),
222
+ `import { value } from "./clean";\nconsole.log(value);\n`,
223
+ );
224
+
225
+ const result = await Bun.build({
226
+ entrypoints: [path.join(dir, "entry.ts")],
227
+ outdir: path.join(dir, "out"),
228
+ target: "browser",
229
+ plugins: [blockGeneratedImports()],
230
+ });
231
+
232
+ expect(result.success).toBe(true);
233
+ });
234
+
235
+ test("allowImporter lets whitelisted files import __generated__", async () => {
236
+ const dir = path.join(tmpRoot, "scenario-allow");
237
+ const runtimeDir = path.join(dir, "packages/core/src/runtime");
238
+ await mkdir(path.join(dir, "__generated__"), { recursive: true });
239
+ await mkdir(runtimeDir, { recursive: true });
240
+ await writeFile(
241
+ path.join(dir, "__generated__/data.ts"),
242
+ "export const routes = [];\n",
243
+ );
244
+ // Use a relative import so the filter can see `__generated__` in the
245
+ // specifier and invoke our hook — at which point the importer path
246
+ // matches the default allow-list.
247
+ await writeFile(
248
+ path.join(runtimeDir, "registry.ts"),
249
+ `import { routes } from "../../../../__generated__/data";\nexport { routes };\n`,
250
+ );
251
+
252
+ const result = await Bun.build({
253
+ entrypoints: [path.join(runtimeDir, "registry.ts")],
254
+ outdir: path.join(dir, "out"),
255
+ target: "browser",
256
+ plugins: [blockGeneratedImports()],
257
+ });
258
+
259
+ // The allow predicate lets the import fall through to Bun's default
260
+ // resolution, which succeeds because the file exists.
261
+ expect(result.success).toBe(true);
262
+ });
263
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Bun bundler plugin — hard-fail on direct `__generated__/` imports.
3
+ *
4
+ * Background
5
+ * ──────────
6
+ * The Guard rule `INVALID_GENERATED_IMPORT` (see `guard/check.ts`) already
7
+ * scans source files for literal `import … from '…generated…'` statements,
8
+ * but it only runs when the user (or CI) invokes `mandu guard check`.
9
+ * Autonomous coding agents routinely bypass that step. This plugin closes
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.
13
+ *
14
+ * Design
15
+ * ──────
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.
19
+ * - The error is `ForbiddenGeneratedImportError`, a named subclass of
20
+ * `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
21
+ * its `result.logs` output for CLI display.
22
+ * - The message is built via the shared Guard helper
23
+ * (`buildForbiddenGeneratedImportMessage`) so the bundler path and the
24
+ * static Guard pass cannot drift out of sync.
25
+ *
26
+ * Legitimate escape hatches
27
+ * ─────────────────────────
28
+ * 1. `getGenerated()` / `tryGetGenerated()` from `@mandujs/core/runtime`
29
+ * read through a global manifest slot (`__MANDU_MANIFEST__`). They do
30
+ * NOT trigger an ESM import for the generated artifact, so they never
31
+ * hit this plugin. That is the officially supported API.
32
+ * 2. `import type` statements are allowed by the rule — TS erases them
33
+ * before emit, so they never become runtime imports. However, a
34
+ * bundler `onResolve` hook cannot distinguish `import type` from a
35
+ * value import because Bun strips the `type` keyword before plugin
36
+ * dispatch. For this reason the plugin exposes an `allowImporter`
37
+ * option (defaulted to recognise `@mandujs/core/runtime` internals)
38
+ * but deliberately does NOT try to parse the source for `type`-only
39
+ * imports. User type imports go through the type-checker, not the
40
+ * bundler, so they remain unaffected in practice.
41
+ * 3. The per-project opt-out lives in `ManduConfig.guard.blockGeneratedImport
42
+ * = false`. The plugin is simply not installed when the flag is off.
43
+ */
44
+
45
+ import type { BunPlugin } from "bun";
46
+ import {
47
+ buildForbiddenGeneratedImportMessage,
48
+ FORBIDDEN_GENERATED_IMPORT_SUGGESTION,
49
+ GENERATED_IMPORT_DOCS_URL,
50
+ } from "../../guard/check";
51
+
52
+ /**
53
+ * Raised by the plugin's `onResolve` hook. Named so tests can
54
+ * `instanceof`-check, and so Bun's log output clearly attributes the
55
+ * failure to the plugin.
56
+ */
57
+ export class ForbiddenGeneratedImportError extends Error {
58
+ /** The literal `from "…"` specifier that tripped the guard. */
59
+ readonly specifier: string;
60
+ /** Absolute path of the file that issued the import (best-effort). */
61
+ readonly importer: string;
62
+ /** Docs URL that explains the official replacement. */
63
+ readonly docsUrl: string;
64
+ /** Short, one-line remediation hint. */
65
+ readonly suggestion: string;
66
+
67
+ constructor(specifier: string, importer: string) {
68
+ const message =
69
+ `${buildForbiddenGeneratedImportMessage(specifier)}\n` +
70
+ ` Importer: ${importer || "<unknown>"}\n` +
71
+ ` Replacement: import { getGenerated } from "@mandujs/core/runtime";\n` +
72
+ ` Then: const data = getGenerated(<key>);\n` +
73
+ ` Docs: ${GENERATED_IMPORT_DOCS_URL}`;
74
+ super(message);
75
+ this.name = "ForbiddenGeneratedImportError";
76
+ this.specifier = specifier;
77
+ this.importer = importer;
78
+ this.docsUrl = GENERATED_IMPORT_DOCS_URL;
79
+ this.suggestion = FORBIDDEN_GENERATED_IMPORT_SUGGESTION;
80
+ }
81
+ }
82
+
83
+ export interface BlockGeneratedImportsOptions {
84
+ /**
85
+ * Predicate that returns `true` when the importer should be exempted
86
+ * from the rule. Default exempts `@mandujs/core/runtime` (which in
87
+ * principle never imports `__generated__`, but is listed here so
88
+ * framework boot code cannot trip over itself during upgrades).
89
+ */
90
+ allowImporter?: (importerPath: string) => boolean;
91
+ /**
92
+ * 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.
95
+ */
96
+ filter?: RegExp;
97
+ }
98
+
99
+ /**
100
+ * Default exempt predicate — matches `@mandujs/core/runtime` internals.
101
+ * The runtime package reads generated artifacts via the global registry,
102
+ * so in practice it never imports `__generated__/*`. Kept as a belt-and-
103
+ * suspenders guard against self-inflicted regressions.
104
+ */
105
+ export function defaultAllowImporter(importerPath: string): boolean {
106
+ if (!importerPath) return false;
107
+ // Normalize Windows backslashes so a single check covers both platforms.
108
+ const norm = importerPath.replace(/\\/g, "/");
109
+ return (
110
+ norm.includes("/@mandujs/core/runtime/") ||
111
+ norm.includes("/packages/core/src/runtime/") ||
112
+ norm.includes("packages/core/src/runtime/")
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Build a `BunPlugin` that blocks direct `__generated__/` imports.
118
+ *
119
+ * Usage — call from `defaultBundlerPlugins(config)` (see `./index.ts`).
120
+ * Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
121
+ * that helper, so a single install point enforces the rule everywhere.
122
+ */
123
+ export function blockGeneratedImports(
124
+ options: BlockGeneratedImportsOptions = {},
125
+ ): BunPlugin {
126
+ const filter = options.filter ?? /__generated__/;
127
+ const allowImporter = options.allowImporter ?? defaultAllowImporter;
128
+
129
+ return {
130
+ name: "mandu:block-generated-imports",
131
+ setup(build) {
132
+ build.onResolve({ filter }, (args) => {
133
+ // Normalise the specifier so a Windows-style import (which would
134
+ // be exotic but technically legal in some toolchains) is still
135
+ // caught.
136
+ const specifier = args.path;
137
+ const importer = args.importer ?? "";
138
+
139
+ if (allowImporter(importer)) {
140
+ // Internal runtime code gets a pass. Return `undefined` so
141
+ // Bun resolves the path through its normal pipeline.
142
+ return undefined;
143
+ }
144
+
145
+ // Throw a structured error. Bun surfaces `error.message` in
146
+ // `BuildResult.logs` (non-success) or re-throws on an exception
147
+ // path; either way the message reaches the developer.
148
+ throw new ForbiddenGeneratedImportError(specifier, importer);
149
+ });
150
+ },
151
+ };
152
+ }
153
+
154
+ /** Exported for unit-test convenience — keep the filter text assertable. */
155
+ export const DEFAULT_BLOCK_FILTER = /__generated__/;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Bundler-plugin barrel.
3
+ *
4
+ * `defaultBundlerPlugins()` is the single choke point for the plugin
5
+ * set that Mandu installs on every `Bun.build` invocation. Adding a new
6
+ * default-on plugin means adding it here — every call-site in
7
+ * `bundler/build.ts` and `cli/src/util/bun.ts` composes the result of
8
+ * this helper with any build-specific plugins.
9
+ */
10
+
11
+ import type { BunPlugin } from "bun";
12
+ import {
13
+ blockGeneratedImports,
14
+ type BlockGeneratedImportsOptions,
15
+ } from "./block-generated-imports";
16
+
17
+ export {
18
+ blockGeneratedImports,
19
+ ForbiddenGeneratedImportError,
20
+ defaultAllowImporter,
21
+ DEFAULT_BLOCK_FILTER,
22
+ type BlockGeneratedImportsOptions,
23
+ } from "./block-generated-imports";
24
+
25
+ /**
26
+ * Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
27
+ * We deliberately don't import the full `ManduConfig` type to keep the
28
+ * plugins module cycle-free.
29
+ */
30
+ export interface DefaultBundlerPluginsConfig {
31
+ guard?: {
32
+ blockGeneratedImport?: boolean;
33
+ };
34
+ }
35
+
36
+ export interface DefaultBundlerPluginsOptions {
37
+ /** Mandu config (only `guard.blockGeneratedImport` is consulted). */
38
+ config?: DefaultBundlerPluginsConfig;
39
+ /** Override options for the block-generated-imports plugin. */
40
+ blockGeneratedImports?: BlockGeneratedImportsOptions;
41
+ }
42
+
43
+ /**
44
+ * Compose Mandu's default plugin list. Current contents:
45
+ *
46
+ * - `mandu:block-generated-imports` — hard-fail on direct
47
+ * `__generated__/` imports. Opt-out via
48
+ * `config.guard.blockGeneratedImport = false`.
49
+ *
50
+ * Always returns a fresh array; callers are free to concat build-local
51
+ * plugins (e.g. `fastRefreshPlugin()` in dev) without mutating the
52
+ * default set.
53
+ */
54
+ export function defaultBundlerPlugins(
55
+ options: DefaultBundlerPluginsOptions = {},
56
+ ): BunPlugin[] {
57
+ const plugins: BunPlugin[] = [];
58
+ const blockEnabled = options.config?.guard?.blockGeneratedImport !== false;
59
+ if (blockEnabled) {
60
+ plugins.push(blockGeneratedImports(options.blockGeneratedImports));
61
+ }
62
+ return plugins;
63
+ }
@@ -156,4 +156,12 @@ export interface BundlerOptions {
156
156
  * - 안전성: 기존 `.mandu/manifest.json` 이 없으면 자동으로 full build로 fallback.
157
157
  */
158
158
  skipFrameworkBundles?: boolean;
159
+ /**
160
+ * Issue #207 — opt-out for the `mandu:block-generated-imports` bundler
161
+ * plugin. Default `true` (plugin installed on every build). Set
162
+ * `false` to skip installation — mirrors
163
+ * `ManduConfig.guard.blockGeneratedImport`. CLI callers pass the
164
+ * resolved config flag straight through.
165
+ */
166
+ blockGeneratedImport?: boolean;
159
167
  }
@@ -147,6 +147,22 @@ export interface ManduConfig {
147
147
  realtime?: boolean;
148
148
  rules?: Record<string, GuardRuleSeverity>;
149
149
  contractRequired?: GuardRuleSeverity;
150
+ /**
151
+ * Issue #207 — hard-fail on direct `__generated__/` imports at the
152
+ * bundler level. When `true` (default), every `mandu dev` /
153
+ * `mandu build` pass installs the
154
+ * `mandu:block-generated-imports` Bun plugin, which throws
155
+ * `ForbiddenGeneratedImportError` the moment any source file
156
+ * resolves an import whose specifier contains `__generated__`.
157
+ *
158
+ * Set to `false` only when a migration path literally requires
159
+ * the legacy barrel re-export pattern. User code should always
160
+ * prefer `getGenerated()` from `@mandujs/core/runtime`; see
161
+ * https://mandujs.com/docs/architect/generated-access.
162
+ *
163
+ * Default: `true`.
164
+ */
165
+ blockGeneratedImport?: boolean;
150
166
  };
151
167
  build?: {
152
168
  outDir?: string;
@@ -80,6 +80,12 @@ const GuardConfigSchema = z
80
80
  exclude: z.array(z.string()).default([]),
81
81
  realtime: z.boolean().default(true),
82
82
  rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
83
+ /**
84
+ * Issue #207 — bundler-level hard-fail on direct `__generated__/`
85
+ * imports. Default `true`. Set `false` to opt out of the
86
+ * `mandu:block-generated-imports` Bun plugin.
87
+ */
88
+ blockGeneratedImport: z.boolean().default(true),
83
89
  })
84
90
  .strict();
85
91
 
@@ -12,6 +12,40 @@ export interface GuardCheckResult {
12
12
  violations: GuardViolation[];
13
13
  }
14
14
 
15
+ /**
16
+ * Canonical docs URL for the `getGenerated()` runtime-registry pattern.
17
+ * Shared by the Guard `INVALID_GENERATED_IMPORT` rule and the bundler
18
+ * plugin `block-generated-imports` so both paths surface the same
19
+ * remediation target.
20
+ */
21
+ export const GENERATED_IMPORT_DOCS_URL =
22
+ "https://mandujs.com/docs/architect/generated-access";
23
+
24
+ /**
25
+ * Build the user-facing message for a detected direct `__generated__/`
26
+ * import. `specifier` is the literal import string that tripped the
27
+ * guard (not the resolved path).
28
+ *
29
+ * This helper is the single source of truth for the message text — both
30
+ * the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
31
+ * plugin (`blockGeneratedImports`) call through it so the two paths
32
+ * cannot drift.
33
+ */
34
+ export function buildForbiddenGeneratedImportMessage(specifier: string): string {
35
+ return (
36
+ `Direct __generated__/ imports are forbidden: ${specifier}. ` +
37
+ `Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Shared remediation hint. Points at `getGenerated()` from
43
+ * `@mandujs/core/runtime` and the decision-tree docs.
44
+ */
45
+ export const FORBIDDEN_GENERATED_IMPORT_SUGGESTION =
46
+ "Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " +
47
+ `See ${GENERATED_IMPORT_DOCS_URL} for the decision tree.`;
48
+
15
49
  function normalizeSeverity(level: GuardRuleSeverity): "error" | "warning" | "off" {
16
50
  if (level === "warn") return "warning";
17
51
  return level;
@@ -119,12 +153,8 @@ export async function checkInvalidGeneratedImport(
119
153
  violations.push({
120
154
  ruleId: GUARD_RULES.INVALID_GENERATED_IMPORT.id,
121
155
  file: relativePath,
122
- message:
123
- `Direct __generated__/ imports are forbidden: ${match[1]}. ` +
124
- `Use the runtime registry: see https://mandujs.com/docs/architect/generated-access`,
125
- suggestion:
126
- "Import getGenerated() from @mandujs/core/runtime and read the generated artifact through the manifest. " +
127
- "See https://mandujs.com/docs/architect/generated-access for the decision tree.",
156
+ message: buildForbiddenGeneratedImportMessage(match[1]),
157
+ suggestion: FORBIDDEN_GENERATED_IMPORT_SUGGESTION,
128
158
  });
129
159
  }
130
160
  }