From d5912a011dcfe2b0f3f71b7d286285985c9bff20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 04:43:37 +0000 Subject: [PATCH] Add lint tooling to prevent inside violations Add two lint mechanisms to catch the Ink error " can't be nested inside component" at build/lint time rather than runtime: 1. Biome GritQL plugin (no-box-in-text.grit) - for Biome v2+ users 2. Build-time check script (check-box-in-text.ts) - standalone AST check These help prevent the runtime error that occurs when Box components are incorrectly nested inside Text components in Ink applications. --- scripts/check-box-in-text.ts | 136 +++++++++++++++++++++++++++++++++++ scripts/no-box-in-text.grit | 22 ++++++ 2 files changed, 158 insertions(+) create mode 100644 scripts/check-box-in-text.ts create mode 100644 scripts/no-box-in-text.grit diff --git a/scripts/check-box-in-text.ts b/scripts/check-box-in-text.ts new file mode 100644 index 00000000..db46d7ba --- /dev/null +++ b/scripts/check-box-in-text.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env npx tsx +/** + * Build-time check: Ensure is never nested inside + * + * This catches the Ink error: " can't be nested inside component" + * + * Usage: + * npx tsx scripts/check-box-in-text.ts [directory] + * # or add to package.json scripts: + * # "lint:ink": "tsx scripts/check-box-in-text.ts src" + */ + +import * as fs from "fs"; +import * as path from "path"; +import { parse } from "@babel/parser"; +import traverse from "@babel/traverse"; +import type { NodePath } from "@babel/traverse"; +import type { JSXElement, JSXOpeningElement } from "@babel/types"; + +interface Violation { + file: string; + line: number; + column: number; + message: string; +} + +function getElementName(openingElement: JSXOpeningElement): string | null { + if (openingElement.name.type === "JSXIdentifier") { + return openingElement.name.name; + } + return null; +} + +function isInsideTextElement(path: NodePath): boolean { + let parent = path.parentPath; + while (parent) { + if ( + parent.isJSXElement() && + parent.node.openingElement.name.type === "JSXIdentifier" && + parent.node.openingElement.name.name === "Text" + ) { + return true; + } + parent = parent.parentPath; + } + return false; +} + +function checkFile(filePath: string): Violation[] { + const violations: Violation[] = []; + const code = fs.readFileSync(filePath, "utf-8"); + + let ast; + try { + ast = parse(code, { + sourceType: "module", + plugins: ["jsx", "typescript"], + }); + } catch { + // Skip files that can't be parsed + return violations; + } + + traverse(ast, { + JSXElement(path) { + const elementName = getElementName(path.node.openingElement); + + if (elementName === "Box" && isInsideTextElement(path)) { + const loc = path.node.loc; + violations.push({ + file: filePath, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + message: " can't be nested inside component", + }); + } + }, + }); + + return violations; +} + +function findTsxFiles(dir: string): string[] { + const files: string[] = []; + + function walk(currentDir: string) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + // Skip node_modules and hidden directories + if (!entry.name.startsWith(".") && entry.name !== "node_modules") { + walk(fullPath); + } + } else if (entry.isFile() && /\.(tsx|jsx)$/.test(entry.name)) { + files.push(fullPath); + } + } + } + + walk(dir); + return files; +} + +function main() { + const targetDir = process.argv[2] || "src"; + + if (!fs.existsSync(targetDir)) { + console.error(`Directory not found: ${targetDir}`); + process.exit(1); + } + + console.log(`Checking for inside violations in ${targetDir}...`); + + const files = findTsxFiles(targetDir); + const allViolations: Violation[] = []; + + for (const file of files) { + const violations = checkFile(file); + allViolations.push(...violations); + } + + if (allViolations.length > 0) { + console.error(`\nFound ${allViolations.length} violation(s):\n`); + for (const v of allViolations) { + console.error(` ${v.file}:${v.line}:${v.column}`); + console.error(` ${v.message}\n`); + } + process.exit(1); + } + + console.log(`Checked ${files.length} files - no violations found.`); + process.exit(0); +} + +main(); diff --git a/scripts/no-box-in-text.grit b/scripts/no-box-in-text.grit new file mode 100644 index 00000000..52e749e0 --- /dev/null +++ b/scripts/no-box-in-text.grit @@ -0,0 +1,22 @@ +// Biome GritQL Plugin: Prevent from being nested inside +// This catches the Ink error: " can't be nested inside component" + +language js; + +// Match Text elements that contain Box children (direct nesting) +`$children` where { + $children <: contains `$_`, + register_diagnostic( + span = $children, + message = " can't be nested inside component. Use as a sibling or wrap inside instead." + ) +} + +// Also match self-closing Box inside Text +`$children` where { + $children <: contains ``, + register_diagnostic( + span = $children, + message = " can't be nested inside component. Use as a sibling or wrap inside instead." + ) +}