@mandujs/core 0.44.0 → 0.45.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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Guard rule — `DESIGN_INLINE_CLASS` (Issue #245).
3
+ *
4
+ * Scans `<rootDir>/src` and `<rootDir>/app` (whichever exist) for
5
+ * `className` literals that contain a forbidden token, and emits a
6
+ * Guard violation with the replacement-component hint.
7
+ *
8
+ * # Detection strategy
9
+ *
10
+ * Regex-based, intentionally not AST-based. The bundler and runtime
11
+ * already pay AST cost; the Guard pass runs *frequently* (every
12
+ * `mandu build` and every `mandu guard check`) and a regex sweep is
13
+ * O(n) over file size with no parse failures. Tradeoff: a forbidden
14
+ * token inside a *comment* will still flag — accepted as the lesser
15
+ * evil vs. dragging in TypeScript parser overhead. False positives
16
+ * are easy to silence with the `exclude` field; false negatives
17
+ * (the regression we exist to prevent) are not.
18
+ *
19
+ * The matcher walks every quoted/backticked string region inside the
20
+ * file content (`"..."`, `'...'`, `` `...` ``). For each region, it
21
+ * tokenises by whitespace, normalises Tailwind-variant prefixes
22
+ * (`hover:btn-hard` → `btn-hard`), and looks for any forbidden token.
23
+ * Comments are not stripped — see tradeoff above.
24
+ *
25
+ * # Forbid-list sources
26
+ *
27
+ * - `guard.design.forbidInlineClasses` — explicit list, always honoured.
28
+ * - `guard.design.autoFromDesignMd` — when true, also pull tokens
29
+ * from DESIGN.md §7 Do's & Don'ts. Each "don't" rule is scanned
30
+ * for a quoted token (`Inline \`btn-hard\`` → "btn-hard"); rules
31
+ * without an extractable token are skipped.
32
+ *
33
+ * # Exclude paths
34
+ *
35
+ * Glob-matched against the file's relative path. Defaults to
36
+ * `src/client/shared/ui/**` and `src/client/widgets/**` so the rule
37
+ * never flags the canonical component dirs themselves — those are
38
+ * where the forbidden classes legitimately live.
39
+ *
40
+ * @module core/guard/design-inline-class
41
+ */
42
+
43
+ import fs from "node:fs/promises";
44
+ import path from "node:path";
45
+
46
+ import { parseDesignMd } from "../design";
47
+ import type { GuardViolation } from "./rules";
48
+
49
+ // ────────────────────────────────────────────────────────────────────
50
+ // Config + types
51
+ // ────────────────────────────────────────────────────────────────────
52
+
53
+ export interface DesignGuardConfig {
54
+ designMd?: string;
55
+ forbidInlineClasses?: readonly string[];
56
+ autoFromDesignMd?: boolean;
57
+ requireComponent?: Readonly<Record<string, string>>;
58
+ exclude?: readonly string[];
59
+ severity?: "warning" | "error";
60
+ }
61
+
62
+ interface ResolvedConfig {
63
+ forbid: Set<string>;
64
+ requireComponent: Record<string, string>;
65
+ exclude: string[];
66
+ severity: "warning" | "error";
67
+ }
68
+
69
+ // ────────────────────────────────────────────────────────────────────
70
+ // File traversal
71
+ // ────────────────────────────────────────────────────────────────────
72
+
73
+ const SOURCE_EXTS = new Set([".tsx", ".ts", ".jsx", ".js"]);
74
+ const SCAN_ROOTS = ["src", "app"] as const;
75
+ const SKIP_DIRS = new Set([
76
+ "node_modules",
77
+ ".mandu",
78
+ ".next",
79
+ "dist",
80
+ "build",
81
+ ".git",
82
+ ".turbo",
83
+ ]);
84
+
85
+ async function pathExists(p: string): Promise<boolean> {
86
+ try {
87
+ await fs.access(p);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function* walk(rootDir: string): AsyncIterable<string> {
95
+ let entries: import("node:fs").Dirent[];
96
+ try {
97
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const entry of entries) {
102
+ if (SKIP_DIRS.has(entry.name)) continue;
103
+ const full = path.join(rootDir, entry.name);
104
+ if (entry.isDirectory()) {
105
+ yield* walk(full);
106
+ } else if (entry.isFile() && SOURCE_EXTS.has(path.extname(entry.name))) {
107
+ yield full;
108
+ }
109
+ }
110
+ }
111
+
112
+ // ────────────────────────────────────────────────────────────────────
113
+ // Glob (minimal)
114
+ // ────────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Tiny glob matcher — `*` matches any character except `/`,
118
+ * `**` matches any character including `/`. Supports the patterns
119
+ * we actually emit as defaults; not a general-purpose minimatch.
120
+ */
121
+ function globToRegExp(pattern: string): RegExp {
122
+ // Normalize backslashes (Windows callers may pass them).
123
+ const normalized = pattern.replace(/\\/g, "/");
124
+ let rx = "";
125
+ let i = 0;
126
+ while (i < normalized.length) {
127
+ const c = normalized[i];
128
+ if (c === "*") {
129
+ if (normalized[i + 1] === "*") {
130
+ rx += ".*";
131
+ i += 2;
132
+ if (normalized[i] === "/") i += 1; // consume `/` after `**`
133
+ continue;
134
+ }
135
+ rx += "[^/]*";
136
+ i += 1;
137
+ continue;
138
+ }
139
+ if (/[.+^${}()|[\]\\]/.test(c)) {
140
+ rx += "\\" + c;
141
+ } else if (c === "?") {
142
+ rx += "[^/]";
143
+ } else {
144
+ rx += c;
145
+ }
146
+ i += 1;
147
+ }
148
+ return new RegExp(`^${rx}$`);
149
+ }
150
+
151
+ function isExcluded(relPath: string, patterns: readonly string[]): boolean {
152
+ const normalized = relPath.replace(/\\/g, "/");
153
+ for (const pat of patterns) {
154
+ if (globToRegExp(pat).test(normalized)) return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ // ────────────────────────────────────────────────────────────────────
160
+ // String-region scanner
161
+ // ────────────────────────────────────────────────────────────────────
162
+
163
+ interface Hit {
164
+ /** Forbidden class as configured. */
165
+ token: string;
166
+ /** 1-indexed line in the file. */
167
+ line: number;
168
+ /** 1-indexed column where the literal starts. */
169
+ column: number;
170
+ /** Original literal text (for the violation message). */
171
+ literal: string;
172
+ }
173
+
174
+ const STRING_RX = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g;
175
+
176
+ /** Strip Tailwind variant prefixes (`hover:btn-hard` → `btn-hard`). */
177
+ function stripVariantPrefix(token: string): string {
178
+ const lastColon = token.lastIndexOf(":");
179
+ return lastColon >= 0 ? token.slice(lastColon + 1) : token;
180
+ }
181
+
182
+ function scanContent(content: string, forbid: Set<string>): Hit[] {
183
+ if (forbid.size === 0) return [];
184
+ const hits: Hit[] = [];
185
+ // Pre-compute line index for fast line/column resolution.
186
+ const lineStarts: number[] = [0];
187
+ for (let i = 0; i < content.length; i += 1) {
188
+ if (content[i] === "\n") lineStarts.push(i + 1);
189
+ }
190
+ function locate(offset: number): { line: number; column: number } {
191
+ // Binary search on lineStarts.
192
+ let lo = 0;
193
+ let hi = lineStarts.length - 1;
194
+ while (lo <= hi) {
195
+ const mid = (lo + hi) >> 1;
196
+ if (lineStarts[mid] <= offset) lo = mid + 1;
197
+ else hi = mid - 1;
198
+ }
199
+ const line = hi + 1; // hi is the largest index with lineStarts[hi] <= offset
200
+ const column = offset - lineStarts[hi] + 1;
201
+ return { line, column };
202
+ }
203
+
204
+ let m: RegExpExecArray | null;
205
+ while ((m = STRING_RX.exec(content)) !== null) {
206
+ const literal = m[2];
207
+ if (!literal) continue;
208
+ // Tokenise by whitespace; classes inside template-literal `${...}`
209
+ // expansions land in the surrounding text — close enough for
210
+ // detection, false negatives only if the user dynamically computes
211
+ // a forbidden class name (rare).
212
+ for (const raw of literal.split(/\s+/)) {
213
+ if (!raw) continue;
214
+ const token = stripVariantPrefix(raw);
215
+ if (forbid.has(token)) {
216
+ const { line, column } = locate(m.index);
217
+ hits.push({ token, line, column, literal });
218
+ }
219
+ }
220
+ }
221
+ return hits;
222
+ }
223
+
224
+ // ────────────────────────────────────────────────────────────────────
225
+ // DESIGN.md §7 Don't extractor (autoFromDesignMd)
226
+ // ────────────────────────────────────────────────────────────────────
227
+
228
+ const QUOTED_TOKEN_RX = /[`'"]([\w-]+)[`'"]/g;
229
+
230
+ function extractTokensFromDontRules(text: string): string[] {
231
+ const tokens: string[] = [];
232
+ let m: RegExpExecArray | null;
233
+ while ((m = QUOTED_TOKEN_RX.exec(text)) !== null) {
234
+ if (m[1]) tokens.push(m[1]);
235
+ }
236
+ return tokens;
237
+ }
238
+
239
+ async function readDontRules(rootDir: string, designMdRel: string): Promise<string[]> {
240
+ const designPath = path.join(rootDir, designMdRel);
241
+ if (!(await pathExists(designPath))) return [];
242
+ let source: string;
243
+ try {
244
+ source = await fs.readFile(designPath, "utf-8");
245
+ } catch {
246
+ return [];
247
+ }
248
+ const spec = parseDesignMd(source);
249
+ const tokens = new Set<string>();
250
+ for (const rule of spec.sections["dos-donts"].rules) {
251
+ if (rule.kind !== "dont") continue;
252
+ for (const t of extractTokensFromDontRules(rule.text)) {
253
+ tokens.add(t);
254
+ }
255
+ }
256
+ return [...tokens];
257
+ }
258
+
259
+ // ────────────────────────────────────────────────────────────────────
260
+ // Public entry point
261
+ // ────────────────────────────────────────────────────────────────────
262
+
263
+ async function resolveConfig(
264
+ rootDir: string,
265
+ config: DesignGuardConfig,
266
+ ): Promise<ResolvedConfig> {
267
+ const forbid = new Set<string>(config.forbidInlineClasses ?? []);
268
+ if (config.autoFromDesignMd === true) {
269
+ const fromDesign = await readDontRules(rootDir, config.designMd ?? "DESIGN.md");
270
+ for (const t of fromDesign) forbid.add(t);
271
+ }
272
+ return {
273
+ forbid,
274
+ requireComponent: { ...(config.requireComponent ?? {}) },
275
+ exclude: [
276
+ ...(config.exclude ?? [
277
+ "src/client/shared/ui/**",
278
+ "src/client/widgets/**",
279
+ ]),
280
+ ],
281
+ severity: config.severity ?? "error",
282
+ };
283
+ }
284
+
285
+ function buildMessage(
286
+ hit: Hit,
287
+ requireComponent: Record<string, string>,
288
+ ): string {
289
+ const replacement = requireComponent[hit.token];
290
+ if (replacement) {
291
+ return (
292
+ `Forbidden inline class "${hit.token}" — use ${replacement} instead. ` +
293
+ `(found in literal ${truncate(hit.literal, 60)})`
294
+ );
295
+ }
296
+ return (
297
+ `Forbidden inline class "${hit.token}" — see DESIGN.md §7 Do's & Don'ts ` +
298
+ `or move to src/client/shared/ui / widgets.`
299
+ );
300
+ }
301
+
302
+ function truncate(s: string, max: number): string {
303
+ if (s.length <= max) return JSON.stringify(s);
304
+ return JSON.stringify(s.slice(0, max - 1) + "…");
305
+ }
306
+
307
+ /**
308
+ * Run the design-inline-class checker against the project source.
309
+ * Returns Guard violations using the standard `GuardViolation` shape
310
+ * so the existing reporter formats them uniformly.
311
+ *
312
+ * Skipped silently when `forbidInlineClasses` is empty AND
313
+ * `autoFromDesignMd` is false (or DESIGN.md has no don't tokens).
314
+ */
315
+ export async function checkDesignInlineClasses(
316
+ rootDir: string,
317
+ config: DesignGuardConfig | undefined,
318
+ ): Promise<GuardViolation[]> {
319
+ if (!config) return [];
320
+ const resolved = await resolveConfig(rootDir, config);
321
+ if (resolved.forbid.size === 0) return [];
322
+
323
+ const violations: GuardViolation[] = [];
324
+ for (const subdir of SCAN_ROOTS) {
325
+ const root = path.join(rootDir, subdir);
326
+ if (!(await pathExists(root))) continue;
327
+ for await (const file of walk(root)) {
328
+ const rel = path.relative(rootDir, file).replace(/\\/g, "/");
329
+ if (isExcluded(rel, resolved.exclude)) continue;
330
+ let content: string;
331
+ try {
332
+ content = await fs.readFile(file, "utf-8");
333
+ } catch {
334
+ continue;
335
+ }
336
+ const hits = scanContent(content, resolved.forbid);
337
+ for (const hit of hits) {
338
+ const replacement = resolved.requireComponent[hit.token];
339
+ violations.push({
340
+ ruleId: "DESIGN_INLINE_CLASS",
341
+ file: rel,
342
+ line: hit.line,
343
+ message: buildMessage(hit, resolved.requireComponent),
344
+ suggestion: replacement
345
+ ? `Replace with ${replacement}.`
346
+ : "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.",
347
+ severity: resolved.severity,
348
+ });
349
+ }
350
+ }
351
+ }
352
+ return violations;
353
+ }
@@ -122,6 +122,15 @@ export const GUARD_RULES: Record<string, GuardRule> = {
122
122
  description: "spec/contracts/ 디렉토리에 .contract.ts가 아닌 파일이 있습니다",
123
123
  severity: "error",
124
124
  },
125
+ // Issue #245 — design system enforcement
126
+ DESIGN_INLINE_CLASS: {
127
+ id: "DESIGN_INLINE_CLASS",
128
+ name: "Forbidden Inline Class",
129
+ description:
130
+ "A className contains a token that DESIGN.md / guard.design declares forbidden outside the canonical component dirs. " +
131
+ "Replace the inline usage with the suggested component (or move to src/client/shared/ui or src/client/widgets).",
132
+ severity: "error",
133
+ },
125
134
  };
126
135
 
127
136
  export const FORBIDDEN_IMPORTS = ["fs", "child_process", "cluster", "worker_threads"];
@@ -1638,17 +1638,38 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
1638
1638
  }
1639
1639
  }
1640
1640
 
1641
+ /**
1642
+ * Issue #251 — public 폴더의 자산을 root URL로도 서빙하기 위한 화이트리스트.
1643
+ *
1644
+ * `mandu build --static` 은 `public/*` 을 dist 루트로 평탄화하므로 prod 에서는
1645
+ * `/images/foo.webp` 가 동작한다. dev 에서는 평탄화가 없어서 같은 URL 이 404
1646
+ * 였다 — 작성자는 `/public/...` (dev OK, prod 도 vercel rewrite 로 OK) 또는
1647
+ * `/...` (dev 깨짐, prod OK) 중 하나를 골라야 했다. 이제 dev 도 자산 확장자가
1648
+ * 있는 경로에 한해 `public/<path>` 를 fallback 으로 시도한다.
1649
+ *
1650
+ * 자산 확장자만 fallback 하므로 `/api/foo` 같은 라우트가 가려질 위험은 없다.
1651
+ * 파일이 없으면 `{ handled: false }` 를 반환해 라우터가 정상 매칭하도록 한다.
1652
+ */
1653
+ const PUBLIC_FLAT_ASSET_EXTENSIONS = new Set<string>([
1654
+ ".webp", ".avif", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
1655
+ ".pdf", ".zip", ".mp4", ".webm", ".mp3", ".wav",
1656
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
1657
+ ".css", ".js", ".map",
1658
+ ]);
1659
+
1641
1660
  /**
1642
1661
  * 정적 파일 서빙
1643
1662
  * - /.mandu/client/* : 클라이언트 번들 (Island hydration)
1644
1663
  * - /public/* : 정적 에셋 (이미지, CSS 등)
1645
1664
  * - /favicon.ico : 파비콘
1665
+ * - /<asset>.<ext> : public/<asset>.<ext> fallback (issue #251)
1646
1666
  *
1647
1667
  * 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
1648
1668
  */
1649
1669
  async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
1650
1670
  let filePath: string | null = null;
1651
1671
  let isBundleFile = false;
1672
+ let isPublicFlatFallback = false;
1652
1673
  let allowedBaseDir: string;
1653
1674
  let relativePath: string;
1654
1675
 
@@ -1678,6 +1699,12 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
1678
1699
  ) {
1679
1700
  relativePath = path.basename(pathname);
1680
1701
  allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1702
+ }
1703
+ // 5. Public flat fallback (#251) — `mandu build --static` 의 평탄화와 dev 패리티
1704
+ else if (PUBLIC_FLAT_ASSET_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
1705
+ relativePath = pathname.slice(1);
1706
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1707
+ isPublicFlatFallback = true;
1681
1708
  } else {
1682
1709
  return { handled: false }; // 정적 파일이 아님
1683
1710
  }
@@ -1717,6 +1744,9 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
1717
1744
  const exists = await file.exists();
1718
1745
 
1719
1746
  if (!exists) {
1747
+ // #251 — flat fallback 은 라우트와 path 충돌이 가능하므로 미존재 시
1748
+ // 404 대신 라우터로 흘려보낸다 (e.g. `/foo.json` 라우트가 가려지지 않도록).
1749
+ if (isPublicFlatFallback) return { handled: false };
1720
1750
  return { handled: true, response: createStaticErrorResponse(404) };
1721
1751
  }
1722
1752