@mandujs/core 0.31.0 → 0.33.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.
Files changed (41) hide show
  1. package/package.json +7 -1
  2. package/src/bundler/build.ts +29 -1
  3. package/src/bundler/generate-static-params.ts +302 -290
  4. package/src/bundler/prerender.ts +446 -368
  5. package/src/bundler/types.ts +20 -0
  6. package/src/config/mandu.ts +58 -1
  7. package/src/config/validate.ts +119 -1
  8. package/src/diagnose/__tests__/checks.test.ts +378 -0
  9. package/src/diagnose/checks.ts +599 -0
  10. package/src/diagnose/index.ts +15 -0
  11. package/src/diagnose/run.ts +87 -0
  12. package/src/diagnose/types.ts +53 -0
  13. package/src/filling/context.ts +60 -0
  14. package/src/guard/check.ts +225 -1
  15. package/src/guard/define-rule.ts +243 -0
  16. package/src/guard/graph.ts +898 -0
  17. package/src/guard/index.ts +40 -0
  18. package/src/guard/rule-presets.ts +379 -0
  19. package/src/i18n/define.ts +126 -0
  20. package/src/i18n/index.ts +52 -0
  21. package/src/i18n/locale-resolver.ts +214 -0
  22. package/src/i18n/message-registry.ts +173 -0
  23. package/src/i18n/types.ts +112 -0
  24. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
  25. package/src/plugins/__tests__/runner.test.ts +409 -0
  26. package/src/plugins/define.ts +124 -0
  27. package/src/plugins/examples/dep-check-plugin.ts +80 -0
  28. package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
  29. package/src/plugins/examples/sitemap-plugin.ts +65 -0
  30. package/src/plugins/hooks.ts +297 -64
  31. package/src/plugins/index.ts +80 -41
  32. package/src/plugins/runner.ts +361 -0
  33. package/src/router/fs-routes.ts +64 -1
  34. package/src/router/fs-scanner.ts +101 -0
  35. package/src/router/index.ts +7 -1
  36. package/src/runtime/server.ts +409 -10
  37. package/src/runtime/ssr.ts +9 -0
  38. package/src/spec/schema.ts +25 -0
  39. package/src/testing/__tests__/reporter.test.ts +454 -0
  40. package/src/testing/index.ts +29 -0
  41. package/src/testing/reporter.ts +676 -0
@@ -164,4 +164,24 @@ export interface BundlerOptions {
164
164
  * resolved config flag straight through.
165
165
  */
166
166
  blockGeneratedImport?: boolean;
167
+
168
+ /**
169
+ * Phase 18.τ — consumer-supplied `BunPlugin`s contributed via plugin
170
+ * `defineBundlerPlugin()` hook. Composed AFTER Mandu's defaults so
171
+ * user transforms see already-resolved imports. Omitted / empty
172
+ * array is a zero-overhead passthrough.
173
+ *
174
+ * Resolved by the CLI (`cli/commands/build.ts`, `cli/commands/dev.ts`)
175
+ * via `runDefineBundlerPlugin()` and fed into every `safeBuild(...)`
176
+ * call-site; library users may populate it directly.
177
+ */
178
+ pluginBundlerPlugins?: readonly import("bun").BunPlugin[];
179
+
180
+ /**
181
+ * Phase 18.τ — fire per-build `onBundleComplete(stats)` hook after
182
+ * `buildClientBundles()` finishes. Paired with `configHooks` for
183
+ * config-level hooks. Zero-overhead when both are omitted.
184
+ */
185
+ plugins?: readonly import("../plugins/hooks").ManduPlugin[];
186
+ configHooks?: Partial<import("../plugins/hooks").ManduHooks>;
167
187
  }
@@ -5,6 +5,8 @@ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
5
5
  import type { Middleware } from "../middleware/define";
6
6
  import type { RpcDefinition, RpcProcedureRecord } from "../contract/rpc";
7
7
  import type { CronDef } from "../scheduler";
8
+ import type { GuardRule as CustomGuardRule } from "../guard/define-rule";
9
+ import type { I18nStrategy, LocaleCode } from "../i18n/types";
8
10
 
