diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/constants.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/constants.ts new file mode 100644 index 000000000000..c43bccabe4a1 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/constants.ts @@ -0,0 +1,108 @@ +import * as path from 'path'; + +export const DATA_GRID_ROOT = path.resolve(__dirname, '..', '..', '..', 'data_grid'); +export const GRID_CORE_ROOT = path.resolve(__dirname, '..', '..', '..', 'grid_core'); +export const OUTPUT_DIR = path.resolve(__dirname, '..', '..', 'artifacts'); + +export const EXCLUDED_DIRS = new Set(['__tests__', '__mock__', 'scripts', 'new', '__docs__']); +export const EXCLUDED_FILE_NAMES = new Set(); + +export const REGISTER_MODULE_RECEIVERS = new Set([ + 'gridCore', + 'core', + 'treeListCore', + 'dataGridCore', +]); + +export const DATA_SOURCE_ADAPTER_PROVIDER = 'dataSourceAdapterProvider'; + +export const GRID_CORE_IMPORT_REGEXP = /grid_core\//; + +/** + * Import path segments that are considered "boring" internal imports + * and should be excluded from cross-dependency analysis. + * Matched as exact path segments (not substrings). + */ +export const CROSS_DEP_IGNORED_SEGMENTS = new Set([ + 'm_core', + 'm_data_source_adapter', +]); + +export type ModificationCategory = 'passthrough' | 'extended' | 'replaced' | 'new'; + +const DATA_GRID_FEATURE_MAP: Record = { + m_data_controller: 'Data', + m_data_source_adapter: 'Data', + m_core: 'Core', + m_widget: 'Core', + m_widget_base: 'Core', + m_utils: 'Core', + m_editing: 'Editing', + grouping: 'Grouping', + summary: 'Summary', + export: 'Export', + keyboard_navigation: 'Navigation', + focus: 'Navigation', + m_columns_controller: 'Columns', + m_aggregate_calculator: 'Data', + module_not_extended: 'Passthrough', +}; + +const MODULE_NOT_EXTENDED_FEATURE_MAP: Record = { + sorting: 'Sorting', + selection: 'Selection', + search: 'Filtering', + filter_row: 'Filtering', + filter_sync: 'Filtering', + filter_panel: 'Filtering', + filter_builder: 'Filtering', + header_filter: 'Filtering', + column_headers: 'Columns', + column_chooser: 'Column Management', + column_fixing: 'Column Management', + sticky_columns: 'Column Management', + virtual_columns: 'Column Management', + columns_resizing_reordering: 'Column Management', + adaptivity: 'Column Management', + keyboard_navigation: 'Navigation', + editing_row_based: 'Editing', + editing_form_based: 'Editing', + editing_cell_based: 'Editing', + editor_factory: 'Editing', + validating: 'Editing', + virtual_scrolling: 'Scrolling', + state_storing: 'State', + pager: 'Paging', + rows: 'Core', + grid_view: 'Core', + header_panel: 'Columns', + context_menu: 'Core', + error_handling: 'Core', + master_detail: 'Master Detail', + row_dragging: 'Row Dragging', + toast: 'Core', + ai_column: 'AI', +}; + +export function getFeatureAreaFromPath(relPath: string): string { + const segments = relPath.split('/'); + const firstSegment = segments[0]; + + if (firstSegment === 'module_not_extended') { + const fileName = segments[1]?.replace('.ts', '') ?? ''; + return MODULE_NOT_EXTENDED_FEATURE_MAP[fileName] ?? 'Other'; + } + + if (DATA_GRID_FEATURE_MAP[firstSegment]) { + return DATA_GRID_FEATURE_MAP[firstSegment]; + } + + const baseName = firstSegment.replace('.ts', ''); + if (DATA_GRID_FEATURE_MAP[baseName]) { + return DATA_GRID_FEATURE_MAP[baseName]; + } + + return 'Other'; +} + +export const WIDGET_BASE_FILE = path.resolve(DATA_GRID_ROOT, 'm_widget_base.ts'); diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/generate.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/generate.ts new file mode 100644 index 000000000000..cd6ecde7d9f9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/generate.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env tsx +/* eslint-disable no-console, spellcheck/spell-checker */ +import * as fs from 'fs'; +import * as path from 'path'; + +import { discoverSourceFiles, getRelativePath } from '../shared/file-discovery'; +import { writeOutputFiles } from '../shared/output-writer'; +import { + DATA_GRID_ROOT, EXCLUDED_DIRS, EXCLUDED_FILE_NAMES, type ModificationCategory, + OUTPUT_DIR, +} from './constants'; +import { generateHtml } from './html-template'; +import { + parseDataGridFile, + parseModulesOrder, +} from './parser'; +import { + buildCrossDependencies, + buildExtenderPipelines, + buildInheritanceChains, + classifyModules, + collectDataSourceAdapterChain, +} from './resolver'; +import type { ArchitectureData, GridCoreModuleInfo, ParsedFile } from './types'; + +const GC_JSON_PATH = path.join(OUTPUT_DIR, 'grid_core_architecture.generated.json'); + +function loadGridCoreModules(): GridCoreModuleInfo[] { + if (!fs.existsSync(GC_JSON_PATH)) { + console.error(`ERROR: grid_core_architecture.generated.json not found at ${GC_JSON_PATH}`); + console.error('Please run the grid_core architecture script first:'); + console.error(' npx tsx __docs__/scripts/grid_core/generate.ts --json'); + process.exit(1); + } + + const raw = JSON.parse(fs.readFileSync(GC_JSON_PATH, 'utf-8')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const modules: GridCoreModuleInfo[] = (raw.modules ?? []).map((m: any) => ({ + moduleName: m.moduleName, + registeredAs: m.registeredAs ?? null, + sourceFile: m.sourceFile, + featureArea: m.featureArea, + controllers: m.controllers ?? {}, + views: m.views ?? {}, + extenders: m.extenders ?? { controllers: {}, views: {} }, + hasDefaultOptions: m.hasDefaultOptions ?? false, + })); + + console.log(`Loaded ${modules.length} grid_core modules from ${GC_JSON_PATH}`); + return modules; +} + +function appendMissingModuleNames(modulesOrder: string[], parsedFiles: ParsedFile[]): void { + for (const pf of parsedFiles) { + for (const reg of pf.registerModuleCalls) { + if (!modulesOrder.includes(reg.moduleName)) { + modulesOrder.push(reg.moduleName); + } + } + } +} + +function main(): void { + console.log('DataGrid Architecture Documentation Generator'); + console.log(`DataGrid root: ${DATA_GRID_ROOT}`); + console.log(`Output dir: ${OUTPUT_DIR}`); + + try { + // 1. Parse module order from source + // NOTE: registerModulesOrder defines ascending priority. + // processModules (m_modules.ts) sorts by: orderIndex1 - orderIndex2, + // which means index 0 processes first and the last index processes last. + // Extenders are applied in the same ascending order. + const modulesOrder = parseModulesOrder(); + console.log(`Parsed ${modulesOrder.length} modules from registerModulesOrder (ascending order)`); + + // 2. Load grid_core modules from pre-generated JSON (prerequisite check) + const gridCoreModules = loadGridCoreModules(); + + // 3. Discover data_grid source files + const sourceFiles = discoverSourceFiles(DATA_GRID_ROOT, EXCLUDED_DIRS, EXCLUDED_FILE_NAMES); + console.log(`Discovered ${sourceFiles.length} data_grid source files`); + + // 4. Parse all files + const allParsedFiles = sourceFiles.flatMap((file) => { + const relPath = getRelativePath(file, DATA_GRID_ROOT); + console.log(` Parsing: ${relPath}`); + try { + return [parseDataGridFile(file)]; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn(` WARN: Failed to parse ${relPath}: ${msg}`); + return []; + } + }); + + // 5. Build full module order + appendMissingModuleNames(modulesOrder, allParsedFiles); + + // 6. Classify modules + const allModules = classifyModules(allParsedFiles, modulesOrder, gridCoreModules); + console.log(`\nClassified ${allModules.length} modules:`); + const counts: Record = { + passthrough: 0, extended: 0, replaced: 0, new: 0, + }; + for (const mod of allModules) { + counts[mod.category] += 1; + console.log(` [${mod.category.toUpperCase().padEnd(11)}] ${mod.moduleName} (${mod.sourceFile})`); + } + console.log(` Passthrough: ${counts.passthrough}, Replaced: ${counts.replaced}, Extended: ${counts.extended}, New: ${counts.new}`); + + // 7. Build extender pipelines (including gc extenders from passthrough modules) + const extenderPipelines = buildExtenderPipelines(allModules, gridCoreModules); + console.log(`\nBuilt ${extenderPipelines.length} extender pipelines:`); + for (const p of extenderPipelines) { + console.log(` ${p.targetType} '${p.targetName}' — ${p.steps.length} step(s): ${p.steps.map((s) => `${s.moduleName}${s.isFromGridCore ? '(gc)' : '(dg)'}`).join(' → ')}`); + } + + // 8. Collect DataSourceAdapter chain + const dsaChain = collectDataSourceAdapterChain(allParsedFiles); + console.log(`\nDataSourceAdapter chain (${dsaChain.length} extensions):`); + for (const ext of dsaChain) { + console.log(` ${ext.order + 1}. ${ext.extenderName} (${ext.relPath})${ext.isImportedFromGridCore ? ' [from grid_core]' : ''}`); + } + + // 9. Build inheritance chains + const inheritanceChains = buildInheritanceChains(allParsedFiles); + console.log(`\nBuilt ${inheritanceChains.length} inheritance chains`); + + // 10. Build cross-dependencies + const crossDependencies = buildCrossDependencies(allParsedFiles, allModules); + console.log(`\nFound ${crossDependencies.length} cross-dependencies:`); + for (const dep of crossDependencies) { + const toLabel = dep.toModule ?? dep.toRelPath; + console.log(` ${dep.fromModule} → ${toLabel} [${dep.importedNames.join(', ')}]`); + } + + // 11. Build output data + const data: ArchitectureData = { + generatedAt: new Date().toISOString(), + dataGridRoot: 'packages/devextreme/js/__internal/grids/data_grid', + gridCoreRoot: 'packages/devextreme/js/__internal/grids/grid_core', + modulesOrder, + modules: allModules, + gridCoreModules, + extenderPipelines, + dataSourceAdapterChain: dsaChain, + inheritanceChains, + crossDependencies, + summary: { total: allModules.length, ...counts }, + }; + + // 12. Write output files + writeOutputFiles(OUTPUT_DIR, 'data_grid_architecture', data, generateHtml); + + console.log('\nDone.'); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`ERROR: ${msg}`); + if (e instanceof Error && e.stack) { + console.error(e.stack); + } + process.exit(1); + } +} + +main(); diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/graph-builder.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/graph-builder.ts new file mode 100644 index 000000000000..61e43e1fbd39 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/graph-builder.ts @@ -0,0 +1,405 @@ +/* eslint-disable spellcheck/spell-checker */ +import type { CytoscapeElement } from '../shared/graph-context'; +import { createGraphContext } from '../shared/graph-context'; +import { buildGcRegistrationLookup } from './resolver'; +import type { + ArchitectureData, ClassifiedModule, ControllerViewRef, GridCoreModuleInfo, +} from './types'; + +/** + * A controller/view is "locally new" when it's authored + * in data_grid, not forwarded from grid_core. + */ +function isLocallyNew(ref: ControllerViewRef): boolean { + return ref.isDefinedLocally && !ref.isImportedFromGridCore; +} + +/** Returns names of controllers/views that are locally defined and not imported from grid_core. */ +function getLocalNames(refs: Record): string[] { + return Object.entries(refs) + .filter(([, ref]) => isLocallyNew(ref)) + .map(([name]) => name); +} + +interface EdgeData extends Record { + edgeType: string; + targetName?: string; +} + +/** Target name used for the DataSourceAdapter extender pipeline visualization. */ +const DSA_TARGET_NAME = 'dataSourceAdapter'; + +interface GraphClassInfo { + className: string; + baseClass: string; + sourceFile: string; +} + +function buildGcLabel(gcMod: GridCoreModuleInfo): string { + const parts: string[] = [gcMod.registeredAs ?? gcMod.moduleName]; + const ctrls = Object.keys(gcMod.controllers); + const vws = Object.keys(gcMod.views); + + if (ctrls.length > 0) { + parts.push(`ctrl: ${ctrls.join(', ')}`); + } + + if (vws.length > 0) { + parts.push(`view: ${vws.join(', ')}`); + } + + return parts.join('\n'); +} + +function buildSynthGcData( + mod: ArchitectureData['modules'][number], + parentId: string, +): { id: string; data: Record } | null { + const gcCtrls = Object.entries(mod.controllers) + .filter(([, ref]) => ref.isImportedFromGridCore); + + const gcViews = Object.entries(mod.views) + .filter(([, ref]) => ref.isImportedFromGridCore); + + if (gcCtrls.length === 0 && gcViews.length === 0) { + return null; + } + + const labelParts: string[] = [mod.moduleName]; + if (gcCtrls.length > 0) { + labelParts.push(`ctrl: ${gcCtrls.map(([n]) => n).join(', ')}`); + } + if (gcViews.length > 0) { + labelParts.push(`view: ${gcViews.map(([n]) => n).join(', ')}`); + } + + const ctrlInfo: Record = {}; + for (const [regName, ref] of gcCtrls) { + ctrlInfo[regName] = { + className: ref.className, + baseClass: ref.baseClass, + sourceFile: ref.sourceFile, + }; + } + const viewInfo: Record = {}; + for (const [regName, ref] of gcViews) { + viewInfo[regName] = { + className: ref.className, + baseClass: ref.baseClass, + sourceFile: ref.sourceFile, + }; + } + + return { + id: `gc-synth-${mod.moduleName}`, + data: { + label: labelParts.join('\n'), + nodeType: 'gridCoreModule', + category: 'grid-core', + sourceFile: mod.gridCoreSourceModule ?? mod.sourceFile, + featureArea: mod.featureArea, + registrationOrder: -1, + moduleName: mod.moduleName, + controllers: JSON.stringify(ctrlInfo), + views: JSON.stringify(viewInfo), + extenders: JSON.stringify(mod.extenders), + parent: parentId, + }, + }; +} + +export function buildCytoscapeElements( + data: ArchitectureData, + modulesBySourceFile: Map, +): CytoscapeElement[] { + const { + elements, nodeIds, edgeIds, addNode, + } = createGraphContext(); + + // ─── Pre-built lookup maps (O(1) instead of repeated linear scans) ──────── + const gcModuleLookup = buildGcRegistrationLookup(data.gridCoreModules); + + // Cache getLocalNames per module to avoid recomputing across sections + const localCtrlCache = new Map(); + const localViewCache = new Map(); + + for (const mod of data.modules) { + localCtrlCache.set(mod.moduleName, getLocalNames(mod.controllers)); + localViewCache.set(mod.moduleName, getLocalNames(mod.views)); + } + + function addEdge(source: string, target: string, edgeData: EdgeData, classes: string): void { + const tName = edgeData.targetName ?? ''; + const id = `e-${source}-${target}-${edgeData.edgeType}-${tName}`; + if (!nodeIds.has(source) || !nodeIds.has(target) || edgeIds.has(id)) return; + edgeIds.add(id); + elements.push({ + group: 'edges', + data: { + id, source, target, ...edgeData, + }, + classes, + }); + } + + function addSynthDefinesEdge( + dgMod: ArchitectureData['modules'][number], + targetName: string, + targetType: 'controller' | 'view', + targetId: string, + defClass: string, + ): void { + const synthId = `gc-synth-${dgMod.moduleName}`; + if (!nodeIds.has(synthId)) { + return; + } + const ref = targetType === 'controller' + ? dgMod.controllers[targetName] : dgMod.views[targetName]; + + if (!ref?.isImportedFromGridCore) { + return; + } + + addEdge(synthId, targetId, { + edgeType: 'gc-defines', + label: 'defines', + targetName, + }, defClass); + } + + // ─── Collect all controller/view targets ─────────────────────────────────── + // Targets come from: (1) extender pipelines (gc or dg origin), + // (2) dg module new controllers/views (always shown even if not extended). + // GC-defined targets that nobody extends are omitted — they add noise. + // Track origin: 'gc' if defined by any grid_core module, 'dg' if only data_grid. + const gcDefinedNames = new Set(); + for (const gcMod of data.gridCoreModules) { + for (const name of Object.keys(gcMod.controllers)) gcDefinedNames.add(name); + for (const name of Object.keys(gcMod.views)) gcDefinedNames.add(name); + } + + const allTargets = new Map(); + + for (const pipeline of data.extenderPipelines) { + allTargets.set(pipeline.targetName, { + type: pipeline.targetType, + origin: gcDefinedNames.has(pipeline.targetName) ? 'gc' : 'dg', + }); + } + // DataSourceAdapter has the same mixin nature as other extender targets + if (data.dataSourceAdapterChain.length > 0) { + allTargets.set(DSA_TARGET_NAME, { + type: 'controller', origin: 'gc', + }); + } + for (const mod of data.modules) { + for (const name of localCtrlCache.get(mod.moduleName) ?? []) { + if (!allTargets.has(name)) { + const origin = gcDefinedNames.has(name) ? 'gc' : 'dg'; + allTargets.set(name, { type: 'controller', origin }); + } + } + + for (const name of localViewCache.get(mod.moduleName) ?? []) { + if (!allTargets.has(name)) { + const origin = gcDefinedNames.has(name) ? 'gc' : 'dg'; + allTargets.set(name, { type: 'view', origin }); + } + } + } + + // ─── Target nodes (each defined controller/view) ────────────────────────── + for (const [targetName, info] of allTargets) { + const targetId = `gc-target-${targetName}`; + const typeLabel = info.type === 'controller' ? 'ctrl' : 'view'; + const originClass = info.origin === 'dg' ? 'dg-target' : 'gc-target'; + + addNode(targetId, { + label: `${targetName}\n(${typeLabel})`, + nodeType: 'gcTarget', + category: 'gc-target', + targetName, + targetType: info.type, + targetOrigin: info.origin, + featureArea: info.origin === 'dg' ? 'DataGrid' : 'Core', + moduleName: targetName, + }, `gc-target gc-target-${info.type} ${originClass}`); + } + + // ─── Identify which gc modules are used ─────────────────────────────────── + const usedGcModules = new Set(); + for (const mod of data.modules) { + const gc = gcModuleLookup.get(mod.moduleName); + if (gc) { + usedGcModules.add(gc.moduleName); + } + } + + // ─── Data Grid module nodes + embedded GC module nodes ──────────────────── + for (const mod of data.modules) { + const moduleId = `mod-${mod.moduleName}`; + const orderNum = mod.registrationOrder + 1; + const namePart = mod.category !== 'passthrough' + ? `#${orderNum} ${mod.moduleName} (${mod.category})` + : `#${orderNum} ${mod.moduleName}`; + const labelParts: string[] = [namePart]; + const localCtrls = localCtrlCache.get(mod.moduleName) ?? []; + const localViews = localViewCache.get(mod.moduleName) ?? []; + + if (localCtrls.length > 0 || localViews.length > 0) { + labelParts.push(''); + } + + if (localCtrls.length > 0) { + labelParts.push(`ctrl: ${localCtrls.join(', ')}`); + } + + if (localViews.length > 0) { + labelParts.push(`view: ${localViews.join(', ')}`); + } + + addNode(moduleId, { + label: labelParts.join('\n'), + nodeType: 'module', + category: mod.category, + sourceFile: mod.sourceFile, + featureArea: mod.featureArea, + registrationOrder: mod.registrationOrder, + gridCoreSource: mod.gridCoreSourceModule ?? '', + moduleName: mod.moduleName, + }, `module ${mod.category}`); + + // GC module node — always embedded inside the dg module + const gc = gcModuleLookup.get(mod.moduleName); + if (gc) { + const gcId = `gc-${gc.moduleName}`; + addNode(gcId, { + label: buildGcLabel(gc), + nodeType: 'gridCoreModule', + category: 'grid-core', + sourceFile: gc.sourceFile, + featureArea: gc.featureArea, + registrationOrder: -1, + moduleName: gc.registeredAs ?? gc.moduleName, + controllers: JSON.stringify(gc.controllers), + views: JSON.stringify(gc.views), + extenders: JSON.stringify(gc.extenders), + parent: moduleId, + }, 'module grid-core'); + } else if (mod.category === 'passthrough') { + // No matching gc module, but controllers/views imported from gc. + const synth = buildSynthGcData(mod, moduleId); + if (synth) addNode(synth.id, synth.data, 'module grid-core'); + } + } + + // ─── "defines" edges ────────────────────────────────────────────────────── + // From the embedded gc module → gc-target node. + // Also from dg modules that define their own controllers/views (replaced/new). + for (const [targetName, { type: targetType }] of allTargets) { + const targetId = `gc-target-${targetName}`; + if (!nodeIds.has(targetId)) { + // eslint-disable-next-line no-continue + continue; + } + + const defClass = targetType === 'controller' + ? 'edge-gc-defines-ctrl' : 'edge-gc-defines-view'; + + for (const gcMod of data.gridCoreModules) { + if (!usedGcModules.has(gcMod.moduleName)) { + // eslint-disable-next-line no-continue + continue; + } + const defines = (targetType === 'controller' && targetName in gcMod.controllers) + || (targetType === 'view' && targetName in gcMod.views); + if (!defines) { + // eslint-disable-next-line no-continue + continue; + } + const gcId = `gc-${gcMod.moduleName}`; + if (nodeIds.has(gcId)) { + addEdge(gcId, targetId, { + edgeType: 'gc-defines', + label: 'defines', + targetName, + }, defClass); + } + } + + for (const dgMod of data.modules) { + if (dgMod.category === 'passthrough') { + // Check if this passthrough module has a synth gc node with matching controllers/views + addSynthDefinesEdge(dgMod, targetName, targetType, targetId, defClass); + // eslint-disable-next-line no-continue + continue; + } + const ref = targetType === 'controller' + ? dgMod.controllers[targetName] : dgMod.views[targetName]; + const defines = ref !== undefined && isLocallyNew(ref); + if (defines) { + addEdge(`mod-${dgMod.moduleName}`, targetId, { + edgeType: 'gc-defines', + label: 'defines', + targetName, + }, defClass); + } + } + } + + // ─── Registration order spine ───────────────────────────────────────────── + for (let i = 0; i < data.modules.length - 1; i += 1) { + addEdge( + `mod-${data.modules[i].moduleName}`, + `mod-${data.modules[i + 1].moduleName}`, + { edgeType: 'order' }, + 'edge-order', + ); + } + + // ─── DG module extends gc-target edges ──────────────────────────────────── + for (const pipeline of data.extenderPipelines) { + const { targetName, targetType, steps } = pipeline; + const targetId = `gc-target-${targetName}`; + const edgeClass = targetType === 'controller' ? 'edge-ext-ctrl' : 'edge-ext-view'; + for (let i = 0; i < steps.length; i += 1) { + addEdge(`mod-${steps[i].moduleName}`, targetId, { + edgeType: 'extender-target', + targetName, + targetType, + label: `#${i + 1} ${steps[i].moduleName}`, + chainIndex: i, + chainLength: steps.length, + stepIsFromGc: steps[i].isFromGridCore, + stepCategory: steps[i].category, + }, edgeClass); + } + } + + // ─── DataSourceAdapter extender edges (same pattern as other targets) ────── + const dsaTargetId = `gc-target-${DSA_TARGET_NAME}`; + if (nodeIds.has(dsaTargetId)) { + for (let i = 0; i < data.dataSourceAdapterChain.length; i += 1) { + const ext = data.dataSourceAdapterChain[i]; + const mod = modulesBySourceFile.get(ext.relPath); + + if (!mod) { + // eslint-disable-next-line no-continue + continue; + } + + addEdge(`mod-${mod.moduleName}`, dsaTargetId, { + edgeType: 'extender-target', + targetName: DSA_TARGET_NAME, + targetType: 'controller', + label: `#${i + 1} ${mod.moduleName}`, + chainIndex: i, + chainLength: data.dataSourceAdapterChain.length, + stepIsFromGc: ext.isImportedFromGridCore, + stepCategory: mod.category, + }, 'edge-ext-ctrl'); + } + } + + return elements; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/html-template.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/html-template.ts new file mode 100644 index 000000000000..3bbb62c538c1 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/html-template.ts @@ -0,0 +1,642 @@ +/* eslint-disable spellcheck/spell-checker */ +import { BASE_CSS, HIGHLIGHT_CYTOSCAPE_STYLES, SHARED_INTERACTIVE_JS } from '../shared/html-helpers'; +import { buildCytoscapeElements } from './graph-builder'; +import { buildModulesBySourceFile } from './resolver'; +import type { ArchitectureData } from './types'; + +export function generateHtml(data: ArchitectureData): string { + const modulesBySourceFile = buildModulesBySourceFile(data.modules); + const cytoscapeElements = buildCytoscapeElements(data, modulesBySourceFile); + const elementsJson = JSON.stringify(cytoscapeElements, null, 2); + const pipelines = data.extenderPipelines.map((p) => ({ + targetName: p.targetName, + targetType: p.targetType, + steps: p.steps.map((s) => ({ + moduleName: s.moduleName, + relPath: s.relPath, + extenderName: s.extenderName, + isFromGridCore: s.isFromGridCore, + registrationOrder: s.registrationOrder, + category: s.category, + })), + })); + // Add synthetic pipeline for DataSourceAdapter (same mixin pattern) + if (data.dataSourceAdapterChain.length > 0) { + pipelines.push({ + targetName: 'dataSourceAdapter', + targetType: 'controller', + steps: data.dataSourceAdapterChain.map((ext) => { + const mod = modulesBySourceFile.get(ext.relPath); + return { + moduleName: mod?.moduleName ?? ext.relPath, + relPath: ext.relPath, + extenderName: ext.extenderName, + isFromGridCore: ext.isImportedFromGridCore, + registrationOrder: mod?.registrationOrder ?? -1, + category: mod?.category ?? 'passthrough', + }; + }), + }); + } + const pipelinesJson = JSON.stringify(pipelines); + const gridCoreModulesJson = JSON.stringify(data.gridCoreModules.map((gc) => ({ + moduleName: gc.moduleName, + registeredAs: gc.registeredAs, + sourceFile: gc.sourceFile, + featureArea: gc.featureArea, + controllers: gc.controllers, + views: gc.views, + extenders: gc.extenders, + hasDefaultOptions: gc.hasDefaultOptions, + }))); + const modulesJson = JSON.stringify(data.modules.map((m) => ({ + moduleName: m.moduleName, + category: m.category, + sourceFile: m.sourceFile, + featureArea: m.featureArea, + registrationOrder: m.registrationOrder, + gridCoreSourceModule: m.gridCoreSourceModule, + hasDefaultOptionsOverride: m.hasDefaultOptionsOverride, + controllers: m.controllers, + views: m.views, + extenders: m.extenders, + }))); + + const categories = ['passthrough', 'extended', 'replaced', 'new', 'gc-target']; + const categoryDisplayNames: Record = { + passthrough: 'Passthrough', + extended: 'Extended', + replaced: 'Replaced', + new: 'New', + 'gc-target': 'Grid Core Target', + }; + const featureAreas = [...new Set(data.modules.map((m) => m.featureArea))].sort(); + + return ` + + + + +DataGrid Architecture + + + + + + + +
+
+
+
+ +

