lion/packages-node/providence-analytics/src/program/analyzers/match-subclasses.js

330 lines
11 KiB
JavaScript

/* eslint-disable no-shadow, no-param-reassign */
const FindClassesAnalyzer = require('./find-classes.js');
const FindExportsAnalyzer = require('./find-exports.js');
const { Analyzer } = require('./helpers/Analyzer.js');
const { fromImportToExportPerspective } = require('./helpers/from-import-to-export-perspective.js');
/** @typedef {import('./types').FindClassesAnalyzerResult} FindClassesAnalyzerResult */
/** @typedef {import('./types').FindExportsAnalyzerResult} FindExportsAnalyzerResult */
function getMemberOverrides(
refClassesAResult,
classMatch,
exportEntry,
exportEntryResult,
exportSpecifier,
) {
if (!classMatch.members) return;
const { rootFile } = exportEntryResult.rootFileMap.find(
m => m.currentFileSpecifier === exportSpecifier,
);
const classFile = rootFile.file === '[current]' ? exportEntry.file : rootFile.file;
// check which methods are overridden as well...?
const entry = refClassesAResult.queryOutput.find(classEntry => classEntry.file === classFile);
if (!entry) {
// TODO: we should look in an external project for our classFile definition
return;
}
const originalClass = entry.result.find(({ name }) => name === classMatch.rootFile.specifier);
const methods = classMatch.members.methods.filter(m =>
originalClass.members.methods.find(({ name }) => name === m.name),
);
const props = classMatch.members.methods.filter(m =>
originalClass.members.methods.find(({ name }) => name === m.name),
);
// eslint-disable-next-line consistent-return
return { methods, props };
}
/**
* @desc Helper method for matchImportsPostprocess. Modifies its resultsObj
* @param {object} resultsObj
* @param {string} exportId like 'myExport::./reference-project/my/export.js::my-project'
* @param {Set<string>} filteredList
*/
function storeResult(resultsObj, exportId, filteredList, meta) {
if (!resultsObj[exportId]) {
// eslint-disable-next-line no-param-reassign
resultsObj[exportId] = { meta };
}
// eslint-disable-next-line no-param-reassign
resultsObj[exportId].files = [...(resultsObj[exportId].files || []), ...Array.from(filteredList)];
}
/**
* @param {FindExportsAnalyzerResult} exportsAnalyzerResult
* @param {FindClassesAnalyzerResult} targetClassesAnalyzerResult
* @param {FindClassesAnalyzerResult} refClassesAResult
* @param {MatchSubclassesConfig} customConfig
* @returns {AnalyzerResult}
*/
function matchSubclassesPostprocess(
exportsAnalyzerResult,
targetClassesAnalyzerResult,
refClassesAResult,
customConfig,
) {
const cfg = {
...customConfig,
};
/**
* Step 1: a 'flat' data structure
* @desc Create a key value storage map for exports/class matches
* - key: `${exportSpecifier}::${normalizedSource}::${project}` from reference project
* - value: an array of import file matches like `${targetProject}::${normalizedSource}::${className}`
* @example
* {
* 'LionDialog::./reference-project/my/export.js::my-project' : {
* meta: {...},
* files: [
* 'target-project-a::./import/file.js::MyDialog',
* 'target-project-b::./another/import/file.js::MyOtherDialog'
* ],
* ]}
* }
*/
const resultsObj = {};
exportsAnalyzerResult.queryOutput.forEach(exportEntry => {
const exportsProjectObj = exportsAnalyzerResult.analyzerMeta.targetProject;
const exportsProjectName = exportsProjectObj.name;
// Look for all specifiers that are exported, like [import {specifier} 'lion-based-ui/foo.js']
exportEntry.result.forEach(exportEntryResult => {
if (!exportEntryResult.exportSpecifiers) {
return;
}
exportEntryResult.exportSpecifiers.forEach(exportSpecifier => {
// Get all unique imports (name::source::project combinations) that match current
// exportSpecifier
const filteredImportsList = new Set();
const exportId = `${exportSpecifier}::${exportEntry.file}::${exportsProjectName}`;
// eslint-disable-next-line no-shadow
const importProject = targetClassesAnalyzerResult.analyzerMeta.targetProject.name;
targetClassesAnalyzerResult.queryOutput.forEach(({ result, file }) =>
result.forEach(classEntryResult => {
/**
* @example
* Example context (read by 'find-classes'/'find-exports' analyzers)
* - export (/folder/exporting-file.js):
* `export class X {}`
* - import (target-project-a/importing-file.js):
* `import { X } from '@reference-repo/folder/exporting-file.js'
*
* class Z extends Mixin(X) {}
* `
* Example variables (extracted by 'find-classes'/'find-exports' analyzers)
* - exportSpecifier: 'X'
* - superClasses: [{ name: 'X', ...}, { name: 'Mixin', ...}]
*/
const classMatch =
// [{ name: 'X', ...}, ...].find(klass => klass.name === 'X')
classEntryResult.superClasses &&
classEntryResult.superClasses.find(
klass => klass.rootFile.specifier === exportSpecifier,
);
if (!classMatch) {
return;
}
/**
* @example
* - project "reference-project"
* - exportFile './foo.js'
* `export const z = 'bar'`
* - project "target-project"
* - importFile './file.js'
* `import { z } from 'reference-project/foo.js'`
*/
const isFromSameSource =
exportEntry.file ===
fromImportToExportPerspective({
requestedExternalSource: classMatch.rootFile.file,
externalProjectMeta: exportsProjectObj,
externalRootPath: cfg.referenceProjectPath,
});
if (classMatch && isFromSameSource) {
const memberOverrides = getMemberOverrides(
refClassesAResult,
classMatch,
exportEntry,
exportEntryResult,
exportSpecifier,
);
filteredImportsList.add({
projectFileId: `${importProject}::${file}::${classEntryResult.name}`,
memberOverrides,
});
}
}),
);
storeResult(resultsObj, exportId, filteredImportsList, exportEntry.meta);
});
});
});
/**
* Step 2: a rich data structure
* @desc Transform resultObj from step 1 into an array of objects
* @example
* [{
* exportSpecifier: {
* // name under which it is registered in npm ("name" attr in package.json)
* name: 'RefClass',
* project: 'exporting-ref-project',
* filePath: './ref-src/core.js',
* id: 'RefClass::ref-src/core.js::exporting-ref-project',
* meta: {...},
*
* // most likely via post processor
* },
* // All the matched targets (files importing the specifier), ordered per project
* matchesPerProject: [
* {
* project: 'importing-target-project',
* files: [
* { file:'./target-src/indirect-imports.js', className: 'X'},
* ...
* ],
* },
* ...
* ],
* }]
*/
const resultsArray = Object.entries(resultsObj)
.map(([id, flatResult]) => {
const [exportSpecifierName, filePath, project] = id.split('::');
const { meta } = flatResult;
const exportSpecifier = {
name: exportSpecifierName,
project,
filePath,
id,
...(meta || {}),
};
// Although we only handle 1 target project, this structure (matchesPerProject, assuming we
// deal with multiple target projects)
// allows for easy aggregation of data in dashboard.
const matchesPerProject = [];
flatResult.files.forEach(({ projectFileId, memberOverrides }) => {
// eslint-disable-next-line no-shadow
const [project, file, identifier] = projectFileId.split('::');
let projectEntry = matchesPerProject.find(m => m.project === project);
if (!projectEntry) {
matchesPerProject.push({ project, files: [] });
projectEntry = matchesPerProject[matchesPerProject.length - 1];
}
projectEntry.files.push({ file, identifier, memberOverrides });
});
return {
exportSpecifier,
matchesPerProject,
};
})
.filter(r => Object.keys(r.matchesPerProject).length);
return /** @type {AnalyzerResult} */ resultsArray;
}
// function postProcessAnalyzerResult(aResult) {
// // Don't bloat the analyzerResult with the outputs (just the configurations) of other analyzers
// // delete aResult.analyzerMeta.configuration.targetClassesAnalyzerResult.queryOutput;
// // delete aResult.analyzerMeta.configuration.exportsAnalyzerResult.queryOutput;
// return aResult;
// }
class MatchSubclassesAnalyzer extends Analyzer {
constructor() {
super();
this.name = 'match-subclasses';
}
static get requiresReference() {
return true;
}
/**
* @desc Based on ExportsAnalyzerResult of reference project(s) (for instance lion-based-ui)
* and targetClassesAnalyzerResult of search-targets (for instance my-app-using-lion-based-ui),
* an overview is returned of all matching imports and exports.
* @param {MatchSubclassesConfig} customConfig
*/
async execute(customConfig = {}) {
/**
* @typedef MatchSubclassesConfig
* @property {FindExportsConfig} [exportsConfig] These will be used when no exportsAnalyzerResult
* is provided (recommended way)
* @property {FindClassesConfig} [findClassesConfig]
* @property {GatherFilesConfig} [gatherFilesConfig]
* @property {GatherFilesConfig} [gatherFilesConfigReference]
* @property {array} [referenceProjectPath] reference paths
* @property {array} [targetProjectPath] search target paths
*/
const cfg = {
gatherFilesConfig: {},
gatherFilesConfigReference: {},
referenceProjectPath: null,
targetProjectPath: null,
...customConfig,
};
/**
* Prepare
*/
const analyzerResult = this._prepare(cfg);
if (analyzerResult) {
return analyzerResult;
}
/**
* Traverse
*/
const findExportsAnalyzer = new FindExportsAnalyzer();
/** @type {FindExportsAnalyzerResult} */
const exportsAnalyzerResult = await findExportsAnalyzer.execute({
targetProjectPath: cfg.referenceProjectPath,
gatherFilesConfig: cfg.gatherFilesConfigReference,
skipCheckMatchCompatibility: cfg.skipCheckMatchCompatibility,
});
const findClassesAnalyzer = new FindClassesAnalyzer();
/** @type {FindClassesAnalyzerResult} */
const targetClassesAnalyzerResult = await findClassesAnalyzer.execute({
targetProjectPath: cfg.targetProjectPath,
skipCheckMatchCompatibility: cfg.skipCheckMatchCompatibility,
});
const findRefClassesAnalyzer = new FindClassesAnalyzer();
/** @type {FindClassesAnalyzerResult} */
const refClassesAnalyzerResult = await findRefClassesAnalyzer.execute({
targetProjectPath: cfg.referenceProjectPath,
gatherFilesConfig: cfg.gatherFilesConfigReference,
skipCheckMatchCompatibility: cfg.skipCheckMatchCompatibility,
});
const queryOutput = matchSubclassesPostprocess(
exportsAnalyzerResult,
targetClassesAnalyzerResult,
refClassesAnalyzerResult,
cfg,
);
/**
* Finalize
*/
return this._finalize(queryOutput, cfg);
}
}
module.exports = MatchSubclassesAnalyzer;