9
11
  export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
10
12
 
@@ -148,7 +150,20 @@ export interface ManduConfig {
148
150
  srcDir?: string;
149
151
  exclude?: string[];
150
152
  realtime?: boolean;
151
- rules?: Record<string, GuardRuleSeverity>;
153
+ /**
154
+ * Built-in rule severity overrides (map) OR consumer-defined
155
+ * custom rules (array). The Guard runner dispatches on shape:
156
+ *
157
+ * - `Record<string, GuardRuleSeverity>` → override severity of
158
+ * Mandu's built-in rules by id.
159
+ * - `GuardRule[]` (Phase 18.ν) → register consumer-defined rules
160
+ * alongside the built-in presets. See `@mandujs/core/guard/define-rule`.
161
+ *
162
+ * Passing both at once is not supported; pick one shape per config.
163
+ * Mixed input falls back to "custom rules only" and the built-in
164
+ * rule severity overrides become unreachable.
165
+ */
166
+ rules?: Record<string, GuardRuleSeverity> | CustomGuardRule[];
152
167
  contractRequired?: GuardRuleSeverity;
153
168
  /**
154
169
  * Issue #207 — hard-fail on direct `__generated__/` imports at the
@@ -422,6 +437,48 @@ export interface ManduConfig {
422
437
  jobs?: CronDef[];
423
438
  disabled?: boolean;
424
439
  };
440
+ /**
441
+ * Phase 18.μ — first-class internationalization.
442
+ *
443
+ * Declaring this block opts the project into the framework's built-in
444
+ * locale resolution + route synthesis. The CLI (`mandu build` /
445
+ * `mandu dev`) materializes per-locale route variants when
446
+ * `strategy === "path-prefix"` so a single `app/docs/page.tsx`
447
+ * serves `/en/docs`, `/ko/docs`, etc. without file duplication.
448
+ *
449
+ * At runtime, the server dispatcher attaches `ctx.locale`
450
+ * ({@link ResolvedLocale}) + `ctx.t` (typed translator, when a
451
+ * message registry is wired) to every loader and stamps
452
+ * `Vary: Accept-Language` on responses so CDNs cache correctly.
453
+ *
454
+ * Coexists with the legacy `app/[lang]/...` manual pattern — users
455
+ * migrate only when ready. See `docs/architect/i18n.md`.
456
+ *
457
+ * @example
458
+ * ```ts
459
+ * export default {
460
+ * i18n: {
461
+ * locales: ['en', 'ko'],
462
+ * defaultLocale: 'en',
463
+ * strategy: 'path-prefix',
464
+ * },
465
+ * } satisfies ManduConfig;
466
+ * ```
467
+ */
468
+ i18n?: {
469
+ /** Non-empty list of supported locale codes. */
470
+ locales: LocaleCode[];
471
+ /** Fallback when no signal matches. MUST be in `locales`. */
472
+ defaultLocale: LocaleCode;
473
+ /** Optional fallback chain between request locale and defaultLocale. */
474
+ fallback?: LocaleCode;
475
+ /** Locale detection strategy. See {@link I18nStrategy}. */
476
+ strategy: I18nStrategy;
477
+ /** Cookie name (default: "mandu_locale"). */
478
+ cookieName?: string;
479
+ /** Domain → locale map; required when strategy === "domain". */
480
+ domains?: Record<string, LocaleCode>;
481
+ };
425
482
  }
426
483
 
