diff options
| author | Fuwn <[email protected]> | 2026-02-03 06:01:31 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-03 19:34:35 -0800 |
| commit | 045f329bc0f51ee8212885604b603a332a42bd27 (patch) | |
| tree | 2cac966531b2f30764078581c684bfe695a21395 | |
| parent | feat(sdk): Implement Supabase storage adapters (diff) | |
| download | archived-imemio-045f329bc0f51ee8212885604b603a332a42bd27.tar.xz archived-imemio-045f329bc0f51ee8212885604b603a332a42bd27.zip | |
feat(iku): Add grammar-aware formatting checker package
| -rw-r--r-- | package.json | 6 | ||||
| -rw-r--r-- | packages/iku/package.json | 20 | ||||
| -rw-r--r-- | packages/iku/src/checker.ts | 246 | ||||
| -rw-r--r-- | packages/iku/src/cli.ts | 42 | ||||
| -rw-r--r-- | packages/iku/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/iku/tsconfig.json | 16 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 64 |
7 files changed, 382 insertions, 13 deletions
diff --git a/package.json b/package.json index a1c5fbf..d1cb03c 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,13 @@ "lint": "biome lint .", "format": "biome format --write .", "check": "biome check --write .", - "test": "turbo test" + "test": "turbo test", + "iku": "pnpm --filter @imemio/iku check" }, "devDependencies": { "@biomejs/biome": "^2.3.13", - "turbo": "^2" + "turbo": "^2", + "typescript": "^5.7.3" }, "packageManager": "[email protected]" } diff --git a/packages/iku/package.json b/packages/iku/package.json new file mode 100644 index 0000000..75b344e --- /dev/null +++ b/packages/iku/package.json @@ -0,0 +1,20 @@ +{ + "name": "@imemio/iku", + "version": "0.0.1", + "description": "Grammar-aware formatting checker for imemio", + "type": "module", + "bin": { + "iku": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "check": "node --import tsx ./src/cli.ts" + }, + "dependencies": { + "typescript": "^5.8.3" + }, + "devDependencies": { + "tsx": "^4.20.3", + "@types/node": "^22.15.21" + } +} diff --git a/packages/iku/src/checker.ts b/packages/iku/src/checker.ts new file mode 100644 index 0000000..d541028 --- /dev/null +++ b/packages/iku/src/checker.ts @@ -0,0 +1,246 @@ +import { readdirSync, statSync } from "node:fs"; +import { extname, join } from "node:path"; +import * as typescript from "typescript"; + +type StatementCategory = + | "import" + | "variableDeclaration" + | "if" + | "for" + | "while" + | "return" + | "throw" + | "expression" + | "other"; + +interface StatementInfo { + category: StatementCategory; + startLine: number; + endLine: number; + hasBlock: boolean; +} + +function getStatementCategory(node: typescript.Statement): StatementCategory { + if (typescript.isImportDeclaration(node)) { + return "import"; + } + + if (typescript.isVariableStatement(node)) { + return "variableDeclaration"; + } + + if (typescript.isIfStatement(node)) { + return "if"; + } + + if (typescript.isForStatement(node) || typescript.isForInStatement(node) || typescript.isForOfStatement(node)) { + return "for"; + } + + if (typescript.isWhileStatement(node) || typescript.isDoStatement(node)) { + return "while"; + } + + if (typescript.isReturnStatement(node)) { + return "return"; + } + + if (typescript.isThrowStatement(node)) { + return "throw"; + } + + if (typescript.isExpressionStatement(node)) { + return "expression"; + } + + return "other"; +} + +function statementHasBlock(node: typescript.Statement): boolean { + if (typescript.isIfStatement(node)) { + return typescript.isBlock(node.thenStatement); + } + + if (typescript.isForStatement(node) || typescript.isForInStatement(node) || typescript.isForOfStatement(node)) { + return typescript.isBlock(node.statement); + } + + if (typescript.isWhileStatement(node)) { + return typescript.isBlock(node.statement); + } + + if (typescript.isDoStatement(node)) { + return typescript.isBlock(node.statement); + } + + return false; +} + +function getStatementsFromBlock( + block: typescript.Block | typescript.SourceFile, + sourceFile: typescript.SourceFile, +): StatementInfo[] { + const statements: StatementInfo[] = []; + + for (const statement of block.statements) { + const category = getStatementCategory(statement); + const startPosition = statement.getStart(sourceFile); + const endPosition = statement.getEnd(); + const startLine = sourceFile.getLineAndCharacterOfPosition(startPosition).line + 1; + const endLine = sourceFile.getLineAndCharacterOfPosition(endPosition).line + 1; + const hasBlock = statementHasBlock(statement); + + statements.push({ category, startLine, endLine, hasBlock }); + } + + return statements; +} + +function countBlankLinesBetween( + previousEndLine: number, + currentStartLine: number, + sourceFile: typescript.SourceFile, +): number { + const text = sourceFile.getFullText(); + const lines = text.split("\n"); + let blankCount = 0; + + for (let lineIndex = previousEndLine; lineIndex < currentStartLine - 1; lineIndex++) { + const line = lines[lineIndex]; + + if (line !== undefined && line.trim() === "") { + blankCount++; + } + } + + return blankCount; +} + +function checkStatementSpacing( + statements: StatementInfo[], + sourceFile: typescript.SourceFile, + filePath: string, +): string[] { + const errors: string[] = []; + + for (let index = 1; index < statements.length; index++) { + const previous = statements[index - 1]; + const current = statements[index]; + + if (previous === undefined || current === undefined) { + continue; + } + + if (previous.category === "other" || current.category === "other") { + continue; + } + + const blankLines = countBlankLinesBetween(previous.endLine, current.startLine, sourceFile); + const sameCategory = previous.category === current.category; + + if (previous.category === "import" && current.category === "import") { + if (blankLines > 0) { + errors.push(`${filePath}:${current.startLine}: Blank line between imports`); + } + + continue; + } + + const bothHaveBlocks = previous.hasBlock && current.hasBlock; + + if (sameCategory && !bothHaveBlocks && blankLines > 0) { + errors.push( + `${filePath}:${current.startLine}: Unnecessary blank line between same statement types (${current.category})`, + ); + } + + if (sameCategory && bothHaveBlocks && blankLines === 0) { + errors.push( + `${filePath}:${current.startLine}: Missing blank line between scoped blocks of same type (${current.category})`, + ); + } + + if (!sameCategory && blankLines === 0) { + errors.push( + `${filePath}:${current.startLine}: Missing blank line between different statement types (${previous.category} -> ${current.category})`, + ); + } + } + + return errors; +} + +function visitBlocks( + node: typescript.Node, + sourceFile: typescript.SourceFile, + filePath: string, + errors: string[], +): void { + if (typescript.isBlock(node)) { + const statements = getStatementsFromBlock(node, sourceFile); + const blockErrors = checkStatementSpacing(statements, sourceFile, filePath); + + errors.push(...blockErrors); + } + + typescript.forEachChild(node, (child) => { + visitBlocks(child, sourceFile, filePath, errors); + }); +} + +export function checkFile(filePath: string): string[] { + const program = typescript.createProgram([filePath], { + target: typescript.ScriptTarget.ESNext, + module: typescript.ModuleKind.ESNext, + jsx: typescript.JsxEmit.ReactJSX, + allowJs: true, + noEmit: true, + skipLibCheck: true, + }); + const sourceFile = program.getSourceFile(filePath); + + if (!sourceFile) { + return []; + } + + const errors: string[] = []; + const topLevelStatements = getStatementsFromBlock(sourceFile, sourceFile); + const topLevelErrors = checkStatementSpacing(topLevelStatements, sourceFile, filePath); + + errors.push(...topLevelErrors); + visitBlocks(sourceFile, sourceFile, filePath, errors); + + return errors; +} + +export function walkDirectory(directory: string): string[] { + const files: string[] = []; + + for (const entry of readdirSync(directory)) { + const fullPath = join(directory, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + if (!entry.includes("node_modules") && !entry.includes("dist") && !entry.includes(".next")) { + files.push(...walkDirectory(fullPath)); + } + } else if ([".ts", ".tsx"].includes(extname(entry)) && !entry.endsWith(".d.ts")) { + files.push(fullPath); + } + } + + return files; +} + +export function checkDirectory(directory: string): { errors: string[]; hasErrors: boolean } { + const files = walkDirectory(directory); + const allErrors: string[] = []; + + for (const file of files) { + const errors = checkFile(file); + + allErrors.push(...errors); + } + + return { errors: allErrors, hasErrors: allErrors.length > 0 }; +} diff --git a/packages/iku/src/cli.ts b/packages/iku/src/cli.ts new file mode 100644 index 0000000..c0a85fc --- /dev/null +++ b/packages/iku/src/cli.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { checkDirectory } from "./checker.js"; + +function findMonorepoRoot(startDirectory: string): string | null { + let current = startDirectory; + + while (current !== dirname(current)) { + const packagesPath = join(current, "packages"); + const packageJsonPath = join(current, "package.json"); + + if (existsSync(packagesPath) && existsSync(packageJsonPath)) { + return current; + } + + current = dirname(current); + } + + return null; +} + +const monorepoRoot = findMonorepoRoot(process.cwd()); + +if (!monorepoRoot) { + console.error("Could not find monorepo root"); + process.exit(1); +} + +const targetDirectory = process.argv[2] ?? join(monorepoRoot, "packages"); +const { errors, hasErrors } = checkDirectory(targetDirectory); + +for (const error of errors) { + console.error(error); +} + +if (hasErrors) { + process.exit(1); +} else { + console.log("Iku formatting check passed"); +} diff --git a/packages/iku/src/index.ts b/packages/iku/src/index.ts new file mode 100644 index 0000000..dc64d12 --- /dev/null +++ b/packages/iku/src/index.ts @@ -0,0 +1 @@ +export { checkFile, walkDirectory, checkDirectory } from "./checker.js"; diff --git a/packages/iku/tsconfig.json b/packages/iku/tsconfig.json new file mode 100644 index 0000000..55742be --- /dev/null +++ b/packages/iku/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d6a79..896beae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,22 @@ importers: turbo: specifier: ^2 version: 2.8.2 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/iku: + dependencies: + typescript: + specifier: ^5.8.3 + version: 5.9.3 + devDependencies: + '@types/node': + specifier: ^22.15.21 + version: 22.19.8 + tsx: + specifier: ^4.20.3 + version: 4.21.0 packages/mcp: dependencies: @@ -48,7 +64,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 packages/web: dependencies: @@ -1285,6 +1301,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1559,6 +1578,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1694,6 +1716,11 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + resolution: {integrity: sha512-sumREbxABHxrWIwlK67sZEaDRE7+BFSU8gZj8OK+X7dLpgW1vTjsHzTECB5m2qzWlXHRdueAk8sKv7wyHqv9jw==} cpu: [x64] @@ -2520,13 +2547,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))': + '@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: '@vitest/[email protected]': dependencies: @@ -2835,6 +2862,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + dependencies: + resolve-pkg-maps: 1.0.0 + @@ -3044,6 +3075,8 @@ snapshots: + [email protected]: {} + dependencies: '@types/estree': 1.0.8 @@ -3233,6 +3266,13 @@ snapshots: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.1 + optionalDependencies: + fsevents: 2.3.3 + optional: true @@ -3274,13 +3314,13 @@ snapshots: + [email protected](@types/[email protected])([email protected])([email protected])([email protected]): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 transitivePeerDependencies: - '@types/node' - jiti @@ -3295,7 +3335,7 @@ snapshots: - tsx - yaml + [email protected](@types/[email protected])([email protected])([email protected])([email protected]): dependencies: esbuild: 0.25.12 fdir: 6.5.0([email protected]) @@ -3308,8 +3348,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + tsx: 4.21.0 + [email protected](@types/[email protected])([email protected])([email protected])([email protected]): dependencies: esbuild: 0.27.2 fdir: 6.5.0([email protected]) @@ -3322,12 +3363,13 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + tsx: 4.21.0 + [email protected](@types/[email protected])([email protected])([email protected])([email protected]): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4([email protected](@types/[email protected])([email protected])([email protected])) + '@vitest/mocker': 3.2.4([email protected](@types/[email protected])([email protected])([email protected])([email protected])) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3345,8 +3387,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 + vite-node: 3.2.4(@types/[email protected])([email protected])([email protected])([email protected]) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.8 |