import type * as __ManduNodeFsTypes0 from "node:fs"; /** * Guard rule — `DESIGN_INLINE_CLASS` (Issue #245). * * Scans `/src` and `/app` (whichever exist) for * `className` literals that contain a forbidden token, and emits a * Guard violation with the replacement-component hint. * * # Detection strategy * * Regex-based, intentionally not AST-based. The bundler and runtime * already pay AST cost; the Guard pass runs *frequently* (every * `mandu build` and every `mandu guard check`) and a regex sweep is * O(n) over file size with no parse failures. Tradeoff: a forbidden * token inside a *comment* will still flag — accepted as the lesser * evil vs. dragging in TypeScript parser overhead. False positives * are easy to silence with the `exclude` field; false negatives * (the regression we exist to prevent) are not. * * The matcher walks every quoted/backticked string region inside the * file content (`"..."`, `'...'`, `` `...` ``). For each region, it * tokenises by whitespace, normalises Tailwind-variant prefixes * (`hover:btn-hard` → `btn-hard`), and looks for any forbidden token. * Comments are not stripped — see tradeoff above. * * # Forbid-list sources * * - `guard.design.forbidInlineClasses` — explicit list, always honoured. * - `guard.design.autoFromDesignMd` — when true, also pull tokens * from DESIGN.md §7 Do's & Don'ts. Each "don't" rule is scanned * for a quoted token (`Inline \`btn-hard\`` → "btn-hard"); rules * without an extractable token are skipped. * * # Exclude paths * * Glob-matched against the file's relative path. Defaults to * `src/client/shared/ui/**` and `src/client/widgets/**` so the rule * never flags the canonical component dirs themselves — those are * where the forbidden classes legitimately live. * * @module core/guard/design-inline-class */ import fs from "node:fs/promises"; import path from "node:path"; import { parseDesignMd } from "../design"; import type { GuardViolation } from "./rules"; // ──────────────────────────────────────────────────────────────────── // Config + types // ──────────────────────────────────────────────────────────────────── export interface DesignGuardConfig { designMd?: string; forbidInlineClasses?: readonly string[]; autoFromDesignMd?: boolean; requireComponent?: Readonly>; exclude?: readonly string[]; severity?: "warning" | "error"; } interface ResolvedConfig { forbid: Set; requireComponent: Record; exclude: string[]; severity: "warning" | "error"; } // ──────────────────────────────────────────────────────────────────── // File traversal // ──────────────────────────────────────────────────────────────────── const SOURCE_EXTS = new Set([".tsx", ".ts", ".jsx", ".js"]); const SCAN_ROOTS = ["src", "app"] as const; const SKIP_DIRS = new Set([ "node_modules", ".mandu", ".next", "dist", "build", ".git", ".turbo", ]); async function pathExists(p: string): Promise { try { await fs.access(p); return true; } catch { return false; } } async function* walk(rootDir: string): AsyncIterable { let entries: __ManduNodeFsTypes0.Dirent[]; try { entries = await fs.readdir(rootDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (SKIP_DIRS.has(entry.name)) continue; const full = path.join(rootDir, entry.name); if (entry.isDirectory()) { yield* walk(full); } else if (entry.isFile() && SOURCE_EXTS.has(path.extname(entry.name))) { yield full; } } } // ──────────────────────────────────────────────────────────────────── // Glob (minimal) // ──────────────────────────────────────────────────────────────────── /** * Tiny glob matcher — `*` matches any character except `/`, * `**` matches any character including `/`. Supports the patterns * we actually emit as defaults; not a general-purpose minimatch. */ function globToRegExp(pattern: string): RegExp { // Normalize backslashes (Windows callers may pass them). const normalized = pattern.replace(/\\/g, "/"); let rx = ""; let i = 0; while (i < normalized.length) { const c = normalized[i]; if (c === "*") { if (normalized[i + 1] === "*") { rx += ".*"; i += 2; if (normalized[i] === "/") i += 1; // consume `/` after `**` continue; } rx += "[^/]*"; i += 1; continue; } if (/[.+^${}()|[\]\\]/.test(c)) { rx += "\\" + c; } else if (c === "?") { rx += "[^/]"; } else { rx += c; } i += 1; } return new RegExp(`^${rx}$`); } function isExcluded(relPath: string, patterns: readonly string[]): boolean { const normalized = relPath.replace(/\\/g, "/"); for (const pat of patterns) { if (globToRegExp(pat).test(normalized)) return true; } return false; } // ──────────────────────────────────────────────────────────────────── // String-region scanner // ──────────────────────────────────────────────────────────────────── interface Hit { /** Forbidden class as configured. */ token: string; /** 1-indexed line in the file. */ line: number; /** 1-indexed column where the literal starts. */ column: number; /** Original literal text (for the violation message). */ literal: string; } const STRING_RX = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g; /** Strip Tailwind variant prefixes (`hover:btn-hard` → `btn-hard`). */ function stripVariantPrefix(token: string): string { const lastColon = token.lastIndexOf(":"); return lastColon >= 0 ? token.slice(lastColon + 1) : token; } function scanContent(content: string, forbid: Set): Hit[] { if (forbid.size === 0) return []; const hits: Hit[] = []; // Pre-compute line index for fast line/column resolution. const lineStarts: number[] = [0]; for (let i = 0; i < content.length; i += 1) { if (content[i] === "\n") lineStarts.push(i + 1); } function locate(offset: number): { line: number; column: number } { // Binary search on lineStarts. let lo = 0; let hi = lineStarts.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (lineStarts[mid] <= offset) lo = mid + 1; else hi = mid - 1; } const line = hi + 1; // hi is the largest index with lineStarts[hi] <= offset const column = offset - lineStarts[hi] + 1; return { line, column }; } let m: RegExpExecArray | null; while ((m = STRING_RX.exec(content)) !== null) { const literal = m[2]; if (!literal) continue; // Tokenise by whitespace; classes inside template-literal `${...}` // expansions land in the surrounding text — close enough for // detection, false negatives only if the user dynamically computes // a forbidden class name (rare). for (const raw of literal.split(/\s+/)) { if (!raw) continue; const token = stripVariantPrefix(raw); if (forbid.has(token)) { const { line, column } = locate(m.index); hits.push({ token, line, column, literal }); } } } return hits; } // ──────────────────────────────────────────────────────────────────── // DESIGN.md §7 Don't extractor (autoFromDesignMd) // ──────────────────────────────────────────────────────────────────── const QUOTED_TOKEN_RX = /[`'"]([\w-]+)[`'"]/g; function extractTokensFromDontRules(text: string): string[] { const tokens: string[] = []; let m: RegExpExecArray | null; while ((m = QUOTED_TOKEN_RX.exec(text)) !== null) { if (m[1]) tokens.push(m[1]); } return tokens; } async function readDontRules(rootDir: string, designMdRel: string): Promise { const designPath = path.join(rootDir, designMdRel); if (!(await pathExists(designPath))) return []; let source: string; try { source = await fs.readFile(designPath, "utf-8"); } catch { return []; } const spec = parseDesignMd(source); const tokens = new Set(); for (const rule of spec.sections["dos-donts"].rules) { if (rule.kind !== "dont") continue; for (const t of extractTokensFromDontRules(rule.text)) { tokens.add(t); } } return [...tokens]; } // ──────────────────────────────────────────────────────────────────── // Public entry point // ──────────────────────────────────────────────────────────────────── async function resolveConfig( rootDir: string, config: DesignGuardConfig, ): Promise { const forbid = new Set(config.forbidInlineClasses ?? []); if (config.autoFromDesignMd === true) { const fromDesign = await readDontRules(rootDir, config.designMd ?? "DESIGN.md"); for (const t of fromDesign) forbid.add(t); } return { forbid, requireComponent: { ...(config.requireComponent ?? {}) }, exclude: [ ...(config.exclude ?? [ "src/client/shared/ui/**", "src/client/widgets/**", ]), ], severity: config.severity ?? "error", }; } function buildMessage( hit: Hit, requireComponent: Record, ): string { const replacement = requireComponent[hit.token]; if (replacement) { return ( `Forbidden inline class "${hit.token}" — use ${replacement} instead. ` + `(found in literal ${truncate(hit.literal, 60)})` ); } return ( `Forbidden inline class "${hit.token}" — see DESIGN.md §7 Do's & Don'ts ` + `or move to src/client/shared/ui / widgets.` ); } function truncate(s: string, max: number): string { if (s.length <= max) return JSON.stringify(s); return JSON.stringify(s.slice(0, max - 1) + "…"); } /** * Run the same DESIGN_INLINE_CLASS scan against a single file — * surface used by the MCP `mandu_design_check` wrapper so an agent * can preview violations on a file it's about to edit, without * walking the whole project tree. Returns the same `GuardViolation` * shape as the project-wide checker. */ export async function checkFileForDesignInlineClasses( rootDir: string, filePath: string, config: DesignGuardConfig | undefined, ): Promise { if (!config) return []; const resolved = await resolveConfig(rootDir, config); if (resolved.forbid.size === 0) return []; const absolute = path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath); const rel = path.relative(rootDir, absolute).replace(/\\/g, "/"); if (isExcluded(rel, resolved.exclude)) return []; let content: string; try { content = await fs.readFile(absolute, "utf-8"); } catch { return []; } const hits = scanContent(content, resolved.forbid); return hits.map((hit) => ({ ruleId: "DESIGN_INLINE_CLASS", file: rel, line: hit.line, message: buildMessage(hit, resolved.requireComponent), suggestion: resolved.requireComponent[hit.token] ? `Replace with ${resolved.requireComponent[hit.token]}.` : "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.", severity: resolved.severity, })); } /** * Run the design-inline-class checker against the project source. * Returns Guard violations using the standard `GuardViolation` shape * so the existing reporter formats them uniformly. * * Skipped silently when `forbidInlineClasses` is empty AND * `autoFromDesignMd` is false (or DESIGN.md has no don't tokens). */ export async function checkDesignInlineClasses( rootDir: string, config: DesignGuardConfig | undefined, ): Promise { if (!config) return []; const resolved = await resolveConfig(rootDir, config); if (resolved.forbid.size === 0) return []; const violations: GuardViolation[] = []; for (const subdir of SCAN_ROOTS) { const root = path.join(rootDir, subdir); if (!(await pathExists(root))) continue; for await (const file of walk(root)) { const rel = path.relative(rootDir, file).replace(/\\/g, "/"); if (isExcluded(rel, resolved.exclude)) continue; let content: string; try { content = await fs.readFile(file, "utf-8"); } catch { continue; } const hits = scanContent(content, resolved.forbid); for (const hit of hits) { const replacement = resolved.requireComponent[hit.token]; violations.push({ ruleId: "DESIGN_INLINE_CLASS", file: rel, line: hit.line, message: buildMessage(hit, resolved.requireComponent), suggestion: replacement ? `Replace with ${replacement}.` : "Extract this class into a component under src/client/shared/ui/ or src/client/widgets/, or remove the inline usage.", severity: resolved.severity, }); } } } return violations; }