aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-03 06:01:31 -0800
committerFuwn <[email protected]>2026-02-03 19:34:35 -0800
commit045f329bc0f51ee8212885604b603a332a42bd27 (patch)
tree2cac966531b2f30764078581c684bfe695a21395
parentfeat(sdk): Implement Supabase storage adapters (diff)
downloadarchived-imemio-045f329bc0f51ee8212885604b603a332a42bd27.tar.xz
archived-imemio-045f329bc0f51ee8212885604b603a332a42bd27.zip
feat(iku): Add grammar-aware formatting checker package
-rw-r--r--package.json6
-rw-r--r--packages/iku/package.json20
-rw-r--r--packages/iku/src/checker.ts246
-rw-r--r--packages/iku/src/cli.ts42
-rw-r--r--packages/iku/src/index.ts1
-rw-r--r--packages/iku/tsconfig.json16
-rw-r--r--pnpm-lock.yaml64
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
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:
+
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:
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
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
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
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@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
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.8