Skip to content

Commit 91f0fbc

Browse files
authored
add ast-grep checks to appkit template (#4154)
## Changes Added ast-grep checks to appkit template ## Why Unlock additional validation layer ## Tests Bulk generation
1 parent 8f66fb8 commit 91f0fbc

File tree

3 files changed

+147
-0
lines changed

3 files changed

+147
-0
lines changed

experimental/apps-mcp/lib/validation/nodejs.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid
5050
errorPrefix: "Failed to run client typecheck",
5151
displayName: "Type check",
5252
},
53+
{
54+
name: "ast-grep-lint",
55+
command: "npm run lint:ast-grep --if-present",
56+
errorPrefix: "AST-grep lint found violations",
57+
displayName: "AST-grep lint",
58+
},
5359
{
5460
name: "tests",
5561
command: "npm run test --if-present",

experimental/apps-mcp/templates/appkit/template/{{.project_name}}/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit",
1313
"lint": "eslint .",
1414
"lint:fix": "eslint . --fix",
15+
"lint:ast-grep": "tsx scripts/lint-ast-grep.ts",
1516
"format": "prettier --check .",
1617
"format:fix": "prettier --write .",
1718
"test": "vitest run && npm run test:smoke",
@@ -76,6 +77,7 @@
7677
"zod": "^4.1.13"
7778
},
7879
"devDependencies": {
80+
"@ast-grep/napi": "^0.37.0",
7981
"@eslint/compat": "^2.0.0",
8082
"@eslint/js": "^9.39.1",
8183
"@playwright/test": "^1.57.0",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* AST-based linting using ast-grep.
3+
* Catches patterns that ESLint/TypeScript miss or handle poorly.
4+
* Usage: npx tsx scripts/lint-ast-grep.ts
5+
*/
6+
7+
import { parse, Lang } from '@ast-grep/napi';
8+
import fs from 'node:fs';
9+
import path from 'node:path';
10+
11+
interface LintViolation {
12+
file: string;
13+
line: number;
14+
column: number;
15+
rule: string;
16+
message: string;
17+
code: string;
18+
}
19+
20+
interface LintRule {
21+
id: string;
22+
pattern: string;
23+
message: string;
24+
filter?: (code: string) => boolean;
25+
includeTests?: boolean; // default true - set false to skip test files
26+
}
27+
28+
const rules: LintRule[] = [
29+
{
30+
id: 'no-double-type-assertion',
31+
pattern: '$X as unknown as $Y',
32+
message: 'Avoid double type assertion (as unknown as). Use proper type guards or fix the source type.',
33+
},
34+
{
35+
id: 'no-as-any',
36+
pattern: '$X as any',
37+
message: 'Avoid "as any" type assertion. Use proper typing or unknown with type guards.',
38+
includeTests: false, // acceptable in test mocks
39+
},
40+
{
41+
id: 'no-array-index-key',
42+
pattern: 'key={$IDX}',
43+
message: 'Avoid using array index as React key. Use a stable unique identifier.',
44+
filter: (code) => /key=\{(idx|index|i)\}/.test(code),
45+
},
46+
{
47+
id: 'no-parse-float-without-validation',
48+
pattern: 'parseFloat($X).toFixed($Y)',
49+
message: 'parseFloat can return NaN. Validate input or use toNumber() helper from shared/types.ts.',
50+
},
51+
];
52+
53+
function isTestFile(filePath: string): boolean {
54+
return /\.(test|spec)\.(ts|tsx)$/.test(filePath) || filePath.includes('/tests/');
55+
}
56+
57+
function findTsFiles(dir: string, files: string[] = []): string[] {
58+
const entries = fs.readdirSync(dir, { withFileTypes: true });
59+
60+
for (const entry of entries) {
61+
const fullPath = path.join(dir, entry.name);
62+
63+
if (entry.isDirectory()) {
64+
if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue;
65+
findTsFiles(fullPath, files);
66+
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
67+
files.push(fullPath);
68+
}
69+
}
70+
71+
return files;
72+
}
73+
74+
function lintFile(filePath: string, rules: LintRule[]): LintViolation[] {
75+
const violations: LintViolation[] = [];
76+
const content = fs.readFileSync(filePath, 'utf-8');
77+
const lang = filePath.endsWith('.tsx') ? Lang.Tsx : Lang.TypeScript;
78+
const testFile = isTestFile(filePath);
79+
80+
const ast = parse(lang, content);
81+
const root = ast.root();
82+
83+
for (const rule of rules) {
84+
// skip rules that don't apply to test files
85+
if (testFile && rule.includeTests === false) continue;
86+
87+
const matches = root.findAll(rule.pattern);
88+
89+
for (const match of matches) {
90+
const code = match.text();
91+
92+
if (rule.filter && !rule.filter(code)) continue;
93+
94+
const range = match.range();
95+
violations.push({
96+
file: filePath,
97+
line: range.start.line + 1,
98+
column: range.start.column + 1,
99+
rule: rule.id,
100+
message: rule.message,
101+
code: code.length > 80 ? code.slice(0, 77) + '...' : code,
102+
});
103+
}
104+
}
105+
106+
return violations;
107+
}
108+
109+
function main(): void {
110+
const rootDir = process.cwd();
111+
const files = findTsFiles(rootDir);
112+
113+
console.log(`Scanning ${files.length} TypeScript files...\n`);
114+
115+
const allViolations: LintViolation[] = [];
116+
117+
for (const file of files) {
118+
const violations = lintFile(file, rules);
119+
allViolations.push(...violations);
120+
}
121+
122+
if (allViolations.length === 0) {
123+
console.log('No ast-grep lint violations found.');
124+
process.exit(0);
125+
}
126+
127+
console.log(`Found ${allViolations.length} violation(s):\n`);
128+
129+
for (const v of allViolations) {
130+
const relPath = path.relative(rootDir, v.file);
131+
console.log(`${relPath}:${v.line}:${v.column}`);
132+
console.log(` ${v.rule}: ${v.message}`);
133+
console.log(` > ${v.code}\n`);
134+
}
135+
136+
process.exit(1);
137+
}
138+
139+
main();

0 commit comments

Comments
 (0)