427
484
  export const CONFIG_FILES = [
@@ -7,6 +7,7 @@ import type { ManduAdapter } from "../runtime/adapter";
7
7
  import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
8
8
  import type { Middleware } from "../middleware/define";
9
9
  import type { CronDef } from "../scheduler";
10
+ import { isGuardRuleLike, type GuardRule as CustomGuardRule } from "../guard/define-rule";
10
11
 
11
12
  /**
12
13
  * DNA-003: Strict mode schema helper
@@ -72,8 +73,30 @@ const ServerConfigSchema = z
72
73
  })
73
74
  .strict();
74
75
 
76
+ /**
77
+ * Phase 18.ν — consumer-defined Guard rule (structural check).
78
+ *
79
+ * Rule objects carry closures (`check`) that Zod cannot introspect, so
80
+ * we validate structurally: must be a non-null object with a non-empty
81
+ * `id` string and a `check` function. Deeper validation (severity enum,
82
+ * description type) happens at `defineGuardRule()` time for the clearest
83
+ * DX error; the Guard runner catches any in-band violations.
84
+ */
85
+ const CustomGuardRuleSchema = z.custom<CustomGuardRule>(
86
+ (v) => isGuardRuleLike(v),
87
+ {
88
+ message:
89
+ "Each custom guard rule must be an object with a non-empty `id` string and a `check` function. Use `defineGuardRule({...})` from `@mandujs/core/guard/define-rule` to construct.",
90
+ }
91
+ );
92
+
75
93
  /**
76
94
  * Guard 설정 스키마 (strict)
95
+ *
96
+ * `guard.rules` is a discriminated union of two shapes:
97
+ * - `Record<string, GuardRuleSeverity>` → built-in rule severity overrides.
98
+ * - `GuardRule[]` (Phase 18.ν) → consumer-defined custom rules.
99
+ * The runner dispatches on `Array.isArray()`.
77
100
  */
78
101
  const GuardConfigSchema = z
79
102
  .object({
@@ -81,7 +104,12 @@ const GuardConfigSchema = z
81
104
  srcDir: z.string().default("src"),
82
105
  exclude: z.array(z.string()).default([]),
83
106
  realtime: z.boolean().default(true),
84
- rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
107
+ rules: z
108
+ .union([
109
+ z.record(z.enum(["error", "warn", "warning", "off"])),
110
+ z.array(CustomGuardRuleSchema),
111
+ ])
112
+ .optional(),
85
113
  /**
86
114
  * Issue #207 — bundler-level hard-fail on direct `__generated__/`
87
115
  * imports. Default `true`. Set `false` to opt out of the
@@ -214,10 +242,32 @@ const TestE2EConfigSchema = z
214
242
  })
215
243
  .strict();
216
244
 
245
+ /**
246
+ * Per-metric threshold sub-block (Phase 18.σ).
247
+ *
248
+ * All four metrics (lines / branches / functions / statements) are
249
+ * independently optional. Omitting the whole sub-block keeps the
250
+ * legacy behavior — the CLI only enforces thresholds for metrics
251
+ * that are explicitly set.
252
+ */
253
+ const TestCoverageThresholdsSchema = z
254
+ .object({
255
+ lines: z.number().min(0).max(100).optional(),
256
+ branches: z.number().min(0).max(100).optional(),
257
+ functions: z.number().min(0).max(100).optional(),
258
+ statements: z.number().min(0).max(100).optional(),
259
+ })
260
+ .strict();
261
+
217
262
  const TestCoverageConfigSchema = z
218
263
  .object({
264
+ // Legacy top-level shorthand (Phase 12.3). When set, the CLI
265
+ // mirrors it into `thresholds.lines` so users who already had
266
+ // `coverage.lines: 80` in their config keep working unchanged.
219
267
  lines: z.number().min(0).max(100).optional(),
220
268
  branches: z.number().min(0).max(100).optional(),
269
+ // Phase 18.σ — preferred per-metric threshold block.
270
+ thresholds: TestCoverageThresholdsSchema.optional(),
221
271
  })
222
272
  .strict();
223
273
 
@@ -377,6 +427,69 @@ const SchedulerConfigSchema = z
377
427
  })
378
428
  .strict();
379
429
 
430
+ /**
431
+ * Phase 18.μ — i18n config schema (strict).
432
+ *
433
+ * Strictly validates shape + cross-field invariants that `defineI18n()`
434
+ * enforces at runtime (defaultLocale ∈ locales, fallback ∈ locales,
435
+ * domain map required when strategy === "domain"). Doing this at
436
+ * config load gives users a clean `mandu validate` error instead of
437
+ * a runtime boot failure.
438
+ */
439
+ const I18nConfigSchema = z
440
+ .object({
441
+ locales: z.array(z.string().min(1)).min(1),
442
+ defaultLocale: z.string().min(1),
443
+ fallback: z.string().min(1).optional(),
444
+ strategy: z.enum(["path-prefix", "domain", "header", "cookie"]),
445
+ cookieName: z.string().min(1).optional(),
446
+ domains: z.record(z.string().min(1)).optional(),
447
+ })
448
+ .strict()
449
+ .superRefine((value, ctx) => {
450
+ const locales = new Set(value.locales);
451
+ if (locales.size !== value.locales.length) {
452
+ ctx.addIssue({
453
+ code: z.ZodIssueCode.custom,
454
+ path: ["locales"],
455
+ message: "locales must not contain duplicates",
456
+ });
457
+ }
458
+ if (!locales.has(value.defaultLocale)) {
459
+ ctx.addIssue({
460
+ code: z.ZodIssueCode.custom,
461
+ path: ["defaultLocale"],
462
+ message: `defaultLocale "${value.defaultLocale}" must be one of locales`,
463
+ });
464
+ }
465
+ if (value.fallback !== undefined && !locales.has(value.fallback)) {
466
+ ctx.addIssue({
467
+ code: z.ZodIssueCode.custom,
468
+ path: ["fallback"],
469
+ message: `fallback "${value.fallback}" must be one of locales`,
470
+ });
471
+ }
472
+ if (value.strategy === "domain") {
473
+ if (!value.domains || Object.keys(value.domains).length === 0) {
474
+ ctx.addIssue({
475
+ code: z.ZodIssueCode.custom,
476
+ path: ["domains"],
477
+ message: "strategy 'domain' requires a non-empty domains map",
478
+ });
479
+ } else {
480
+ for (const [host, locale] of Object.entries(value.domains)) {
481
+ if (!locales.has(locale)) {
482
+ ctx.addIssue({
483
+ code: z.ZodIssueCode.custom,
484
+ path: ["domains", host],
485
+ message: `domains["${host}"] = "${locale}" is not in locales`,
486
+ });
487
+ }
488
+ }
489
+ }
490
+ }
491
+ });
492
+
380
493
  export const ManduConfigSchema = z
381
494
  .object({
382
495
  adapter: AdapterConfigSchema.optional(),
@@ -423,6 +536,11 @@ export const ManduConfigSchema = z
423
536
  * passthrough).
424
537
  */
425
538
  scheduler: SchedulerConfigSchema.optional(),
539
+ /**
540
+ * Phase 18.μ — first-class i18n config. See {@link I18nConfigSchema}.
541
+ * Optional; omission leaves i18n disabled with zero runtime overhead.
542
+ */
543
+ i18n: I18nConfigSchema.optional(),
426
544
  })