Click a node or edge to see details.

+
+
+
+ + +`; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/parser.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/parser.ts new file mode 100644 index 000000000000..4ba576ae7c46 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/parser.ts @@ -0,0 +1,583 @@ +/* eslint-disable spellcheck/spell-checker,max-depth */ +import * as fs from 'fs'; +// eslint-disable-next-line import/no-extraneous-dependencies +import ts from 'typescript'; + +import { + collectImportSpecs, + getClassHeritage, + getNodeText, + hasExportModifier, +} from '../shared/ast-helpers'; +import { getRelativePath } from '../shared/file-discovery'; +import { + DATA_GRID_ROOT, + DATA_SOURCE_ADAPTER_PROVIDER, + GRID_CORE_IMPORT_REGEXP, + REGISTER_MODULE_RECEIVERS, + WIDGET_BASE_FILE, +} from './constants'; +import type { + ControllerViewRef, + ExtenderRef, + ParsedFile, + RegisterModuleCall, +} from './types'; + +function isGridCoreImport(fromPath: string): boolean { + return GRID_CORE_IMPORT_REGEXP.test(fromPath); +} + +// ─── registerModule Call Detection ─────────────────────────────────────────── + +function isRegisterModuleCall(node: ts.Node, sf: ts.SourceFile): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + const expr = node.expression; + if (!ts.isPropertyAccessExpression(expr)) { + return false; + } + + const methodName = expr.name.text; + if (methodName !== 'registerModule') { + return false; + } + + const baseObj = getNodeText(expr.expression, sf).split('.')[0]; + + return REGISTER_MODULE_RECEIVERS.has(baseObj); +} + +function isDataSourceAdapterExtendCall( + node: ts.Node, + sf: ts.SourceFile, + imports: Map, +): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + const expr = node.expression; + if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'extend') { + return false; + } + + const obj = getNodeText(expr.expression, sf); + if (obj === DATA_SOURCE_ADAPTER_PROVIDER) { + return true; + } + + return imports.get(obj)?.localName === DATA_SOURCE_ADAPTER_PROVIDER; +} + +// ─── Inline Controllers/Views/Extenders Parsing ───────────────────────────── + +function parseInlineControllerViews( + objLiteral: ts.ObjectLiteralExpression, + sf: ts.SourceFile, + parsedFile: ParsedFile, +): Record { + const result: Record = {}; + + for (const prop of objLiteral.properties) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) { + // eslint-disable-next-line no-continue + continue; + } + + const regName = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : ''; + if (!regName) { + // eslint-disable-next-line no-continue + continue; + } + + let className = ''; + let isImportedFromGridCore = false; + let isDefinedLocally = false; + let baseClass = ''; + let mixins: string[] = []; + + if (ts.isShorthandPropertyAssignment(prop)) { + className = regName; + } else { + className = getNodeText(prop.initializer, sf); + } + + const imp = parsedFile.imports.get(className); + if (imp) { + isImportedFromGridCore = imp.isFromGridCore; + } + + const classInfo = parsedFile.classes.get(className); + if (classInfo) { + isDefinedLocally = true; + baseClass = classInfo.baseClass; + mixins = classInfo.mixins; + } + + result[regName] = { + regName, + className, + isImportedFromGridCore, + isDefinedLocally, + baseClass, + mixins, + sourceFile: parsedFile.relPath, + }; + } + + return result; +} + +function extractExtenderName(node: ts.Expression, sf: ts.SourceFile): string { + // Simple identifier: `data`, `GroupingDataControllerExtender` + if (ts.isIdentifier(node)) { + return node.text; + } + + // Property access: `editingModule.extenders.controllers.data` + if (ts.isPropertyAccessExpression(node)) { + return getNodeText(node, sf); + } + + // Arrow function: `(Base) => class FooExtender extends Base { ... }` + if (ts.isArrowFunction(node)) { + let { body } = node; + if (ts.isParenthesizedExpression(body)) { + body = body.expression; + } + if (ts.isClassExpression(body) && body.name) { + return body.name.text; + } + // Try to extract class name from body text + const text = getNodeText(node, sf); + const classMatch = /class\s+(\w+)/.exec(text); + if (classMatch) { + return classMatch[1]; + } + return '(inline)'; + } + + // Fallback: use text but truncate if too long + const text = getNodeText(node, sf); + if (text.length > 60) { + const classMatch = /class\s+(\w+)/.exec(text); + if (classMatch) { + return classMatch[1]; + } + return '(inline)'; + } + return text; +} + +function parseInlineExtenders( + objLiteral: ts.ObjectLiteralExpression, + sf: ts.SourceFile, + parsedFile: ParsedFile, +): { controllers: Record; views: Record } { + const result = { + controllers: {} as Record, + views: {} as Record, + }; + + for (const prop of objLiteral.properties) { + if (!ts.isPropertyAssignment(prop)) { + // eslint-disable-next-line no-continue + continue; + } + const sectionName = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : ''; + if (sectionName !== 'controllers' && sectionName !== 'views') { + // eslint-disable-next-line no-continue + continue; + } + + const initExpr = prop.initializer; + // Handle spread: { ...editingModule.extenders.controllers, data } + if (ts.isObjectLiteralExpression(initExpr)) { + for (const innerProp of initExpr.properties) { + if (ts.isSpreadAssignment(innerProp)) { + // eslint-disable-next-line no-continue + continue; + } + + let targetName = ''; + let extenderName = ''; + let isImportedFromGridCore = false; + let isDefinedLocally = false; + + if (ts.isPropertyAssignment(innerProp)) { + targetName = innerProp.name && ts.isIdentifier(innerProp.name) ? innerProp.name.text : ''; + extenderName = extractExtenderName(innerProp.initializer, sf); + } else if (ts.isShorthandPropertyAssignment(innerProp)) { + targetName = innerProp.name.text; + extenderName = targetName; + } else { + // eslint-disable-next-line no-continue + continue; + } + + const imp = parsedFile.imports.get(extenderName); + if (imp) { + isImportedFromGridCore = imp.isFromGridCore; + } + if (parsedFile.localVars.has(extenderName) || parsedFile.classes.has(extenderName)) { + isDefinedLocally = true; + } + + result[sectionName][targetName] = { + targetName, + extenderName, + isImportedFromGridCore, + isDefinedLocally, + }; + } + } + } + + return result; +} + +// ─── registerModule Argument Parser ────────────────────────────────────────── + +function parseRegisterModuleCall( + moduleName: string, + arg: ts.Expression, + sf: ts.SourceFile, + parsedFile: ParsedFile, +): RegisterModuleCall { + const { relPath } = parsedFile; + const reg: RegisterModuleCall = { + moduleName, + sourceFile: relPath, + relPath, + argIsIdentifier: false, + argIdentifierName: null, + spreadSources: [], + hasInlineControllers: false, + hasInlineViews: false, + hasInlineExtenders: false, + hasDefaultOptions: false, + referencesGridCoreModule: false, + gridCoreRefs: [], + controllers: {}, + views: {}, + extenders: { controllers: {}, views: {} }, + forwardedControllersRef: null, + forwardedViewsRef: null, + }; + + if (ts.isIdentifier(arg)) { + reg.argIsIdentifier = true; + reg.argIdentifierName = arg.text; + const imp = parsedFile.imports.get(arg.text); + if (imp?.isFromGridCore) { + reg.referencesGridCoreModule = true; + reg.gridCoreRefs.push(arg.text); + } + return reg; + } + + if (!ts.isObjectLiteralExpression(arg)) return reg; + + for (const prop of arg.properties) { + if (ts.isSpreadAssignment(prop)) { + const spreadText = getNodeText(prop.expression, sf); + const baseIdent = spreadText.split('.')[0]; + reg.spreadSources.push(baseIdent); + const imp = parsedFile.imports.get(baseIdent); + if (imp?.isFromGridCore) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(baseIdent)) { + reg.gridCoreRefs.push(baseIdent); + } + } + // eslint-disable-next-line no-continue + continue; + } + + if ( + !ts.isPropertyAssignment(prop) + && !ts.isMethodDeclaration(prop) + && !ts.isShorthandPropertyAssignment(prop) + ) { + // eslint-disable-next-line no-continue + continue; + } + + const propName = prop.name && ts.isIdentifier(prop.name) + ? prop.name.text : ''; + + if (propName === 'defaultOptions') { + reg.hasDefaultOptions = true; + if (ts.isPropertyAssignment(prop)) { + const initText = getNodeText(prop.initializer, sf); + for (const [name, imp] of parsedFile.imports) { + if (imp.isFromGridCore && initText.includes(name)) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(name)) { + reg.gridCoreRefs.push(name); + } + } + } + } + } + + if (propName === 'controllers' + && ts.isPropertyAssignment(prop)) { + if (ts.isObjectLiteralExpression(prop.initializer)) { + reg.hasInlineControllers = true; + reg.controllers = parseInlineControllerViews(prop.initializer, sf, parsedFile); + for (const ref of Object.values(reg.controllers)) { + if (ref.isImportedFromGridCore) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(ref.className)) { + reg.gridCoreRefs.push(ref.className); + } + } + } + } else { + const refText = getNodeText(prop.initializer, sf); + const baseIdent = refText.split('.')[0]; + const imp = parsedFile.imports.get(baseIdent); + if (imp?.isFromGridCore) { + reg.referencesGridCoreModule = true; + reg.forwardedControllersRef = baseIdent; + if (!reg.gridCoreRefs.includes(baseIdent)) { + reg.gridCoreRefs.push(baseIdent); + } + } + } + } + + if (propName === 'views' + && ts.isPropertyAssignment(prop)) { + if (ts.isObjectLiteralExpression(prop.initializer)) { + reg.hasInlineViews = true; + reg.views = parseInlineControllerViews(prop.initializer, sf, parsedFile); + for (const ref of Object.values(reg.views)) { + if (ref.isImportedFromGridCore) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(ref.className)) { + reg.gridCoreRefs.push(ref.className); + } + } + } + } else { + const refText = getNodeText(prop.initializer, sf); + const baseIdent = refText.split('.')[0]; + const imp = parsedFile.imports.get(baseIdent); + if (imp?.isFromGridCore) { + reg.referencesGridCoreModule = true; + reg.forwardedViewsRef = baseIdent; + if (!reg.gridCoreRefs.includes(baseIdent)) { + reg.gridCoreRefs.push(baseIdent); + } + } + } + } + + if (propName === 'extenders' + && ts.isPropertyAssignment(prop)) { + if (ts.isObjectLiteralExpression(prop.initializer)) { + reg.hasInlineExtenders = true; + reg.extenders = parseInlineExtenders(prop.initializer, sf, parsedFile); + const allExts = [ + ...Object.values(reg.extenders.controllers), + ...Object.values(reg.extenders.views), + ]; + for (const ext of allExts) { + if (ext.isImportedFromGridCore) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(ext.extenderName)) { + reg.gridCoreRefs.push(ext.extenderName); + } + } + } + } else { + const refText = getNodeText(prop.initializer, sf); + const baseIdent = refText.split('.')[0]; + const imp = parsedFile.imports.get(baseIdent); + if (imp?.isFromGridCore) { + reg.referencesGridCoreModule = true; + if (!reg.gridCoreRefs.includes(baseIdent)) { + reg.gridCoreRefs.push(baseIdent); + } + } + } + } + } + + return reg; +} + +// ─── Main File Parser ──────────────────────────────────────────────────────── + +export function parseDataGridFile(filePath: string): ParsedFile { + const relPath = getRelativePath(filePath, DATA_GRID_ROOT); + const content = fs.readFileSync(filePath, 'utf-8'); + const sf = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + const parsedFile: ParsedFile = { + filePath, + relPath, + registerModuleCalls: [], + dataSourceAdapterExtensions: [], + classes: new Map(), + imports: new Map(), + localVars: new Map(), + }; + + // ── Pass 1: Collect imports ── + collectImportSpecs(sf).forEach((spec) => { + parsedFile.imports.set(spec.localName, { + localName: spec.localName, + originalName: spec.originalName, + fromPath: spec.fromPath, + isFromGridCore: isGridCoreImport(spec.fromPath), + }); + }); + + // ── Pass 2: Collect classes and local variables ── + function collectClassesAndVars(node: ts.Node): void { + // Class declarations + if (ts.isClassDeclaration(node) && node.name) { + const heritage = getClassHeritage(node, sf, parsedFile.localVars); + parsedFile.classes.set(node.name.text, { + className: node.name.text, + baseClass: heritage.baseClass, + mixins: heritage.mixins, + sourceFile: relPath, + isExported: hasExportModifier(node), + }); + } + + // Variable declarations with class expressions or arrow functions + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (!decl.initializer || !ts.isIdentifier(decl.name)) { + // eslint-disable-next-line no-continue + continue; + } + const varName = decl.name.text; + const init = decl.initializer; + + // Arrow function: const data = (Base) => class extends ... + if (ts.isArrowFunction(init)) { + let { body } = init; + if (ts.isParenthesizedExpression(body)) { + body = body.expression; + } + if (ts.isClassExpression(body)) { + const heritage = getClassHeritage(body, sf, parsedFile.localVars); + const className = body.name?.text ?? varName; + parsedFile.classes.set(varName, { + className, + baseClass: heritage.baseClass, + mixins: heritage.mixins, + sourceFile: relPath, + isExported: hasExportModifier(node), + }); + } + // Store raw text for resolution + parsedFile.localVars.set(varName, getNodeText(init, sf)); + } + + // Class expression: const Foo = class extends Bar {} + if (ts.isClassExpression(init)) { + const heritage = getClassHeritage(init, sf, parsedFile.localVars); + parsedFile.classes.set(varName, { + className: init.name?.text ?? varName, + baseClass: heritage.baseClass, + mixins: heritage.mixins, + sourceFile: relPath, + isExported: hasExportModifier(node), + }); + } + + // Call expression (mixin): const Foo = SomeMixin(Base) + if (ts.isCallExpression(init)) { + parsedFile.localVars.set(varName, getNodeText(init, sf)); + } + + // Simple identifier or property access + if (ts.isIdentifier(init) || ts.isPropertyAccessExpression(init)) { + parsedFile.localVars.set(varName, getNodeText(init, sf)); + } + } + } + + ts.forEachChild(node, collectClassesAndVars); + } + + ts.forEachChild(sf, collectClassesAndVars); + + // ── Pass 3: Find registerModule calls and DataSourceAdapter extensions ── + function findCalls(node: ts.Node): void { + if (isRegisterModuleCall(node, sf)) { + const call = node; + if (call.arguments.length >= 2) { + const moduleNameArg = call.arguments[0]; + if (ts.isStringLiteral(moduleNameArg)) { + const reg = parseRegisterModuleCall( + moduleNameArg.text, + call.arguments[1], + sf, + parsedFile, + ); + parsedFile.registerModuleCalls.push(reg); + } + } + } + + if (isDataSourceAdapterExtendCall(node, sf, parsedFile.imports)) { + if (node.arguments.length >= 1) { + const extenderArg = node.arguments[0]; + const extenderName = getNodeText(extenderArg, sf); + const imp = parsedFile.imports.get(extenderName); + parsedFile.dataSourceAdapterExtensions.push({ + sourceFile: relPath, + relPath, + extenderName, + isImportedFromGridCore: imp?.isFromGridCore ?? false, + order: 0, + }); + } + } + + ts.forEachChild(node, findCalls); + } + + ts.forEachChild(sf, findCalls); + + return parsedFile; +} + +// ─── Module Order Parser ───────────────────────────────────────────────────── + +export function parseModulesOrder(): string[] { + const content = fs.readFileSync(WIDGET_BASE_FILE, 'utf-8'); + const sf = ts.createSourceFile(WIDGET_BASE_FILE, content, ts.ScriptTarget.Latest, true); + + const order: string[] = []; + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === 'registerModulesOrder' + && node.arguments.length > 0 + && ts.isArrayLiteralExpression(node.arguments[0]) + ) { + for (const elem of node.arguments[0].elements) { + if (ts.isStringLiteral(elem)) { + order.push(elem.text); + } + } + } + ts.forEachChild(node, visit); + } + + ts.forEachChild(sf, visit); + return order; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/resolver.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/resolver.ts new file mode 100644 index 000000000000..3fdd4ed0107b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/resolver.ts @@ -0,0 +1,426 @@ +/* eslint-disable max-depth,no-continue */ +import { buildInheritanceChainCore } from '../shared/inheritance'; +import type { HeritageInfo } from '../shared/types'; +import type { ModificationCategory } from './constants'; +import { CROSS_DEP_IGNORED_SEGMENTS, getFeatureAreaFromPath } from './constants'; +import type { + ClassifiedModule, + CrossDependency, + DataSourceAdapterExtension, + ExtenderPipeline, + ExtenderPipelineStep, + GridCoreModuleInfo, + InheritanceEntry, + ParsedFile, + RegisterModuleCall, +} from './types'; + +// ─── Module Classification ─────────────────────────────────────────────────── + +function hasLocallyDefinedExtenders( + reg: RegisterModuleCall, + parsedFile: ParsedFile, +): boolean { + const allExtenders = [ + ...Object.values(reg.extenders.controllers), + ...Object.values(reg.extenders.views), + ]; + + return allExtenders.some((ext) => { + if (ext.isDefinedLocally) { + return true; + } + + if (ext.isImportedFromGridCore) { + return false; + } + + return parsedFile.localVars.has(ext.extenderName) + || parsedFile.classes.has(ext.extenderName); + }); +} + +function classifyModule( + reg: RegisterModuleCall, + parsedFile: ParsedFile, +): ModificationCategory { + // No grid_core reference at all → new (data_grid-only module) + if (!reg.referencesGridCoreModule) { + return 'new'; + } + + // Direct passthrough: gridCore.registerModule('sorting', sortingModule) + if (reg.argIsIdentifier) { + return 'passthrough'; + } + + // Check for locally-defined controllers/views (→ replaced) + const hasLocalControllers = Object.values(reg.controllers).some((c) => c.isDefinedLocally); + const hasLocalViews = Object.values(reg.views).some((v) => v.isDefinedLocally); + if (hasLocalControllers || hasLocalViews) { + return 'replaced'; + } + + // Check for locally-defined extenders (→ extended) + if (reg.hasInlineExtenders && hasLocallyDefinedExtenders(reg, parsedFile)) { + return 'extended'; + } + + // Everything else (including defaultOptions-only overrides) is passthrough + // defaultOptions are initial property values and do NOT affect classification + return 'passthrough'; +} + +// ─── Resolve forwarded controllers/views from grid_core module data ────────── + +function buildGcSourceLookup( + gridCoreModules: GridCoreModuleInfo[], +): Map { + const map = new Map(); + for (const gc of gridCoreModules) { + const key = gc.sourceFile.replace(/\.ts$/, ''); + map.set(key, gc); + } + return map; +} + +/** Build a lookup map: registeredAs → GridCoreModuleInfo for O(1) access. */ +export function buildGcRegistrationLookup( + gridCoreModules: GridCoreModuleInfo[], +): Map { + const map = new Map(); + for (const gc of gridCoreModules) { + if (gc.registeredAs) { + map.set(gc.registeredAs, gc); + } + } + return map; +} + +/** Build a lookup map: sourceFile → ClassifiedModule for O(1) access. */ +export function buildModulesBySourceFile( + modules: ClassifiedModule[], +): Map { + return new Map(modules.map((m) => [m.sourceFile, m])); +} + +function resolveForwardedRefs( + reg: RegisterModuleCall, + pf: ParsedFile, + gcSourceLookup: Map, + kind: 'controllers' | 'views', +): void { + const forwardedRef = kind === 'controllers' + ? reg.forwardedControllersRef + : reg.forwardedViewsRef; + const target = reg[kind]; + + if (!forwardedRef || Object.keys(target).length > 0) { + return; + } + + const imp = pf.imports.get(forwardedRef); + if (!imp?.isFromGridCore) { + return; + } + + const normalized = imp.fromPath + .replace(/^@ts\/grids\/grid_core\//, '') + .replace(/\.ts$/, ''); + const gcMod = gcSourceLookup.get(normalized); + + if (gcMod) { + for (const [regName, info] of Object.entries(gcMod[kind])) { + target[regName] = { + regName, + className: info.className, + isImportedFromGridCore: true, + isDefinedLocally: false, + baseClass: info.baseClass, + mixins: info.mixins ?? [], + sourceFile: info.sourceFile, + }; + } + } else { + console.warn(`WARN: gc module not found for forwarded ${kind} ref '${forwardedRef}' (from ${imp.fromPath}). Ensure grid_core JSON is up-to-date.`); + } +} + +// ─── Public: classifyModules ───────────────────────────────────────────────── + +export function classifyModules( + parsedFiles: ParsedFile[], + modulesOrder: string[], + gridCoreModules: GridCoreModuleInfo[], +): ClassifiedModule[] { + const results: ClassifiedModule[] = []; + const orderMap = new Map(modulesOrder.map((name, i) => [name, i])); + const gcSourceLookup = buildGcSourceLookup(gridCoreModules); + + for (const pf of parsedFiles) { + for (const reg of pf.registerModuleCalls) { + // Resolve forwarded controllers/views from gc module data before classification + resolveForwardedRefs(reg, pf, gcSourceLookup, 'controllers'); + resolveForwardedRefs(reg, pf, gcSourceLookup, 'views'); + + const category = classifyModule(reg, pf); + const orderIndex = orderMap.get(reg.moduleName) ?? -1; + + let gridCoreSourceModule: string | null = null; + for (const ref of reg.gridCoreRefs) { + const imp = pf.imports.get(ref); + if (imp?.isFromGridCore) { + gridCoreSourceModule = imp.fromPath.replace(/^@ts\/grids\//, ''); + break; + } + } + + results.push({ + moduleName: reg.moduleName, + category, + sourceFile: reg.relPath, + featureArea: getFeatureAreaFromPath(reg.relPath), + registrationOrder: orderIndex >= 0 ? orderIndex : modulesOrder.length, + gridCoreModuleName: reg.argIsIdentifier ? reg.argIdentifierName : null, + gridCoreSourceModule, + controllers: reg.controllers, + views: reg.views, + extenders: reg.extenders, + hasDefaultOptionsOverride: reg.hasDefaultOptions && category !== 'passthrough', + }); + } + } + + results.sort((a, b) => a.registrationOrder - b.registrationOrder); + return results; +} + +// ─── Public: collectDataSourceAdapterChain ─────────────────────────────────── + +export function collectDataSourceAdapterChain( + parsedFiles: ParsedFile[], +): DataSourceAdapterExtension[] { + const all: DataSourceAdapterExtension[] = []; + for (const pf of parsedFiles) { + all.push(...pf.dataSourceAdapterExtensions); + } + return all.map((ext, i) => ({ ...ext, order: i })); +} + +// ─── Public: buildExtenderPipelines ────────────────────────────────────────── + +function addPipelineStep( + stepsMap: Map, + targetName: string, + step: ExtenderPipelineStep, +): void { + const existing = stepsMap.get(targetName); + if (existing) { + existing.push(step); + } else { + stepsMap.set(targetName, [step]); + } +} + +function collectExtenderSteps( + mod: ClassifiedModule, + gcMod: GridCoreModuleInfo | undefined, + kind: 'controllers' | 'views', + stepsMap: Map, +): void { + // 1. Extenders explicitly declared in data_grid registerModule call + const declaredTargets = new Set(Object.keys(mod.extenders[kind])); + for (const [targetName, ext] of Object.entries(mod.extenders[kind])) { + addPipelineStep(stepsMap, targetName, { + moduleName: mod.moduleName, + relPath: mod.sourceFile, + extenderName: ext.extenderName, + isFromGridCore: ext.isImportedFromGridCore, + registrationOrder: mod.registrationOrder, + category: mod.category, + }); + } + // 2. GC extenders from passthrough/extended modules (not already declared by dg) + if (gcMod && (mod.category === 'passthrough' || mod.category === 'extended')) { + for (const [targetName, ext] of Object.entries(gcMod.extenders[kind])) { + if (!declaredTargets.has(targetName)) { + addPipelineStep(stepsMap, targetName, { + moduleName: mod.moduleName, + relPath: mod.sourceFile, + extenderName: ext.extenderName, + isFromGridCore: true, + registrationOrder: mod.registrationOrder, + category: mod.category, + }); + } + } + } +} + +export function buildExtenderPipelines( + modules: ClassifiedModule[], + gridCoreModules: GridCoreModuleInfo[], +): ExtenderPipeline[] { + const controllerSteps = new Map(); + const viewSteps = new Map(); + const gcRegLookup = buildGcRegistrationLookup(gridCoreModules); + + for (const mod of modules) { + const gcMod = gcRegLookup.get(mod.moduleName); + collectExtenderSteps(mod, gcMod, 'controllers', controllerSteps); + collectExtenderSteps(mod, gcMod, 'views', viewSteps); + } + + const pipelines: ExtenderPipeline[] = []; + for (const [targetName, steps] of controllerSteps) { + steps.sort((a, b) => a.registrationOrder - b.registrationOrder); + pipelines.push({ targetName, targetType: 'controller', steps }); + } + for (const [targetName, steps] of viewSteps) { + steps.sort((a, b) => a.registrationOrder - b.registrationOrder); + pipelines.push({ targetName, targetType: 'view', steps }); + } + return pipelines.sort((a, b) => a.targetName.localeCompare(b.targetName)); +} + +// ─── Public: buildInheritanceChains ────────────────────────────────────────── + +const MAX_INHERITANCE_DEPTH = 50; + +export function buildInheritanceChains(parsedFiles: ParsedFile[]): InheritanceEntry[] { + const allClasses = new Map(); + for (const pf of parsedFiles) { + for (const [name, info] of pf.classes) { + if (allClasses.has(name)) { + const existing = allClasses.get(name); + console.warn(`WARN: Duplicate class "${name}" found in ${existing?.sourceFile ?? 'unknown'} and ${info.sourceFile}; keeping last-seen entry.`); + } + allClasses.set(name, { + baseClass: info.baseClass, + mixins: info.mixins, + sourceFile: info.sourceFile, + }); + } + } + + const entries: InheritanceEntry[] = []; + for (const [className, info] of allClasses) { + if (info.baseClass) { + const visited = new Set(); + const chain = buildInheritanceChainCore( + className, + (name) => allClasses.get(name), + visited, + { maxDepth: MAX_INHERITANCE_DEPTH, formatMixin: (m) => `[mixin] ${m}` }, + ); + if (chain.length > 0) { + entries.push({ className, chain, sourceFile: info.sourceFile }); + } + } + } + return entries.sort((a, b) => a.className.localeCompare(b.className)); +} + +// ─── Public: buildCrossDependencies ────────────────────────────────────────── + +function resolveRelativeImportPath(fromRelPath: string, importPath: string): string | null { + const fromDir = fromRelPath.split('/').slice(0, -1).join('/'); + const segments = importPath.split('/'); + const resolved: string[] = fromDir ? fromDir.split('/') : []; + for (const seg of segments) { + if (seg === '.') { /* current */ } else if (seg === '..') { resolved.pop(); } else { resolved.push(seg); } + } + return resolved.join('/') || null; +} + +function findTargetFile(resolved: string, relPaths: Set): string | null { + if (relPaths.has(resolved)) { + return resolved; + } + + const withTs = `${resolved}.ts`; + if (relPaths.has(withTs)) { + return withTs; + } + + const asIndex = `${resolved}/index.ts`; + if (relPaths.has(asIndex)) { + return asIndex; + } + + return null; +} + +export function buildCrossDependencies( + parsedFiles: ParsedFile[], + modules: ClassifiedModule[], +): CrossDependency[] { + const relPathToModule = new Map(); + for (const mod of modules) { + relPathToModule.set(mod.sourceFile, mod.moduleName); + } + + const allRelPaths = new Set(); + for (const pf of parsedFiles) { + allRelPaths.add(pf.relPath); + } + + const depsMap = new Map(); + + for (const pf of parsedFiles) { + const fromModule = relPathToModule.get(pf.relPath); + if (!fromModule) { + continue; + } + + for (const [localName, imp] of pf.imports) { + if (imp.isFromGridCore) { + continue; + } + if (!imp.fromPath.startsWith('.') && !imp.fromPath.startsWith('..')) { + continue; + } + + const resolvedTarget = resolveRelativeImportPath(pf.relPath, imp.fromPath); + if (!resolvedTarget) { + continue; + } + + const targetFile = findTargetFile(resolvedTarget, allRelPaths); + if (!targetFile) { + continue; + } + + const toModule = relPathToModule.get(targetFile) ?? null; + if (toModule === fromModule) { + continue; + } + + // Skip boring internal imports (exact path segment match) + const targetSegments = resolvedTarget.split('/'); + if (targetSegments.some((seg) => CROSS_DEP_IGNORED_SEGMENTS.has(seg))) { + continue; + } + + const key = `${fromModule}→${targetFile}`; + const existing = depsMap.get(key); + if (existing) { + if (!existing.importedNames.includes(localName)) { + existing.importedNames.push(localName); + } + continue; + } + + depsMap.set(key, { + fromModule, + fromRelPath: pf.relPath, + toRelPath: targetFile, + toModule, + importedNames: [localName], + importPath: imp.fromPath, + }); + } + } + + return [...depsMap.values()].sort((a, b) => a.fromModule.localeCompare(b.fromModule)); +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/types.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/types.ts new file mode 100644 index 000000000000..a7b4623a90d3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/data_grid/types.ts @@ -0,0 +1,156 @@ +import type { BaseClassInfo, HeritageInfo, InheritanceEntry as SharedInheritanceEntry } from '../shared/types'; +import type { ModificationCategory } from './constants'; + +// ─── Parsed file data ──────────────────────────────────────────────────────── + +export interface ImportInfo { + localName: string; + originalName: string; + fromPath: string; + isFromGridCore: boolean; +} + +export interface ClassInfo extends BaseClassInfo {} + +export interface RegisterModuleCall { + moduleName: string; + sourceFile: string; + relPath: string; + argIsIdentifier: boolean; + argIdentifierName: string | null; + spreadSources: string[]; + hasInlineControllers: boolean; + hasInlineViews: boolean; + hasInlineExtenders: boolean; + hasDefaultOptions: boolean; + referencesGridCoreModule: boolean; + gridCoreRefs: string[]; + controllers: Record; + views: Record; + extenders: { + controllers: Record; + views: Record; + }; + /** When controllers is a property access like `someModule.controllers` from gc */ + forwardedControllersRef: string | null; + /** When views is a property access like `someModule.views` from gc */ + forwardedViewsRef: string | null; +} + +export interface ControllerViewRef extends HeritageInfo { + regName: string; + className: string; + isImportedFromGridCore: boolean; + isDefinedLocally: boolean; + sourceFile: string; +} + +export interface ExtenderRef { + targetName: string; + extenderName: string; + isImportedFromGridCore: boolean; + isDefinedLocally: boolean; +} + +export interface DataSourceAdapterExtension { + sourceFile: string; + relPath: string; + extenderName: string; + isImportedFromGridCore: boolean; + order: number; +} + +export interface ParsedFile { + filePath: string; + relPath: string; + registerModuleCalls: RegisterModuleCall[]; + dataSourceAdapterExtensions: DataSourceAdapterExtension[]; + classes: Map; + imports: Map; + localVars: Map; +} + +// ─── Resolved architecture data ────────────────────────────────────────────── + +export interface ClassifiedModule { + moduleName: string; + category: ModificationCategory; + sourceFile: string; + featureArea: string; + registrationOrder: number; + + gridCoreModuleName: string | null; + gridCoreSourceModule: string | null; + + controllers: Record; + views: Record; + extenders: { + controllers: Record; + views: Record; + }; + + hasDefaultOptionsOverride: boolean; +} + +export interface ExtenderPipelineStep { + moduleName: string; + relPath: string; + extenderName: string; + isFromGridCore: boolean; + registrationOrder: number; + category: ModificationCategory; +} + +export interface ExtenderPipeline { + targetName: string; + targetType: 'controller' | 'view'; + steps: ExtenderPipelineStep[]; +} + +export interface InheritanceEntry extends SharedInheritanceEntry { + sourceFile: string; +} + +export interface CrossDependency { + fromModule: string; + fromRelPath: string; + toRelPath: string; + toModule: string | null; + importedNames: string[]; + importPath: string; +} + +/** Controller/view entry as serialized in the grid_core JSON output. */ +export interface GridCoreControllerViewInfo extends HeritageInfo { + regName: string; + className: string; + sourceFile: string; +} + +export interface GridCoreModuleInfo { + moduleName: string; + registeredAs: string | null; + sourceFile: string; + featureArea: string; + controllers: Record; + views: Record; + extenders: { + controllers: Record; + views: Record; + }; + hasDefaultOptions: boolean; +} + +export interface ArchitectureData { + generatedAt: string; + dataGridRoot: string; + gridCoreRoot: string; + modulesOrder: string[]; + modules: ClassifiedModule[]; + gridCoreModules: GridCoreModuleInfo[]; + extenderPipelines: ExtenderPipeline[]; + dataSourceAdapterChain: DataSourceAdapterExtension[]; + inheritanceChains: InheritanceEntry[]; + crossDependencies: CrossDependency[]; + summary: Record & { total: number }; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/constants.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/constants.ts similarity index 94% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/constants.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/constants.ts index f0009df017fa..9631b90542ff 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/constants.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/constants.ts @@ -1,7 +1,7 @@ import * as path from 'path'; -export const GRID_CORE_ROOT = path.resolve(__dirname, '..', '..', 'grid_core'); -export const OUTPUT_DIR = path.resolve(__dirname, '..', 'artifacts'); +export const GRID_CORE_ROOT = path.resolve(__dirname, '..', '..', '..', 'grid_core'); +export const OUTPUT_DIR = path.resolve(__dirname, '..', '..', 'artifacts'); export const MODULE_SUFFIX = 'Module'; export const MODULES_PREFIX = 'modules.'; diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/generate-architecture-doc.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/generate.ts similarity index 77% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/generate-architecture-doc.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/generate.ts index ead74165dec3..1c573cca5b4c 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/generate-architecture-doc.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/generate.ts @@ -1,12 +1,13 @@ #!/usr/bin/env tsx -/* eslint-disable no-console, no-restricted-syntax */ -import * as fs from 'fs'; -import * as path from 'path'; +/* eslint-disable no-console */ -import { parseArgs } from './cli'; -import { GRID_CORE_ROOT, OUTPUT_DIR } from './constants'; +import { discoverSourceFiles, getRelativePath } from '../shared/file-discovery'; +import { writeOutputFiles } from '../shared/output-writer'; +import { + EXCLUDED_DIRS, EXCLUDED_FILE_NAMES, GRID_CORE_ROOT, OUTPUT_DIR, +} from './constants'; import { generateHtml } from './html-template'; -import { discoverSourceFiles, getRelativePath, parseFile } from './parser'; +import { parseFile } from './parser'; import { buildGlobalAliasMap, buildGlobalClassRegistry, @@ -30,17 +31,18 @@ function main(): void { try { // 1. Discover source files - const sourceFiles = discoverSourceFiles(GRID_CORE_ROOT); + const sourceFiles = discoverSourceFiles(GRID_CORE_ROOT, EXCLUDED_DIRS, EXCLUDED_FILE_NAMES); console.log(`Discovered ${sourceFiles.length} source files`); // 2. Parse all files const allParsedFiles = sourceFiles.flatMap((file) => { - console.log(`Parsing: ${getRelativePath(file)}`); + const relPath = getRelativePath(file, GRID_CORE_ROOT); + console.log(`Parsing: ${relPath}`); try { return [parseFile(file)]; } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); - console.warn(`WARN: Failed to parse ${getRelativePath(file)}: ${errorMessage}`); + console.warn(`WARN: Failed to parse ${relPath}: ${errorMessage}`); return []; } @@ -58,10 +60,11 @@ function main(): void { resolveAliasesInClasses(globalClasses, globalAliasMap); // 3. Collect modules and re-resolve cross-file class references + const fileByRelPath = new Map(allParsedFiles.map((pf) => [pf.relPath, pf])); const modules: ModuleInfo[] = []; for (const pf of allParsedFiles) { for (const mod of pf.modules) { - resolveModuleClassRefs(mod, pf, globalClasses, allParsedFiles, globalAliasMap); + resolveModuleClassRefs(mod, pf, globalClasses, fileByRelPath, globalAliasMap); modules.push(mod); } } @@ -103,22 +106,7 @@ function main(): void { }; // 9. Write output files - if (!fs.existsSync(OUTPUT_DIR)) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - } - - const args = parseArgs(); - if (!args.htmlOnly) { - const jsonPath = path.join(OUTPUT_DIR, 'grid_core_architecture.generated.json'); - fs.writeFileSync(jsonPath, `${JSON.stringify(data, null, 2)}\n`); - console.log(`✓ JSON written to: ${jsonPath}`); - } - - if (!args.jsonOnly) { - const htmlPath = path.join(OUTPUT_DIR, 'grid_core_architecture.generated.html'); - fs.writeFileSync(htmlPath, generateHtml(data)); - console.log(`✓ HTML written to: ${htmlPath}`); - } + writeOutputFiles(OUTPUT_DIR, 'grid_core_architecture', data, generateHtml); console.log('\nSummary:'); console.log(` Modules: ${modules.length}`); @@ -132,6 +120,9 @@ function main(): void { } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); console.error(`ERROR: ${errorMessage}`); + if (e instanceof Error && e.stack) { + console.error(e.stack); + } process.exit(1); } } diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/graph-builder.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/graph-builder.ts similarity index 84% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/graph-builder.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/graph-builder.ts index 23fa0de5c07b..059cb1b10495 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/graph-builder.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/graph-builder.ts @@ -1,13 +1,9 @@ -/* eslint-disable spellcheck/spell-checker, no-restricted-syntax, max-depth */ +/* eslint-disable spellcheck/spell-checker, max-depth */ +import type { CytoscapeElement } from '../shared/graph-context'; +import { createGraphContext } from '../shared/graph-context'; import { MODULE_ITEM_CLASS, MODULES_PREFIX } from './constants'; import type { ArchitectureData } from './types'; -interface CytoscapeElement { - group: 'nodes' | 'edges'; - data: Record; - classes?: string; -} - function nonEmpty(value: string): string | undefined { return value || undefined; } @@ -43,30 +39,21 @@ function buildNodeIdMap(data: ArchitectureData): Map { } export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement[] { - const elements: CytoscapeElement[] = []; - const nodeIds = new Set(); - const edgeIds = new Set(); - const nodeParent = new Map(); // nodeId → parentId + const ctx = createGraphContext({ trackParent: true }); + const { + elements, nodeIds, edgeIds, nodeParent, addNode, + } = ctx; const nodeIdMap = buildNodeIdMap(data); - function addNode(id: string, nodeData: Record, classes: string): void { - if (nodeIds.has(id)) { - return; - } - - nodeIds.add(id); - if (nodeData.parent) { - nodeParent.set(id, nodeData.parent as string); - } - elements.push({ group: 'nodes', data: { id, ...nodeData }, classes }); - } - function addEdge( source: string, target: string, edgeData: Record, classes: string, ): void { + // ID keyed by classes — safe because each edge type (inheritance, extension, + // runtime) uses a distinct class string, and at most one edge of each type + // exists per source→target pair. const id = `e-${source}-${target}-${classes}`; if (!nodeIds.has(source) || !nodeIds.has(target) || edgeIds.has(id)) { @@ -122,7 +109,7 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement mixins: nonEmpty(ctrl.mixins.join(', ')), sourceFile: ctrl.sourceFile, featureArea: mod.featureArea, - }, 'controller'); + }, 'gc-target gc-target-controller'); } // Add view children @@ -137,7 +124,7 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement mixins: nonEmpty(view.mixins.join(', ')), sourceFile: view.sourceFile, featureArea: mod.featureArea, - }, 'view'); + }, 'gc-target gc-target-view'); } } @@ -152,7 +139,7 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement mixins: nonEmpty(ctrl.mixins.join(', ')), sourceFile: ctrl.sourceFile, featureArea: ctrl.featureArea, - }, 'controller standalone'); + }, 'gc-target gc-target-controller'); } for (const [regName, view] of Object.entries(data.standaloneViews)) { @@ -165,12 +152,12 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement mixins: nonEmpty(view.mixins.join(', ')), sourceFile: view.sourceFile, featureArea: view.featureArea, - }, 'view standalone'); + }, 'gc-target gc-target-view'); } // 3. Add inheritance edges for (const entry of data.inheritanceChains) { - const sourceId = nodeIdMap.get(entry.class); + const sourceId = nodeIdMap.get(entry.className); if (!sourceId || !nodeIds.has(sourceId)) { // eslint-disable-next-line no-continue continue; @@ -184,7 +171,8 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement } const targetId = nodeIdMap.get(base); if (targetId && nodeIds.has(targetId)) { - addEdge(sourceId, targetId, { edgeType: 'inheritance' }, 'inheritance'); + const inheritClass = targetId.startsWith('ctrl-') ? 'edge-inherit-ctrl' : 'edge-inherit-view'; + addEdge(sourceId, targetId, { edgeType: 'inheritance', label: sourceId.replace(/^(ctrl|view)-/, '') }, inheritClass); break; } } @@ -201,7 +189,8 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement addEdge(moduleId, targetId, { edgeType: 'extension', extenderName: ext.extenderName, - }, 'extension'); + label: mod.registeredAs ?? mod.moduleName, + }, 'edge-ext-ctrl'); } } @@ -211,7 +200,8 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement addEdge(moduleId, targetId, { edgeType: 'extension', extenderName: ext.extenderName, - }, 'extension'); + label: mod.registeredAs ?? mod.moduleName, + }, 'edge-ext-view'); } } } @@ -225,7 +215,7 @@ export function buildCytoscapeElements(data: ArchitectureData): CytoscapeElement addEdge(sourceId, targetId, { edgeType: 'runtime', via: dep.via, - }, 'runtime'); + }, 'edge-runtime'); } } diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/html-template.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/html-template.ts new file mode 100644 index 000000000000..e370b294c00a --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/html-template.ts @@ -0,0 +1,748 @@ +/* eslint-disable spellcheck/spell-checker */ +/** + * HTML visualization template for Grid Core Architecture Documentation Generator. + * Generates an interactive Cytoscape.js visualization. + */ + +import { + BASE_CSS, + EXTENDER_EDGE_BASE_STYLES, + GC_TARGET_CYTOSCAPE_STYLES, + HIGHLIGHT_CYTOSCAPE_STYLES, + SHARED_INTERACTIVE_JS, +} from '../shared/html-helpers'; +import { buildCytoscapeElements } from './graph-builder'; +import type { ArchitectureData } from './types'; + +export function generateHtml(data: ArchitectureData): string { + const cytoscapeElements = buildCytoscapeElements(data); + const elementsJson = JSON.stringify(cytoscapeElements, null, 2); + const featureAreas = [...new Set([ + ...data.modules.map((m) => m.featureArea), + ...Object.values(data.standaloneControllers).map((c) => c.featureArea), + ...Object.values(data.standaloneViews).map((v) => v.featureArea), + ])].sort(); + + return ` + + + + +Grid Core Architecture + + + + + + + +
+
+
+
+ +
+

