@mandujs/core 0.53.3 → 0.54.1
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/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/diagnose/checks.ts
CHANGED
|
@@ -1,832 +1,832 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Diagnose — individual check implementations.
|
|
3
|
-
*
|
|
4
|
-
* Each check is an independent async function that accepts a project root
|
|
5
|
-
* and returns a `DiagnoseCheckResult`. Checks NEVER throw — they catch
|
|
6
|
-
* their own I/O errors and surface them as `severity: 'error'` results
|
|
7
|
-
* with a helpful `suggestion`.
|
|
8
|
-
*
|
|
9
|
-
* Issue #215 motivation: the previous diagnose surface only ran four
|
|
10
|
-
* structural checks (kitchen_errors, guard_check, contract_validation,
|
|
11
|
-
* manifest_validation) and returned `healthy: true` in environments where
|
|
12
|
-
* #211 / #212 / #213 / #214 were actively breaking production. These new
|
|
13
|
-
* checks close the false-signal gap.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import path from "path";
|
|
17
|
-
import fs from "fs/promises";
|
|
18
|
-
import type { Dirent } from "fs";
|
|
19
|
-
import type { DiagnoseCheckResult } from "./types";
|
|
20
|
-
|
|
21
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
22
|
-
// 1. manifest_freshness
|
|
23
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* #215 check 1: bundle manifest freshness.
|
|
27
|
-
*
|
|
28
|
-
* Reads `.mandu/manifest.json` (the bundle manifest, NOT the FS-routes
|
|
29
|
-
* manifest) and flags:
|
|
30
|
-
* - `env === 'development'` → error (dev-mode manifest shipped to prod)
|
|
31
|
-
* - `bundles` empty + `islands` non-empty → warning (incomplete build)
|
|
32
|
-
* - missing file → error (build never ran)
|
|
33
|
-
*
|
|
34
|
-
* Returns `ok: true` when env is `production` AND at least one bundle
|
|
35
|
-
* exists OR no hydrated routes were declared (pure-SSR projects get a
|
|
36
|
-
* stub manifest with empty `bundles` — that's fine).
|
|
37
|
-
*/
|
|
38
|
-
export async function checkManifestFreshness(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
39
|
-
const manifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
40
|
-
|
|
41
|
-
let raw: string;
|
|
42
|
-
try {
|
|
43
|
-
raw = await fs.readFile(manifestPath, "utf-8");
|
|
44
|
-
} catch {
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
rule: "manifest_freshness",
|
|
48
|
-
severity: "error",
|
|
49
|
-
message: "Bundle manifest .mandu/manifest.json is missing. The build has never run.",
|
|
50
|
-
suggestion: "Run `mandu build` before deploying.",
|
|
51
|
-
details: { manifestPath },
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let parsed: Record<string, unknown>;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
58
|
-
} catch (err) {
|
|
59
|
-
return {
|
|
60
|
-
ok: false,
|
|
61
|
-
rule: "manifest_freshness",
|
|
62
|
-
severity: "error",
|
|
63
|
-
message: "Bundle manifest .mandu/manifest.json is corrupted (invalid JSON).",
|
|
64
|
-
suggestion: "Run `mandu clean && mandu build` to regenerate.",
|
|
65
|
-
details: { manifestPath, error: err instanceof Error ? err.message : String(err) },
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const env = typeof parsed.env === "string" ? parsed.env : undefined;
|
|
70
|
-
const bundles = (parsed.bundles && typeof parsed.bundles === "object") ? parsed.bundles as Record<string, unknown> : {};
|
|
71
|
-
const islands = (parsed.islands && typeof parsed.islands === "object") ? parsed.islands as Record<string, unknown> : {};
|
|
72
|
-
const bundleCount = Object.keys(bundles).length;
|
|
73
|
-
const islandCount = Object.keys(islands).length;
|
|
74
|
-
|
|
75
|
-
if (env === "development") {
|
|
76
|
-
return {
|
|
77
|
-
ok: false,
|
|
78
|
-
rule: "manifest_freshness",
|
|
79
|
-
severity: "error",
|
|
80
|
-
message: `Bundle manifest is dev-mode (env=development). Dev artifacts should never reach prod.`,
|
|
81
|
-
suggestion: "Run `mandu build` to produce a production manifest.",
|
|
82
|
-
details: { env, bundleCount, islandCount, buildTime: parsed.buildTime },
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (env !== "production") {
|
|
87
|
-
return {
|
|
88
|
-
ok: false,
|
|
89
|
-
rule: "manifest_freshness",
|
|
90
|
-
severity: "error",
|
|
91
|
-
message: `Bundle manifest has unrecognized env value: ${JSON.stringify(env)}. Expected "production".`,
|
|
92
|
-
suggestion: "Run `mandu build` to regenerate the manifest.",
|
|
93
|
-
details: { env, bundleCount, islandCount },
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (islandCount > 0 && bundleCount === 0) {
|
|
98
|
-
return {
|
|
99
|
-
ok: false,
|
|
100
|
-
rule: "manifest_freshness",
|
|
101
|
-
severity: "warning",
|
|
102
|
-
message: `Manifest declares ${islandCount} island(s) but 0 route bundles. Build may be incomplete.`,
|
|
103
|
-
suggestion: "Run `mandu clean && mandu build` to rebuild from scratch.",
|
|
104
|
-
details: { env, bundleCount, islandCount },
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
ok: true,
|
|
110
|
-
rule: "manifest_freshness",
|
|
111
|
-
message: `Manifest is production-mode with ${bundleCount} bundle(s), ${islandCount} island(s).`,
|
|
112
|
-
details: { env, bundleCount, islandCount, buildTime: parsed.buildTime },
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
117
|
-
// 2. prerender_pollution
|
|
118
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
const SUSPICIOUS_SEGMENT_PATTERNS: Array<{ match: (segment: string) => boolean; why: string }> = [
|
|
121
|
-
{ match: (s) => s.includes("..."), why: "contains literal '...' (likely copy-pasted docs placeholder)" },
|
|
122
|
-
{ match: (s) => s === "path", why: "literal 'path' segment (docs placeholder for `/path`)" },
|
|
123
|
-
{ match: (s) => s === "route", why: "literal 'route' segment (docs placeholder)" },
|
|
124
|
-
{ match: (s) => s === "example", why: "literal 'example' segment (docs placeholder)" },
|
|
125
|
-
{ match: (s) => /^[A-Z]/.test(s) && !s.startsWith("[") && s !== s.toLowerCase(), why: "starts with uppercase (not kebab-case)" },
|
|
126
|
-
{ match: (s) => s.length === 1 && /[a-z]/i.test(s), why: "single-character segment (likely a typo)" },
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
function classifyRouteSegment(segment: string): string | null {
|
|
130
|
-
for (const { match, why } of SUSPICIOUS_SEGMENT_PATTERNS) {
|
|
131
|
-
if (match(segment)) return why;
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Walk a directory tree collecting HTML pathnames (relative, POSIX-joined)
|
|
138
|
-
* that correspond to `index.html` leaves. Max depth is bounded to avoid
|
|
139
|
-
* runaway traversal on misconfigured projects.
|
|
140
|
-
*/
|
|
141
|
-
async function collectPrerenderedRoutes(baseDir: string, maxDepth = 8): Promise<string[]> {
|
|
142
|
-
const routes: string[] = [];
|
|
143
|
-
|
|
144
|
-
async function walk(dir: string, relativeSegments: string[], depth: number): Promise<void> {
|
|
145
|
-
if (depth > maxDepth) return;
|
|
146
|
-
let entries: Dirent[];
|
|
147
|
-
try {
|
|
148
|
-
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
149
|
-
} catch {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
for (const entry of entries) {
|
|
153
|
-
if (entry.name.startsWith(".") || entry.name === "_manifest.json") continue;
|
|
154
|
-
const full = path.join(dir, entry.name);
|
|
155
|
-
if (entry.isDirectory()) {
|
|
156
|
-
await walk(full, [...relativeSegments, entry.name], depth + 1);
|
|
157
|
-
} else if (entry.isFile() && entry.name === "index.html") {
|
|
158
|
-
const route = "/" + relativeSegments.join("/");
|
|
159
|
-
routes.push(route === "//" ? "/" : route);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const stat = await fs.stat(baseDir);
|
|
166
|
-
if (stat.isDirectory()) await walk(baseDir, [], 0);
|
|
167
|
-
} catch {
|
|
168
|
-
// directory missing is fine — no prerendered output
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return routes;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* #215 check 2 (#213 remediation): scan `.mandu/prerendered/` and
|
|
176
|
-
* `.mandu/static/` for suspicious route shapes that typically come from
|
|
177
|
-
* docs code-block leakage into `generateStaticParams()`.
|
|
178
|
-
*/
|
|
179
|
-
export async function checkPrerenderPollution(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
180
|
-
const dirs = [
|
|
181
|
-
path.join(rootDir, ".mandu", "prerendered"),
|
|
182
|
-
path.join(rootDir, ".mandu", "static"),
|
|
183
|
-
];
|
|
184
|
-
|
|
185
|
-
const allRoutes: Array<{ dir: string; route: string }> = [];
|
|
186
|
-
for (const dir of dirs) {
|
|
187
|
-
const routes = await collectPrerenderedRoutes(dir);
|
|
188
|
-
for (const route of routes) {
|
|
189
|
-
allRoutes.push({ dir: path.relative(rootDir, dir), route });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const suspicious: Array<{ route: string; segment: string; reason: string; dir: string }> = [];
|
|
194
|
-
for (const { route, dir } of allRoutes) {
|
|
195
|
-
const segments = route.split("/").filter(Boolean);
|
|
196
|
-
for (const segment of segments) {
|
|
197
|
-
const reason = classifyRouteSegment(segment);
|
|
198
|
-
if (reason) {
|
|
199
|
-
suspicious.push({ route, segment, reason, dir });
|
|
200
|
-
break; // one hit per route is enough
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (suspicious.length === 0) {
|
|
206
|
-
return {
|
|
207
|
-
ok: true,
|
|
208
|
-
rule: "prerender_pollution",
|
|
209
|
-
message: `Scanned ${allRoutes.length} prerendered route(s). No suspicious shapes detected.`,
|
|
210
|
-
details: { scanned: allRoutes.length },
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const sample = suspicious.slice(0, 5).map((s) => `${s.route} (${s.reason})`);
|
|
215
|
-
return {
|
|
216
|
-
ok: false,
|
|
217
|
-
rule: "prerender_pollution",
|
|
218
|
-
severity: "warning",
|
|
219
|
-
message: `Found ${suspicious.length} suspicious prerendered route(s). Likely doc placeholder leak (#213). First: ${sample[0]}`,
|
|
220
|
-
suggestion: "Check `generateStaticParams()` — often fence-block params escape the MDX extractor.",
|
|
221
|
-
details: { suspiciousCount: suspicious.length, scanned: allRoutes.length, sample: suspicious.slice(0, 10) },
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
226
|
-
// 3. cloneelement_warnings
|
|
227
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* #215 check 3 (#212 remediation): scan recent build output for the
|
|
231
|
-
* React "Each child in a list should have a unique key prop" warning
|
|
232
|
-
* that was caused by pre-0.32 `resolveAsyncElement` cloneElement spread.
|
|
233
|
-
*
|
|
234
|
-
* Log file locations scanned (first existing wins):
|
|
235
|
-
* - `.mandu/build.log`
|
|
236
|
-
* - `.mandu/dev-server.stderr.log`
|
|
237
|
-
* - `.mandu/dev-server.stdout.log`
|
|
238
|
-
*/
|
|
239
|
-
export async function checkCloneElementWarnings(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
240
|
-
const candidates = [
|
|
241
|
-
path.join(rootDir, ".mandu", "build.log"),
|
|
242
|
-
path.join(rootDir, ".mandu", "dev-server.stderr.log"),
|
|
243
|
-
path.join(rootDir, ".mandu", "dev-server.stdout.log"),
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
let logPath: string | null = null;
|
|
247
|
-
let content = "";
|
|
248
|
-
for (const candidate of candidates) {
|
|
249
|
-
try {
|
|
250
|
-
content = await fs.readFile(candidate, "utf-8");
|
|
251
|
-
logPath = candidate;
|
|
252
|
-
break;
|
|
253
|
-
} catch {
|
|
254
|
-
// try next
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!logPath) {
|
|
259
|
-
return {
|
|
260
|
-
ok: true,
|
|
261
|
-
rule: "cloneelement_warnings",
|
|
262
|
-
message: "No build log found — nothing to scan (this is normal for fresh clones).",
|
|
263
|
-
details: { scanned: candidates.map((c) => path.relative(rootDir, c)) },
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Match the React key-warning signature. Two common phrasings are in the
|
|
268
|
-
// wild since React 18/19 reworded the message; both matter for #212.
|
|
269
|
-
const pattern = /Each child in a list should have a unique ["“]key["”] prop/g;
|
|
270
|
-
const matches = content.match(pattern) ?? [];
|
|
271
|
-
const count = matches.length;
|
|
272
|
-
|
|
273
|
-
if (count === 0) {
|
|
274
|
-
return {
|
|
275
|
-
ok: true,
|
|
276
|
-
rule: "cloneelement_warnings",
|
|
277
|
-
message: `No cloneElement key warnings in ${path.relative(rootDir, logPath)}.`,
|
|
278
|
-
details: { logPath: path.relative(rootDir, logPath), count: 0 },
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (count <= 10) {
|
|
283
|
-
return {
|
|
284
|
-
ok: false,
|
|
285
|
-
rule: "cloneelement_warnings",
|
|
286
|
-
severity: "info",
|
|
287
|
-
message: `Found ${count} "unique key prop" warning(s) in ${path.relative(rootDir, logPath)}.`,
|
|
288
|
-
suggestion: "Upgrade @mandujs/core to >= 0.32.0 (resolveAsyncElement cloneElement fix, #212).",
|
|
289
|
-
details: { logPath: path.relative(rootDir, logPath), count },
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
ok: false,
|
|
295
|
-
rule: "cloneelement_warnings",
|
|
296
|
-
severity: "warning",
|
|
297
|
-
message: `Found ${count} "unique key prop" warning(s) in ${path.relative(rootDir, logPath)} (threshold: 10).`,
|
|
298
|
-
suggestion: "Upgrade @mandujs/core to >= 0.32.0 (resolveAsyncElement cloneElement fix, #212).",
|
|
299
|
-
details: { logPath: path.relative(rootDir, logPath), count },
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
304
|
-
// 4. dev_artifacts_in_prod
|
|
305
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Read `mandu.config.ts|js|json` best-effort to check the `dev.devtools`
|
|
309
|
-
* flag. Returns `null` when config is missing / unreadable (fall back to
|
|
310
|
-
* "devtools is on by default in dev").
|
|
311
|
-
*
|
|
312
|
-
* NOTE: We deliberately do NOT import the config module here — we want
|
|
313
|
-
* the diagnose bundle to stay side-effect free. Parsing as text and
|
|
314
|
-
* looking for the `devtools: false` pattern is sufficient for this
|
|
315
|
-
* check (false-positives are acceptable; false-negatives are not).
|
|
316
|
-
*/
|
|
317
|
-
async function readDevtoolsFlag(rootDir: string): Promise<boolean | null> {
|
|
318
|
-
for (const name of ["mandu.config.ts", "mandu.config.js", "mandu.config.json"]) {
|
|
319
|
-
const p = path.join(rootDir, name);
|
|
320
|
-
try {
|
|
321
|
-
const raw = await fs.readFile(p, "utf-8");
|
|
322
|
-
if (name.endsWith(".json")) {
|
|
323
|
-
try {
|
|
324
|
-
const parsed = JSON.parse(raw) as { dev?: { devtools?: boolean } };
|
|
325
|
-
return parsed.dev?.devtools ?? null;
|
|
326
|
-
} catch {
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
// TS/JS: look for `devtools: false` inside a `dev:` block (cheap
|
|
331
|
-
// heuristic, full AST parse is overkill for a flag lookup).
|
|
332
|
-
const devBlock = raw.match(/dev\s*:\s*\{[\s\S]*?\}/);
|
|
333
|
-
if (devBlock) {
|
|
334
|
-
if (/devtools\s*:\s*false/.test(devBlock[0])) return false;
|
|
335
|
-
if (/devtools\s*:\s*true/.test(devBlock[0])) return true;
|
|
336
|
-
}
|
|
337
|
-
return null;
|
|
338
|
-
} catch {
|
|
339
|
-
// next candidate
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
return null;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* #215 check 4: detect `_devtools.js` shipping to prod.
|
|
347
|
-
*
|
|
348
|
-
* Flags both the filesystem artifact (`.mandu/client/_devtools.js` present
|
|
349
|
-
* when manifest env=production OR when user explicitly disabled devtools)
|
|
350
|
-
* AND HTML pollution (prerendered HTML referencing a devtools script).
|
|
351
|
-
*/
|
|
352
|
-
export async function checkDevArtifactsInProd(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
353
|
-
const devtoolsPath = path.join(rootDir, ".mandu", "client", "_devtools.js");
|
|
354
|
-
let devtoolsPresent = false;
|
|
355
|
-
try {
|
|
356
|
-
await fs.access(devtoolsPath);
|
|
357
|
-
devtoolsPresent = true;
|
|
358
|
-
} catch {
|
|
359
|
-
devtoolsPresent = false;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Load manifest env
|
|
363
|
-
const manifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
364
|
-
let manifestEnv: string | null = null;
|
|
365
|
-
try {
|
|
366
|
-
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
367
|
-
const parsed = JSON.parse(raw) as { env?: string };
|
|
368
|
-
manifestEnv = parsed.env ?? null;
|
|
369
|
-
} catch {
|
|
370
|
-
manifestEnv = null;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const configDevtools = await readDevtoolsFlag(rootDir);
|
|
374
|
-
|
|
375
|
-
// Scan prerendered HTML for devtools <script>
|
|
376
|
-
const prerenderDirs = [
|
|
377
|
-
path.join(rootDir, ".mandu", "prerendered"),
|
|
378
|
-
path.join(rootDir, ".mandu", "static"),
|
|
379
|
-
];
|
|
380
|
-
const pollutedHtml: string[] = [];
|
|
381
|
-
async function scanHtml(dir: string, depth = 0): Promise<void> {
|
|
382
|
-
if (depth > 8) return;
|
|
383
|
-
let entries: Dirent[];
|
|
384
|
-
try {
|
|
385
|
-
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
386
|
-
} catch {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
for (const entry of entries) {
|
|
390
|
-
if (entry.name.startsWith(".")) continue;
|
|
391
|
-
const full = path.join(dir, entry.name);
|
|
392
|
-
if (entry.isDirectory()) {
|
|
393
|
-
await scanHtml(full, depth + 1);
|
|
394
|
-
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
395
|
-
try {
|
|
396
|
-
const html = await fs.readFile(full, "utf-8");
|
|
397
|
-
if (/<script[^>]*(src|href)=["'][^"']*_?devtools[^"']*["']/i.test(html)) {
|
|
398
|
-
pollutedHtml.push(path.relative(rootDir, full));
|
|
399
|
-
}
|
|
400
|
-
} catch {
|
|
401
|
-
// ignore unreadable
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
for (const dir of prerenderDirs) await scanHtml(dir);
|
|
407
|
-
|
|
408
|
-
// Decide severity
|
|
409
|
-
const explicitlyDisabled = configDevtools === false;
|
|
410
|
-
const isProduction = manifestEnv === "production";
|
|
411
|
-
|
|
412
|
-
const problems: string[] = [];
|
|
413
|
-
if (devtoolsPresent && explicitlyDisabled) {
|
|
414
|
-
problems.push("_devtools.js present despite dev.devtools: false in mandu.config");
|
|
415
|
-
}
|
|
416
|
-
if (devtoolsPresent && isProduction) {
|
|
417
|
-
problems.push("_devtools.js present in a production build (manifest env=production)");
|
|
418
|
-
}
|
|
419
|
-
if (pollutedHtml.length > 0) {
|
|
420
|
-
problems.push(`${pollutedHtml.length} prerendered HTML file(s) reference a devtools script`);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (problems.length === 0) {
|
|
424
|
-
return {
|
|
425
|
-
ok: true,
|
|
426
|
-
rule: "dev_artifacts_in_prod",
|
|
427
|
-
message: devtoolsPresent
|
|
428
|
-
? "_devtools.js present — expected for dev builds."
|
|
429
|
-
: "No _devtools.js artifact detected.",
|
|
430
|
-
details: { devtoolsPresent, manifestEnv, configDevtools, pollutedHtml: pollutedHtml.length },
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
ok: false,
|
|
436
|
-
rule: "dev_artifacts_in_prod",
|
|
437
|
-
severity: "error",
|
|
438
|
-
message: problems.join("; "),
|
|
439
|
-
suggestion: "Run `mandu clean && mandu build` to produce a dev-artifact-free production bundle.",
|
|
440
|
-
details: { devtoolsPresent, manifestEnv, configDevtools, pollutedHtml },
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
445
|
-
// 5. package_export_gaps
|
|
446
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Walk a directory and collect `.ts/.tsx/.js/.jsx/.mts/.cts` source files.
|
|
450
|
-
* `node_modules`, `.mandu`, and dot-directories are skipped.
|
|
451
|
-
*/
|
|
452
|
-
async function collectSourceFiles(rootDir: string, maxDepth = 10): Promise<string[]> {
|
|
453
|
-
const found: string[] = [];
|
|
454
|
-
const extensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
455
|
-
async function walk(dir: string, depth: number): Promise<void> {
|
|
456
|
-
if (depth > maxDepth) return;
|
|
457
|
-
let entries: Dirent[];
|
|
458
|
-
try {
|
|
459
|
-
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
460
|
-
} catch {
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
for (const entry of entries) {
|
|
464
|
-
if (entry.name === "node_modules" || entry.name === ".mandu" || entry.name.startsWith(".")) continue;
|
|
465
|
-
const full = path.join(dir, entry.name);
|
|
466
|
-
if (entry.isDirectory()) {
|
|
467
|
-
await walk(full, depth + 1);
|
|
468
|
-
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
|
469
|
-
found.push(full);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
await walk(rootDir, 0);
|
|
474
|
-
return found;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Parse an import specifier starting with `@mandujs/core` into a subpath.
|
|
479
|
-
* Returns `"."` for bare `@mandujs/core`, `"./client"` for
|
|
480
|
-
* `@mandujs/core/client`, etc. Normalizes backslashes for Windows sources.
|
|
481
|
-
*/
|
|
482
|
-
function extractCoreSubpath(specifier: string): string | null {
|
|
483
|
-
const normalized = specifier.replace(/\\/g, "/");
|
|
484
|
-
if (normalized === "@mandujs/core") return ".";
|
|
485
|
-
if (normalized.startsWith("@mandujs/core/")) return "./" + normalized.slice("@mandujs/core/".length);
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Resolve the `@mandujs/core` package export map. Tries, in order:
|
|
491
|
-
* 1. `<rootDir>/node_modules/@mandujs/core/package.json`
|
|
492
|
-
* 2. walk up one level to the monorepo root and retry
|
|
493
|
-
*
|
|
494
|
-
* Returns the parsed `exports` map (or `null` when the package is not
|
|
495
|
-
* resolvable — e.g. pure-fixture test directories).
|
|
496
|
-
*/
|
|
497
|
-
async function resolveCoreExports(rootDir: string): Promise<Record<string, unknown> | null> {
|
|
498
|
-
const candidates = [
|
|
499
|
-
path.join(rootDir, "node_modules", "@mandujs", "core", "package.json"),
|
|
500
|
-
path.join(rootDir, "..", "node_modules", "@mandujs", "core", "package.json"),
|
|
501
|
-
path.join(rootDir, "..", "..", "node_modules", "@mandujs", "core", "package.json"),
|
|
502
|
-
];
|
|
503
|
-
for (const candidate of candidates) {
|
|
504
|
-
try {
|
|
505
|
-
const raw = await fs.readFile(candidate, "utf-8");
|
|
506
|
-
const parsed = JSON.parse(raw) as { exports?: Record<string, unknown> };
|
|
507
|
-
if (parsed.exports && typeof parsed.exports === "object") {
|
|
508
|
-
return parsed.exports;
|
|
509
|
-
}
|
|
510
|
-
} catch {
|
|
511
|
-
// try next
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Match a user subpath against the exports map. The map supports exact
|
|
519
|
-
* keys plus `./*` wildcard fallback. We accept either.
|
|
520
|
-
*/
|
|
521
|
-
function subpathIsExported(subpath: string, exports: Record<string, unknown>): boolean {
|
|
522
|
-
if (subpath in exports) return true;
|
|
523
|
-
// Wildcard — `"./*": "./src/*"`
|
|
524
|
-
if ("./*" in exports) return true;
|
|
525
|
-
// Pattern export — `"./foo/*": "./src/foo/*.ts"` (less common but valid).
|
|
526
|
-
for (const key of Object.keys(exports)) {
|
|
527
|
-
if (!key.endsWith("/*")) continue;
|
|
528
|
-
const prefix = key.slice(0, -1); // strip trailing `*`, keep `/`
|
|
529
|
-
if (subpath.startsWith(prefix) && subpath.length > prefix.length) return true;
|
|
530
|
-
}
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* #215 check 5: detect user imports of `@mandujs/core/<subpath>` that are
|
|
536
|
-
* not declared in the installed core's `exports` map. This catches the
|
|
537
|
-
* #194/#202/#210 pattern where example code in docs or agent-generated
|
|
538
|
-
* code imports a subpath that was renamed or never shipped.
|
|
539
|
-
*/
|
|
540
|
-
export async function checkPackageExportGaps(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
541
|
-
const exportsMap = await resolveCoreExports(rootDir);
|
|
542
|
-
if (!exportsMap) {
|
|
543
|
-
return {
|
|
544
|
-
ok: true,
|
|
545
|
-
rule: "package_export_gaps",
|
|
546
|
-
message: "@mandujs/core package.json not resolvable from this project — skipping (OK for fixture/test roots).",
|
|
547
|
-
details: { skipped: true },
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const files = await collectSourceFiles(rootDir);
|
|
552
|
-
// Match both `from "@mandujs/core/..."` and `require("@mandujs/core/...")` forms.
|
|
553
|
-
const importRegex = /(?:from|require\()\s*["']([^"']+)["']/g;
|
|
554
|
-
const userSubpaths = new Map<string, string[]>();
|
|
555
|
-
|
|
556
|
-
for (const file of files) {
|
|
557
|
-
let content: string;
|
|
558
|
-
try {
|
|
559
|
-
content = await fs.readFile(file, "utf-8");
|
|
560
|
-
} catch {
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
let match: RegExpExecArray | null;
|
|
564
|
-
importRegex.lastIndex = 0;
|
|
565
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
566
|
-
const specifier = match[1];
|
|
567
|
-
const subpath = extractCoreSubpath(specifier);
|
|
568
|
-
if (!subpath) continue;
|
|
569
|
-
if (!userSubpaths.has(subpath)) userSubpaths.set(subpath, []);
|
|
570
|
-
userSubpaths.get(subpath)!.push(path.relative(rootDir, file));
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const gaps: Array<{ subpath: string; files: string[] }> = [];
|
|
575
|
-
for (const [subpath, filesUsingIt] of userSubpaths.entries()) {
|
|
576
|
-
if (!subpathIsExported(subpath, exportsMap)) {
|
|
577
|
-
gaps.push({ subpath: `@mandujs/core${subpath === "." ? "" : subpath.replace(/^\./, "")}`, files: filesUsingIt.slice(0, 5) });
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (gaps.length === 0) {
|
|
582
|
-
return {
|
|
583
|
-
ok: true,
|
|
584
|
-
rule: "package_export_gaps",
|
|
585
|
-
message: `Scanned ${files.length} source file(s) using ${userSubpaths.size} unique @mandujs/core subpath(s). All declared in exports map.`,
|
|
586
|
-
details: { scanned: files.length, uniqueSubpaths: userSubpaths.size },
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const first = gaps[0];
|
|
591
|
-
return {
|
|
592
|
-
ok: false,
|
|
593
|
-
rule: "package_export_gaps",
|
|
594
|
-
severity: "error",
|
|
595
|
-
message: `${gaps.length} @mandujs/core subpath(s) imported but not in exports map. First: ${first.subpath} (used by ${first.files[0]}).`,
|
|
596
|
-
suggestion: "Verify the subpath exists in the installed core version, or upgrade @mandujs/core.",
|
|
597
|
-
details: { gapCount: gaps.length, gaps },
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
602
|
-
// 6. a11y_hints (Phase 18.χ)
|
|
603
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Find the first prerendered HTML leaf under `.mandu/prerendered/` or
|
|
607
|
-
* `.mandu/static/`. Returns `null` when nothing has been prerendered.
|
|
608
|
-
* Used as a cheap smoke target — auditing every page would be orders
|
|
609
|
-
* of magnitude heavier than every other diagnose check.
|
|
610
|
-
*/
|
|
611
|
-
async function findFirstPrerenderedHtml(rootDir: string): Promise<string | null> {
|
|
612
|
-
const dirs = [
|
|
613
|
-
path.join(rootDir, ".mandu", "prerendered"),
|
|
614
|
-
path.join(rootDir, ".mandu", "static"),
|
|
615
|
-
];
|
|
616
|
-
async function walk(dir: string, depth: number): Promise<string | null> {
|
|
617
|
-
if (depth > 8) return null;
|
|
618
|
-
let entries: Dirent[];
|
|
619
|
-
try {
|
|
620
|
-
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
621
|
-
} catch {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
624
|
-
// Stable sort so the smoke sample is deterministic across runs —
|
|
625
|
-
// otherwise a flaky a11y hint could bounce on/off depending on
|
|
626
|
-
// FS iteration order.
|
|
627
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
628
|
-
for (const entry of entries) {
|
|
629
|
-
if (entry.name.startsWith(".") || entry.name === "_manifest.json") continue;
|
|
630
|
-
const full = path.join(dir, entry.name);
|
|
631
|
-
if (entry.isDirectory()) {
|
|
632
|
-
const found = await walk(full, depth + 1);
|
|
633
|
-
if (found) return found;
|
|
634
|
-
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
635
|
-
return full;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
for (const dir of dirs) {
|
|
641
|
-
const found = await walk(dir, 0);
|
|
642
|
-
if (found) return found;
|
|
643
|
-
}
|
|
644
|
-
return null;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Phase 18.χ — accessibility smoke hint.
|
|
649
|
-
*
|
|
650
|
-
* Runs the a11y audit against a single prerendered HTML file — the
|
|
651
|
-
* first one we can find. This is strictly a smoke test (auditing a
|
|
652
|
-
* whole build is too expensive for `mandu diagnose`, which is meant to
|
|
653
|
-
* run in < 1s). The check stays `warning` severity at worst; a11y
|
|
654
|
-
* failures should surface attention, not block deploys (that's what
|
|
655
|
-
* `mandu build --audit --audit-fail-on=critical` is for).
|
|
656
|
-
*
|
|
657
|
-
* Behaviour matrix:
|
|
658
|
-
* - No prerendered HTML on disk → ok (nothing to audit yet)
|
|
659
|
-
* - axe-core / DOM provider missing → ok (optional deps; informational)
|
|
660
|
-
* - Runner succeeds, zero criticals → ok
|
|
661
|
-
* - Runner succeeds with criticals → warning + suggestion pointing
|
|
662
|
-
* at the full-build command
|
|
663
|
-
*/
|
|
664
|
-
export async function checkA11yHints(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
665
|
-
const sample = await findFirstPrerenderedHtml(rootDir);
|
|
666
|
-
if (!sample) {
|
|
667
|
-
return {
|
|
668
|
-
ok: true,
|
|
669
|
-
rule: "a11y_hints",
|
|
670
|
-
message: "No prerendered HTML found — nothing to audit.",
|
|
671
|
-
details: { scanned: 0 },
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Lazy import — keeps the diagnose bundle tree free of the a11y
|
|
676
|
-
// module unless this specific check is actually invoked.
|
|
677
|
-
const { runAudit } = await import("../a11y/run-audit");
|
|
678
|
-
const report = await runAudit([sample], { minImpact: "critical" });
|
|
679
|
-
|
|
680
|
-
if (report.outcome === "axe-missing") {
|
|
681
|
-
return {
|
|
682
|
-
ok: true,
|
|
683
|
-
rule: "a11y_hints",
|
|
684
|
-
message: "axe-core not installed — a11y smoke skipped (optional).",
|
|
685
|
-
details: {
|
|
686
|
-
sample: path.relative(rootDir, sample),
|
|
687
|
-
note: report.note,
|
|
688
|
-
},
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
if (report.outcome === "ok") {
|
|
693
|
-
return {
|
|
694
|
-
ok: true,
|
|
695
|
-
rule: "a11y_hints",
|
|
696
|
-
message: `a11y smoke PASS on ${path.relative(rootDir, sample)} (no critical violations).`,
|
|
697
|
-
details: {
|
|
698
|
-
sample: path.relative(rootDir, sample),
|
|
699
|
-
filesScanned: report.filesScanned,
|
|
700
|
-
},
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const top = report.violations[0];
|
|
705
|
-
return {
|
|
706
|
-
ok: false,
|
|
707
|
-
rule: "a11y_hints",
|
|
708
|
-
severity: "warning",
|
|
709
|
-
message:
|
|
710
|
-
`a11y smoke found ${report.violations.length} critical violation(s) in ` +
|
|
711
|
-
`${path.relative(rootDir, sample)}. First: ${top.rule} — ${top.help}`,
|
|
712
|
-
suggestion: "Run `mandu build --audit` to audit every prerendered page.",
|
|
713
|
-
details: {
|
|
714
|
-
sample: path.relative(rootDir, sample),
|
|
715
|
-
violationCount: report.violations.length,
|
|
716
|
-
firstRule: top.rule,
|
|
717
|
-
impactCounts: report.impactCounts,
|
|
718
|
-
},
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
723
|
-
// 7. nested_internal_core (#261)
|
|
724
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
725
|
-
|
|
726
|
-
interface NestedCoreSite {
|
|
727
|
-
parentPackage: string;
|
|
728
|
-
nestedVersion: string;
|
|
729
|
-
relativePath: string;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
async function readPackageVersion(pkgJsonPath: string): Promise<string | null> {
|
|
733
|
-
try {
|
|
734
|
-
const raw = await fs.readFile(pkgJsonPath, "utf-8");
|
|
735
|
-
const parsed = JSON.parse(raw) as { version?: string };
|
|
736
|
-
return typeof parsed.version === "string" ? parsed.version : null;
|
|
737
|
-
} catch {
|
|
738
|
-
return null;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
async function listMandujsSiblings(rootDir: string): Promise<string[]> {
|
|
743
|
-
const dir = path.join(rootDir, "node_modules", "@mandujs");
|
|
744
|
-
try {
|
|
745
|
-
const entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
746
|
-
return entries
|
|
747
|
-
.filter((e) => e.isDirectory() && e.name !== "core")
|
|
748
|
-
.map((e) => e.name);
|
|
749
|
-
} catch {
|
|
750
|
-
return [];
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* #261 check 7: detect a stale `@mandujs/core` nested inside another
|
|
756
|
-
* `@mandujs/*` package's `node_modules`.
|
|
757
|
-
*
|
|
758
|
-
* Symptom: `bunx @mandujs/mcp` fails with `Cannot find module
|
|
759
|
-
* @mandujs/core/<subpath>` even though the project has the latest
|
|
760
|
-
* `@mandujs/core` hoisted at the top level. Cause: a previous install
|
|
761
|
-
* left an older core nested under `@mandujs/<sibling>/node_modules`,
|
|
762
|
-
* which wins module resolution from inside that sibling's code, and the
|
|
763
|
-
* older core's `exports` map lacks the subpath the sibling now imports.
|
|
764
|
-
*
|
|
765
|
-
* This check walks `node_modules/@mandujs/*` siblings, looks for a
|
|
766
|
-
* nested `node_modules/@mandujs/core/package.json`, and compares the
|
|
767
|
-
* version to the hoisted core. A mismatch is reported as `error`
|
|
768
|
-
* (boot-breaking on the user's machine) with a copy-pastable fix.
|
|
769
|
-
*/
|
|
770
|
-
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
771
|
-
const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
|
|
772
|
-
const hoistedVersion = await readPackageVersion(hoistedPath);
|
|
773
|
-
|
|
774
|
-
if (!hoistedVersion) {
|
|
775
|
-
return {
|
|
776
|
-
ok: true,
|
|
777
|
-
rule: "nested_internal_core",
|
|
778
|
-
message: "@mandujs/core not installed at the project root — skipping nested-version check.",
|
|
779
|
-
details: { skipped: true },
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const siblings = await listMandujsSiblings(rootDir);
|
|
784
|
-
const mismatches: NestedCoreSite[] = [];
|
|
785
|
-
|
|
786
|
-
for (const sibling of siblings) {
|
|
787
|
-
const nestedPkgJson = path.join(
|
|
788
|
-
rootDir,
|
|
789
|
-
"node_modules",
|
|
790
|
-
"@mandujs",
|
|
791
|
-
sibling,
|
|
792
|
-
"node_modules",
|
|
793
|
-
"@mandujs",
|
|
794
|
-
"core",
|
|
795
|
-
"package.json",
|
|
796
|
-
);
|
|
797
|
-
const nestedVersion = await readPackageVersion(nestedPkgJson);
|
|
798
|
-
if (!nestedVersion) continue;
|
|
799
|
-
if (nestedVersion === hoistedVersion) continue;
|
|
800
|
-
mismatches.push({
|
|
801
|
-
parentPackage: `@mandujs/${sibling}`,
|
|
802
|
-
nestedVersion,
|
|
803
|
-
relativePath: path.relative(rootDir, nestedPkgJson),
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
if (mismatches.length === 0) {
|
|
808
|
-
return {
|
|
809
|
-
ok: true,
|
|
810
|
-
rule: "nested_internal_core",
|
|
811
|
-
message: `No stale nested @mandujs/core found (hoisted: ${hoistedVersion}, scanned ${siblings.length} sibling(s)).`,
|
|
812
|
-
details: { hoistedVersion, scannedSiblings: siblings.length },
|
|
813
|
-
};
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const first = mismatches[0];
|
|
817
|
-
const fixCommand = `rm -rf node_modules/${first.parentPackage}/node_modules`;
|
|
818
|
-
return {
|
|
819
|
-
ok: false,
|
|
820
|
-
rule: "nested_internal_core",
|
|
821
|
-
severity: "error",
|
|
822
|
-
message:
|
|
823
|
-
`${mismatches.length} stale nested @mandujs/core install(s) shadow the hoisted ${hoistedVersion}. ` +
|
|
824
|
-
`First: ${first.parentPackage} pinned to ${first.nestedVersion} (${first.relativePath}).`,
|
|
825
|
-
suggestion: `Run \`${fixCommand}\` and re-test \`bunx @mandujs/mcp\`, or remove node_modules and bun.lock entirely and \`bun install\`.`,
|
|
826
|
-
details: {
|
|
827
|
-
hoistedVersion,
|
|
828
|
-
mismatchCount: mismatches.length,
|
|
829
|
-
mismatches,
|
|
830
|
-
},
|
|
831
|
-
};
|
|
832
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Diagnose — individual check implementations.
|
|
3
|
+
*
|
|
4
|
+
* Each check is an independent async function that accepts a project root
|
|
5
|
+
* and returns a `DiagnoseCheckResult`. Checks NEVER throw — they catch
|
|
6
|
+
* their own I/O errors and surface them as `severity: 'error'` results
|
|
7
|
+
* with a helpful `suggestion`.
|
|
8
|
+
*
|
|
9
|
+
* Issue #215 motivation: the previous diagnose surface only ran four
|
|
10
|
+
* structural checks (kitchen_errors, guard_check, contract_validation,
|
|
11
|
+
* manifest_validation) and returned `healthy: true` in environments where
|
|
12
|
+
* #211 / #212 / #213 / #214 were actively breaking production. These new
|
|
13
|
+
* checks close the false-signal gap.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from "path";
|
|
17
|
+
import fs from "fs/promises";
|
|
18
|
+
import type { Dirent } from "fs";
|
|
19
|
+
import type { DiagnoseCheckResult } from "./types";
|
|
20
|
+
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// 1. manifest_freshness
|
|
23
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* #215 check 1: bundle manifest freshness.
|
|
27
|
+
*
|
|
28
|
+
* Reads `.mandu/manifest.json` (the bundle manifest, NOT the FS-routes
|
|
29
|
+
* manifest) and flags:
|
|
30
|
+
* - `env === 'development'` → error (dev-mode manifest shipped to prod)
|
|
31
|
+
* - `bundles` empty + `islands` non-empty → warning (incomplete build)
|
|
32
|
+
* - missing file → error (build never ran)
|
|
33
|
+
*
|
|
34
|
+
* Returns `ok: true` when env is `production` AND at least one bundle
|
|
35
|
+
* exists OR no hydrated routes were declared (pure-SSR projects get a
|
|
36
|
+
* stub manifest with empty `bundles` — that's fine).
|
|
37
|
+
*/
|
|
38
|
+
export async function checkManifestFreshness(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
39
|
+
const manifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
40
|
+
|
|
41
|
+
let raw: string;
|
|
42
|
+
try {
|
|
43
|
+
raw = await fs.readFile(manifestPath, "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
rule: "manifest_freshness",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: "Bundle manifest .mandu/manifest.json is missing. The build has never run.",
|
|
50
|
+
suggestion: "Run `mandu build` before deploying.",
|
|
51
|
+
details: { manifestPath },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let parsed: Record<string, unknown>;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
rule: "manifest_freshness",
|
|
62
|
+
severity: "error",
|
|
63
|
+
message: "Bundle manifest .mandu/manifest.json is corrupted (invalid JSON).",
|
|
64
|
+
suggestion: "Run `mandu clean && mandu build` to regenerate.",
|
|
65
|
+
details: { manifestPath, error: err instanceof Error ? err.message : String(err) },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const env = typeof parsed.env === "string" ? parsed.env : undefined;
|
|
70
|
+
const bundles = (parsed.bundles && typeof parsed.bundles === "object") ? parsed.bundles as Record<string, unknown> : {};
|
|
71
|
+
const islands = (parsed.islands && typeof parsed.islands === "object") ? parsed.islands as Record<string, unknown> : {};
|
|
72
|
+
const bundleCount = Object.keys(bundles).length;
|
|
73
|
+
const islandCount = Object.keys(islands).length;
|
|
74
|
+
|
|
75
|
+
if (env === "development") {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
rule: "manifest_freshness",
|
|
79
|
+
severity: "error",
|
|
80
|
+
message: `Bundle manifest is dev-mode (env=development). Dev artifacts should never reach prod.`,
|
|
81
|
+
suggestion: "Run `mandu build` to produce a production manifest.",
|
|
82
|
+
details: { env, bundleCount, islandCount, buildTime: parsed.buildTime },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (env !== "production") {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
rule: "manifest_freshness",
|
|
90
|
+
severity: "error",
|
|
91
|
+
message: `Bundle manifest has unrecognized env value: ${JSON.stringify(env)}. Expected "production".`,
|
|
92
|
+
suggestion: "Run `mandu build` to regenerate the manifest.",
|
|
93
|
+
details: { env, bundleCount, islandCount },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (islandCount > 0 && bundleCount === 0) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
rule: "manifest_freshness",
|
|
101
|
+
severity: "warning",
|
|
102
|
+
message: `Manifest declares ${islandCount} island(s) but 0 route bundles. Build may be incomplete.`,
|
|
103
|
+
suggestion: "Run `mandu clean && mandu build` to rebuild from scratch.",
|
|
104
|
+
details: { env, bundleCount, islandCount },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
rule: "manifest_freshness",
|
|
111
|
+
message: `Manifest is production-mode with ${bundleCount} bundle(s), ${islandCount} island(s).`,
|
|
112
|
+
details: { env, bundleCount, islandCount, buildTime: parsed.buildTime },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
117
|
+
// 2. prerender_pollution
|
|
118
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const SUSPICIOUS_SEGMENT_PATTERNS: Array<{ match: (segment: string) => boolean; why: string }> = [
|
|
121
|
+
{ match: (s) => s.includes("..."), why: "contains literal '...' (likely copy-pasted docs placeholder)" },
|
|
122
|
+
{ match: (s) => s === "path", why: "literal 'path' segment (docs placeholder for `/path`)" },
|
|
123
|
+
{ match: (s) => s === "route", why: "literal 'route' segment (docs placeholder)" },
|
|
124
|
+
{ match: (s) => s === "example", why: "literal 'example' segment (docs placeholder)" },
|
|
125
|
+
{ match: (s) => /^[A-Z]/.test(s) && !s.startsWith("[") && s !== s.toLowerCase(), why: "starts with uppercase (not kebab-case)" },
|
|
126
|
+
{ match: (s) => s.length === 1 && /[a-z]/i.test(s), why: "single-character segment (likely a typo)" },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
function classifyRouteSegment(segment: string): string | null {
|
|
130
|
+
for (const { match, why } of SUSPICIOUS_SEGMENT_PATTERNS) {
|
|
131
|
+
if (match(segment)) return why;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Walk a directory tree collecting HTML pathnames (relative, POSIX-joined)
|
|
138
|
+
* that correspond to `index.html` leaves. Max depth is bounded to avoid
|
|
139
|
+
* runaway traversal on misconfigured projects.
|
|
140
|
+
*/
|
|
141
|
+
async function collectPrerenderedRoutes(baseDir: string, maxDepth = 8): Promise<string[]> {
|
|
142
|
+
const routes: string[] = [];
|
|
143
|
+
|
|
144
|
+
async function walk(dir: string, relativeSegments: string[], depth: number): Promise<void> {
|
|
145
|
+
if (depth > maxDepth) return;
|
|
146
|
+
let entries: Dirent[];
|
|
147
|
+
try {
|
|
148
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.name.startsWith(".") || entry.name === "_manifest.json") continue;
|
|
154
|
+
const full = path.join(dir, entry.name);
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
await walk(full, [...relativeSegments, entry.name], depth + 1);
|
|
157
|
+
} else if (entry.isFile() && entry.name === "index.html") {
|
|
158
|
+
const route = "/" + relativeSegments.join("/");
|
|
159
|
+
routes.push(route === "//" ? "/" : route);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const stat = await fs.stat(baseDir);
|
|
166
|
+
if (stat.isDirectory()) await walk(baseDir, [], 0);
|
|
167
|
+
} catch {
|
|
168
|
+
// directory missing is fine — no prerendered output
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return routes;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* #215 check 2 (#213 remediation): scan `.mandu/prerendered/` and
|
|
176
|
+
* `.mandu/static/` for suspicious route shapes that typically come from
|
|
177
|
+
* docs code-block leakage into `generateStaticParams()`.
|
|
178
|
+
*/
|
|
179
|
+
export async function checkPrerenderPollution(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
180
|
+
const dirs = [
|
|
181
|
+
path.join(rootDir, ".mandu", "prerendered"),
|
|
182
|
+
path.join(rootDir, ".mandu", "static"),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const allRoutes: Array<{ dir: string; route: string }> = [];
|
|
186
|
+
for (const dir of dirs) {
|
|
187
|
+
const routes = await collectPrerenderedRoutes(dir);
|
|
188
|
+
for (const route of routes) {
|
|
189
|
+
allRoutes.push({ dir: path.relative(rootDir, dir), route });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const suspicious: Array<{ route: string; segment: string; reason: string; dir: string }> = [];
|
|
194
|
+
for (const { route, dir } of allRoutes) {
|
|
195
|
+
const segments = route.split("/").filter(Boolean);
|
|
196
|
+
for (const segment of segments) {
|
|
197
|
+
const reason = classifyRouteSegment(segment);
|
|
198
|
+
if (reason) {
|
|
199
|
+
suspicious.push({ route, segment, reason, dir });
|
|
200
|
+
break; // one hit per route is enough
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (suspicious.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
rule: "prerender_pollution",
|
|
209
|
+
message: `Scanned ${allRoutes.length} prerendered route(s). No suspicious shapes detected.`,
|
|
210
|
+
details: { scanned: allRoutes.length },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sample = suspicious.slice(0, 5).map((s) => `${s.route} (${s.reason})`);
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
rule: "prerender_pollution",
|
|
218
|
+
severity: "warning",
|
|
219
|
+
message: `Found ${suspicious.length} suspicious prerendered route(s). Likely doc placeholder leak (#213). First: ${sample[0]}`,
|
|
220
|
+
suggestion: "Check `generateStaticParams()` — often fence-block params escape the MDX extractor.",
|
|
221
|
+
details: { suspiciousCount: suspicious.length, scanned: allRoutes.length, sample: suspicious.slice(0, 10) },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// 3. cloneelement_warnings
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* #215 check 3 (#212 remediation): scan recent build output for the
|
|
231
|
+
* React "Each child in a list should have a unique key prop" warning
|
|
232
|
+
* that was caused by pre-0.32 `resolveAsyncElement` cloneElement spread.
|
|
233
|
+
*
|
|
234
|
+
* Log file locations scanned (first existing wins):
|
|
235
|
+
* - `.mandu/build.log`
|
|
236
|
+
* - `.mandu/dev-server.stderr.log`
|
|
237
|
+
* - `.mandu/dev-server.stdout.log`
|
|
238
|
+
*/
|
|
239
|
+
export async function checkCloneElementWarnings(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
240
|
+
const candidates = [
|
|
241
|
+
path.join(rootDir, ".mandu", "build.log"),
|
|
242
|
+
path.join(rootDir, ".mandu", "dev-server.stderr.log"),
|
|
243
|
+
path.join(rootDir, ".mandu", "dev-server.stdout.log"),
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
let logPath: string | null = null;
|
|
247
|
+
let content = "";
|
|
248
|
+
for (const candidate of candidates) {
|
|
249
|
+
try {
|
|
250
|
+
content = await fs.readFile(candidate, "utf-8");
|
|
251
|
+
logPath = candidate;
|
|
252
|
+
break;
|
|
253
|
+
} catch {
|
|
254
|
+
// try next
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!logPath) {
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
rule: "cloneelement_warnings",
|
|
262
|
+
message: "No build log found — nothing to scan (this is normal for fresh clones).",
|
|
263
|
+
details: { scanned: candidates.map((c) => path.relative(rootDir, c)) },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Match the React key-warning signature. Two common phrasings are in the
|
|
268
|
+
// wild since React 18/19 reworded the message; both matter for #212.
|
|
269
|
+
const pattern = /Each child in a list should have a unique ["“]key["”] prop/g;
|
|
270
|
+
const matches = content.match(pattern) ?? [];
|
|
271
|
+
const count = matches.length;
|
|
272
|
+
|
|
273
|
+
if (count === 0) {
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
rule: "cloneelement_warnings",
|
|
277
|
+
message: `No cloneElement key warnings in ${path.relative(rootDir, logPath)}.`,
|
|
278
|
+
details: { logPath: path.relative(rootDir, logPath), count: 0 },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (count <= 10) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
rule: "cloneelement_warnings",
|
|
286
|
+
severity: "info",
|
|
287
|
+
message: `Found ${count} "unique key prop" warning(s) in ${path.relative(rootDir, logPath)}.`,
|
|
288
|
+
suggestion: "Upgrade @mandujs/core to >= 0.32.0 (resolveAsyncElement cloneElement fix, #212).",
|
|
289
|
+
details: { logPath: path.relative(rootDir, logPath), count },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
rule: "cloneelement_warnings",
|
|
296
|
+
severity: "warning",
|
|
297
|
+
message: `Found ${count} "unique key prop" warning(s) in ${path.relative(rootDir, logPath)} (threshold: 10).`,
|
|
298
|
+
suggestion: "Upgrade @mandujs/core to >= 0.32.0 (resolveAsyncElement cloneElement fix, #212).",
|
|
299
|
+
details: { logPath: path.relative(rootDir, logPath), count },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
304
|
+
// 4. dev_artifacts_in_prod
|
|
305
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read `mandu.config.ts|js|json` best-effort to check the `dev.devtools`
|
|
309
|
+
* flag. Returns `null` when config is missing / unreadable (fall back to
|
|
310
|
+
* "devtools is on by default in dev").
|
|
311
|
+
*
|
|
312
|
+
* NOTE: We deliberately do NOT import the config module here — we want
|
|
313
|
+
* the diagnose bundle to stay side-effect free. Parsing as text and
|
|
314
|
+
* looking for the `devtools: false` pattern is sufficient for this
|
|
315
|
+
* check (false-positives are acceptable; false-negatives are not).
|
|
316
|
+
*/
|
|
317
|
+
async function readDevtoolsFlag(rootDir: string): Promise<boolean | null> {
|
|
318
|
+
for (const name of ["mandu.config.ts", "mandu.config.js", "mandu.config.json"]) {
|
|
319
|
+
const p = path.join(rootDir, name);
|
|
320
|
+
try {
|
|
321
|
+
const raw = await fs.readFile(p, "utf-8");
|
|
322
|
+
if (name.endsWith(".json")) {
|
|
323
|
+
try {
|
|
324
|
+
const parsed = JSON.parse(raw) as { dev?: { devtools?: boolean } };
|
|
325
|
+
return parsed.dev?.devtools ?? null;
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// TS/JS: look for `devtools: false` inside a `dev:` block (cheap
|
|
331
|
+
// heuristic, full AST parse is overkill for a flag lookup).
|
|
332
|
+
const devBlock = raw.match(/dev\s*:\s*\{[\s\S]*?\}/);
|
|
333
|
+
if (devBlock) {
|
|
334
|
+
if (/devtools\s*:\s*false/.test(devBlock[0])) return false;
|
|
335
|
+
if (/devtools\s*:\s*true/.test(devBlock[0])) return true;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
} catch {
|
|
339
|
+
// next candidate
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* #215 check 4: detect `_devtools.js` shipping to prod.
|
|
347
|
+
*
|
|
348
|
+
* Flags both the filesystem artifact (`.mandu/client/_devtools.js` present
|
|
349
|
+
* when manifest env=production OR when user explicitly disabled devtools)
|
|
350
|
+
* AND HTML pollution (prerendered HTML referencing a devtools script).
|
|
351
|
+
*/
|
|
352
|
+
export async function checkDevArtifactsInProd(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
353
|
+
const devtoolsPath = path.join(rootDir, ".mandu", "client", "_devtools.js");
|
|
354
|
+
let devtoolsPresent = false;
|
|
355
|
+
try {
|
|
356
|
+
await fs.access(devtoolsPath);
|
|
357
|
+
devtoolsPresent = true;
|
|
358
|
+
} catch {
|
|
359
|
+
devtoolsPresent = false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Load manifest env
|
|
363
|
+
const manifestPath = path.join(rootDir, ".mandu", "manifest.json");
|
|
364
|
+
let manifestEnv: string | null = null;
|
|
365
|
+
try {
|
|
366
|
+
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
367
|
+
const parsed = JSON.parse(raw) as { env?: string };
|
|
368
|
+
manifestEnv = parsed.env ?? null;
|
|
369
|
+
} catch {
|
|
370
|
+
manifestEnv = null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const configDevtools = await readDevtoolsFlag(rootDir);
|
|
374
|
+
|
|
375
|
+
// Scan prerendered HTML for devtools <script>
|
|
376
|
+
const prerenderDirs = [
|
|
377
|
+
path.join(rootDir, ".mandu", "prerendered"),
|
|
378
|
+
path.join(rootDir, ".mandu", "static"),
|
|
379
|
+
];
|
|
380
|
+
const pollutedHtml: string[] = [];
|
|
381
|
+
async function scanHtml(dir: string, depth = 0): Promise<void> {
|
|
382
|
+
if (depth > 8) return;
|
|
383
|
+
let entries: Dirent[];
|
|
384
|
+
try {
|
|
385
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
386
|
+
} catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
if (entry.name.startsWith(".")) continue;
|
|
391
|
+
const full = path.join(dir, entry.name);
|
|
392
|
+
if (entry.isDirectory()) {
|
|
393
|
+
await scanHtml(full, depth + 1);
|
|
394
|
+
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
395
|
+
try {
|
|
396
|
+
const html = await fs.readFile(full, "utf-8");
|
|
397
|
+
if (/<script[^>]*(src|href)=["'][^"']*_?devtools[^"']*["']/i.test(html)) {
|
|
398
|
+
pollutedHtml.push(path.relative(rootDir, full));
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// ignore unreadable
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const dir of prerenderDirs) await scanHtml(dir);
|
|
407
|
+
|
|
408
|
+
// Decide severity
|
|
409
|
+
const explicitlyDisabled = configDevtools === false;
|
|
410
|
+
const isProduction = manifestEnv === "production";
|
|
411
|
+
|
|
412
|
+
const problems: string[] = [];
|
|
413
|
+
if (devtoolsPresent && explicitlyDisabled) {
|
|
414
|
+
problems.push("_devtools.js present despite dev.devtools: false in mandu.config");
|
|
415
|
+
}
|
|
416
|
+
if (devtoolsPresent && isProduction) {
|
|
417
|
+
problems.push("_devtools.js present in a production build (manifest env=production)");
|
|
418
|
+
}
|
|
419
|
+
if (pollutedHtml.length > 0) {
|
|
420
|
+
problems.push(`${pollutedHtml.length} prerendered HTML file(s) reference a devtools script`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (problems.length === 0) {
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
rule: "dev_artifacts_in_prod",
|
|
427
|
+
message: devtoolsPresent
|
|
428
|
+
? "_devtools.js present — expected for dev builds."
|
|
429
|
+
: "No _devtools.js artifact detected.",
|
|
430
|
+
details: { devtoolsPresent, manifestEnv, configDevtools, pollutedHtml: pollutedHtml.length },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
rule: "dev_artifacts_in_prod",
|
|
437
|
+
severity: "error",
|
|
438
|
+
message: problems.join("; "),
|
|
439
|
+
suggestion: "Run `mandu clean && mandu build` to produce a dev-artifact-free production bundle.",
|
|
440
|
+
details: { devtoolsPresent, manifestEnv, configDevtools, pollutedHtml },
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
445
|
+
// 5. package_export_gaps
|
|
446
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Walk a directory and collect `.ts/.tsx/.js/.jsx/.mts/.cts` source files.
|
|
450
|
+
* `node_modules`, `.mandu`, and dot-directories are skipped.
|
|
451
|
+
*/
|
|
452
|
+
async function collectSourceFiles(rootDir: string, maxDepth = 10): Promise<string[]> {
|
|
453
|
+
const found: string[] = [];
|
|
454
|
+
const extensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
455
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
456
|
+
if (depth > maxDepth) return;
|
|
457
|
+
let entries: Dirent[];
|
|
458
|
+
try {
|
|
459
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
460
|
+
} catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
if (entry.name === "node_modules" || entry.name === ".mandu" || entry.name.startsWith(".")) continue;
|
|
465
|
+
const full = path.join(dir, entry.name);
|
|
466
|
+
if (entry.isDirectory()) {
|
|
467
|
+
await walk(full, depth + 1);
|
|
468
|
+
} else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
|
469
|
+
found.push(full);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
await walk(rootDir, 0);
|
|
474
|
+
return found;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Parse an import specifier starting with `@mandujs/core` into a subpath.
|
|
479
|
+
* Returns `"."` for bare `@mandujs/core`, `"./client"` for
|
|
480
|
+
* `@mandujs/core/client`, etc. Normalizes backslashes for Windows sources.
|
|
481
|
+
*/
|
|
482
|
+
function extractCoreSubpath(specifier: string): string | null {
|
|
483
|
+
const normalized = specifier.replace(/\\/g, "/");
|
|
484
|
+
if (normalized === "@mandujs/core") return ".";
|
|
485
|
+
if (normalized.startsWith("@mandujs/core/")) return "./" + normalized.slice("@mandujs/core/".length);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Resolve the `@mandujs/core` package export map. Tries, in order:
|
|
491
|
+
* 1. `<rootDir>/node_modules/@mandujs/core/package.json`
|
|
492
|
+
* 2. walk up one level to the monorepo root and retry
|
|
493
|
+
*
|
|
494
|
+
* Returns the parsed `exports` map (or `null` when the package is not
|
|
495
|
+
* resolvable — e.g. pure-fixture test directories).
|
|
496
|
+
*/
|
|
497
|
+
async function resolveCoreExports(rootDir: string): Promise<Record<string, unknown> | null> {
|
|
498
|
+
const candidates = [
|
|
499
|
+
path.join(rootDir, "node_modules", "@mandujs", "core", "package.json"),
|
|
500
|
+
path.join(rootDir, "..", "node_modules", "@mandujs", "core", "package.json"),
|
|
501
|
+
path.join(rootDir, "..", "..", "node_modules", "@mandujs", "core", "package.json"),
|
|
502
|
+
];
|
|
503
|
+
for (const candidate of candidates) {
|
|
504
|
+
try {
|
|
505
|
+
const raw = await fs.readFile(candidate, "utf-8");
|
|
506
|
+
const parsed = JSON.parse(raw) as { exports?: Record<string, unknown> };
|
|
507
|
+
if (parsed.exports && typeof parsed.exports === "object") {
|
|
508
|
+
return parsed.exports;
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// try next
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Match a user subpath against the exports map. The map supports exact
|
|
519
|
+
* keys plus `./*` wildcard fallback. We accept either.
|
|
520
|
+
*/
|
|
521
|
+
function subpathIsExported(subpath: string, exports: Record<string, unknown>): boolean {
|
|
522
|
+
if (subpath in exports) return true;
|
|
523
|
+
// Wildcard — `"./*": "./src/*"`
|
|
524
|
+
if ("./*" in exports) return true;
|
|
525
|
+
// Pattern export — `"./foo/*": "./src/foo/*.ts"` (less common but valid).
|
|
526
|
+
for (const key of Object.keys(exports)) {
|
|
527
|
+
if (!key.endsWith("/*")) continue;
|
|
528
|
+
const prefix = key.slice(0, -1); // strip trailing `*`, keep `/`
|
|
529
|
+
if (subpath.startsWith(prefix) && subpath.length > prefix.length) return true;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* #215 check 5: detect user imports of `@mandujs/core/<subpath>` that are
|
|
536
|
+
* not declared in the installed core's `exports` map. This catches the
|
|
537
|
+
* #194/#202/#210 pattern where example code in docs or agent-generated
|
|
538
|
+
* code imports a subpath that was renamed or never shipped.
|
|
539
|
+
*/
|
|
540
|
+
export async function checkPackageExportGaps(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
541
|
+
const exportsMap = await resolveCoreExports(rootDir);
|
|
542
|
+
if (!exportsMap) {
|
|
543
|
+
return {
|
|
544
|
+
ok: true,
|
|
545
|
+
rule: "package_export_gaps",
|
|
546
|
+
message: "@mandujs/core package.json not resolvable from this project — skipping (OK for fixture/test roots).",
|
|
547
|
+
details: { skipped: true },
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const files = await collectSourceFiles(rootDir);
|
|
552
|
+
// Match both `from "@mandujs/core/..."` and `require("@mandujs/core/...")` forms.
|
|
553
|
+
const importRegex = /(?:from|require\()\s*["']([^"']+)["']/g;
|
|
554
|
+
const userSubpaths = new Map<string, string[]>();
|
|
555
|
+
|
|
556
|
+
for (const file of files) {
|
|
557
|
+
let content: string;
|
|
558
|
+
try {
|
|
559
|
+
content = await fs.readFile(file, "utf-8");
|
|
560
|
+
} catch {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
let match: RegExpExecArray | null;
|
|
564
|
+
importRegex.lastIndex = 0;
|
|
565
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
566
|
+
const specifier = match[1];
|
|
567
|
+
const subpath = extractCoreSubpath(specifier);
|
|
568
|
+
if (!subpath) continue;
|
|
569
|
+
if (!userSubpaths.has(subpath)) userSubpaths.set(subpath, []);
|
|
570
|
+
userSubpaths.get(subpath)!.push(path.relative(rootDir, file));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const gaps: Array<{ subpath: string; files: string[] }> = [];
|
|
575
|
+
for (const [subpath, filesUsingIt] of userSubpaths.entries()) {
|
|
576
|
+
if (!subpathIsExported(subpath, exportsMap)) {
|
|
577
|
+
gaps.push({ subpath: `@mandujs/core${subpath === "." ? "" : subpath.replace(/^\./, "")}`, files: filesUsingIt.slice(0, 5) });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (gaps.length === 0) {
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
rule: "package_export_gaps",
|
|
585
|
+
message: `Scanned ${files.length} source file(s) using ${userSubpaths.size} unique @mandujs/core subpath(s). All declared in exports map.`,
|
|
586
|
+
details: { scanned: files.length, uniqueSubpaths: userSubpaths.size },
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const first = gaps[0];
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
rule: "package_export_gaps",
|
|
594
|
+
severity: "error",
|
|
595
|
+
message: `${gaps.length} @mandujs/core subpath(s) imported but not in exports map. First: ${first.subpath} (used by ${first.files[0]}).`,
|
|
596
|
+
suggestion: "Verify the subpath exists in the installed core version, or upgrade @mandujs/core.",
|
|
597
|
+
details: { gapCount: gaps.length, gaps },
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
602
|
+
// 6. a11y_hints (Phase 18.χ)
|
|
603
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Find the first prerendered HTML leaf under `.mandu/prerendered/` or
|
|
607
|
+
* `.mandu/static/`. Returns `null` when nothing has been prerendered.
|
|
608
|
+
* Used as a cheap smoke target — auditing every page would be orders
|
|
609
|
+
* of magnitude heavier than every other diagnose check.
|
|
610
|
+
*/
|
|
611
|
+
async function findFirstPrerenderedHtml(rootDir: string): Promise<string | null> {
|
|
612
|
+
const dirs = [
|
|
613
|
+
path.join(rootDir, ".mandu", "prerendered"),
|
|
614
|
+
path.join(rootDir, ".mandu", "static"),
|
|
615
|
+
];
|
|
616
|
+
async function walk(dir: string, depth: number): Promise<string | null> {
|
|
617
|
+
if (depth > 8) return null;
|
|
618
|
+
let entries: Dirent[];
|
|
619
|
+
try {
|
|
620
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
// Stable sort so the smoke sample is deterministic across runs —
|
|
625
|
+
// otherwise a flaky a11y hint could bounce on/off depending on
|
|
626
|
+
// FS iteration order.
|
|
627
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
628
|
+
for (const entry of entries) {
|
|
629
|
+
if (entry.name.startsWith(".") || entry.name === "_manifest.json") continue;
|
|
630
|
+
const full = path.join(dir, entry.name);
|
|
631
|
+
if (entry.isDirectory()) {
|
|
632
|
+
const found = await walk(full, depth + 1);
|
|
633
|
+
if (found) return found;
|
|
634
|
+
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
635
|
+
return full;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
for (const dir of dirs) {
|
|
641
|
+
const found = await walk(dir, 0);
|
|
642
|
+
if (found) return found;
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Phase 18.χ — accessibility smoke hint.
|
|
649
|
+
*
|
|
650
|
+
* Runs the a11y audit against a single prerendered HTML file — the
|
|
651
|
+
* first one we can find. This is strictly a smoke test (auditing a
|
|
652
|
+
* whole build is too expensive for `mandu diagnose`, which is meant to
|
|
653
|
+
* run in < 1s). The check stays `warning` severity at worst; a11y
|
|
654
|
+
* failures should surface attention, not block deploys (that's what
|
|
655
|
+
* `mandu build --audit --audit-fail-on=critical` is for).
|
|
656
|
+
*
|
|
657
|
+
* Behaviour matrix:
|
|
658
|
+
* - No prerendered HTML on disk → ok (nothing to audit yet)
|
|
659
|
+
* - axe-core / DOM provider missing → ok (optional deps; informational)
|
|
660
|
+
* - Runner succeeds, zero criticals → ok
|
|
661
|
+
* - Runner succeeds with criticals → warning + suggestion pointing
|
|
662
|
+
* at the full-build command
|
|
663
|
+
*/
|
|
664
|
+
export async function checkA11yHints(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
665
|
+
const sample = await findFirstPrerenderedHtml(rootDir);
|
|
666
|
+
if (!sample) {
|
|
667
|
+
return {
|
|
668
|
+
ok: true,
|
|
669
|
+
rule: "a11y_hints",
|
|
670
|
+
message: "No prerendered HTML found — nothing to audit.",
|
|
671
|
+
details: { scanned: 0 },
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Lazy import — keeps the diagnose bundle tree free of the a11y
|
|
676
|
+
// module unless this specific check is actually invoked.
|
|
677
|
+
const { runAudit } = await import("../a11y/run-audit");
|
|
678
|
+
const report = await runAudit([sample], { minImpact: "critical" });
|
|
679
|
+
|
|
680
|
+
if (report.outcome === "axe-missing") {
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
rule: "a11y_hints",
|
|
684
|
+
message: "axe-core not installed — a11y smoke skipped (optional).",
|
|
685
|
+
details: {
|
|
686
|
+
sample: path.relative(rootDir, sample),
|
|
687
|
+
note: report.note,
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (report.outcome === "ok") {
|
|
693
|
+
return {
|
|
694
|
+
ok: true,
|
|
695
|
+
rule: "a11y_hints",
|
|
696
|
+
message: `a11y smoke PASS on ${path.relative(rootDir, sample)} (no critical violations).`,
|
|
697
|
+
details: {
|
|
698
|
+
sample: path.relative(rootDir, sample),
|
|
699
|
+
filesScanned: report.filesScanned,
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const top = report.violations[0];
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
rule: "a11y_hints",
|
|
708
|
+
severity: "warning",
|
|
709
|
+
message:
|
|
710
|
+
`a11y smoke found ${report.violations.length} critical violation(s) in ` +
|
|
711
|
+
`${path.relative(rootDir, sample)}. First: ${top.rule} — ${top.help}`,
|
|
712
|
+
suggestion: "Run `mandu build --audit` to audit every prerendered page.",
|
|
713
|
+
details: {
|
|
714
|
+
sample: path.relative(rootDir, sample),
|
|
715
|
+
violationCount: report.violations.length,
|
|
716
|
+
firstRule: top.rule,
|
|
717
|
+
impactCounts: report.impactCounts,
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
723
|
+
// 7. nested_internal_core (#261)
|
|
724
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
interface NestedCoreSite {
|
|
727
|
+
parentPackage: string;
|
|
728
|
+
nestedVersion: string;
|
|
729
|
+
relativePath: string;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function readPackageVersion(pkgJsonPath: string): Promise<string | null> {
|
|
733
|
+
try {
|
|
734
|
+
const raw = await fs.readFile(pkgJsonPath, "utf-8");
|
|
735
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
736
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
737
|
+
} catch {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function listMandujsSiblings(rootDir: string): Promise<string[]> {
|
|
743
|
+
const dir = path.join(rootDir, "node_modules", "@mandujs");
|
|
744
|
+
try {
|
|
745
|
+
const entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
746
|
+
return entries
|
|
747
|
+
.filter((e) => e.isDirectory() && e.name !== "core")
|
|
748
|
+
.map((e) => e.name);
|
|
749
|
+
} catch {
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* #261 check 7: detect a stale `@mandujs/core` nested inside another
|
|
756
|
+
* `@mandujs/*` package's `node_modules`.
|
|
757
|
+
*
|
|
758
|
+
* Symptom: `bunx @mandujs/mcp` fails with `Cannot find module
|
|
759
|
+
* @mandujs/core/<subpath>` even though the project has the latest
|
|
760
|
+
* `@mandujs/core` hoisted at the top level. Cause: a previous install
|
|
761
|
+
* left an older core nested under `@mandujs/<sibling>/node_modules`,
|
|
762
|
+
* which wins module resolution from inside that sibling's code, and the
|
|
763
|
+
* older core's `exports` map lacks the subpath the sibling now imports.
|
|
764
|
+
*
|
|
765
|
+
* This check walks `node_modules/@mandujs/*` siblings, looks for a
|
|
766
|
+
* nested `node_modules/@mandujs/core/package.json`, and compares the
|
|
767
|
+
* version to the hoisted core. A mismatch is reported as `error`
|
|
768
|
+
* (boot-breaking on the user's machine) with a copy-pastable fix.
|
|
769
|
+
*/
|
|
770
|
+
export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
|
|
771
|
+
const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
|
|
772
|
+
const hoistedVersion = await readPackageVersion(hoistedPath);
|
|
773
|
+
|
|
774
|
+
if (!hoistedVersion) {
|
|
775
|
+
return {
|
|
776
|
+
ok: true,
|
|
777
|
+
rule: "nested_internal_core",
|
|
778
|
+
message: "@mandujs/core not installed at the project root — skipping nested-version check.",
|
|
779
|
+
details: { skipped: true },
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const siblings = await listMandujsSiblings(rootDir);
|
|
784
|
+
const mismatches: NestedCoreSite[] = [];
|
|
785
|
+
|
|
786
|
+
for (const sibling of siblings) {
|
|
787
|
+
const nestedPkgJson = path.join(
|
|
788
|
+
rootDir,
|
|
789
|
+
"node_modules",
|
|
790
|
+
"@mandujs",
|
|
791
|
+
sibling,
|
|
792
|
+
"node_modules",
|
|
793
|
+
"@mandujs",
|
|
794
|
+
"core",
|
|
795
|
+
"package.json",
|
|
796
|
+
);
|
|
797
|
+
const nestedVersion = await readPackageVersion(nestedPkgJson);
|
|
798
|
+
if (!nestedVersion) continue;
|
|
799
|
+
if (nestedVersion === hoistedVersion) continue;
|
|
800
|
+
mismatches.push({
|
|
801
|
+
parentPackage: `@mandujs/${sibling}`,
|
|
802
|
+
nestedVersion,
|
|
803
|
+
relativePath: path.relative(rootDir, nestedPkgJson),
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (mismatches.length === 0) {
|
|
808
|
+
return {
|
|
809
|
+
ok: true,
|
|
810
|
+
rule: "nested_internal_core",
|
|
811
|
+
message: `No stale nested @mandujs/core found (hoisted: ${hoistedVersion}, scanned ${siblings.length} sibling(s)).`,
|
|
812
|
+
details: { hoistedVersion, scannedSiblings: siblings.length },
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const first = mismatches[0];
|
|
817
|
+
const fixCommand = `rm -rf node_modules/${first.parentPackage}/node_modules`;
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
rule: "nested_internal_core",
|
|
821
|
+
severity: "error",
|
|
822
|
+
message:
|
|
823
|
+
`${mismatches.length} stale nested @mandujs/core install(s) shadow the hoisted ${hoistedVersion}. ` +
|
|
824
|
+
`First: ${first.parentPackage} pinned to ${first.nestedVersion} (${first.relativePath}).`,
|
|
825
|
+
suggestion: `Run \`${fixCommand}\` and re-test \`bunx @mandujs/mcp\`, or remove node_modules and bun.lock entirely and \`bun install\`.`,
|
|
826
|
+
details: {
|
|
827
|
+
hoistedVersion,
|
|
828
|
+
mismatchCount: mismatches.length,
|
|
829
|
+
mismatches,
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|