Removing Redundant Test Files
Each time the Angular CLI scaffolds a component, directive, or pipe, it also generates a corresponding spec file containing just a basic "should create" check. In my workspace, a significant number of these files were lingering around — they contribute nothing to the test suite's value while adding unnecessary overhead to the test command's runtime. The objective was to remove every spec file that contains only this simple creation test.
With Ng-Morph, the first step involves specifying the file tree and identifying the relevant file types. It is essential to set the active project using this line:
setActiveProject(createProject(new NgMorphTree(), '/', ['**/*.spec.ts']));
const sourceFiles = getSourceFiles(['apps/**/*.spec.ts', 'libs/**/*.spec.ts']);
sourceFiles.forEach(s => {
const text = s.getFullText();
if (text.match(new RegExp("(it\\('should be created'|it\\('should create)", 'g'))) {
const secondTest = text.match(new RegExp("(it\\(')", 'g'));
if (secondTest && secondTest.length === 1) {
s.delete();
}
}
});
After that, gather all spec files through the getSourceFiles function. The next step is to loop through each file, evaluate whether it fits our deletion criteria (a straightforward regex scan of the file’s contents), and remove it when it does.
To persist these modifications, invoke saveActiveProject at the end.
The script itself is straightforward and highly readable. Despite having zero prior exposure to the library, I wrote it in under a quarter of an hour. With a bit of familiarity, the same script can be written in about 5 minutes. Doing the same work by hand would take considerably more time and offer far less satisfaction.
A useful tip: don't worry about crafting the script with perfectly named variables or polished, maintainable code. This is a one-time utility meant to accomplish a single task, so keep it pragmatic.
Finally, execute the script using ts-node:
npx ts-node path/to/script
Generating library-wide exports in the public API file
Within an Nx workspace, code is organized into libraries (or packages/modules, however you prefer to name them). For that code to be consumable by other parts of the application, it must be re-exported through a barrel file, commonly referred to as the public API. In our current project, every function or class is pulled in directly from its own source file, producing import statements that look like this:
import { FooComponent } from 'src/path/to/foo.component';
With Nx, however, reusing a FooComponent inside another library requires it to be listed in the library's public API file. Doing this by hand is labor-intensive — you'd have to locate and then export the paths for every single file contained in the library.
Ng-Morph can handle this repetitive work for us with ease. Here is the script that does it:
const project = process.argv[2];
const folder = `libs/${project}`;
const fs = require('fs');
if (!fs.existsSync(folder)) {
console.log("name of project doesn't exist");
process.exit();
}
This time around, I chose to make the script accept a single argument, since the same logic will be reused across multiple libraries.
setActiveProject(createProject(new NgMorphTree(), '/', ['**/*.ts']));
Just like before, the active project needs to be configured so that Ng-Morph can access our file tree.
const sourceFiles = getSourceFiles([`/${folder}/src/lib/**/*.ts`, `!/${folder}/src/lib/**/*.spec.ts`])
.map(s => s.getFilePath().replace(`/${folder}/src`, '.').replace('.ts', ''))
.map(n => `export * from '${n}';`);
const barrelFile = sourceFiles.reduce((acc, file) => `${acc}\n${file}`, '');
createSourceFile(`/${folder}/src/index.ts`, barrelFile, {
overwrite: true
});
saveActiveProject();
All of the real work is packed into just a handful of lines.
We begin by collecting every TypeScript file of the library, skipping anything that ends in .spec.ts. For each file path, we strip away the .ts suffix and prefix it with an export * from declaration.
Those generated statements are consolidated into one single string, which is then written back into the library's barrel file.
Finally, we call setActiveProject to persist the changes we made.
A word of caution: this script is intentionally simple and not optimized. It publishes everything from the library, including files that are never referenced externally. That's acceptable in our situation — we are in the middle of migrating several projects into an Nx monorepo, and the main goal right now is to keep everything compiling. Other teammates are still on a separate branch with the legacy architecture, and the longer those two branches diverge, the more painful merging them will become.
Down the road, we can refine this approach by splitting libraries apart and trimming the public API to only expose what is truly needed.
Updating file imports after moving code
This scenario comes up whenever code is relocated. A typical example: you have one large utility file with all sorts of helpers, and you decide to break it up — moving a few of those helpers into a shared utilities library elsewhere in the repo. After that move, every obsolete import path has to be swapped out for the new one.
The kind of imports we want to find look like either of these:
import { addDate, removeDate } from '@test/shared'
import { addDate } from '@test/shared'
The second pattern is simple enough to fix with the global find-and-replace feature available in any IDE. The first one, though, is trickier: we need to drop addDate from the list of imported named exports while leaving removeDate in place.
With Ng-Morph, automating this is straightforward:
const importToReplace = 'addDate';
const newNamespace = '@test/shared/date-utils';
function importIfNotPresent(source: string) {
addImports(source, [
{
namedImports: [importToReplace],
moduleSpecifier: newNamespace
}
]);
}
setActiveProject(createProject(new NgMorphTree(), '/', ['**/*.ts']));
const sourceFiles = getSourceFiles([`**/*.ts`]).map(s => s.getFilePath());
sourceFiles.forEach(s => {
const imports = getImports(s, { namedImports: importToReplace }).filter(
s => s.getModuleSpecifier().getText().replace(/'/g, '') !== newNamespace
);
imports.forEach(i => {
const namedImports = i.getNamedImports();
if (namedImports.length === 1) {
i.remove();
importIfNotPresent(s);
} else {
namedImports.forEach(n => {
if (n.getName() === importToReplace) {
n.remove();
importIfNotPresent(s);
}
});
}
});
});
saveActiveProject();
The logic here is easy to follow, but let's walk through it step by step.
First, we declare the import we are looking for — importToReplace — as well as the new namespace, newNamespace, which represents the destination library that now holds the function.
As always, the script begins with a call to setActiveProject. From there, we grab all TypeScript files and start iterating.
Inside each file, we search the import declarations for any occurrence of addDate, filtering out files where the namespace has already been migrated to the new one.
Next, we examine the named imports for that statement. When it's the only import on the line, we remove the whole import statement and replace it with a freshly written one pointing at the new path.
When the import shares the statement with other named exports, we simply delete the specific function from the named import list and append the new import statement further down in the file.
Note: Notice that we never have to touch low-level AST manipulation or write complex traversal logic. Ng-Morph exposes a clean, high-level API that lets us read and modify files in a surprisingly intuitive way.
Once you get comfortable with the basic patterns, applying Ng-Morph across an entire codebase becomes both simple and pleasant.
You can find the full documentation for Ng-Morph here.
I hope this gives you a clearer picture of what Ng-Morph is capable of and the kinds of problems it can solve. 🚀
If you'd like to chat, you can find me on Twitter or GitHub — don't hesitate to reach out.