Click a node or edge to see details.

+
+
+
+
+ + + +`; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/parser.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/parser.ts similarity index 67% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/parser.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/parser.ts index 7c27c23c2c29..72516fe05717 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/parser.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/parser.ts @@ -1,12 +1,16 @@ -/* eslint-disable spellcheck/spell-checker,no-restricted-syntax,max-depth */ +/* eslint-disable spellcheck/spell-checker,max-depth */ import * as fs from 'fs'; -import * as path from 'path'; // eslint-disable-next-line import/no-extraneous-dependencies import ts from 'typescript'; import { - EXCLUDED_DIRS, - EXCLUDED_FILE_NAMES, + collectImportSpecs, + getClassHeritage, + getNodeText, + hasExportModifier, +} from '../shared/ast-helpers'; +import { getRelativePath } from '../shared/file-discovery'; +import { getFeatureAreaFromPath, GRID_CORE_ROOT, MODULE_SUFFIX, @@ -15,97 +19,7 @@ import type { ClassRegistrationInfo, ExtenderInfo, ModuleInfo, ParsedFile, RuntimeDependency, } from './types'; -// ─── File Discovery ────────────────────────────────────────────────────────── - -export function discoverSourceFiles(rootDir: string): string[] { - const results: string[] = []; - - function walk(dir: string): void { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (EXCLUDED_DIRS.has(entry.name)) { - // eslint-disable-next-line no-continue - continue; - } - walk(fullPath); - } else if ( - entry.isFile() - && !EXCLUDED_FILE_NAMES.has(entry.name) - && entry.name.endsWith('.ts') - && !entry.name.includes('.test.') - ) { - results.push(fullPath); - } - } - } - - walk(rootDir); - return results.sort(); -} - -// ─── AST Helpers ───────────────────────────────────────────────────────────── - -export function getRelativePath(filePath: string): string { - return path.relative(GRID_CORE_ROOT, filePath).replace(/\\/g, '/'); -} - -function getNodeText(node: ts.Node, sourceFile: ts.SourceFile): string { - return node.getText(sourceFile).trim(); -} - -/** - * Parse a heritage string like "Mixin(Base)" or "Mixin(Mixin2(Base))". - * - * Note: only handles single-argument mixin calls (which is the pattern used - * throughout the grid_core codebase). Multi-argument patterns are not supported. - */ -export function parseHeritageString(text: string): { baseClass: string; mixins: string[] } { - const mixins: string[] = []; - - let current = text; - while (true) { - const match = /^(\w+)\((.+)\)$/.exec(current); - if (match) { - const [, mixinName, innerExpr] = match; - mixins.push(mixinName); - current = innerExpr; - } else { - break; - } - } - - return { baseClass: mixins.length > 0 ? `${mixins[mixins.length - 1]}(${current})` : current, mixins }; -} - -/** - * Parse a class heritage expression like: - * - `modules.Controller` - * - `ColumnsView` - * - `ColumnStateMixin(modules.View)` - * - `ColumnContextMenuMixin(ColumnsView)` - * - `EditorFactoryMixin(modules.ViewController)` - * - * Returns { baseClass, mixins } - */ -function parseHeritageExpression( - expr: ts.Expression, - sourceFile: ts.SourceFile, - localVars: Map, -): { baseClass: string; mixins: string[] } { - const text = getNodeText(expr, sourceFile); - - if (ts.isIdentifier(expr) && localVars.has(text)) { - const resolved = localVars.get(text) ?? ''; - return parseHeritageString(resolved); - } - - return parseHeritageString(text); -} - // ─── Runtime Dependency Collection ─────────────────────────────────────────── - function collectRuntimeDeps( node: ts.Node, sourceFile: ts.SourceFile, @@ -158,32 +72,6 @@ function collectRuntimeDeps( // ─── Module Parsing ────────────────────────────────────────────────────────── -function hasExportModifier(node: ts.Node): boolean { - if (!ts.canHaveModifiers(node)) { - return false; - } - const modifiers = ts.getModifiers(node); - return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; -} - -function getClassHeritage( - node: ts.ClassDeclaration | ts.ClassExpression, - sourceFile: ts.SourceFile, - localVars: Map, -): { baseClass: string; mixins: string[] } { - if (!node.heritageClauses) { - return { baseClass: '', mixins: [] }; - } - - for (const clause of node.heritageClauses) { - if (clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length > 0) { - return parseHeritageExpression(clause.types[0].expression, sourceFile, localVars); - } - } - - return { baseClass: '', mixins: [] }; -} - function guessRegisteredName(moduleName: string): string | null { if (moduleName.endsWith(MODULE_SUFFIX)) { return moduleName.slice(0, -MODULE_SUFFIX.length); @@ -343,7 +231,7 @@ export function parseFile(filePath: string): ParsedFile { ts.ScriptKind.TS, ); - const relPath = getRelativePath(filePath); + const relPath = getRelativePath(filePath, GRID_CORE_ROOT); const result: ParsedFile = { filePath, relPath, @@ -356,32 +244,19 @@ export function parseFile(filePath: string): ParsedFile { }; // Collect import aliases (import { X as Y } from '...') - ts.forEachChild(sourceFile, (node) => { - if (ts.isImportDeclaration(node) - && node.importClause && node.moduleSpecifier - && ts.isStringLiteral(node.moduleSpecifier) - ) { - const fromPath = node.moduleSpecifier.text; - const { namedBindings } = node.importClause; - if (namedBindings && ts.isNamedImports(namedBindings)) { - for (const spec of namedBindings.elements) { - const localName = spec.name.text; - const originalName = spec.propertyName ? spec.propertyName.text : spec.name.text; - result.importedNames.set(localName, originalName); - if (spec.propertyName) { - result.importAliases.set(localName, { - localName, - originalName, - fromPath, - }); - } - } - } - // Handle default imports: import X from '...' - if (node.importClause.name) { - const localName = node.importClause.name.text; - result.importedNames.set(localName, localName); - } + collectImportSpecs(sourceFile).forEach((spec) => { + if (!spec.isNamespace) { + result.importedNames.set( + spec.localName, + spec.isDefault ? spec.localName : spec.originalName, + ); + } + if (spec.isRenamed) { + result.importAliases.set(spec.localName, { + localName: spec.localName, + originalName: spec.originalName, + fromPath: spec.fromPath, + }); } }); diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/resolver.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/resolver.ts similarity index 82% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/resolver.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/resolver.ts index b347176efc87..850b587d37cb 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/resolver.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/resolver.ts @@ -1,4 +1,7 @@ -/* eslint-disable spellcheck/spell-checker, no-restricted-syntax, max-depth */ +/* eslint-disable spellcheck/spell-checker, max-depth */ +import { parseMixinCall, stripAllMixins } from '../shared/ast-helpers'; +import { buildInheritanceChainCore } from '../shared/inheritance'; +import type { HeritageInfo } from '../shared/types'; import { BARE_MODULE_BASES, getFeatureAreaFromPath, @@ -33,7 +36,7 @@ export function buildGlobalClassRegistry( for (const pf of allParsedFiles) { for (const [className, info] of pf.classes) { - const entry: GlobalClassInfo = { ...info, sourceFile: pf.relPath }; + const entry: GlobalClassInfo = { ...info, className, sourceFile: pf.relPath }; const existingEntry = registry.get(className); if (!existingEntry) { registry.set(className, entry); @@ -57,6 +60,8 @@ export function buildGlobalClassRegistry( }); if (baseEntry) { registry.set(className, baseEntry); + } else { + console.warn(`WARN: Duplicate class "${className}" found in ${entries.map((e) => e.sourceFile).join(', ')}; keeping first-seen entry (no entry matched the base-class heuristic).`); } } @@ -104,11 +109,10 @@ export function resolveAliasInString(text: string, aliasMap: Map } // Mixin pattern: "Mixin(AliasName)" -> "Mixin(RealName)" - const mixinMatch = /^(\w+)\((.+)\)$/.exec(text); - if (mixinMatch) { - const [, mixinPart, innerPart] = mixinMatch; - const mixin = resolveAliasInString(mixinPart, aliasMap); - const inner = resolveAliasInString(innerPart, aliasMap); + const parsed = parseMixinCall(text); + if (parsed) { + const mixin = resolveAliasInString(parsed.mixinName, aliasMap); + const inner = resolveAliasInString(parsed.inner, aliasMap); return `${mixin}(${inner})`; } @@ -143,10 +147,9 @@ export function normalizeModuleRef(baseClass: string): string { result = result.replace(/^core\./, 'modules.'); // Also normalize inside mixin calls: "Mixin(Modules.View)" → "Mixin(modules.View)" - const mixinMatch = /^(\w+)\((.+)\)$/.exec(result); - if (mixinMatch) { - const [, mixinName, mixinInner] = mixinMatch; - return `${mixinName}(${normalizeModuleRef(mixinInner)})`; + const parsed = parseMixinCall(result); + if (parsed) { + return `${parsed.mixinName}(${normalizeModuleRef(parsed.inner)})`; } return result; @@ -222,12 +225,7 @@ function isModuleBaseDescendant( } // Strip mixin wrapper to get the innermost base - let rawBase = baseClass; - let match = /^\w+\((.+)\)$/.exec(rawBase); - while (match) { - [, rawBase] = match; - match = /^\w+\((.+)\)$/.exec(rawBase); - } + const rawBase = stripAllMixins(baseClass); if (rawBase.startsWith(MODULES_PREFIX)) { return true; @@ -262,6 +260,19 @@ export function findStandaloneRegistrations( } } + // Pre-build a set of class names that are used as a base class or mixin by any other class. + // Uses exact name matching (via stripAllMixins) instead of substring includes(). + const usedAsBaseOrMixin = new Set(); + for (const [, info] of globalClasses) { + const rawBase = stripAllMixins(info.baseClass); + if (rawBase) { + usedAsBaseOrMixin.add(rawBase); + } + for (const mixin of info.mixins) { + usedAsBaseOrMixin.add(mixin); + } + } + const controllers: Record = {}; const views: Record = {}; @@ -271,16 +282,8 @@ export function findStandaloneRegistrations( continue; } - // Include if used as a base class OR if it descends from a module base - let isUsed = false; - for (const [otherName, otherInfo] of globalClasses) { - if (otherName !== className - && (otherInfo.baseClass.includes(className) || otherInfo.mixins.includes(className)) - ) { - isUsed = true; - break; - } - } + // Include if used as a base class or mixin OR if it descends from a module base + const isUsed = usedAsBaseOrMixin.has(className); if (!isUsed && !isModuleBaseDescendant(className, globalClasses)) { // eslint-disable-next-line no-continue continue; @@ -324,7 +327,7 @@ export function buildInheritanceChains( standaloneViews: Record, globalClasses: Map, ): InheritanceEntry[] { - const allClasses = new Map(); + const allClasses = new Map(); const bareModuleBasesSet = new Set(BARE_MODULE_BASES); for (const [className, info] of globalClasses) { @@ -336,61 +339,41 @@ export function buildInheritanceChains( } const entries: InheritanceEntry[] = []; - const processed = new Set(); - - function buildChain(className: string): string[] { - if (processed.has(className)) { - console.warn(`Circular inheritance detected in class hierarchy involving "${className}".`); - return []; - } - processed.add(className); - const info = allClasses.get(className); - if (!info?.baseClass) { - return []; - } - - const chain: string[] = []; - - for (const mixin of info.mixins) { - chain.push(mixin); - } - - let rawBase = info.baseClass; - const mixinMatch = /^\w+\((.+)\)$/.exec(rawBase); - if (mixinMatch) { - [, rawBase] = mixinMatch; - } - - chain.push(rawBase); - - const baseName = rawBase.replace(MODULES_PREFIX, ''); - if (allClasses.has(baseName)) { - chain.push(...buildChain(baseName)); - } else if (rawBase.startsWith(MODULES_PREFIX)) { - if (rawBase === 'modules.View' || rawBase === 'modules.Controller') { - chain.push('ModuleItem'); - } else if (rawBase === 'modules.ViewController') { - chain.push('modules.Controller', 'ModuleItem'); + const visited = new Set(); + + const getClassInfo = (name: string): HeritageInfo | undefined => allClasses.get(name); + const chainOptions = { + resolveNext: (rawBase: string): string => rawBase.replace(MODULES_PREFIX, ''), + onTerminal: (rawBase: string): string[] => { + switch (rawBase) { + case 'modules.View': + case 'modules.Controller': + return ['ModuleItem']; + case 'modules.ViewController': + return ['modules.Controller', 'ModuleItem']; + default: + return []; } - } - - return chain; - } + }, + onCycle: (name: string): void => { + console.warn(`Circular inheritance detected in class hierarchy involving "${name}".`); + }, + }; // Build chains for all controllers and views in modules for (const mod of modules) { for (const ctrl of Object.values(mod.controllers)) { - processed.clear(); - const chain = buildChain(ctrl.className); + visited.clear(); + const chain = buildInheritanceChainCore(ctrl.className, getClassInfo, visited, chainOptions); if (chain.length > 0) { - entries.push({ class: ctrl.className, chain }); + entries.push({ className: ctrl.className, chain }); } } for (const view of Object.values(mod.views)) { - processed.clear(); - const chain = buildChain(view.className); + visited.clear(); + const chain = buildInheritanceChainCore(view.className, getClassInfo, visited, chainOptions); if (chain.length > 0) { - entries.push({ class: view.className, chain }); + entries.push({ className: view.className, chain }); } } } @@ -401,14 +384,14 @@ export function buildInheritanceChains( ...Object.values(standaloneViews), ]; for (const entry of standAloneEntries) { - processed.clear(); - const chain = buildChain(entry.className); + visited.clear(); + const chain = buildInheritanceChainCore(entry.className, getClassInfo, visited, chainOptions); if (chain.length > 0) { - entries.push({ class: entry.className, chain }); + entries.push({ className: entry.className, chain }); } } - return entries.sort((a, b) => a.class.localeCompare(b.class)); + return entries.sort((a, b) => a.className.localeCompare(b.className)); } // ─── Runtime Dependency Resolution ─────────────────────────────────────────── @@ -441,20 +424,18 @@ export function resolveRuntimeDeps( } } + // Build a map: sourceFile → moduleName (for fallback lookup) + const sourceFileToModule = new Map(); + for (const mod of modules) { + sourceFileToModule.set(mod.sourceFile, mod.moduleName); + } + for (const pf of allParsedFiles) { for (const dep of pf.runtimeDeps) { - let fromModule = classToModule.get(dep.from) ?? ''; - if (!fromModule) { - fromModule = extenderToModule.get(dep.from) ?? ''; - } - if (!fromModule) { - for (const mod of modules) { - if (mod.sourceFile === pf.relPath) { - fromModule = mod.moduleName; - break; - } - } - } + const fromModule = classToModule.get(dep.from) + ?? extenderToModule.get(dep.from) + ?? sourceFileToModule.get(pf.relPath) + ?? ''; allDeps.push({ ...dep, fromModule }); } @@ -480,19 +461,21 @@ export function resolveRuntimeDeps( * Re-resolve controllers and views in a module definition using the global class registry. * This fixes the case where a class is defined in one file and referenced * in the module definition in another file. + * + * @param mod + * @param parsedFile + * @param globalClasses + * @param fileByRelPath - Pre-built map from relPath to ParsedFile. + * Build once with: `new Map(allParsedFiles.map(pf => [pf.relPath, pf]))` + * @param globalAliasMap */ export function resolveModuleClassRefs( mod: ModuleInfo, parsedFile: ParsedFile, globalClasses: Map, - allParsedFiles: ParsedFile[], + fileByRelPath: Map, globalAliasMap: Map, ): void { - const fileByRelPath = new Map(); - for (const pf of allParsedFiles) { - fileByRelPath.set(pf.relPath, pf); - } - const resolveEntry = ( entry: { baseClass: string; diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/types.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/types.ts similarity index 75% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/types.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/types.ts index 7a931c6a1c8c..43fa08362ac3 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/types.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/grid_core/types.ts @@ -1,9 +1,8 @@ -export interface ClassRegistrationInfo { - className: string; - baseClass: string; - mixins: string[]; - sourceFile: string; - isExported: boolean; +import type { BaseClassInfo, HeritageInfo, InheritanceEntry } from '../shared/types'; + +export type { InheritanceEntry }; + +export interface ClassRegistrationInfo extends BaseClassInfo { featureArea: string; } @@ -35,11 +34,6 @@ export interface RuntimeDependency { location: string; } -export interface InheritanceEntry { - class: string; - chain: string[]; -} - export interface ArchitectureData { generatedAt: string; sourceRoot: string; @@ -60,16 +54,11 @@ export interface ParsedFile { filePath: string; relPath: string; modules: ModuleInfo[]; - classes: Map; + classes: Map; runtimeDeps: RuntimeDependency[]; localVars: Map; importAliases: Map; importedNames: Map; } -export interface GlobalClassInfo { - baseClass: string; - mixins: string[]; - sourceFile: string; - isExported: boolean; -} +export interface GlobalClassInfo extends BaseClassInfo {} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/html-template.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/html-template.ts deleted file mode 100644 index 3aa360f5c7fb..000000000000 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/html-template.ts +++ /dev/null @@ -1,581 +0,0 @@ -/* eslint-disable spellcheck/spell-checker, @stylistic/max-len */ -/** - * HTML visualization template for Grid Core Architecture Documentation Generator. - * Generates an interactive Cytoscape.js visualization. - */ - -import { buildCytoscapeElements } from './graph-builder'; -import type { ArchitectureData } from './types'; - -export function generateHtml(data: ArchitectureData): string { - const cytoscapeElements = buildCytoscapeElements(data); - const elementsJson = JSON.stringify(cytoscapeElements, null, 2); - const featureAreas = [...new Set([ - ...data.modules.map((m) => m.featureArea), - ...Object.values(data.standaloneControllers).map((c) => c.featureArea), - ...Object.values(data.standaloneViews).map((v) => v.featureArea), - ])].sort(); - - return ` - - - - -Grid Core Architecture - - - - - - - - - - -
-
-
-

