mascot-app
Version:
Master ActionScript COnfigurator Tool. MASCOT automatically resolves ActionScript project dependencies and generates `asconfig.json` files for easy compilation with `asconfigc`.
442 lines (398 loc) • 15.1 kB
JavaScript
const fs = require("fs");
const path = require("path");
const {
packageToRelPath,
splitQualifiedName,
toForwardSlash,
relPathToPackageName,
} = require("./utils");
/**
* Performs a shallow scan of the workspace to identify ActionScript projects.
* Outputs a `projects.json` catalog containing metadata for each project and a `problems.log` file for anomalies.
*
* @param {string} workspaceDir - The directory containing cloned repositories.
* @param {string} outputDir - The directory to save `projects.json` and `problems.log`.
* @param {boolean} [replace=false] - Whether to overwrite existing `projects.json` if found.
*/
function doShallowScan(workspaceDir, outputDir, replace = false) {
const projectsFilePath = path.join(outputDir, "projects.json");
const problemsFilePath = path.join(outputDir, "problems.log");
// Check for existing projects.json
if (fs.existsSync(projectsFilePath)) {
if (!replace) {
console.log("projects.json already exists. Skipping scan.");
return;
} else {
fs.unlinkSync(projectsFilePath);
console.log("Existing projects.json deleted. Starting fresh.");
}
}
const projects = [];
const problems = [];
function scanDir(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const srcFolder = entries.find(
(entry) => entry.name === "src" && entry.isDirectory()
);
const libFolder = entries.find(
(entry) => entry.name === "lib" && entry.isDirectory()
);
const binFolder = entries.find(
(entry) => entry.name === "bin" && entry.isDirectory()
);
// Ensure this is a valid project
if (!srcFolder) return;
const projectDir = dir;
const projectName = path.basename(dir).replace(/[^a-zA-Z0-9$_\-\.]/g, "");
const srcPath = path.join(dir, "src");
// Check for nested projects
const nestedSrcs = fs
.readdirSync(srcPath, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(srcPath, entry.name, "src"))
);
if (nestedSrcs.length > 0) {
problems.push(`Nested project detected in: ${srcPath}`);
return;
}
// Collect files
const classFiles = [];
const assetFiles = [];
const classNames = [];
let codeTimestamp = 0;
function collectFiles(folder) {
const items = fs.readdirSync(folder, { withFileTypes: true });
items.forEach((item) => {
const itemName = item.name;
const fullPath = path.join(folder, itemName);
if (item.isDirectory()) {
collectFiles(fullPath);
} else if (
itemName.endsWith(".as") ||
itemName.endsWith(".mxml") ||
itemName.endsWith(".fxg")
) {
const className = itemName.split(".")[0];
if (!classNames.includes(className)) {
classNames.push(className);
}
classFiles.push(path.relative(srcPath, fullPath));
const stats = fs.statSync(fullPath);
codeTimestamp = Math.max(codeTimestamp, stats.mtimeMs, stats.ctimeMs);
} else {
assetFiles.push(path.relative(srcPath, fullPath));
}
});
}
collectFiles(srcPath);
// Check for descriptor. We only care about descriptors matching one of the
// collected class names.
const descriptorFiles = fs
.readdirSync(srcPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith("-app.xml"));
const knownDescriptors = descriptorFiles
.map((entry) => {
const descName = path.basename(entry.name, "-app.xml");
const descFileName = entry.name;
const descFilePath = path.join(entry.path, entry.name);
const relativeClassPath = classFiles.find((relFilePath) =>
relFilePath.startsWith(descName)
) || '';
return {
descName,
descFileName,
descFilePath,
relativeClassPath,
related_class: {
file_path: path.join(entry.path, relativeClassPath),
descriptor_file_path: descFilePath,
},
};
})
.filter((descriptorInfo) => classNames.includes(descriptorInfo.descName));
const hasDescriptor = knownDescriptors.length > 0;
// Check for binaries
let binaryTimestamp = 0;
let hasBinaries = false;
let hasAppBinary = false;
if (binFolder) {
const binPath = path.join(dir, "bin");
const binaries = fs.readdirSync(binPath, { withFileTypes: true });
binaries.forEach((bin) => {
if (
bin.isFile() &&
(bin.name.endsWith(".swf") || bin.name.endsWith(".swc"))
) {
hasBinaries = true;
const stats = fs.statSync(path.join(binPath, bin.name));
binaryTimestamp = Math.max(
binaryTimestamp,
stats.mtimeMs,
stats.ctimeMs
);
if (bin.name.endsWith(".swf")) hasAppBinary = true;
}
});
}
// Determine dirtiness
const isDirty = codeTimestamp > binaryTimestamp;
// Determine app probability
const isAppProbability = hasDescriptor || hasAppBinary ? 1 : 0;
// Check for libraries
const hasLibDir = libFolder
? fs
.readdirSync(path.join(dir, "lib"))
.some((file) => file.endsWith(".swc"))
: false;
// Create project object
projects.push({
project_name: projectName,
project_home_dir: projectDir,
has_descriptor: hasDescriptor,
known_descriptors: knownDescriptors,
has_lib_dir: !!libFolder && hasLibDir,
has_binaries: hasBinaries,
has_app_binary: hasAppBinary,
classFiles,
assetFiles,
code_timestamp: codeTimestamp,
binary_timestamp: binaryTimestamp,
is_dirty: isDirty,
is_app_probability: isAppProbability,
});
}
// Traverse workspace
function traverse(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
entries.forEach((entry) => {
if (entry.isDirectory()) {
const subDir = path.join(dir, entry.name);
scanDir(subDir);
traverse(subDir); // Continue traversal
}
});
}
traverse(workspaceDir);
// Write outputs
fs.writeFileSync(projectsFilePath, JSON.stringify(projects, null, 2));
fs.writeFileSync(problemsFilePath, problems.join("\n\n"));
console.log(`Scan complete. Projects catalog saved to ${projectsFilePath}`);
console.log(`Problems log saved to ${problemsFilePath}`);
}
/**
* Performs a deep scan of ActionScript class files to analyze dependencies and verify alignment between file structure and package declarations.
* Outputs a `classes.json` catalog of analyzed classes and a `problems.log` file for unresolved dependencies or anomalies.
*
* @param {string} workspaceDir - The directory containing cloned repositories.
* @param {string} outputDir - The directory containing `projects.json` and where `classes.json` and `problems.log` will be saved.
* @param {boolean} [replace=false] - Whether to overwrite existing `classes.json` if found.
*/
function doDeepScan(workspaceDir, outputDir, replace = false) {
const projectsFilePath = path.join(outputDir, "projects.json");
const classesFilePath = path.join(outputDir, "classes.json");
const problemsFilePath = path.join(outputDir, "problems.log");
// Check for required files
if (!fs.existsSync(projectsFilePath)) {
console.error("projects.json not found in the specified output directory.");
return;
}
if (fs.existsSync(classesFilePath)) {
if (!replace) {
console.log("classes.json already exists. Skipping deep scan.");
return;
} else {
fs.unlinkSync(classesFilePath);
console.log("Existing classes.json deleted. Starting fresh.");
}
}
const projects = JSON.parse(fs.readFileSync(projectsFilePath));
const problems = [];
const analyzedClasses = [];
// Build a flat map of all project files and absolute paths.
// We normalize path separators to forward slashes since all of our class
// relative paths are normalized this way, and otherwise they would not match.
const projectFilesMap = {};
let projectDir;
projects.forEach((project) => {
projectDir = project.project_home_dir;
const classFiles = project.classFiles.map((file) =>
toForwardSlash(path.join(projectDir, "src", file))
);
projectFilesMap[projectDir] = classFiles;
});
// Analyze each class file
projects.forEach((project) => {
projectDir = project.project_home_dir;
const srcPath = path.join(project.project_home_dir, "src");
project.classFiles.forEach((classFileRelative) => {
const filePath = path.join(srcPath, classFileRelative);
const isAsFile = classFileRelative.endsWith(".as");
let className, packageName, expectedRelativePath, pathMatchesPackage;
const classCouplings = [];
try {
const content = fs.readFileSync(filePath, "utf-8");
const inferredPackageName = relPathToPackageName(classFileRelative);
if (isAsFile) {
// If class is an *.as file, scan its content to find its name and package.
const packageMatch = content.match(
/package\s+([a-zA-Z_$][a-zA-Z0-9_$\.]*)\s*{/
);
packageName = packageMatch ? packageMatch[1] : null;
const classMatch = content.match(
/class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s+/
);
className = classMatch ? classMatch[1] : null;
// Path verification (check if class file whereabouts match its package declaration).
pathMatchesPackage = inferredPackageName === packageName;
expectedRelativePath = packageToRelPath(packageName, className);
if (!pathMatchesPackage) {
problems.push(
`Class "${className}" (in file: "${toForwardSlash(
filePath
)}") declares ${
packageName
? 'package "' + packageName + '"'
: "no package name"
} but its actual relative path is "${toForwardSlash(
classFileRelative
)}", whereas "${toForwardSlash(
expectedRelativePath
)}" was expected.`
);
}
} else {
// Otherwise (if class is an *.mxml or *.fxg file), rely on file whereabouts only.
className = path.basename(classFileRelative).split(".")[0];
packageName = inferredPackageName;
expectedRelativePath = classFileRelative;
pathMatchesPackage = true;
}
// Find imports
const imports = [
...content.matchAll(/import\s+([a-zA-Z_$][a-zA-Z0-9_$\.]*)\s*;/g),
];
imports.forEach(([, fullImport]) => {
const [importPackage, importName] = splitQualifiedName(fullImport);
resolveDependency(
classCouplings,
problems,
projectFilesMap,
importPackage,
importName,
"import",
className,
filePath
);
});
// Find fully qualified instantiations
const instantiations = [
...content.matchAll(
/new\s+([a-zA-Z_$]{1}[a-zA-Z_$0-9\.]{0,})\.([a-zA-Z_$]{1}[a-zA-Z_$0-9]{0,})/g
),
];
instantiations.forEach(([, fullInstantiation]) => {
const [instPackage, instName] = splitQualifiedName(fullInstantiation);
resolveDependency(
classCouplings,
problems,
projectFilesMap,
instPackage,
instName,
"fqn_instantiation",
className,
filePath
);
});
// Add analyzed class
analyzedClasses.push({
analyzed_class: {
file_path: filePath,
name: className,
package: packageName,
expected_relative_path: expectedRelativePath,
path_matches_package: pathMatchesPackage,
project_dir: projectDir,
},
class_couplings: classCouplings,
});
} catch (err) {
problems.push(
`Failed to analyze class at ${filePath}: ${err.message}. Stack is:\n${err.stack}`
);
}
});
});
// Write results to classes.json and problems.log
fs.writeFileSync(classesFilePath, JSON.stringify(analyzedClasses, null, 2));
fs.appendFileSync(problemsFilePath, problems.join("\n\n"));
console.log(`Deep scan complete. Results saved to ${classesFilePath}`);
console.log(`Problems logged to ${problemsFilePath}`);
}
/**
* Resolves a dependency for an ActionScript class by matching it to a known project and verifying its existence on disk.
*
* @param {Object[]} classCouplings - An array to store the dependency information for the analyzed class.
* @param {string[]} problems - An array to log any unresolved dependencies.
* @param {Object} projectFilesMap - A map of project directories to their class files (absolute paths).
* @param {string|null} packageName - The package name of the dependency (e.g., "com.example.utils").
* @param {string} className - The name of the class (e.g., "MyClass").
* @param {string} couplingType - The type of coupling (e.g., "import" or "fqn_instantiation").
* @param {string} parentClassName - The name of the class declaring the dependency.
* @param {string} parentClassPath - The file path of the class declaring the dependency.
*/
function resolveDependency(
classCouplings,
problems,
projectFilesMap,
packageName,
className,
couplingType,
parentClassName,
parentClassPath
) {
const inferredRelativePath = packageToRelPath(packageName, className);
let found = false;
for (const [projectDir, classFiles] of Object.entries(projectFilesMap)) {
if (classFiles.some((file) => file.endsWith(inferredRelativePath))) {
const expectedClassFile = path.join(
projectDir,
"src",
inferredRelativePath
);
if (fs.existsSync(expectedClassFile)) {
classCouplings.push({
name: className,
package: packageName,
expected_relative_path: inferredRelativePath,
coupling_type: couplingType,
matching_project: projectDir,
expected_class_file: expectedClassFile,
class_exists: true,
});
found = true;
break;
}
}
}
if (!found) {
classCouplings.push({
name: className,
package: packageName,
expected_relative_path: inferredRelativePath,
coupling_type: couplingType,
matching_project: null,
expected_class_file: null,
class_exists: false,
});
problems.push(
`Unresolved ${!packageName ? "global " : ""}dependency: "${
packageName ? packageName + "." : ""
}${className}" declared by class "${parentClassName}" (in file: "${toForwardSlash(
parentClassPath
)}")`
);
}
}
module.exports = { doShallowScan, doDeepScan };