427
545
  .strict();
428
546
 
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Tests for the Issue #215 extended diagnose checks.
3
+ *
4
+ * Every test uses an isolated `mkdtemp` fixture root so we never touch
5
+ * the real project tree. Checks are pure I/O functions; seeding the
6
+ * fixture directly (no build pipeline required) keeps these tests fast
7
+ * and deterministic.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
11
+ import fs from "fs/promises";
12
+ import path from "path";
13
+ import os from "os";
14
+ import {
15
+ checkManifestFreshness,
16
+ checkPrerenderPollution,
17
+ checkCloneElementWarnings,
18
+ checkDevArtifactsInProd,
19
+ checkPackageExportGaps,
20
+ } from "../checks";
21
+ import { runExtendedDiagnose, buildReport } from "../run";
22
+
23
+ async function mkTmpRoot(): Promise<string> {
24
+ return fs.mkdtemp(path.join(os.tmpdir(), "mandu-diagnose-"));
25
+ }
26
+
27
+ async function writeFile(rootDir: string, rel: string, content: string): Promise<void> {
28
+ const abs = path.join(rootDir, rel);
29
+ await fs.mkdir(path.dirname(abs), { recursive: true });
30
+ await fs.writeFile(abs, content, "utf-8");
31
+ }
32
+
33
+ // ──────────────────────────────────────────────────────────────────
34
+ // manifest_freshness
35
+ // ──────────────────────────────────────────────────────────────────
36
+
37
+ describe("checkManifestFreshness", () => {
38
+ let rootDir: string;
39
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
40
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
41
+
42
+ it("returns error when .mandu/manifest.json is missing", async () => {
43
+ const result = await checkManifestFreshness(rootDir);
44
+ expect(result.ok).toBe(false);
45
+ expect(result.rule).toBe("manifest_freshness");
46
+ expect(result.severity).toBe("error");
47
+ expect(result.message).toMatch(/missing/);
48
+ expect(result.suggestion).toMatch(/mandu build/);
49
+ });
50
+
51
+ it("returns error when env=development (dev manifest shipped to prod)", async () => {
52
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
53
+ version: 1,
54
+ buildTime: "2026-01-01T00:00:00.000Z",
55
+ env: "development",
56
+ bundles: { "page-home": { js: "/x.js", dependencies: [], priority: "immediate" } },
57
+ shared: { runtime: "", vendor: "" },
58
+ }));
59
+ const result = await checkManifestFreshness(rootDir);
60
+ expect(result.ok).toBe(false);
61
+ expect(result.severity).toBe("error");
62
+ expect(result.message).toMatch(/dev-mode/);
63
+ expect(result.details?.env).toBe("development");
64
+ });
65
+
66
+ it("returns ok for production manifest with populated bundles", async () => {
67
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
68
+ version: 1, buildTime: "2026-01-01T00:00:00.000Z", env: "production",
69
+ bundles: { "page-home": { js: "/x.js", dependencies: [], priority: "immediate" } },
70
+ shared: { runtime: "/r.js", vendor: "/v.js" },
71
+ }));
72
+ const result = await checkManifestFreshness(rootDir);
73
+ expect(result.ok).toBe(true);
74
+ expect(result.rule).toBe("manifest_freshness");
75
+ });
76
+
77
+ it("returns warning when production but islands present with no bundles", async () => {
78
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
79
+ version: 1, buildTime: "x", env: "production",
80
+ bundles: {},
81
+ islands: { "Foo": { js: "/f.js", route: "/x", priority: "visible" } },
82
+ shared: { runtime: "", vendor: "" },
83
+ }));
84
+ const result = await checkManifestFreshness(rootDir);
85
+ expect(result.ok).toBe(false);
86
+ expect(result.severity).toBe("warning");
87
+ expect(result.message).toMatch(/0 route bundles/);
88
+ });
89
+
90
+ it("returns ok for production with empty bundles AND empty islands (pure-SSR)", async () => {
91
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
92
+ version: 1, buildTime: "x", env: "production",
93
+ bundles: {}, shared: { runtime: "", vendor: "" },
94
+ }));
95
+ const result = await checkManifestFreshness(rootDir);
96
+ expect(result.ok).toBe(true);
97
+ });
98
+
99
+ it("returns error on corrupted JSON", async () => {
100
+ await writeFile(rootDir, ".mandu/manifest.json", "{ not json }");
101
+ const result = await checkManifestFreshness(rootDir);
102
+ expect(result.ok).toBe(false);
103
+ expect(result.severity).toBe("error");
104
+ expect(result.message).toMatch(/corrupted/);
105
+ });
106
+ });
107
+
108
+ // ──────────────────────────────────────────────────────────────────
109
+ // prerender_pollution
110
+ // ──────────────────────────────────────────────────────────────────
111
+
112
+ describe("checkPrerenderPollution", () => {
113
+ let rootDir: string;
114
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
115
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
116
+
117
+ it("returns ok when no prerendered output exists", async () => {
118
+ const result = await checkPrerenderPollution(rootDir);
119
+ expect(result.ok).toBe(true);
120
+ });
121
+
122
+ it("returns ok for clean route shapes", async () => {
123
+ await writeFile(rootDir, ".mandu/prerendered/index.html", "<html></html>");
124
+ await writeFile(rootDir, ".mandu/prerendered/blog/hello/index.html", "<html></html>");
125
+ await writeFile(rootDir, ".mandu/prerendered/docs/getting-started/index.html", "<html></html>");
126
+ const result = await checkPrerenderPollution(rootDir);
127
+ expect(result.ok).toBe(true);
128
+ expect(result.details?.scanned).toBe(3);
129
+ });
130
+
131
+ it("flags a literal 'path' placeholder route (#213)", async () => {
132
+ await writeFile(rootDir, ".mandu/prerendered/path/index.html", "<html></html>");
133
+ const result = await checkPrerenderPollution(rootDir);
134
+ expect(result.ok).toBe(false);
135
+ expect(result.severity).toBe("warning");
136
+ expect(result.message).toMatch(/placeholder/);
137
+ });
138
+
139
+ it("flags routes containing '...' inside a segment", async () => {
140
+ // We can't create a directory literally named "..." on Windows
141
+ // (trailing dots are stripped), so we use a segment that embeds
142
+ // "..." in the middle — the classifier still catches it.
143
+ await writeFile(rootDir, ".mandu/prerendered/blog/a...b/index.html", "<html></html>");
144
+ const result = await checkPrerenderPollution(rootDir);
145
+ expect(result.ok).toBe(false);
146
+ expect(result.severity).toBe("warning");
147
+ });
148
+
149
+ it("flags uppercase-starting segments as suspicious", async () => {
150
+ await writeFile(rootDir, ".mandu/prerendered/Foo/index.html", "<html></html>");
151
+ const result = await checkPrerenderPollution(rootDir);
152
+ expect(result.ok).toBe(false);
153
+ });
154
+
155
+ it("scans legacy .mandu/static/ location too", async () => {
156
+ await writeFile(rootDir, ".mandu/static/path/index.html", "<html></html>");
157
+ const result = await checkPrerenderPollution(rootDir);
158
+ expect(result.ok).toBe(false);
159
+ expect(result.severity).toBe("warning");
160
+ });
161
+ });
162
+
163
+ // ──────────────────────────────────────────────────────────────────
164
+ // cloneelement_warnings
165
+ // ──────────────────────────────────────────────────────────────────
166
+
167
+ describe("checkCloneElementWarnings", () => {
168
+ let rootDir: string;
169
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
170
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
171
+
172
+ it("returns ok when no build log exists", async () => {
173
+ const result = await checkCloneElementWarnings(rootDir);
174
+ expect(result.ok).toBe(true);
175
+ });
176
+
177
+ it("returns ok when log has no key warnings", async () => {
178
+ await writeFile(rootDir, ".mandu/build.log", "Build succeeded\nNo warnings\n");
179
+ const result = await checkCloneElementWarnings(rootDir);
180
+ expect(result.ok).toBe(true);
181
+ });
182
+
183
+ it("returns info severity for 1-10 warnings", async () => {
184
+ const warn = 'Warning: Each child in a list should have a unique "key" prop.\n';
185
+ await writeFile(rootDir, ".mandu/build.log", warn.repeat(5));
186
+ const result = await checkCloneElementWarnings(rootDir);
187
+ expect(result.ok).toBe(false);
188
+ expect(result.severity).toBe("info");
189
+ expect(result.details?.count).toBe(5);
190
+ });
191
+
192
+ it("returns warning severity for >10 warnings (#212)", async () => {
193
+ const warn = 'Warning: Each child in a list should have a unique "key" prop.\n';
194
+ await writeFile(rootDir, ".mandu/build.log", warn.repeat(25));
195
+ const result = await checkCloneElementWarnings(rootDir);
196
+ expect(result.ok).toBe(false);
197
+ expect(result.severity).toBe("warning");
198
+ expect(result.details?.count).toBe(25);
199
+ expect(result.suggestion).toMatch(/0\.32\.0/);
200
+ });
201
+
202
+ it("falls back to dev-server.stderr.log when build.log is absent", async () => {
203
+ const warn = 'Each child in a list should have a unique "key" prop\n';
204
+ await writeFile(rootDir, ".mandu/dev-server.stderr.log", warn.repeat(12));
205
+ const result = await checkCloneElementWarnings(rootDir);
206
+ expect(result.ok).toBe(false);
207
+ expect(result.details?.logPath).toMatch(/dev-server\.stderr\.log$/);
208
+ });
209
+ });
210
+
211
+ // ──────────────────────────────────────────────────────────────────
212
+ // dev_artifacts_in_prod
213
+ // ──────────────────────────────────────────────────────────────────
214
+
215
+ describe("checkDevArtifactsInProd", () => {
216
+ let rootDir: string;
217
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
218
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
219
+
220
+ it("returns ok when no _devtools.js artifact exists", async () => {
221
+ const result = await checkDevArtifactsInProd(rootDir);
222
+ expect(result.ok).toBe(true);
223
+ });
224
+
225
+ it("flags _devtools.js in production manifest", async () => {
226
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
227
+ version: 1, buildTime: "x", env: "production",
228
+ bundles: {}, shared: { runtime: "", vendor: "" },
229
+ }));
230
+ await writeFile(rootDir, ".mandu/client/_devtools.js", "console.log('devtools');");
231
+ const result = await checkDevArtifactsInProd(rootDir);
232
+ expect(result.ok).toBe(false);
233
+ expect(result.severity).toBe("error");
234
+ expect(result.message).toMatch(/production/);
235
+ });
236
+
237
+ it("flags _devtools.js when mandu.config sets dev.devtools: false", async () => {
238
+ await writeFile(rootDir, "mandu.config.ts", `export default { dev: { devtools: false } };`);
239
+ await writeFile(rootDir, ".mandu/client/_devtools.js", "x");
240
+ const result = await checkDevArtifactsInProd(rootDir);
241
+ expect(result.ok).toBe(false);
242
+ expect(result.severity).toBe("error");
243
+ expect(result.message).toMatch(/dev\.devtools: false/);
244
+ });
245
+
246
+ it("flags prerendered HTML with a <script src=\".../devtools.js\"> reference", async () => {
247
+ await writeFile(rootDir, ".mandu/prerendered/index.html",
248
+ '<html><head><script src="/.mandu/client/_devtools.js"></script></head></html>'
249
+ );
250
+ const result = await checkDevArtifactsInProd(rootDir);
251
+ expect(result.ok).toBe(false);
252
+ expect(result.message).toMatch(/prerendered HTML/);
253
+ });
254
+
255
+ it("stays ok in dev builds (env=development, devtools expected)", async () => {
256
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
257
+ version: 1, buildTime: "x", env: "development",
258
+ bundles: {}, shared: { runtime: "", vendor: "" },
259
+ }));
260
+ await writeFile(rootDir, ".mandu/client/_devtools.js", "x");
261
+ const result = await checkDevArtifactsInProd(rootDir);
262
+ expect(result.ok).toBe(true);
263
+ });
264
+ });
265
+
266
+ // ──────────────────────────────────────────────────────────────────
267
+ // package_export_gaps
268
+ // ──────────────────────────────────────────────────────────────────
269
+
270
+ describe("checkPackageExportGaps", () => {
271
+ let rootDir: string;
272
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
273
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
274
+
275
+ async function seedCore(exports: Record<string, unknown>): Promise<void> {
276
+ await writeFile(rootDir, "node_modules/@mandujs/core/package.json", JSON.stringify({
277
+ name: "@mandujs/core", version: "0.32.0", exports,
278
+ }));
279
+ }
280
+
281
+ it("skips gracefully when @mandujs/core is not installed", async () => {
282
+ await writeFile(rootDir, "src/foo.ts", `import { x } from "@mandujs/core/unknown";`);
283
+ const result = await checkPackageExportGaps(rootDir);
284
+ expect(result.ok).toBe(true);
285
+ expect(result.details?.skipped).toBe(true);
286
+ });
287
+
288
+ it("returns ok when all user imports match the exports map", async () => {
289
+ await seedCore({ ".": "./src/index.ts", "./client": "./src/client/index.ts" });
290
+ await writeFile(rootDir, "src/a.ts", `import { foo } from "@mandujs/core";`);
291
+ await writeFile(rootDir, "src/b.ts", `import { island } from "@mandujs/core/client";`);
292
+ const result = await checkPackageExportGaps(rootDir);
293
+ expect(result.ok).toBe(true);
294
+ expect(result.details?.uniqueSubpaths).toBe(2);
295
+ });
296
+
297
+ it("flags an import missing from the exports map", async () => {
298
+ await seedCore({ ".": "./src/index.ts", "./client": "./src/client/index.ts" });
299
+ await writeFile(rootDir, "src/bad.ts", `import { x } from "@mandujs/core/nonexistent";`);
300
+ const result = await checkPackageExportGaps(rootDir);
301
+ expect(result.ok).toBe(false);
302
+ expect(result.severity).toBe("error");
303
+ expect(result.message).toMatch(/nonexistent/);
304
+ });
305
+
306
+ it("honors ./* wildcard export as a catch-all", async () => {
307
+ await seedCore({ ".": "./src/index.ts", "./*": "./src/*" });
308
+ await writeFile(rootDir, "src/a.ts", `import { x } from "@mandujs/core/anything";`);
309
+ const result = await checkPackageExportGaps(rootDir);
310
+ expect(result.ok).toBe(true);
311
+ });
312
+
313
+ it("recognizes require() specifiers in addition to import", async () => {
314
+ await seedCore({ ".": "./src/index.ts" });
315
+ await writeFile(rootDir, "src/a.cjs", `const { x } = require("@mandujs/core/ghost");`);
316
+ const result = await checkPackageExportGaps(rootDir);
317
+ expect(result.ok).toBe(false);
318
+ expect(result.severity).toBe("error");
319
+ });
320
+ });
321
+
322
+ // ──────────────────────────────────────────────────────────────────
323
+ // aggregator
324
+ // ──────────────────────────────────────────────────────────────────
325
+
326
+ describe("runExtendedDiagnose", () => {
327
+ let rootDir: string;
328
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
329
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
330
+
331
+ it("runs all 5 extended checks and returns a structured report", async () => {
332
+ const report = await runExtendedDiagnose(rootDir);
333
+ expect(report.summary.total).toBe(5);
334
+ // manifest is missing → at least one error
335
+ expect(report.healthy).toBe(false);
336
+ expect(report.errorCount).toBeGreaterThanOrEqual(1);
337
+ const rules = report.checks.map((c) => c.rule);
338
+ expect(rules).toContain("manifest_freshness");
339
+ expect(rules).toContain("prerender_pollution");
340
+ expect(rules).toContain("cloneelement_warnings");
341
+ expect(rules).toContain("dev_artifacts_in_prod");
342
+ expect(rules).toContain("package_export_gaps");
343
+ });
344
+
345
+ it("returns healthy=true when all checks pass (production manifest, no gaps)", async () => {
346
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
347
+ version: 1, buildTime: "x", env: "production",
348
+ bundles: { h: { js: "/h.js", dependencies: [], priority: "immediate" } },
349
+ shared: { runtime: "/r.js", vendor: "/v.js" },
350
+ }));
351
+ const report = await runExtendedDiagnose(rootDir);
352
+ expect(report.healthy).toBe(true);
353
+ expect(report.errorCount).toBe(0);
354
+ });
355
+ });
356
+
357
+ describe("buildReport", () => {
358
+ it("computes healthy=true when no error-severity check fires", () => {
359
+ const report = buildReport([
360
+ { ok: true, rule: "a", message: "ok" },
361
+ { ok: false, rule: "b", severity: "warning", message: "warn" },
362
+ { ok: false, rule: "c", severity: "info", message: "info" },
363
+ ]);
364
+ expect(report.healthy).toBe(true);
365
+ expect(report.errorCount).toBe(0);
366
+ expect(report.warningCount).toBe(1);
367
+ expect(report.summary.failed).toBe(2);
368
+ });
369
+
370
+ it("computes healthy=false when at least one error fires", () => {
371
+ const report = buildReport([
372
+ { ok: false, rule: "a", severity: "error", message: "fail" },
373
+ { ok: true, rule: "b", message: "ok" },
374
+ ]);
375
+ expect(report.healthy).toBe(false);
376
+ expect(report.errorCount).toBe(1);
377
+ });
378
+ });