Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/codemods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"author": "",
"license": "MIT",
"dependencies": {
"@ast-grep/napi": "^0.28.0",
"@ast-grep/napi": "^0.42.0",
"chalk": "^5.3.0",
"commander": "^14.0.0",
"glob": "^11.0.0",
Expand All @@ -60,4 +60,4 @@
"volta": {
"extends": "../../package.json"
}
}
}
1 change: 1 addition & 0 deletions packages/codemods/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ createListCommand(program);
program.showHelpAfterError();

await program.parseAsync(process.argv);
await new Promise((resolve) => setTimeout(resolve, 5000));
20 changes: 6 additions & 14 deletions packages/codemods/src/schema-migration/processors/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parse, type SgNode } from '@ast-grep/napi';
import type { SgNode } from '@ast-grep/napi';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join, resolve } from 'path';

Expand All @@ -20,7 +20,6 @@ import {
FRAGMENT_BASE_SOURCE,
generateMergedSchemaCode,
getEmberDataImports,
getLanguageFromPath,
getMixinImports,
getModelImportSources,
isModelFile,
Expand Down Expand Up @@ -55,10 +54,7 @@ import { normalizePath, removeFileExtension, toKebabCase } from '../utils/string
* for each identifier in `identifierRefs`. These are included verbatim in the schema file
* so that schema field options can reference them by name.
*/
function collectConstantDecls(filePath: string, source: string, identifierRefs: Set<string>): string {
const lang = getLanguageFromPath(filePath);
const ast = parse(lang, source);
const root = ast.root();
function collectConstantDecls(root: SgNode, identifierRefs: Set<string>): string {
const declarations: string[] = [];

for (const exportStmt of root.findAll({ rule: { kind: 'export_statement' } })) {
Expand Down Expand Up @@ -140,14 +136,10 @@ interface ModelASTValidation {
}

/**
* Validate model AST: parse source, find imports, validate default export, check model class and fragment status.
* Validate model AST: find imports, validate default export, check model class and fragment status.
* Returns null if the file is not a valid model.
*/
function validateModelAST(filePath: string, source: string, options: TransformOptions): ModelASTValidation | null {
const lang = getLanguageFromPath(filePath);
const ast = parse(lang, source);
const root = ast.root();

function validateModelAST(root: SgNode, filePath: string, options: TransformOptions): ModelASTValidation | null {
const expectedSources = getModelImportSources(options);
const modelImportLocal = findEmberImportLocalName(root, expectedSources, options, filePath, process.cwd());
log.debug(`DEBUG: Model import local: ${modelImportLocal}`);
Expand Down Expand Up @@ -537,7 +529,7 @@ function generateRegularModelArtifacts(
}
}
const constantDeclarations =
identifierRefs.size > 0 ? collectConstantDecls(filePath, source, identifierRefs) : undefined;
identifierRefs.size > 0 ? collectConstantDecls(entity.parsedFile.root, identifierRefs) : undefined;

// Generate merged schema code (schema + types in one file)
const mergedSchemaCode = generateMergedSchemaCode({
Expand Down Expand Up @@ -749,7 +741,7 @@ function analyzeModelFromParsed(
const baseName = parsedFile.baseName;

try {
const validation = validateModelAST(filePath, parsedFile.source, options);
const validation = validateModelAST(parsedFile.root, filePath, options);
if (!validation) {
return createInvalidResult(modelName, baseName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,49 +40,29 @@ function getImportLocalNames(importNode: SgNode): string[] {
return names.filter(Boolean);
}

const NOT_INSIDE_IMPORT = { not: { inside: { kind: 'import_statement', stopBy: 'end' } } } as const;

/**
* Check whether any of the given names appear as identifiers in the non-import
* portion of the source. Checks both value identifiers and type identifiers
* Check whether any of the given names appear as identifiers outside of
* import statements. Checks both value identifiers and type identifiers
* (e.g., in `extends Foo` within interface declarations) to avoid false removals.
*/
function areAnyNamesUsed(names: string[], nonImportRoot: SgNode, lang: Lang): boolean {
function areAnyNamesUsed(names: string[], root: SgNode, lang: Lang): boolean {
const nameSet = new Set(names);

// In TypeScript, interface extends clauses produce `type_identifier` nodes
// rather than `identifier`, so we must check both kinds.
// JavaScript grammars don't have `type_identifier`.
const identifiers = nonImportRoot.findAll({ rule: { kind: 'identifier' } });
if (identifiers.some((id) => nameSet.has(id.text()))) return true;

if (lang === AstLang.TypeScript) {
const typeIdentifiers = nonImportRoot.findAll({ rule: { kind: 'type_identifier' } });
if (typeIdentifiers.some((id) => nameSet.has(id.text()))) return true;
}

return false;
const kinds: string[] = lang === AstLang.TypeScript ? ['identifier', 'type_identifier'] : ['identifier'];
return kinds.some((kind) =>
root.findAll({ rule: { kind, ...NOT_INSIDE_IMPORT } }).some((id) => nameSet.has(id.text()))
);
}

/**
* Shared setup for unused import removal: parse the source, split into
* import nodes and a non-import AST root for usage checking.
*/
function prepareUnusedImportRemoval(source: string, lang: Lang) {
function removeUnusedImports(source: string, lang: Lang): string {
const ast = parse(lang, source);
const root = ast.root();
const importNodes = root.findAll({ rule: { kind: 'import_statement' } });

// Build source without imports so identifier lookups only check usage sites
let nonImportSource = source;
for (const imp of importNodes) {
nonImportSource = nonImportSource.replaceAll(imp.text(), '');
}
const nonImportRoot = parse(lang, nonImportSource).root();

return { root, importNodes, nonImportRoot };
}

function removeUnusedImports(source: string, lang: Lang): string {
const { root, importNodes, nonImportRoot } = prepareUnusedImportRemoval(source, lang);
if (importNodes.length === 0) return source;

type Edit = ReturnType<SgNode['replace']>;
Expand All @@ -92,7 +72,7 @@ function removeUnusedImports(source: string, lang: Lang): string {
const localNames = getImportLocalNames(imp);
if (localNames.length === 0) continue;

if (!areAnyNamesUsed(localNames, nonImportRoot, lang)) {
if (!areAnyNamesUsed(localNames, root, lang)) {
edits.push(imp.replace(''));
}
}
Expand All @@ -101,7 +81,9 @@ function removeUnusedImports(source: string, lang: Lang): string {
}

function removeUnusedTypeImports(source: string, lang: Lang): string {
const { root, importNodes, nonImportRoot } = prepareUnusedImportRemoval(source, lang);
const ast = parse(lang, source);
const root = ast.root();
const importNodes = root.findAll({ rule: { kind: 'import_statement' } });
if (importNodes.length === 0) return source;

type Edit = ReturnType<SgNode['replace']>;
Expand All @@ -115,7 +97,7 @@ function removeUnusedTypeImports(source: string, lang: Lang): string {
const localNames = getImportLocalNames(imp);
if (localNames.length === 0) continue;

if (!areAnyNamesUsed(localNames, nonImportRoot, lang)) {
if (!areAnyNamesUsed(localNames, root, lang)) {
edits.push(imp.replace(''));
}
continue;
Expand All @@ -138,7 +120,7 @@ function removeUnusedTypeImports(source: string, lang: Lang): string {

const localName = (specifier.field('alias') ?? specifier.field('name'))?.text();

if (localName && !areAnyNamesUsed([localName], nonImportRoot, lang)) {
if (localName && !areAnyNamesUsed([localName], root, lang)) {
hasChanges = true;
} else {
keptSpecifiers.push(specText);
Expand Down
5 changes: 5 additions & 0 deletions packages/codemods/src/schema-migration/utils/file-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export interface ParsedFile {
extension: '.ts' | '.js';
/** Whether the file should be treated as TypeScript (extension is .ts or forceTypeScript is enabled) */
readonly isTypeScript: boolean;
/** Parsed AST — kept alive so downstream consumers can reuse it instead of re-parsing */
ast: ReturnType<typeof parse>;
root: SgNode;
/** All imports in the file */
imports: ParsedFileImport[];
/** EmberData schema fields (@attr, @hasMany, @belongsTo, fragment, etc.) */
Expand Down Expand Up @@ -878,6 +881,8 @@ export function parseFile(filePath: string, code: string, options: TransformOpti
get isTypeScript() {
return this.extension === '.ts' || forceTypeScript;
},
ast,
root,
imports,
fields,
behaviors,
Expand Down
84 changes: 42 additions & 42 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading