@mandujs/core 0.54.31 → 0.55.0-beta.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 +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- 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 +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- 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 +14 -2
- 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/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- 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 +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- 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/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- 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 +16 -15
- 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/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- 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__/props-serialization.test.ts +37 -37
- 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 +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- 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-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- 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/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- 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/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- 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 +82 -82
- 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__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- 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/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- 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/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 +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- 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/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/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- 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/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 +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- 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/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- 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/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- 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/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- 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/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- 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/perf/user-marks.ts +1 -1
- 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 +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- 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/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- 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__/devtools-adapter.test.ts +68 -68
- 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__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- 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/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- 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/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +39 -20
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- 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/spec/schema.ts +35 -35
- 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/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- 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/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
import type * as __ManduNodeFsTypes0 from "node:fs";
|
|
2
|
-
/**
|
|
3
|
-
* Guard rule — `DESIGN_INLINE_CLASS` (Issue #245).
|
|
4
|
-
*
|
|
5
|
-
* Scans `<rootDir>/src` and `<rootDir>/app` (whichever exist) for
|
|
6
|
-
* `className` literals that contain a forbidden token, and emits a
|
|
7
|
-
* Guard violation with the replacement-component hint.
|
|
8
|
-
*
|
|
9
|
-
* # Detection strategy
|
|
10
|
-
*
|
|
11
|
-
* Regex-based, intentionally not AST-based. The bundler and runtime
|
|
12
|
-
* already pay AST cost; the Guard pass runs *frequently* (every
|
|
13
|
-
* `mandu build` and every `mandu guard check`) and a regex sweep is
|
|
14
|
-
* O(n) over file size with no parse failures. Tradeoff: a forbidden
|
|
15
|
-
* token inside a *comment* will still flag — accepted as the lesser
|
|
16
|
-
* evil vs. dragging in TypeScript parser overhead. False positives
|
|
17
|
-
* are easy to silence with the `exclude` field; false negatives
|
|
18
|
-
* (the regression we exist to prevent) are not.
|
|
19
|
-
*
|
|
20
|
-
* The matcher walks every quoted/backticked string region inside the
|
|
21
|
-
* file content (`"..."`, `'...'`, `` `...` ``). For each region, it
|
|
22
|
-
* tokenises by whitespace, normalises Tailwind-variant prefixes
|
|
23
|
-
* (`hover:btn-hard` → `btn-hard`), and looks for any forbidden token.
|
|
24
|
-
* Comments are not stripped — see tradeoff above.
|
|
25
|
-
*
|
|
26
|
-
* # Forbid-list sources
|
|
27
|
-
*
|
|
28
|
-
* - `guard.design.forbidInlineClasses` — explicit list, always honoured.
|
|
29
|
-
* - `guard.design.autoFromDesignMd` — when true, also pull tokens
|
|
30
|
-
* from DESIGN.md §7 Do's & Don'ts. Each "don't" rule is scanned
|
|
31
|
-
* for a quoted token (`Inline \`btn-hard\`` → "btn-hard"); rules
|
|
32
|
-
* without an extractable token are skipped.
|
|
33
|
-
*
|
|
34
|
-
* # Exclude paths
|
|
35
|
-
*
|
|
36
|
-
* Glob-matched against the file's relative path. Defaults to
|
|
37
|
-
* `src/client/shared/ui/**` and `src/client/widgets/**` so the rule
|
|
38
|
-
* never flags the canonical component dirs themselves — those are
|
|
39
|
-
* where the forbidden classes legitimately live.
|
|
40
|
-
*
|
|
41
|
-
* @module core/guard/design-inline-class
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
import fs from "node:fs/promises";
|
|
45
|
-
import path from "node:path";
|
|
46
|
-
|
|
47
|
-
import { parseDesignMd } from "../design";
|
|
48
|
-
import type { GuardViolation } from "./rules";
|
|
49
|
-
|
|
50
|
-
// ────────────────────────────────────────────────────────────────────
|
|
51
|
-
// Config + types
|
|
52
|
-
// ────────────────────────────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
export interface DesignGuardConfig {
|
|
55
|
-
designMd?: string;
|
|
56
|
-
forbidInlineClasses?: readonly string[];
|
|
57
|
-
autoFromDesignMd?: boolean;
|
|
58
|
-
requireComponent?: Readonly<Record<string, string>>;
|
|
59
|
-
exclude?: readonly string[];
|
|
60
|
-
severity?: "warning" | "error";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface ResolvedConfig {
|
|
64
|
-
forbid: Set<string>;
|
|
65
|
-
requireComponent: Record<string, string>;
|
|
66
|
-
exclude: string[];
|
|
67
|
-
severity: "warning" | "error";
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ────────────────────────────────────────────────────────────────────
|
|
71
|
-
// File traversal
|
|
72
|
-
// ────────────────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
const SOURCE_EXTS = new Set([".tsx", ".ts", ".jsx", ".js"]);
|
|
75
|
-
const SCAN_ROOTS = ["src", "app"] as const;
|
|
76
|
-
const SKIP_DIRS = new Set([
|
|
77
|
-
"node_modules",
|
|
78
|
-
".mandu",
|
|
79
|
-
".next",
|
|
80
|
-
"dist",
|
|
81
|
-
"build",
|
|
82
|
-
".git",
|
|
83
|
-
".turbo",
|
|
84
|
-
]);
|
|
85
|
-
|
|
86
|
-
async function pathExists(p: string): Promise<boolean> {
|
|
87
|
-
try {
|
|
88
|
-
await fs.access(p);
|
|
89
|
-
return true;
|
|
90
|
-
} catch {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function* walk(rootDir: string): AsyncIterable<string> {
|
|
96
|
-
let entries: __ManduNodeFsTypes0.Dirent[];
|
|
97
|
-
try {
|
|
98
|
-
entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
99
|
-
} catch {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
for (const entry of entries) {
|
|
103
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
104
|
-
const full = path.join(rootDir, entry.name);
|
|
105
|
-
if (entry.isDirectory()) {
|
|
106
|
-
yield* walk(full);
|
|
107
|
-
} else if (entry.isFile() && SOURCE_EXTS.has(path.extname(entry.name))) {
|
|
108
|
-
yield full;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ────────────────────────────────────────────────────────────────────
|
|
114
|
-
// Glob (minimal)
|
|
115
|
-
// ────────────────────────────────────────────────────────────────────
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Tiny glob matcher — `*` matches any character except `/`,
|
|
119
|
-
* `**` matches any character including `/`. Supports the patterns
|
|
120
|
-
* we actually emit as defaults; not a general-purpose minimatch.
|
|
121
|
-
*/
|
|
122
|
-
function globToRegExp(pattern: string): RegExp {
|
|
123
|
-
// Normalize backslashes (Windows callers may pass them).
|
|
124
|
-
const normalized = pattern.replace(/\\/g, "/");
|
|
125
|
-
let rx = "";
|
|
126
|
-
let i = 0;
|
|
127
|
-
while (i < normalized.length) {
|
|
128
|
-
const c = normalized[i];
|
|
129
|
-
if (c === "*") {
|
|
130
|
-
if (normalized[i + 1] === "*") {
|
|
131
|
-
rx += ".*";
|
|
132
|
-
i += 2;
|
|
133
|
-
if (normalized[i] === "/") i += 1; // consume `/` after `**`
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
rx += "[^/]*";
|
|
137
|
-
i += 1;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
141
|
-
rx += "\\" + c;
|
|
142
|
-
} else if (c === "?") {
|
|
143
|
-
rx += "[^/]";
|
|
144
|
-
} else {
|
|
145
|
-
rx += c;
|
|
146
|
-
}
|
|
147
|
-
i += 1;
|
|
148
|
-
}
|
|
149
|
-
return new RegExp(`^${rx}$`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function isExcluded(relPath: string, patterns: readonly string[]): boolean {
|
|
153
|
-
const normalized = relPath.replace(/\\/g, "/");
|
|
154
|
-
for (const pat of patterns) {
|
|
155
|
-
if (globToRegExp(pat).test(normalized)) return true;
|
|
156
|
-
}
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ────────────────────────────────────────────────────────────────────
|
|
161
|
-
// String-region scanner
|
|
162
|
-
// ────────────────────────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
interface Hit {
|
|
165
|
-
/** Forbidden class as configured. */
|
|
166
|
-
token: string;
|
|
167
|
-
/** 1-indexed line in the file. */
|
|
168
|
-
line: number;
|
|
169
|
-
/** 1-indexed column where the literal starts. */
|
|
170
|
-
column: number;
|
|
171
|
-
/** Original literal text (for the violation message). */
|
|
172
|
-
literal: string;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const STRING_RX = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g;
|
|
176
|
-
|
|
177
|
-
/** Strip Tailwind variant prefixes (`hover:btn-hard` → `btn-hard`). */
|
|
178
|
-
function stripVariantPrefix(token: string): string {
|
|
179
|
-
const lastColon = token.lastIndexOf(":");
|
|
180
|
-
return lastColon >= 0 ? token.slice(lastColon + 1) : token;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function scanContent(content: string, forbid: Set<string>): Hit[] {
|
|
184
|
-
if (forbid.size === 0) return [];
|
|
185
|
-
const hits: Hit[] = [];
|
|
186
|
-
// Pre-compute line index for fast line/column resolution.
|
|
187
|
-
const lineStarts: number[] = [0];
|
|
188
|
-
for (let i = 0; i < content.length; i += 1) {
|
|
189
|
-
if (content[i] === "\n") lineStarts.push(i + 1);
|
|
190
|
-
}
|
|
191
|
-
function locate(offset: number): { line: number; column: number } {
|
|
192
|
-
// Binary search on lineStarts.
|
|
193
|
-
let lo = 0;
|
|
194
|
-
let hi = lineStarts.length - 1;
|
|
195
|
-
while (lo <= hi) {
|
|
196
|
-
const mid = (lo + hi) >> 1;
|
|
197
|
-
if (lineStarts[mid] <= offset) lo = mid + 1;
|
|
198
|
-
else hi = mid - 1;
|
|
199
|
-
}
|
|
200
|
-
const line = hi + 1; // hi is the largest index with lineStarts[hi] <= offset
|
|
201
|
-
const column = offset - lineStarts[hi] + 1;
|
|
202
|
-
return { line, column };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
let m: RegExpExecArray | null;
|
|
206
|
-
while ((m = STRING_RX.exec(content)) !== null) {
|
|
207
|
-
const literal = m[2];
|
|
208
|
-
if (!literal) continue;
|
|
209
|
-
// Tokenise by whitespace; classes inside template-literal `${...}`
|
|
210
|
-
// expansions land in the surrounding text — close enough for
|
|
211
|
-
// detection, false negatives only if the user dynamically computes
|
|
212
|
-
// a forbidden class name (rare).
|
|
213
|
-
for (const raw of literal.split(/\s+/)) {
|
|
214
|
-
if (!raw) continue;
|
|
215
|
-
const token = stripVariantPrefix(raw);
|
|
216
|
-
if (forbid.has(token)) {
|
|
217
|
-
const { line, column } = locate(m.index);
|
|
218
|
-
hits.push({ token, line, column, literal });
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return hits;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ────────────────────────────────────────────────────────────────────
|
|
226
|
-
// DESIGN.md §7 Don't extractor (autoFromDesignMd)
|
|
227
|
-
// ────────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
const QUOTED_TOKEN_RX = /[`'"]([\w-]+)[`'"]/g;
|
|
230
|
-
|
|
231
|
-
function extractTokensFromDontRules(text: string): string[] {
|
|
232
|
-
const tokens: string[] = [];
|
|
233
|
-
let m: RegExpExecArray | null;
|
|
234
|
-
while ((m = QUOTED_TOKEN_RX.exec(text)) !== null) {
|
|
235
|
-
if (m[1]) tokens.push(m[1]);
|
|
236
|
-
}
|
|
237
|
-
return tokens;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function readDontRules(rootDir: string, designMdRel: string): Promise<string[]> {
|
|
241
|
-
const designPath = path.join(rootDir, designMdRel);
|
|
242
|
-
if (!(await pathExists(designPath))) return [];
|
|
243
|
-
let source: string;
|
|
244
|
-
try {
|
|
245
|
-
source = await fs.readFile(designPath, "utf-8");
|
|
246
|
-
} catch {
|
|
247
|
-
return [];
|
|
248
|
-
}
|
|
249
|
-
const spec = parseDesignMd(source);
|
|
250
|
-
const tokens = new Set<string>();
|
|
251
|
-
for (const rule of spec.sections["dos-donts"].rules) {
|
|
252
|
-
if (rule.kind !== "dont") continue;
|
|
253
|
-
for (const t of extractTokensFromDontRules(rule.text)) {
|
|
254
|
-
tokens.add(t);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return [...tokens];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// ────────────────────────────────────────────────────────────────────
|
|
261
|
-
// Public entry point
|
|
262
|
-
// ────────────────────────────────────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
async function resolveConfig(
|
|
265
|
-
rootDir: string,
|
|
266
|
-
config: DesignGuardConfig,
|
|
267
|
-
): Promise<ResolvedConfig> {
|
|
268
|
-
const forbid = new Set<string>(config.forbidInlineClasses ?? []);
|
|
269
|
-
if (config.autoFromDesignMd === true) {
|
|
270
|
-
const fromDesign = await readDontRules(rootDir, config.designMd ?? "DESIGN.md");
|
|
271
|
-
for (const t of fromDesign) forbid.add(t);
|
|
272
|
-
}
|
|
273
|
-
return {
|
|
274
|
-
forbid,
|
|
275
|
-
requireComponent: { ...(config.requireComponent ?? {}) },
|
|
276
|
-
exclude: [
|
|
277
|
-
...(config.exclude ?? [
|
|
278
|
-
"src/client/shared/ui/**",
|
|
279
|
-
"src/client/widgets/**",
|
|
280
|
-
]),
|
|
281
|
-
],
|
|
282
|
-
severity: config.severity ?? "error",
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function buildMessage(
|
|
287
|
-
hit: Hit,
|
|
288
|
-
requireComponent: Record<string, string>,
|
|
289
|
-
): string {
|
|
290
|
-
const replacement = requireComponent[hit.token];
|
|
291
|
-
if (replacement) {
|
|
292
|
-
return (
|
|
293
|
-
`Forbidden inline class "${hit.token}" — use ${replacement} instead. ` +
|
|
294
|
-
`(found in literal ${truncate(hit.literal, 60)})`
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
return (
|
|
298
|
-
`Forbidden inline class "${hit.token}" — see DESIGN.md §7 Do's & Don'ts ` +
|
|
299
|
-
`or move to src/client/shared/ui / widgets.`
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function truncate(s: string, max: number): string {
|
|
304
|
-
if (s.length <= max) return JSON.stringify(s);
|
|
305
|
-
return JSON.stringify(s.slice(0, max - 1) + "…");
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Run the same DESIGN_INLINE_CLASS scan against a single file —
|
|
310
|
-
* surface used by the MCP `mandu_design_check` wrapper so an agent
|
|
311
|
-
* can preview violations on a file it's about to edit, without
|
|
312
|
-
* walking the whole project tree. Returns the same `GuardViolation`
|
|
313
|
-
* shape as the project-wide checker.
|
|
314
|
-
*/
|
|
315
|
-
export async function checkFileForDesignInlineClasses(
|
|
316
|
-
rootDir: string,
|
|
317
|
-
filePath: string,
|
|
318
|
-
config: DesignGuardConfig | undefined,
|
|
319
|
-
): Promise<GuardViolation[]> {
|
|
320
|
-
if (!config) return [];
|
|
321
|
-
const resolved = await resolveConfig(rootDir, config);
|
|
322
|
-
if (resolved.forbid.size === 0) return [];
|
|
323
|
-
|
|
324
|
-
const absolute = path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath);
|
|
325
|
-
const rel = path.relative(rootDir, absolute).replace(/\\/g, "/");
|
|
326
|
-
if (isExcluded(rel, resolved.exclude)) return [];
|
|
327
|
-
|
|
328
|
-
let content: string;
|
|
329
|
-
try {
|
|
330
|
-
content = await fs.readFile(absolute, "utf-8");
|
|
331
|
-
} catch {
|
|
332
|
-
return [];
|
|
333
|
-
}
|
|
334
|
-
const hits = scanContent(content, resolved.forbid);
|
|
335
|
-
return hits.map((hit) => ({
|
|
336
|
-
ruleId: "DESIGN_INLINE_CLASS",
|
|
337
|
-
file: rel,
|
|
338
|
-
line: hit.line,
|
|
339
|
-
message: buildMessage(hit, resolved.requireComponent),
|
|
340
|
-
suggestion: resolved.requireComponent[hit.token]
|
|
341
|
-
? `Replace with ${resolved.requireComponent[hit.token]}.`
|
|
342
|
-
: "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.",
|
|
343
|
-
severity: resolved.severity,
|
|
344
|
-
}));
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Run the design-inline-class checker against the project source.
|
|
349
|
-
* Returns Guard violations using the standard `GuardViolation` shape
|
|
350
|
-
* so the existing reporter formats them uniformly.
|
|
351
|
-
*
|
|
352
|
-
* Skipped silently when `forbidInlineClasses` is empty AND
|
|
353
|
-
* `autoFromDesignMd` is false (or DESIGN.md has no don't tokens).
|
|
354
|
-
*/
|
|
355
|
-
export async function checkDesignInlineClasses(
|
|
356
|
-
rootDir: string,
|
|
357
|
-
config: DesignGuardConfig | undefined,
|
|
358
|
-
): Promise<GuardViolation[]> {
|
|
359
|
-
if (!config) return [];
|
|
360
|
-
const resolved = await resolveConfig(rootDir, config);
|
|
361
|
-
if (resolved.forbid.size === 0) return [];
|
|
362
|
-
|
|
363
|
-
const violations: GuardViolation[] = [];
|
|
364
|
-
for (const subdir of SCAN_ROOTS) {
|
|
365
|
-
const root = path.join(rootDir, subdir);
|
|
366
|
-
if (!(await pathExists(root))) continue;
|
|
367
|
-
for await (const file of walk(root)) {
|
|
368
|
-
const rel = path.relative(rootDir, file).replace(/\\/g, "/");
|
|
369
|
-
if (isExcluded(rel, resolved.exclude)) continue;
|
|
370
|
-
let content: string;
|
|
371
|
-
try {
|
|
372
|
-
content = await fs.readFile(file, "utf-8");
|
|
373
|
-
} catch {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
const hits = scanContent(content, resolved.forbid);
|
|
377
|
-
for (const hit of hits) {
|
|
378
|
-
const replacement = resolved.requireComponent[hit.token];
|
|
379
|
-
violations.push({
|
|
380
|
-
ruleId: "DESIGN_INLINE_CLASS",
|
|
381
|
-
file: rel,
|
|
382
|
-
line: hit.line,
|
|
383
|
-
message: buildMessage(hit, resolved.requireComponent),
|
|
384
|
-
suggestion: replacement
|
|
385
|
-
? `Replace with ${replacement}.`
|
|
386
|
-
: "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.",
|
|
387
|
-
severity: resolved.severity,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return violations;
|
|
393
|
-
}
|
|
1
|
+
import type * as __ManduNodeFsTypes0 from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Guard rule — `DESIGN_INLINE_CLASS` (Issue #245).
|
|
4
|
+
*
|
|
5
|
+
* Scans `<rootDir>/src` and `<rootDir>/app` (whichever exist) for
|
|
6
|
+
* `className` literals that contain a forbidden token, and emits a
|
|
7
|
+
* Guard violation with the replacement-component hint.
|
|
8
|
+
*
|
|
9
|
+
* # Detection strategy
|
|
10
|
+
*
|
|
11
|
+
* Regex-based, intentionally not AST-based. The bundler and runtime
|
|
12
|
+
* already pay AST cost; the Guard pass runs *frequently* (every
|
|
13
|
+
* `mandu build` and every `mandu guard check`) and a regex sweep is
|
|
14
|
+
* O(n) over file size with no parse failures. Tradeoff: a forbidden
|
|
15
|
+
* token inside a *comment* will still flag — accepted as the lesser
|
|
16
|
+
* evil vs. dragging in TypeScript parser overhead. False positives
|
|
17
|
+
* are easy to silence with the `exclude` field; false negatives
|
|
18
|
+
* (the regression we exist to prevent) are not.
|
|
19
|
+
*
|
|
20
|
+
* The matcher walks every quoted/backticked string region inside the
|
|
21
|
+
* file content (`"..."`, `'...'`, `` `...` ``). For each region, it
|
|
22
|
+
* tokenises by whitespace, normalises Tailwind-variant prefixes
|
|
23
|
+
* (`hover:btn-hard` → `btn-hard`), and looks for any forbidden token.
|
|
24
|
+
* Comments are not stripped — see tradeoff above.
|
|
25
|
+
*
|
|
26
|
+
* # Forbid-list sources
|
|
27
|
+
*
|
|
28
|
+
* - `guard.design.forbidInlineClasses` — explicit list, always honoured.
|
|
29
|
+
* - `guard.design.autoFromDesignMd` — when true, also pull tokens
|
|
30
|
+
* from DESIGN.md §7 Do's & Don'ts. Each "don't" rule is scanned
|
|
31
|
+
* for a quoted token (`Inline \`btn-hard\`` → "btn-hard"); rules
|
|
32
|
+
* without an extractable token are skipped.
|
|
33
|
+
*
|
|
34
|
+
* # Exclude paths
|
|
35
|
+
*
|
|
36
|
+
* Glob-matched against the file's relative path. Defaults to
|
|
37
|
+
* `src/client/shared/ui/**` and `src/client/widgets/**` so the rule
|
|
38
|
+
* never flags the canonical component dirs themselves — those are
|
|
39
|
+
* where the forbidden classes legitimately live.
|
|
40
|
+
*
|
|
41
|
+
* @module core/guard/design-inline-class
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import fs from "node:fs/promises";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
|
|
47
|
+
import { parseDesignMd } from "../design";
|
|
48
|
+
import type { GuardViolation } from "./rules";
|
|
49
|
+
|
|
50
|
+
// ────────────────────────────────────────────────────────────────────
|
|
51
|
+
// Config + types
|
|
52
|
+
// ────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export interface DesignGuardConfig {
|
|
55
|
+
designMd?: string;
|
|
56
|
+
forbidInlineClasses?: readonly string[];
|
|
57
|
+
autoFromDesignMd?: boolean;
|
|
58
|
+
requireComponent?: Readonly<Record<string, string>>;
|
|
59
|
+
exclude?: readonly string[];
|
|
60
|
+
severity?: "warning" | "error";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ResolvedConfig {
|
|
64
|
+
forbid: Set<string>;
|
|
65
|
+
requireComponent: Record<string, string>;
|
|
66
|
+
exclude: string[];
|
|
67
|
+
severity: "warning" | "error";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ────────────────────────────────────────────────────────────────────
|
|
71
|
+
// File traversal
|
|
72
|
+
// ────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const SOURCE_EXTS = new Set([".tsx", ".ts", ".jsx", ".js"]);
|
|
75
|
+
const SCAN_ROOTS = ["src", "app"] as const;
|
|
76
|
+
const SKIP_DIRS = new Set([
|
|
77
|
+
"node_modules",
|
|
78
|
+
".mandu",
|
|
79
|
+
".next",
|
|
80
|
+
"dist",
|
|
81
|
+
"build",
|
|
82
|
+
".git",
|
|
83
|
+
".turbo",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
await fs.access(p);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function* walk(rootDir: string): AsyncIterable<string> {
|
|
96
|
+
let entries: __ManduNodeFsTypes0.Dirent[];
|
|
97
|
+
try {
|
|
98
|
+
entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
104
|
+
const full = path.join(rootDir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
yield* walk(full);
|
|
107
|
+
} else if (entry.isFile() && SOURCE_EXTS.has(path.extname(entry.name))) {
|
|
108
|
+
yield full;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ────────────────────────────────────────────────────────────────────
|
|
114
|
+
// Glob (minimal)
|
|
115
|
+
// ────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Tiny glob matcher — `*` matches any character except `/`,
|
|
119
|
+
* `**` matches any character including `/`. Supports the patterns
|
|
120
|
+
* we actually emit as defaults; not a general-purpose minimatch.
|
|
121
|
+
*/
|
|
122
|
+
function globToRegExp(pattern: string): RegExp {
|
|
123
|
+
// Normalize backslashes (Windows callers may pass them).
|
|
124
|
+
const normalized = pattern.replace(/\\/g, "/");
|
|
125
|
+
let rx = "";
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < normalized.length) {
|
|
128
|
+
const c = normalized[i];
|
|
129
|
+
if (c === "*") {
|
|
130
|
+
if (normalized[i + 1] === "*") {
|
|
131
|
+
rx += ".*";
|
|
132
|
+
i += 2;
|
|
133
|
+
if (normalized[i] === "/") i += 1; // consume `/` after `**`
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
rx += "[^/]*";
|
|
137
|
+
i += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
141
|
+
rx += "\\" + c;
|
|
142
|
+
} else if (c === "?") {
|
|
143
|
+
rx += "[^/]";
|
|
144
|
+
} else {
|
|
145
|
+
rx += c;
|
|
146
|
+
}
|
|
147
|
+
i += 1;
|
|
148
|
+
}
|
|
149
|
+
return new RegExp(`^${rx}$`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isExcluded(relPath: string, patterns: readonly string[]): boolean {
|
|
153
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
154
|
+
for (const pat of patterns) {
|
|
155
|
+
if (globToRegExp(pat).test(normalized)) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ────────────────────────────────────────────────────────────────────
|
|
161
|
+
// String-region scanner
|
|
162
|
+
// ────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
interface Hit {
|
|
165
|
+
/** Forbidden class as configured. */
|
|
166
|
+
token: string;
|
|
167
|
+
/** 1-indexed line in the file. */
|
|
168
|
+
line: number;
|
|
169
|
+
/** 1-indexed column where the literal starts. */
|
|
170
|
+
column: number;
|
|
171
|
+
/** Original literal text (for the violation message). */
|
|
172
|
+
literal: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const STRING_RX = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g;
|
|
176
|
+
|
|
177
|
+
/** Strip Tailwind variant prefixes (`hover:btn-hard` → `btn-hard`). */
|
|
178
|
+
function stripVariantPrefix(token: string): string {
|
|
179
|
+
const lastColon = token.lastIndexOf(":");
|
|
180
|
+
return lastColon >= 0 ? token.slice(lastColon + 1) : token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function scanContent(content: string, forbid: Set<string>): Hit[] {
|
|
184
|
+
if (forbid.size === 0) return [];
|
|
185
|
+
const hits: Hit[] = [];
|
|
186
|
+
// Pre-compute line index for fast line/column resolution.
|
|
187
|
+
const lineStarts: number[] = [0];
|
|
188
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
189
|
+
if (content[i] === "\n") lineStarts.push(i + 1);
|
|
190
|
+
}
|
|
191
|
+
function locate(offset: number): { line: number; column: number } {
|
|
192
|
+
// Binary search on lineStarts.
|
|
193
|
+
let lo = 0;
|
|
194
|
+
let hi = lineStarts.length - 1;
|
|
195
|
+
while (lo <= hi) {
|
|
196
|
+
const mid = (lo + hi) >> 1;
|
|
197
|
+
if (lineStarts[mid] <= offset) lo = mid + 1;
|
|
198
|
+
else hi = mid - 1;
|
|
199
|
+
}
|
|
200
|
+
const line = hi + 1; // hi is the largest index with lineStarts[hi] <= offset
|
|
201
|
+
const column = offset - lineStarts[hi] + 1;
|
|
202
|
+
return { line, column };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let m: RegExpExecArray | null;
|
|
206
|
+
while ((m = STRING_RX.exec(content)) !== null) {
|
|
207
|
+
const literal = m[2];
|
|
208
|
+
if (!literal) continue;
|
|
209
|
+
// Tokenise by whitespace; classes inside template-literal `${...}`
|
|
210
|
+
// expansions land in the surrounding text — close enough for
|
|
211
|
+
// detection, false negatives only if the user dynamically computes
|
|
212
|
+
// a forbidden class name (rare).
|
|
213
|
+
for (const raw of literal.split(/\s+/)) {
|
|
214
|
+
if (!raw) continue;
|
|
215
|
+
const token = stripVariantPrefix(raw);
|
|
216
|
+
if (forbid.has(token)) {
|
|
217
|
+
const { line, column } = locate(m.index);
|
|
218
|
+
hits.push({ token, line, column, literal });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return hits;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ────────────────────────────────────────────────────────────────────
|
|
226
|
+
// DESIGN.md §7 Don't extractor (autoFromDesignMd)
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const QUOTED_TOKEN_RX = /[`'"]([\w-]+)[`'"]/g;
|
|
230
|
+
|
|
231
|
+
function extractTokensFromDontRules(text: string): string[] {
|
|
232
|
+
const tokens: string[] = [];
|
|
233
|
+
let m: RegExpExecArray | null;
|
|
234
|
+
while ((m = QUOTED_TOKEN_RX.exec(text)) !== null) {
|
|
235
|
+
if (m[1]) tokens.push(m[1]);
|
|
236
|
+
}
|
|
237
|
+
return tokens;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function readDontRules(rootDir: string, designMdRel: string): Promise<string[]> {
|
|
241
|
+
const designPath = path.join(rootDir, designMdRel);
|
|
242
|
+
if (!(await pathExists(designPath))) return [];
|
|
243
|
+
let source: string;
|
|
244
|
+
try {
|
|
245
|
+
source = await fs.readFile(designPath, "utf-8");
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const spec = parseDesignMd(source);
|
|
250
|
+
const tokens = new Set<string>();
|
|
251
|
+
for (const rule of spec.sections["dos-donts"].rules) {
|
|
252
|
+
if (rule.kind !== "dont") continue;
|
|
253
|
+
for (const t of extractTokensFromDontRules(rule.text)) {
|
|
254
|
+
tokens.add(t);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return [...tokens];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ────────────────────────────────────────────────────────────────────
|
|
261
|
+
// Public entry point
|
|
262
|
+
// ────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
async function resolveConfig(
|
|
265
|
+
rootDir: string,
|
|
266
|
+
config: DesignGuardConfig,
|
|
267
|
+
): Promise<ResolvedConfig> {
|
|
268
|
+
const forbid = new Set<string>(config.forbidInlineClasses ?? []);
|
|
269
|
+
if (config.autoFromDesignMd === true) {
|
|
270
|
+
const fromDesign = await readDontRules(rootDir, config.designMd ?? "DESIGN.md");
|
|
271
|
+
for (const t of fromDesign) forbid.add(t);
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
forbid,
|
|
275
|
+
requireComponent: { ...(config.requireComponent ?? {}) },
|
|
276
|
+
exclude: [
|
|
277
|
+
...(config.exclude ?? [
|
|
278
|
+
"src/client/shared/ui/**",
|
|
279
|
+
"src/client/widgets/**",
|
|
280
|
+
]),
|
|
281
|
+
],
|
|
282
|
+
severity: config.severity ?? "error",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function buildMessage(
|
|
287
|
+
hit: Hit,
|
|
288
|
+
requireComponent: Record<string, string>,
|
|
289
|
+
): string {
|
|
290
|
+
const replacement = requireComponent[hit.token];
|
|
291
|
+
if (replacement) {
|
|
292
|
+
return (
|
|
293
|
+
`Forbidden inline class "${hit.token}" — use ${replacement} instead. ` +
|
|
294
|
+
`(found in literal ${truncate(hit.literal, 60)})`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return (
|
|
298
|
+
`Forbidden inline class "${hit.token}" — see DESIGN.md §7 Do's & Don'ts ` +
|
|
299
|
+
`or move to src/client/shared/ui / widgets.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function truncate(s: string, max: number): string {
|
|
304
|
+
if (s.length <= max) return JSON.stringify(s);
|
|
305
|
+
return JSON.stringify(s.slice(0, max - 1) + "…");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Run the same DESIGN_INLINE_CLASS scan against a single file —
|
|
310
|
+
* surface used by the MCP `mandu_design_check` wrapper so an agent
|
|
311
|
+
* can preview violations on a file it's about to edit, without
|
|
312
|
+
* walking the whole project tree. Returns the same `GuardViolation`
|
|
313
|
+
* shape as the project-wide checker.
|
|
314
|
+
*/
|
|
315
|
+
export async function checkFileForDesignInlineClasses(
|
|
316
|
+
rootDir: string,
|
|
317
|
+
filePath: string,
|
|
318
|
+
config: DesignGuardConfig | undefined,
|
|
319
|
+
): Promise<GuardViolation[]> {
|
|
320
|
+
if (!config) return [];
|
|
321
|
+
const resolved = await resolveConfig(rootDir, config);
|
|
322
|
+
if (resolved.forbid.size === 0) return [];
|
|
323
|
+
|
|
324
|
+
const absolute = path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath);
|
|
325
|
+
const rel = path.relative(rootDir, absolute).replace(/\\/g, "/");
|
|
326
|
+
if (isExcluded(rel, resolved.exclude)) return [];
|
|
327
|
+
|
|
328
|
+
let content: string;
|
|
329
|
+
try {
|
|
330
|
+
content = await fs.readFile(absolute, "utf-8");
|
|
331
|
+
} catch {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
const hits = scanContent(content, resolved.forbid);
|
|
335
|
+
return hits.map((hit) => ({
|
|
336
|
+
ruleId: "DESIGN_INLINE_CLASS",
|
|
337
|
+
file: rel,
|
|
338
|
+
line: hit.line,
|
|
339
|
+
message: buildMessage(hit, resolved.requireComponent),
|
|
340
|
+
suggestion: resolved.requireComponent[hit.token]
|
|
341
|
+
? `Replace with ${resolved.requireComponent[hit.token]}.`
|
|
342
|
+
: "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.",
|
|
343
|
+
severity: resolved.severity,
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Run the design-inline-class checker against the project source.
|
|
349
|
+
* Returns Guard violations using the standard `GuardViolation` shape
|
|
350
|
+
* so the existing reporter formats them uniformly.
|
|
351
|
+
*
|
|
352
|
+
* Skipped silently when `forbidInlineClasses` is empty AND
|
|
353
|
+
* `autoFromDesignMd` is false (or DESIGN.md has no don't tokens).
|
|
354
|
+
*/
|
|
355
|
+
export async function checkDesignInlineClasses(
|
|
356
|
+
rootDir: string,
|
|
357
|
+
config: DesignGuardConfig | undefined,
|
|
358
|
+
): Promise<GuardViolation[]> {
|
|
359
|
+
if (!config) return [];
|
|
360
|
+
const resolved = await resolveConfig(rootDir, config);
|
|
361
|
+
if (resolved.forbid.size === 0) return [];
|
|
362
|
+
|
|
363
|
+
const violations: GuardViolation[] = [];
|
|
364
|
+
for (const subdir of SCAN_ROOTS) {
|
|
365
|
+
const root = path.join(rootDir, subdir);
|
|
366
|
+
if (!(await pathExists(root))) continue;
|
|
367
|
+
for await (const file of walk(root)) {
|
|
368
|
+
const rel = path.relative(rootDir, file).replace(/\\/g, "/");
|
|
369
|
+
if (isExcluded(rel, resolved.exclude)) continue;
|
|
370
|
+
let content: string;
|
|
371
|
+
try {
|
|
372
|
+
content = await fs.readFile(file, "utf-8");
|
|
373
|
+
} catch {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const hits = scanContent(content, resolved.forbid);
|
|
377
|
+
for (const hit of hits) {
|
|
378
|
+
const replacement = resolved.requireComponent[hit.token];
|
|
379
|
+
violations.push({
|
|
380
|
+
ruleId: "DESIGN_INLINE_CLASS",
|
|
381
|
+
file: rel,
|
|
382
|
+
line: hit.line,
|
|
383
|
+
message: buildMessage(hit, resolved.requireComponent),
|
|
384
|
+
suggestion: replacement
|
|
385
|
+
? `Replace with ${replacement}.`
|
|
386
|
+
: "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.",
|
|
387
|
+
severity: resolved.severity,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return violations;
|
|
393
|
+
}
|