Click a node or edge to see details.

-
-
- - - -`; -} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/ast-helpers.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/ast-helpers.ts new file mode 100644 index 000000000000..f28510de9143 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/ast-helpers.ts @@ -0,0 +1,169 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import ts from 'typescript'; + +import type { HeritageInfo } from './types'; + +export function getNodeText(node: ts.Node, sourceFile: ts.SourceFile): string { + return node.getText(sourceFile).trim(); +} + +export function hasExportModifier(node: ts.Node): boolean { + if (!ts.canHaveModifiers(node)) { + return false; + } + + const modifiers = ts.getModifiers(node); + + return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; +} + +export interface RawImportSpec { + localName: string; + originalName: string; + fromPath: string; + isDefault: boolean; + isNamespace: boolean; + isRenamed: boolean; +} + +/** + * Collect all import specifiers from a source file. + * Returns a flat list of raw import specs that callers can map into their own structures. + */ +export function collectImportSpecs(sourceFile: ts.SourceFile): RawImportSpec[] { + const specs: RawImportSpec[] = []; + + ts.forEachChild(sourceFile, (node) => { + if ( + !ts.isImportDeclaration(node) + || !node.moduleSpecifier + || !ts.isStringLiteral(node.moduleSpecifier) + ) { + return; + } + + const fromPath = node.moduleSpecifier.text; + const { importClause } = node; + + if (!importClause) { + return; + } + + // Default import: import X from '...' + if (importClause.name) { + specs.push({ + localName: importClause.name.text, + originalName: 'default', + fromPath, + isDefault: true, + isNamespace: false, + isRenamed: false, + }); + } + + if (!importClause.namedBindings) { + return; + } + + // Named imports: import { X, Y as Z } from '...' + if (ts.isNamedImports(importClause.namedBindings)) { + importClause.namedBindings.elements.forEach((spec) => { + const localName = spec.name.text; + const originalName = spec.propertyName ? spec.propertyName.text : localName; + specs.push({ + localName, + originalName, + fromPath, + isDefault: false, + isNamespace: false, + isRenamed: !!spec.propertyName, + }); + }); + } else if (ts.isNamespaceImport(importClause.namedBindings)) { + // Namespace import: import * as X from '...' + specs.push({ + localName: importClause.namedBindings.name.text, + originalName: '*', + fromPath, + isDefault: false, + isNamespace: true, + isRenamed: false, + }); + } + }); + + return specs; +} + +// ─── Mixin-wrapper utilities ───────────────────────────────────────────────── + +const MIXIN_CALL_RE = /^(\w+)\((.+)\)$/; + +/** Decompose the outermost mixin call: "Mixin(Inner)" → { mixinName, inner } */ +export function parseMixinCall(text: string): { mixinName: string; inner: string } | null { + const match = MIXIN_CALL_RE.exec(text); + return match ? { mixinName: match[1], inner: match[2] } : null; +} + +/** Strip the outermost mixin wrapper: "Mixin(Base)" → "Base" */ +export function stripOuterMixin(text: string): string { + const parsed = parseMixinCall(text); + return parsed ? parsed.inner : text; +} + +/** Strip all mixin wrappers: "Mixin(Mixin2(Base))" → "Base" */ +export function stripAllMixins(text: string): string { + let current = text; + let parsed = parseMixinCall(current); + while (parsed) { + current = parsed.inner; + parsed = parseMixinCall(current); + } + return current; +} + +/** + * Parse a heritage string like "Mixin(Base)" or "Mixin(Mixin2(Base))". + * Returns { baseClass, mixins }. + */ +export function parseHeritageString(text: string): HeritageInfo { + const mixins: string[] = []; + let current = text; + + let parsed = parseMixinCall(current); + while (parsed) { + mixins.push(parsed.mixinName); + current = parsed.inner; + parsed = parseMixinCall(current); + } + + return { + baseClass: mixins.length > 0 ? `${mixins[mixins.length - 1]}(${current})` : current, + mixins, + }; +} + +/** + * Extract heritage (base class + mixins) from a class declaration/expression. + * Resolves local variable aliases before parsing. + */ +export function getClassHeritage( + node: ts.ClassDeclaration | ts.ClassExpression, + sourceFile: ts.SourceFile, + localVars: Map, +): HeritageInfo { + if (!node.heritageClauses) { + return { baseClass: '', mixins: [] }; + } + const extendsClause = node.heritageClauses.find( + (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length > 0, + ); + if (extendsClause) { + const text = getNodeText(extendsClause.types[0].expression, sourceFile); + if (ts.isIdentifier(extendsClause.types[0].expression) && localVars.has(text)) { + return parseHeritageString(localVars.get(text) ?? ''); + } + return parseHeritageString(text); + } + return { baseClass: '', mixins: [] }; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/cli.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/cli.ts similarity index 62% rename from packages/devextreme/js/__internal/grids/__docs__/scripts/cli.ts rename to packages/devextreme/js/__internal/grids/__docs__/scripts/shared/cli.ts index b3f3411b21e7..88b4128ad803 100644 --- a/packages/devextreme/js/__internal/grids/__docs__/scripts/cli.ts +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/cli.ts @@ -1,18 +1,14 @@ -interface CliArgs { +export interface CliArgs { jsonOnly: boolean; htmlOnly: boolean; } export function parseArgs(): CliArgs { const args = process.argv.slice(2); - const result: CliArgs = { - jsonOnly: false, - htmlOnly: false, - }; + const result: CliArgs = { jsonOnly: false, htmlOnly: false }; - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < args.length; i += 1) { - switch (args[i]) { + args.forEach((arg) => { + switch (arg) { case '--json': result.jsonOnly = true; break; @@ -20,10 +16,10 @@ export function parseArgs(): CliArgs { result.htmlOnly = true; break; default: - console.error(`Error: Unknown argument "${args[i]}"`); + console.error(`Error: Unknown argument "${arg}". Valid options are: --json, --html`); process.exit(1); } - } + }); if (result.jsonOnly && result.htmlOnly) { console.error('Error: Cannot specify both --json and --html. Use neither to generate both.'); diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/file-discovery.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/file-discovery.ts new file mode 100644 index 000000000000..112d71ae1795 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/file-discovery.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Recursively discover TypeScript source files under `rootDir`, + * excluding specified directories and file names. + */ +export function discoverSourceFiles( + rootDir: string, + excludedDirs: Set, + excludedFileNames: Set, +): string[] { + const results: string[] = []; + + function walk(dir: string): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + entries.forEach((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!excludedDirs.has(entry.name)) { + walk(fullPath); + } + } else if ( + entry.isFile() + && !excludedFileNames.has(entry.name) + && entry.name.endsWith('.ts') + && !entry.name.includes('.test.') + ) { + results.push(fullPath); + } + }); + } + + walk(rootDir); + return results.sort(); +} + +/** + * Get a forward-slash-separated relative path from `rootDir` to `filePath`. + */ +export function getRelativePath(filePath: string, rootDir: string): string { + return path.relative(rootDir, filePath).replace(/\\/g, '/'); +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/graph-context.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/graph-context.ts new file mode 100644 index 000000000000..3b571aa8b1da --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/graph-context.ts @@ -0,0 +1,56 @@ +/* eslint-disable spellcheck/spell-checker */ +/** + * Shared types used by both grid_core and data_grid graph builders. + */ + +export interface CytoscapeElement { + group: 'nodes' | 'edges'; + data: Record; + classes?: string; +} + +/** + * Provides shared addNode/addEdge helpers backed by common state (elements, nodeIds, edgeIds). + * Each graph-builder creates its own context and can extend edge creation via `buildEdgeId`. + */ +export interface GraphContext { + elements: CytoscapeElement[]; + nodeIds: Set; + edgeIds: Set; + addNode: (id: string, nodeData: Record, classes: string) => void; +} + +export interface GraphContextOptions { + /** + * If true, track each node's parent in a Map for compound-graph queries. + * The Map is returned in the context as `nodeParent`. + */ + trackParent?: boolean; +} + +export interface GraphContextWithParent extends GraphContext { + nodeParent: Map; +} + +export function createGraphContext(opts?: GraphContextOptions): GraphContextWithParent { + const elements: CytoscapeElement[] = []; + const nodeIds = new Set(); + const edgeIds = new Set(); + const nodeParent = new Map(); + const trackParent = opts?.trackParent ?? false; + + function addNode(id: string, nodeData: Record, classes: string): void { + if (nodeIds.has(id)) { + return; + } + nodeIds.add(id); + if (trackParent && nodeData.parent) { + nodeParent.set(id, nodeData.parent as string); + } + elements.push({ group: 'nodes', data: { id, ...nodeData }, classes }); + } + + return { + elements, nodeIds, edgeIds, nodeParent, addNode, + }; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/html-helpers.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/html-helpers.ts new file mode 100644 index 000000000000..f2ee1323954c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/html-helpers.ts @@ -0,0 +1,283 @@ +/** + * Shared CSS for html-templates. + */ +export const BASE_CSS = ` +* { + box-sizing: border-box; margin: 0; padding: 0 +} +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; height: 100vh; background: #1a1a1a; color: #e8e8e8; overflow: hidden +} +#sidebar { + width: 270px; min-width: 270px; background: #2a2a2a; border-right: 1px solid #555; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px +} +#sidebar h2 { + font-size: 13px; color: #aaa; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px +} +#sidebar label { + display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; padding: 1px 0; color: #e8e8e8 +} +#sidebar input[type="checkbox"] { + accent-color: #5B9BD5 +} +#sidebar input[type="text"] { + width: 100%; padding: 6px 10px; border: 1px solid #444; border-radius: 4px; font-size: 13px; background: #333; color: #e8e8e8; outline: none +} +#sidebar input[type="text"]:focus-visible { + border-color: #777 +} +#sidebar button { + padding: 5px 10px; border: 1px solid #555; border-radius: 4px; background: #333; cursor: pointer; font-size: 12px; color: #e8e8e8 +} +#sidebar button:hover { + background: #444 +} +.radio-group { + display: flex; flex-direction: column; gap: 3px +} +.radio-group label { + font-size: 11px +} +#main { + flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; min-width: 0 +} +#cy { + flex: 1; background: #212121; min-height: 0; min-width: 0 +} +#info-panel-wrap { + background: #2a2a2a; font-size: 12px; line-height: 1.5; color: #e8e8e8; + height: 180px; min-height: 80px; border-top: 1px solid #555; + width: auto; border-left: none; flex-shrink: 0 +} +#info-panel { + height: 100%; padding: 12px 16px; overflow-y: auto +} +#btn-toggle-panel { + float: right; margin: 0 0 4px 8px; + padding: 2px 6px; border: 1px solid #777; border-radius: 3px; + background: #333; color: #aaa; cursor: pointer; font-size: 11px; line-height: 1 +} +#btn-toggle-panel:hover { + background: #444; color: #e8e8e8 +} +#main.panel-right { + flex-direction: row +} +#main.panel-right #info-panel-wrap { + width: 350px; min-width: 350px; height: auto; min-height: 0; border-top: none; border-left: 1px solid #555 +} +#info-panel h3 { + font-size: 13px; margin-bottom: 4px +} +#info-panel .lbl { + color: #aaa; font-weight: 500 +} +.tag { + display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin: 1px 2px +} +#legend { + padding: 8px; font-size: 12px; border-top: 1px solid #555 +} +.leg-item { + display: flex; align-items: center; gap: 6px; margin: 2px 0 +} +.leg-sw { + width: 18px; height: 12px; border-radius: 2px; flex-shrink: 0 +} +.leg-ln { + width: 24px; height: 0; flex-shrink: 0 +} +.select-all-row { + border-bottom: 1px solid #444; padding-bottom: 3px; margin-bottom: 2px +} +`; + +/** + * Cytoscape styles for highlight/faded/search states. + */ +export const HIGHLIGHT_CYTOSCAPE_STYLES = ` + { selector: '.highlighted', + style: { 'opacity': 1, 'z-index': 999 } + }, + { selector: 'edge.highlighted', + style: { 'opacity': 1, 'z-index': 999, 'width': 3 } + }, + { selector: '.faded', + style: { 'opacity': 0.08 } + }, + { selector: 'node.search-match', + style: { 'border-width': 4, 'border-color': '#39FF14', 'border-opacity': 0.9 } + },`; + +/** + * Cytoscape styles for gc-target nodes (controllers & views). + */ +export const GC_TARGET_CYTOSCAPE_STYLES = ` + { selector: 'node.gc-target', + style: { + 'background-color': '#1e1e3a', + 'background-opacity': 0.6, + 'border-width': 2, + 'border-style': 'dashed', + 'border-color': '#c084fc', + 'color': '#d8b4fe', + 'font-size': 9, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': '120px', + 'padding': '12px', + 'label': 'data(label)', + } + }, + { selector: 'node.gc-target-controller', + style: { + 'border-color': '#7dd3fc', + 'color': '#bae6fd', + 'shape': 'hexagon', + } + }, + { selector: 'node.gc-target-view', + style: { + 'border-color': '#c084fc', + 'color': '#d8b4fe', + 'shape': 'ellipse', + } + },`; + +/** + * Base Cytoscape styles for extender edges (controller & view). + */ +export const EXTENDER_EDGE_BASE_STYLES = ` + { selector: 'edge.edge-ext-ctrl', + style: { + 'line-color': '#0ea5e9', + 'target-arrow-color': '#0ea5e9', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + 'width': 2, + 'arrow-scale': 0.8, + } + }, + { selector: 'edge.edge-ext-view', + style: { + 'line-color': '#a855f7', + 'target-arrow-color': '#a855f7', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + 'width': 2, + 'arrow-scale': 0.8, + } + },`; + +/** + * Shared interactive JavaScript helpers for diagram templates. + * Contains: highlight/clear, edge toggle wiring, search, click handlers, + * fit button, getEdgeRouting(), and overlap-detection for taxi routing. + * + * NOT included (template-specific): reset button, feature area/category toggles. + * + * Templates must define before interpolating: + * - `cy` — the cytoscape instance + * - `computeHighlightSet(target)` — returns the collection to highlight for a given target + * - `showInfo(target)` — renders info panel content for a given target + * - `selectedTarget` — mutable state variable (will be set/cleared by this code) + * - `updateEdgeStyles()` — applies current edge routing to all edges + * + * Optional hooks: + * - `normalizeClickTarget(target)` — transform click target before + * info/selection (e.g. gc child → dg parent) + */ +export const SHARED_INTERACTIVE_JS = ` +/* ── Shared: Highlight helpers ── */ +function applyHighlight(s) { + cy.elements().addClass('faded').removeClass('highlighted'); + s.removeClass('faded').addClass('highlighted'); +} +function clearHighlight() { + cy.elements().removeClass('faded').removeClass('highlighted'); +} + +/* ── Shared: Edge routing helper ── */ +function getEdgeRouting() { + var c = document.querySelector('input[name="edge-routing"]:checked'); + return c ? c.value : 'bezier'; +} + +function hasOverlappingBounds(edge) { + var sb = edge.source().boundingBox(); + var tb = edge.target().boundingBox(); + return !(sb.x2 < tb.x1 || tb.x2 < sb.x1 || sb.y2 < tb.y1 || tb.y2 < sb.y1); +} + +/* ── Shared: Edge type toggles ── */ +document.querySelectorAll('.edge-toggle').forEach(function(cb) { + cb.addEventListener('change', function() { + var cls = this.getAttribute('data-cls'); + cy.edges('.' + cls).style('display', this.checked ? 'element' : 'none'); + }); + if (!cb.checked) { + var cls = cb.getAttribute('data-cls'); + cy.edges('.' + cls).style('display', 'none'); + } +}); + +/* ── Shared: Search ── */ +document.getElementById('search').addEventListener('input', function() { + var q = this.value.toLowerCase().trim(); + cy.nodes().removeClass('search-match'); + if (!q) return; + cy.nodes().forEach(function(n) { + var label = (n.data('label') || '').toLowerCase(); + var name = (n.data('moduleName') || n.data('id') || '').toLowerCase(); + if (label.indexOf(q) >= 0 || name.indexOf(q) >= 0) n.addClass('search-match'); + }); +}); + +/* ── Shared: Click-to-select / deselect ── */ +var infoPanel = document.getElementById('info-content'); +var defaultInfoHtml = '

Click a node or edge to see details.

'; + +cy.on('tap', 'node, edge', function(e) { + cy.nodes().removeClass('search-match'); + var t = e.target; + var infoTarget = typeof normalizeClickTarget === 'function' ? normalizeClickTarget(t) : t; + var checkId = infoTarget.id(); + if (selectedTarget && selectedTarget.id() === checkId) { + selectedTarget = null; + clearHighlight(); + infoPanel.innerHTML = defaultInfoHtml; + return; + } + selectedTarget = infoTarget; + applyHighlight(computeHighlightSet(t)); + showInfo(infoTarget); +}); +cy.on('tap', function(e) { + if (e.target !== cy) return; + cy.nodes().removeClass('search-match'); + if (selectedTarget) { + selectedTarget = null; + clearHighlight(); + infoPanel.innerHTML = defaultInfoHtml; + } +}); + +/* ── Shared: Fit button ── */ +document.getElementById('btn-fit').addEventListener('click', function() { cy.fit(undefined, 30); }); + +/* ── Shared: Edge routing radio ── */ +document.querySelectorAll('input[name="edge-routing"]').forEach(function(r) { + r.addEventListener('change', function() { updateEdgeStyles(); }); +}); + +/* ── Shared: Details panel position toggle ── */ +var togglePanelBtn = document.getElementById('btn-toggle-panel'); +togglePanelBtn.addEventListener('click', function() { + var main = document.getElementById('main'); + var isRight = main.classList.toggle('panel-right'); + togglePanelBtn.textContent = isRight ? '\\u2193' : '\\u2192'; + togglePanelBtn.title = isRight ? 'Move panel to bottom' : 'Move panel to right'; + cy.resize(); +}); +`; diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/inheritance.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/inheritance.ts new file mode 100644 index 000000000000..92e4ba9bea36 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/inheritance.ts @@ -0,0 +1,79 @@ +import { stripOuterMixin } from './ast-helpers'; +import type { HeritageInfo } from './types'; + +export interface BuildChainOptions { + /** Maximum recursion depth. Default: no limit (relies on visited set). */ + maxDepth?: number; + /** Format a mixin name before pushing to chain (e.g. add "[mixin] " prefix). */ + formatMixin?: (mixin: string) => string; + /** + * Map rawBase to the key used for class-map lookup and recursion. + * Default: identity (use rawBase as-is). + */ + resolveNext?: (rawBase: string) => string | null; + /** + * Called when the resolved key is not found in the class map. + * Return extra chain entries for well-known terminal nodes. + * Default: no extra entries. + */ + onTerminal?: (rawBase: string) => string[]; + /** Called when a cycle is detected (className already in visited set). */ + onCycle?: (className: string) => void; +} + +/** + * Core recursive algorithm for building an inheritance chain. + * + * Steps: + * 1. Check visited set (+ optional depth limit) + * 2. Look up class info via `getClassInfo` + * 3. Collect mixins into chain + * 4. Strip outermost mixin wrapper from baseClass + * 5. Push rawBase into chain + * 6. Recurse if the resolved key exists in the class map + * + * Both `grid_core` and `data_grid` resolvers use this with different hooks. + */ +export function buildInheritanceChainCore( + className: string, + getClassInfo: (name: string) => HeritageInfo | undefined, + visited: Set, + options: BuildChainOptions = {}, + depth = 0, +): string[] { + const { + maxDepth, formatMixin, resolveNext, onTerminal, onCycle, + } = options; + + if (visited.has(className)) { + onCycle?.(className); + return []; + } + if (maxDepth !== undefined && depth > maxDepth) { + return []; + } + visited.add(className); + + const info = getClassInfo(className); + if (!info?.baseClass) { + return []; + } + + const chain: string[] = []; + + info.mixins.forEach((mixin) => { + chain.push(formatMixin ? formatMixin(mixin) : mixin); + }); + + const rawBase = stripOuterMixin(info.baseClass); + chain.push(rawBase); + + const nextKey = resolveNext ? resolveNext(rawBase) : rawBase; + if (nextKey !== null && getClassInfo(nextKey)) { + chain.push(...buildInheritanceChainCore(nextKey, getClassInfo, visited, options, depth + 1)); + } else if (onTerminal) { + chain.push(...onTerminal(rawBase)); + } + + return chain; +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/output-writer.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/output-writer.ts new file mode 100644 index 000000000000..cbb218ff73f8 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/output-writer.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { parseArgs } from './cli'; + +/** + * Writes architecture output files (JSON and/or HTML) to `outputDir`. + * + * Respects CLI flags `--json` (JSON only) and `--html` (HTML only). + * When neither flag is set, both files are written. + */ +export function writeOutputFiles( + outputDir: string, + baseName: string, + data: T, + generateHtml: (d: T) => string, +): void { + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const args = parseArgs(); + + if (!args.htmlOnly) { + const jsonPath = path.join(outputDir, `${baseName}.generated.json`); + fs.writeFileSync(jsonPath, `${JSON.stringify(data, null, 2)}\n`); + // eslint-disable-next-line no-console + console.log(`✓ JSON written to: ${jsonPath}`); + } + + if (!args.jsonOnly) { + const htmlPath = path.join(outputDir, `${baseName}.generated.html`); + fs.writeFileSync(htmlPath, generateHtml(data)); + // eslint-disable-next-line no-console + console.log(`✓ HTML written to: ${htmlPath}`); + } +} diff --git a/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/types.ts b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/types.ts new file mode 100644 index 000000000000..06a66e267af9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/__docs__/scripts/shared/types.ts @@ -0,0 +1,15 @@ +export interface HeritageInfo { + baseClass: string; + mixins: string[]; +} + +export interface BaseClassInfo extends HeritageInfo { + className: string; + sourceFile: string; + isExported: boolean; +} + +export interface InheritanceEntry { + className: string; + chain: string[]; +}