6482 lines
204 KiB
TypeScript
6482 lines
204 KiB
TypeScript
/**
|
|
* G7 DevTools Core
|
|
*
|
|
* 그누보드7 템플릿 엔진 디버깅 시스템의 핵심 싱글톤 클래스
|
|
* 상태 추적, 액션 로깅, 캐시 통계, 라이프사이클 모니터링 등을 담당합니다.
|
|
*
|
|
* 활성화 조건: 환경설정 > 고급 설정 > 디버그 모드 (debug_mode)
|
|
*/
|
|
|
|
import type {
|
|
StateSnapshot,
|
|
StateView,
|
|
StateDiff,
|
|
ActionLog,
|
|
ActionMetrics,
|
|
CacheStats,
|
|
ComponentInfo,
|
|
ListenerInfo,
|
|
LifecycleInfo,
|
|
MountEvent,
|
|
UnmountEvent,
|
|
PerformanceInfo,
|
|
MemoryWarning,
|
|
ProfileEntry,
|
|
ProfileReport,
|
|
RequestInfo,
|
|
RequestLog,
|
|
NetworkInfo,
|
|
ConditionInfo,
|
|
IterationInfo,
|
|
ConditionalInfo,
|
|
ScopeVariable,
|
|
FormInfo,
|
|
InputInfo,
|
|
FormChange,
|
|
WebSocketConnectionInfo,
|
|
WebSocketMessage,
|
|
WebSocketInfo,
|
|
WatcherCallback,
|
|
ActionWatcherCallback,
|
|
RequestWatcherCallback,
|
|
WebSocketWatcherCallback,
|
|
FormWatcherCallback,
|
|
ConditionWatcherCallback,
|
|
DevToolsConfig,
|
|
ExpressionEvalInfo,
|
|
ExpressionWarning,
|
|
ExpressionWarningType,
|
|
ExpressionWatcherCallback,
|
|
ExpressionStats,
|
|
DataSourceInfo,
|
|
HandlerInfo,
|
|
ComponentEventSubscription,
|
|
ComponentEventEmitLog,
|
|
ComponentEventInfo,
|
|
StateRenderingLog,
|
|
RenderedComponentInfo,
|
|
StateRenderingInfo,
|
|
StateRenderingStats,
|
|
StateChangeContext,
|
|
StateHierarchyInfo,
|
|
StateHierarchyLayer,
|
|
StateConflict,
|
|
ComponentStateSource,
|
|
ContextFlowInfo,
|
|
ContextFlowNode,
|
|
StyleValidationInfo,
|
|
StyleIssue,
|
|
ComponentStyleInfo,
|
|
AuthDebugInfo,
|
|
AuthStateInfo,
|
|
AuthEventLog,
|
|
AuthHeaderAnalysis,
|
|
AuthEventType,
|
|
LogLevel,
|
|
LogEntry,
|
|
LogStats,
|
|
LogFilterOptions,
|
|
LogInfo,
|
|
ParsedQueryParams,
|
|
CurrentLayoutInfo,
|
|
LayoutJsonData,
|
|
LayoutHistoryEntry,
|
|
LayoutDebugInfo,
|
|
// 변경 감지 타입
|
|
HandlerExitReason,
|
|
HandlerExecutionDetail,
|
|
StateChangeRecord,
|
|
DataSourceChangeRecord,
|
|
ChangeExpectation,
|
|
ChangeAlert,
|
|
ChangeAlertType,
|
|
ChangeDetectionInfo,
|
|
ChangeDetectionStats,
|
|
// Sequence 추적 타입
|
|
SequenceActionInfo,
|
|
SequenceExecutionInfo,
|
|
SequenceTrackingInfo,
|
|
SequenceStats,
|
|
// Stale Closure 추적 타입
|
|
StaleClosureWarning,
|
|
StaleClosureWarningType,
|
|
StaleClosureTrackingInfo,
|
|
StaleClosureStats,
|
|
// Form 바인딩 검증 타입
|
|
FormBindingIssue,
|
|
FormBindingIssueType,
|
|
FormBindingValidationInfo,
|
|
FormBindingValidationStats,
|
|
FormBindingValidationTrackingInfo,
|
|
// Computed 의존성 추적 타입
|
|
ComputedDependencyType,
|
|
ComputedRecalcTrigger,
|
|
ComputedPropertyInfo,
|
|
ComputedRecalcLog,
|
|
ComputedDependencyChain,
|
|
ComputedDependencyNode,
|
|
ComputedDependencyStats,
|
|
ComputedDependencyTrackingInfo,
|
|
// 모달 상태 스코프 추적 타입
|
|
ModalStateScopeType,
|
|
ModalStateIssueType,
|
|
ModalStateInfo,
|
|
ModalStateIssue,
|
|
ModalStateRelation,
|
|
ModalStateChangeLog,
|
|
ModalStateScopeStats,
|
|
ModalStateScopeTrackingInfo,
|
|
// Named Actions 추적 타입
|
|
NamedActionRefLog,
|
|
NamedActionTrackingInfo,
|
|
} from './types';
|
|
import { parseQueryParams, extractPath } from './types';
|
|
|
|
/**
|
|
* G7 DevTools 핵심 클래스
|
|
*/
|
|
export class G7DevToolsCore {
|
|
// ============================================
|
|
// 싱글톤
|
|
// ============================================
|
|
private static instance: G7DevToolsCore;
|
|
|
|
static getInstance(): G7DevToolsCore {
|
|
if (!G7DevToolsCore.instance) {
|
|
G7DevToolsCore.instance = new G7DevToolsCore();
|
|
}
|
|
return G7DevToolsCore.instance;
|
|
}
|
|
|
|
private constructor() {
|
|
// private constructor for singleton
|
|
}
|
|
|
|
// ============================================
|
|
// 설정
|
|
// ============================================
|
|
private config: DevToolsConfig = {
|
|
enabled: false,
|
|
maxHistorySize: 100,
|
|
logLevel: 'info',
|
|
serverEndpoint: '/_boost/g7-debug/dump-state',
|
|
autoCapture: true,
|
|
};
|
|
|
|
// ============================================
|
|
// 상태 관리
|
|
// ============================================
|
|
private stateHistory: StateSnapshot[] = [];
|
|
private snapshotIdCounter = 0;
|
|
private stateWatchers: Map<string, Set<WatcherCallback>> = new Map();
|
|
|
|
// _local과 _computed 상태를 별도로 저장
|
|
private currentLocalState: Record<string, any> = {};
|
|
private currentComputedState: Record<string, any> = {};
|
|
// $parent 컨텍스트 저장 (모달/자식 레이아웃에서 부모 상태 접근용)
|
|
private currentParentContext: Record<string, any> | undefined = undefined;
|
|
|
|
// ============================================
|
|
// 액션 추적
|
|
// ============================================
|
|
private actionHistory: ActionLog[] = [];
|
|
private actionWatchers: Set<ActionWatcherCallback> = new Set();
|
|
|
|
// ============================================
|
|
// 캐시 통계
|
|
// ============================================
|
|
private cacheStats: CacheStats = { hits: 0, misses: 0, entries: 0 };
|
|
|
|
// ============================================
|
|
// 캐시 결정 추적
|
|
// ============================================
|
|
private cacheDecisions: import('./types').CacheDecisionInfo[] = [];
|
|
private cacheDecisionIdCounter = 0;
|
|
private readonly maxCacheDecisions = 200;
|
|
private currentRenderCycleId: string | null = null;
|
|
private isInActionExecution = false;
|
|
private isInIteration = false;
|
|
|
|
// ============================================
|
|
// 라이프사이클 추적
|
|
// ============================================
|
|
private mountedComponents: Map<string, ComponentInfo> = new Map();
|
|
private eventListeners: Map<string, ListenerInfo[]> = new Map();
|
|
private mountWatchers: Set<(info: MountEvent) => void> = new Set();
|
|
private unmountWatchers: Set<(info: UnmountEvent) => void> = new Set();
|
|
|
|
// ============================================
|
|
// 성능 추적
|
|
// ============================================
|
|
private renderCounts: Map<string, number> = new Map();
|
|
private bindingEvalCount = 0;
|
|
private profilingData: ProfileEntry[] = [];
|
|
private isProfiling = false;
|
|
private profilingStartTime = 0;
|
|
|
|
// ============================================
|
|
// 네트워크 추적
|
|
// ============================================
|
|
private activeRequests: Map<string, RequestInfo> = new Map();
|
|
private requestHistory: RequestLog[] = [];
|
|
private pendingDataSources: Set<string> = new Set();
|
|
private requestWatchers: Set<RequestWatcherCallback> = new Set();
|
|
|
|
// ============================================
|
|
// WebSocket 추적
|
|
// ============================================
|
|
private wsConnections: Map<string, WebSocketConnectionInfo> = new Map();
|
|
private wsMessageHistory: WebSocketMessage[] = [];
|
|
private wsMessageWatchers: Set<WebSocketWatcherCallback> = new Set();
|
|
|
|
// ============================================
|
|
// 조건부 렌더링 추적
|
|
// ============================================
|
|
private ifConditions: Map<string, ConditionInfo> = new Map();
|
|
private iterations: Map<string, IterationInfo> = new Map();
|
|
private conditionWatchers: Set<ConditionWatcherCallback> = new Set();
|
|
|
|
// ============================================
|
|
// Form 추적
|
|
// ============================================
|
|
private forms: Map<string, FormInfo> = new Map();
|
|
private formWatchers: Set<FormWatcherCallback> = new Set();
|
|
|
|
// ============================================
|
|
// 표현식 추적
|
|
// ============================================
|
|
private expressionHistory: ExpressionEvalInfo[] = [];
|
|
private expressionWatchers: Set<ExpressionWatcherCallback> = new Set();
|
|
private expressionIdCounter = 0;
|
|
|
|
// ============================================
|
|
// 데이터소스 추적
|
|
// ============================================
|
|
private dataSources: Map<string, DataSourceInfo> = new Map();
|
|
|
|
// ============================================
|
|
// 데이터 경로 변환 추적
|
|
// ============================================
|
|
private dataPathTransforms: import('./types').DataPathTransformInfo[] = [];
|
|
private readonly maxDataPathTransforms = 100;
|
|
|
|
// ============================================
|
|
// Nested Context 추적
|
|
// ============================================
|
|
private nestedContexts: import('./types').NestedContextInfo[] = [];
|
|
private nestedContextIdCounter = 0;
|
|
private readonly maxNestedContexts = 100;
|
|
|
|
// ============================================
|
|
// Form 바인딩 검증 추적
|
|
// ============================================
|
|
private formBindingIssues: FormBindingIssue[] = [];
|
|
private formBindingValidations: FormBindingValidationInfo[] = [];
|
|
private formBindingIssueIdCounter = 0;
|
|
private readonly maxFormBindingIssues = 100;
|
|
|
|
// ============================================
|
|
// Computed 의존성 추적
|
|
// ============================================
|
|
private computedProperties: Map<string, ComputedPropertyInfo> = new Map();
|
|
private computedRecalcLogs: ComputedRecalcLog[] = [];
|
|
private computedRecalcIdCounter = 0;
|
|
private readonly maxComputedRecalcLogs = 100;
|
|
|
|
// ============================================
|
|
// 핸들러 추적
|
|
// ============================================
|
|
private handlers: Map<string, HandlerInfo> = new Map();
|
|
|
|
// ============================================
|
|
// 컴포넌트 이벤트 추적
|
|
// ============================================
|
|
private componentEventSubscriptions: Map<string, ComponentEventSubscription> = new Map();
|
|
private componentEventEmitHistory: ComponentEventEmitLog[] = [];
|
|
private componentEventIdCounter = 0;
|
|
private readonly maxComponentEventHistory = 100;
|
|
|
|
// ============================================
|
|
// 상태-렌더링 추적
|
|
// ============================================
|
|
private stateRenderingLogs: StateRenderingLog[] = [];
|
|
private currentStateChangeContext: StateChangeContext | null = null;
|
|
private stateRenderingIdCounter = 0;
|
|
private componentRenderCounts: Map<string, number> = new Map();
|
|
private stateToComponentMap: Map<string, Set<string>> = new Map();
|
|
private readonly maxStateRenderingLogs = 100;
|
|
|
|
// ============================================
|
|
// 상태 계층 추적
|
|
// ============================================
|
|
private componentStateSources: Map<string, ComponentStateSource> = new Map();
|
|
private contextFlowNodes: Map<string, ContextFlowNode> = new Map();
|
|
private dynamicStates: Map<string, Record<string, any>> = new Map();
|
|
|
|
// ============================================
|
|
// CSS 스타일 검증 추적
|
|
// ============================================
|
|
private styleIssues: StyleIssue[] = [];
|
|
private componentStyles: Map<string, ComponentStyleInfo> = new Map();
|
|
|
|
// ============================================
|
|
// 인증 디버깅 추적
|
|
// ============================================
|
|
private authEvents: AuthEventLog[] = [];
|
|
private authHeaderHistory: AuthHeaderAnalysis[] = [];
|
|
private authEventIdCounter = 0;
|
|
private readonly maxAuthEventHistory = 50;
|
|
|
|
// ============================================
|
|
// 로그 추적
|
|
// ============================================
|
|
private logHistory: LogEntry[] = [];
|
|
private logIdCounter = 0;
|
|
private readonly maxLogHistory = 500;
|
|
|
|
// ============================================
|
|
// 레이아웃 추적
|
|
// ============================================
|
|
private currentLayout: CurrentLayoutInfo | null = null;
|
|
private layoutHistory: LayoutHistoryEntry[] = [];
|
|
private layoutIdCounter = 0;
|
|
private layoutStats = { totalLoads: 0, cacheHits: 0, apiLoads: 0 };
|
|
private readonly maxLayoutHistory = 50;
|
|
|
|
// ============================================
|
|
// 변경 감지 추적
|
|
// ============================================
|
|
private executionDetails: Map<string, HandlerExecutionDetail> = new Map();
|
|
private stateChangeHistory: StateChangeRecord[] = [];
|
|
private dataSourceChangeHistory: DataSourceChangeRecord[] = [];
|
|
private changeAlerts: ChangeAlert[] = [];
|
|
private changeExpectations: Map<string, ChangeExpectation> = new Map();
|
|
private executionIdCounter = 0;
|
|
private stateChangeIdCounter = 0;
|
|
private alertIdCounter = 0;
|
|
private readonly maxExecutionDetails = 100;
|
|
private readonly maxStateChangeHistory = 200;
|
|
private readonly maxChangeAlerts = 100;
|
|
|
|
// ============================================
|
|
// Sequence 실행 추적
|
|
// ============================================
|
|
private sequenceExecutions: SequenceExecutionInfo[] = [];
|
|
/** 실행 중인 sequence 스택 (중첩 sequence 지원) */
|
|
private sequenceExecutionStack: SequenceExecutionInfo[] = [];
|
|
private sequenceIdCounter = 0;
|
|
private readonly maxSequenceExecutions = 50;
|
|
|
|
// ============================================
|
|
// Stale Closure 감지 추적
|
|
// ============================================
|
|
private staleClosureWarnings: StaleClosureWarning[] = [];
|
|
private staleClosureIdCounter = 0;
|
|
private readonly maxStaleClosureWarnings = 100;
|
|
/** 상태 캡처 시점 추적 (handlerId → { path: captureTime }) */
|
|
private stateCaptureRegistry: Map<string, Map<string, { value: any; capturedAt: number }>> = new Map();
|
|
|
|
// ============================================
|
|
// 모달 상태 스코프 추적
|
|
// ============================================
|
|
private modalStates: Map<string, ModalStateInfo> = new Map();
|
|
private modalStateIssues: ModalStateIssue[] = [];
|
|
private modalStateRelations: ModalStateRelation[] = [];
|
|
private modalStateChangeLogs: ModalStateChangeLog[] = [];
|
|
private modalStateIssueIdCounter = 0;
|
|
private modalStateChangeLogIdCounter = 0;
|
|
private readonly maxModalStateIssues = 100;
|
|
private readonly maxModalStateChangeLogs = 200;
|
|
|
|
// ============================================
|
|
// Named Actions 추적
|
|
// ============================================
|
|
/** 현재 레이아웃의 named_actions 정의 */
|
|
private namedActionDefinitions: Record<string, any> = {};
|
|
/** actionRef 해석 이력 */
|
|
private namedActionRefLogs: NamedActionRefLog[] = [];
|
|
private namedActionRefIdCounter = 0;
|
|
private readonly maxNamedActionRefLogs = 200;
|
|
|
|
// ============================================
|
|
// 초기화 및 활성화
|
|
// ============================================
|
|
|
|
/**
|
|
* DevTools 초기화
|
|
* settings 로드 완료 후 호출해야 합니다.
|
|
*/
|
|
initialize(): void {
|
|
this.config.enabled = this.checkDebugMode();
|
|
|
|
if (!this.config.enabled) {
|
|
console.log('[G7DevTools] 비활성화됨 (환경설정 > 고급 설정 > 디버그 모드를 켜세요)');
|
|
return;
|
|
}
|
|
|
|
console.log('[G7DevTools] 활성화됨');
|
|
this.setupGlobalErrorHandler();
|
|
}
|
|
|
|
/**
|
|
* 디버그 모드 확인
|
|
*
|
|
* 다음 순서로 디버그 모드를 확인합니다:
|
|
* 1. G7Config.debug (initTemplateApp의 debug 옵션)
|
|
* 2. _global.settings.advanced.debug_mode (서버 환경설정)
|
|
*/
|
|
private checkDebugMode(): boolean {
|
|
try {
|
|
// 1. initTemplateApp의 debug 옵션 확인 (G7Config.debug)
|
|
const g7Config = (window as any).G7Config;
|
|
if (g7Config?.debug === true) {
|
|
return true;
|
|
}
|
|
|
|
// 2. 서버 환경설정 확인 (settings.advanced.debug_mode)
|
|
const g7Core = (window as any).G7Core;
|
|
const state = g7Core?.state?.get?.();
|
|
if (state?.settings?.advanced?.debug_mode === true) {
|
|
return true;
|
|
}
|
|
|
|
// 3. _global.settings.advanced.debug_mode 확인
|
|
if (state?._global?.settings?.advanced?.debug_mode === true) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DevTools 활성화 상태 확인
|
|
*/
|
|
isEnabled(): boolean {
|
|
return this.config.enabled;
|
|
}
|
|
|
|
/**
|
|
* 전역 에러 핸들러 설정
|
|
*/
|
|
private setupGlobalErrorHandler(): void {
|
|
window.addEventListener('error', (event) => {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.logAction({
|
|
id: this.generateId(),
|
|
type: 'globalError',
|
|
startTime: Date.now(),
|
|
status: 'error',
|
|
error: {
|
|
name: 'Error',
|
|
message: event.message,
|
|
stack: event.error?.stack,
|
|
},
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.logAction({
|
|
id: this.generateId(),
|
|
type: 'unhandledRejection',
|
|
startTime: Date.now(),
|
|
status: 'error',
|
|
error: {
|
|
name: 'UnhandledRejection',
|
|
message: String(event.reason),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// 상태 관리 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 상태 스냅샷 캡처
|
|
*/
|
|
captureStateSnapshot(data: {
|
|
source: string;
|
|
prev: Record<string, any>;
|
|
next: Record<string, any>;
|
|
diff?: StateDiff;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const snapshot: StateSnapshot = {
|
|
id: ++this.snapshotIdCounter,
|
|
timestamp: Date.now(),
|
|
source: data.source,
|
|
prev: this.sanitizeObject(data.prev),
|
|
next: this.sanitizeObject(data.next),
|
|
diff: data.diff,
|
|
};
|
|
|
|
this.stateHistory.push(snapshot);
|
|
|
|
// 이력 크기 제한
|
|
if (this.stateHistory.length > this.config.maxHistorySize) {
|
|
this.stateHistory.shift();
|
|
}
|
|
|
|
// 감시자 알림
|
|
this.notifyStateWatchers(snapshot);
|
|
}
|
|
|
|
/**
|
|
* 현재 상태 조회
|
|
*
|
|
* _global: G7Core.state.get()에서 가져옴
|
|
* _local: DynamicRenderer에서 updateLocalState()로 등록된 상태
|
|
* _computed: DynamicRenderer에서 updateComputedState()로 등록된 상태
|
|
* _isolated: IsolatedStateProvider에서 등록된 격리 상태 (G7Core._isolatedStates)
|
|
* $parent: 모달/자식 레이아웃에서 부모 상태 접근용 컨텍스트
|
|
*/
|
|
getState(): StateView {
|
|
try {
|
|
const g7Core = (window as any).G7Core;
|
|
const state = g7Core?.state?.get?.() || {};
|
|
|
|
// _isolated 상태 수집 (IsolatedStateContext에서 등록)
|
|
const isolated: Record<string, Record<string, any>> = {};
|
|
if (g7Core?._isolatedStates && typeof g7Core._isolatedStates === 'object') {
|
|
for (const [scopeId, scopeState] of Object.entries(g7Core._isolatedStates)) {
|
|
isolated[scopeId] = this.sanitizeObject(scopeState as Record<string, any>);
|
|
}
|
|
}
|
|
|
|
return {
|
|
_global: state._global || state,
|
|
_local: this.currentLocalState,
|
|
_computed: this.currentComputedState,
|
|
_isolated: Object.keys(isolated).length > 0 ? isolated : undefined,
|
|
$parent: this.currentParentContext,
|
|
};
|
|
} catch {
|
|
return { _global: {}, _local: this.currentLocalState, _computed: this.currentComputedState };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _local 상태 업데이트
|
|
*
|
|
* DynamicRenderer에서 localDynamicState가 변경될 때 호출됩니다.
|
|
*/
|
|
updateLocalState(localState: Record<string, any>): void {
|
|
if (!this.config.enabled) return;
|
|
this.currentLocalState = this.sanitizeObject(localState);
|
|
}
|
|
|
|
/**
|
|
* _computed 상태 업데이트
|
|
*
|
|
* DynamicRenderer에서 _computed 컨텍스트가 변경될 때 호출됩니다.
|
|
*/
|
|
updateComputedState(computedState: Record<string, any>): void {
|
|
if (!this.config.enabled) return;
|
|
this.currentComputedState = this.sanitizeObject(computedState);
|
|
}
|
|
|
|
/**
|
|
* $parent 컨텍스트 업데이트
|
|
*
|
|
* DynamicRenderer에서 모달/자식 레이아웃 렌더링 시 부모 데이터 컨텍스트를 전달합니다.
|
|
* 이를 통해 DevTools에서 {{$parent._local.xxx}} 형태의 바인딩 디버깅이 가능합니다.
|
|
*
|
|
* @param parentContext 부모 데이터 컨텍스트 (undefined이면 $parent 없음)
|
|
*/
|
|
updateParentContext(parentContext: Record<string, any> | undefined): void {
|
|
if (!this.config.enabled) return;
|
|
this.currentParentContext = parentContext ? this.sanitizeObject(parentContext) : undefined;
|
|
}
|
|
|
|
/**
|
|
* 상태 이력 조회
|
|
*/
|
|
getStateHistory(): StateSnapshot[] {
|
|
return [...this.stateHistory];
|
|
}
|
|
|
|
/**
|
|
* 상태 감시 등록
|
|
*/
|
|
watchState(path: string, callback: WatcherCallback): () => void {
|
|
if (!this.stateWatchers.has(path)) {
|
|
this.stateWatchers.set(path, new Set());
|
|
}
|
|
this.stateWatchers.get(path)!.add(callback);
|
|
|
|
return () => {
|
|
this.stateWatchers.get(path)?.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 상태 감시자들에게 알림
|
|
*/
|
|
private notifyStateWatchers(snapshot: StateSnapshot): void {
|
|
// '*' 감시자
|
|
this.stateWatchers.get('*')?.forEach((cb) => {
|
|
try {
|
|
cb(snapshot.next, snapshot.prev, '*');
|
|
} catch (e) {
|
|
console.error('[G7DevTools] State watcher error:', e);
|
|
}
|
|
});
|
|
|
|
// 특정 경로 감시자
|
|
if (snapshot.diff) {
|
|
for (const change of snapshot.diff.changed) {
|
|
this.stateWatchers.get(change.path)?.forEach((cb) => {
|
|
try {
|
|
cb(change.newValue, change.oldValue, change.path);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] State watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 두 스냅샷 간 차이 계산
|
|
*/
|
|
diffSnapshots(snapshot1Id: number, snapshot2Id: number): StateDiff | null {
|
|
const s1 = this.stateHistory.find((s) => s.id === snapshot1Id);
|
|
const s2 = this.stateHistory.find((s) => s.id === snapshot2Id);
|
|
|
|
if (!s1 || !s2) return null;
|
|
|
|
return this.calculateDiff(s1.next, s2.next);
|
|
}
|
|
|
|
/**
|
|
* 상태 차이 계산
|
|
*/
|
|
private calculateDiff(
|
|
prev: Record<string, any>,
|
|
next: Record<string, any>,
|
|
prefix = ''
|
|
): StateDiff {
|
|
const diff: StateDiff = { added: [], removed: [], changed: [] };
|
|
|
|
const prevKeys = new Set(Object.keys(prev));
|
|
const nextKeys = new Set(Object.keys(next));
|
|
|
|
for (const key of nextKeys) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (!prevKeys.has(key)) {
|
|
diff.added.push(path);
|
|
} else if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) {
|
|
diff.changed.push({
|
|
path,
|
|
oldValue: prev[key],
|
|
newValue: next[key],
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const key of prevKeys) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (!nextKeys.has(key)) {
|
|
diff.removed.push(path);
|
|
}
|
|
}
|
|
|
|
return diff;
|
|
}
|
|
|
|
// ============================================
|
|
// 액션 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 액션 로그 기록
|
|
*/
|
|
logAction(action: ActionLog): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 기존 액션 업데이트 또는 새 액션 추가
|
|
const existingIndex = this.actionHistory.findIndex((a) => a.id === action.id);
|
|
|
|
if (existingIndex >= 0) {
|
|
this.actionHistory[existingIndex] = {
|
|
...this.actionHistory[existingIndex],
|
|
...action,
|
|
};
|
|
} else {
|
|
this.actionHistory.push(action);
|
|
|
|
// 이력 크기 제한
|
|
if (this.actionHistory.length > this.config.maxHistorySize) {
|
|
this.actionHistory.shift();
|
|
}
|
|
}
|
|
|
|
// 프로파일링 중이면 기록
|
|
if (this.isProfiling) {
|
|
this.profilingData.push({
|
|
type: 'action',
|
|
action: action.type,
|
|
timestamp: performance.now(),
|
|
duration: action.duration,
|
|
});
|
|
}
|
|
|
|
// 감시자 알림
|
|
this.notifyActionWatchers(action);
|
|
}
|
|
|
|
/**
|
|
* 액션 이력 조회
|
|
*/
|
|
getActionHistory(): ActionLog[] {
|
|
return [...this.actionHistory];
|
|
}
|
|
|
|
/**
|
|
* 액션 감시 등록
|
|
*/
|
|
watchActions(callback: ActionWatcherCallback): () => void {
|
|
this.actionWatchers.add(callback);
|
|
return () => {
|
|
this.actionWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 액션 감시자들에게 알림
|
|
*/
|
|
private notifyActionWatchers(action: ActionLog): void {
|
|
this.actionWatchers.forEach((cb) => {
|
|
try {
|
|
cb(action);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Action watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 액션 메트릭 조회
|
|
*/
|
|
getActionMetrics(): ActionMetrics {
|
|
const successActions = this.actionHistory.filter((a) => a.status === 'success');
|
|
const errorActions = this.actionHistory.filter((a) => a.status === 'error');
|
|
|
|
const actionsByType: Record<string, number> = {};
|
|
for (const action of this.actionHistory) {
|
|
actionsByType[action.type] = (actionsByType[action.type] || 0) + 1;
|
|
}
|
|
|
|
const totalDuration = successActions.reduce((sum, a) => sum + (a.duration || 0), 0);
|
|
|
|
return {
|
|
totalActions: this.actionHistory.length,
|
|
successCount: successActions.length,
|
|
errorCount: errorActions.length,
|
|
averageDuration: successActions.length > 0 ? totalDuration / successActions.length : 0,
|
|
actionsByType,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 캐시 통계 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 캐시 히트 기록
|
|
*/
|
|
recordCacheHit(): void {
|
|
if (!this.config.enabled) return;
|
|
this.cacheStats.hits++;
|
|
}
|
|
|
|
/**
|
|
* 캐시 미스 기록
|
|
*/
|
|
recordCacheMiss(): void {
|
|
if (!this.config.enabled) return;
|
|
this.cacheStats.misses++;
|
|
}
|
|
|
|
/**
|
|
* 캐시 엔트리 수 업데이트
|
|
*/
|
|
updateCacheEntries(count: number): void {
|
|
if (!this.config.enabled) return;
|
|
this.cacheStats.entries = count;
|
|
}
|
|
|
|
/**
|
|
* 캐시 통계 조회
|
|
*/
|
|
getCacheStats(): CacheStats {
|
|
const total = this.cacheStats.hits + this.cacheStats.misses;
|
|
return {
|
|
...this.cacheStats,
|
|
hitRate: total > 0 ? this.cacheStats.hits / total : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 캐시 통계 초기화
|
|
*/
|
|
resetCacheStats(): void {
|
|
this.cacheStats = { hits: 0, misses: 0, entries: 0 };
|
|
}
|
|
|
|
// ============================================
|
|
// 캐시 결정 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 캐시 결정 추적
|
|
*
|
|
* 표현식 평가 시 캐시 사용/스킵 결정 과정을 기록합니다.
|
|
*/
|
|
trackCacheDecision(info: {
|
|
expression: string;
|
|
decision: import('./types').CacheDecisionType;
|
|
reason: string;
|
|
componentId?: string;
|
|
skipCacheOption?: boolean;
|
|
cachedValue?: any;
|
|
freshValue?: any;
|
|
duration?: number;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const decision: import('./types').CacheDecisionInfo = {
|
|
id: `cache-${++this.cacheDecisionIdCounter}`,
|
|
timestamp: Date.now(),
|
|
expression: info.expression,
|
|
decision: info.decision,
|
|
reason: info.reason,
|
|
context: {
|
|
isInIteration: this.isInIteration,
|
|
isInAction: this.isInActionExecution,
|
|
renderCycleId: this.currentRenderCycleId || undefined,
|
|
componentId: info.componentId,
|
|
skipCacheOption: info.skipCacheOption,
|
|
},
|
|
cachedValue: info.cachedValue !== undefined ? this.safeClone(info.cachedValue) : undefined,
|
|
freshValue: info.freshValue !== undefined ? this.safeClone(info.freshValue) : undefined,
|
|
valueMatch: info.cachedValue !== undefined && info.freshValue !== undefined
|
|
? JSON.stringify(info.cachedValue) === JSON.stringify(info.freshValue)
|
|
: undefined,
|
|
duration: info.duration,
|
|
};
|
|
|
|
this.cacheDecisions.push(decision);
|
|
|
|
// 최대 크기 제한
|
|
if (this.cacheDecisions.length > this.maxCacheDecisions) {
|
|
this.cacheDecisions.shift();
|
|
}
|
|
|
|
// 캐시 통계도 업데이트
|
|
if (info.decision === 'cache_hit') {
|
|
this.cacheStats.hits++;
|
|
} else if (info.decision === 'cache_miss' || info.decision === 'skip_cache') {
|
|
this.cacheStats.misses++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 렌더 사이클 시작 표시
|
|
*/
|
|
startRenderCycle(componentId?: string): string {
|
|
this.currentRenderCycleId = `render-${Date.now()}-${componentId || 'root'}`;
|
|
return this.currentRenderCycleId;
|
|
}
|
|
|
|
/**
|
|
* 렌더 사이클 종료 표시
|
|
*/
|
|
endRenderCycle(): void {
|
|
this.currentRenderCycleId = null;
|
|
}
|
|
|
|
/**
|
|
* 액션 실행 컨텍스트 시작
|
|
*/
|
|
startActionContext(): void {
|
|
this.isInActionExecution = true;
|
|
}
|
|
|
|
/**
|
|
* 액션 실행 컨텍스트 종료
|
|
*/
|
|
endActionContext(): void {
|
|
this.isInActionExecution = false;
|
|
}
|
|
|
|
/**
|
|
* iteration 컨텍스트 시작
|
|
*/
|
|
startIterationContext(): void {
|
|
this.isInIteration = true;
|
|
}
|
|
|
|
/**
|
|
* iteration 컨텍스트 종료
|
|
*/
|
|
endIterationContext(): void {
|
|
this.isInIteration = false;
|
|
}
|
|
|
|
/**
|
|
* 캐시 결정 추적 정보 반환
|
|
*/
|
|
getCacheDecisionTrackingInfo(): import('./types').CacheDecisionTrackingInfo {
|
|
const stats = this.getCacheDecisionStats();
|
|
|
|
return {
|
|
decisions: [...this.cacheDecisions],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 캐시 결정 통계 계산
|
|
*/
|
|
private getCacheDecisionStats(): import('./types').CacheDecisionStats {
|
|
const decisions = this.cacheDecisions;
|
|
|
|
// 결정 유형별 개수
|
|
let cacheHits = 0;
|
|
let cacheMisses = 0;
|
|
let skipCacheCount = 0;
|
|
let invalidateCount = 0;
|
|
|
|
const byReason: Record<string, number> = {};
|
|
const byComponent: Record<string, number> = {};
|
|
|
|
for (const d of decisions) {
|
|
if (d.decision === 'cache_hit') cacheHits++;
|
|
else if (d.decision === 'cache_miss') cacheMisses++;
|
|
else if (d.decision === 'skip_cache') skipCacheCount++;
|
|
else if (d.decision === 'invalidate') invalidateCount++;
|
|
|
|
byReason[d.reason] = (byReason[d.reason] || 0) + 1;
|
|
|
|
if (d.context.componentId) {
|
|
byComponent[d.context.componentId] = (byComponent[d.context.componentId] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const total = cacheHits + cacheMisses + skipCacheCount;
|
|
|
|
return {
|
|
totalDecisions: decisions.length,
|
|
cacheHits,
|
|
cacheMisses,
|
|
skipCacheCount,
|
|
invalidateCount,
|
|
byReason,
|
|
byComponent,
|
|
avgHitRate: total > 0 ? cacheHits / total : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 최근 캐시 결정 반환
|
|
*/
|
|
getRecentCacheDecisions(limit = 20, decision?: import('./types').CacheDecisionType): import('./types').CacheDecisionInfo[] {
|
|
let filtered = [...this.cacheDecisions];
|
|
|
|
if (decision) {
|
|
filtered = filtered.filter(d => d.decision === decision);
|
|
}
|
|
|
|
return filtered.slice(-limit);
|
|
}
|
|
|
|
/**
|
|
* 캐시 결정 데이터 초기화
|
|
*/
|
|
clearCacheDecisionData(): void {
|
|
this.cacheDecisions = [];
|
|
this.cacheDecisionIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 라이프사이클 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 라이프사이클 추적용 props 간소화
|
|
*
|
|
* 덤프 파일 크기를 줄이기 위해 큰 props는 요약만 저장합니다.
|
|
*/
|
|
private summarizePropsForLifecycle(props: any): any {
|
|
if (!props || typeof props !== 'object') return props;
|
|
|
|
const result: Record<string, any> = {};
|
|
const skipKeys = ['children', 'ref', 'key'];
|
|
|
|
for (const [key, value] of Object.entries(props)) {
|
|
if (skipKeys.includes(key)) continue;
|
|
|
|
if (value === null || value === undefined) {
|
|
result[key] = value;
|
|
} else if (Array.isArray(value)) {
|
|
// 배열: 길이만 표시
|
|
result[key] = `[Array(${value.length})]`;
|
|
} else if (typeof value === 'object') {
|
|
// 객체: 키 목록만 표시
|
|
const keys = Object.keys(value);
|
|
if (keys.length <= 3) {
|
|
result[key] = `{${keys.join(', ')}}`;
|
|
} else {
|
|
result[key] = `{${keys.slice(0, 3).join(', ')}, ... +${keys.length - 3}}`;
|
|
}
|
|
} else if (typeof value === 'string' && value.length > 50) {
|
|
// 긴 문자열: 축약
|
|
result[key] = value.substring(0, 50) + '...';
|
|
} else if (typeof value === 'function') {
|
|
result[key] = '[Function]';
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 마운트 추적
|
|
*/
|
|
trackMount(componentId: string, info: Omit<ComponentInfo, 'mountTime'>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// props 간소화: 큰 객체/배열은 요약만 저장
|
|
const summarizedProps = this.summarizePropsForLifecycle(info.props);
|
|
|
|
const componentInfo: ComponentInfo = {
|
|
...info,
|
|
props: summarizedProps,
|
|
id: componentId,
|
|
mountTime: Date.now(),
|
|
};
|
|
|
|
this.mountedComponents.set(componentId, componentInfo);
|
|
|
|
// 감시자 알림
|
|
this.mountWatchers.forEach((cb) => {
|
|
try {
|
|
cb({
|
|
componentId,
|
|
componentName: info.name,
|
|
timestamp: componentInfo.mountTime,
|
|
});
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Mount watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 언마운트 추적
|
|
*/
|
|
trackUnmount(componentId: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const component = this.mountedComponents.get(componentId);
|
|
const listeners = this.eventListeners.get(componentId) || [];
|
|
|
|
if (listeners.length > 0) {
|
|
console.warn(
|
|
`[G7DevTools] 컴포넌트 ${componentId} 언마운트 시 ${listeners.length}개 리스너 미정리`
|
|
);
|
|
}
|
|
|
|
// 감시자 알림
|
|
if (component) {
|
|
this.unmountWatchers.forEach((cb) => {
|
|
try {
|
|
cb({
|
|
componentId,
|
|
componentName: component.name,
|
|
timestamp: Date.now(),
|
|
orphanedListeners: listeners.length,
|
|
});
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Unmount watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.mountedComponents.delete(componentId);
|
|
}
|
|
|
|
/**
|
|
* 이벤트 리스너 추적
|
|
*/
|
|
trackListener(componentId: string, type: string, target: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const listeners = this.eventListeners.get(componentId) || [];
|
|
listeners.push({ type, target, addedAt: Date.now() });
|
|
this.eventListeners.set(componentId, listeners);
|
|
}
|
|
|
|
/**
|
|
* 이벤트 리스너 제거 추적
|
|
*/
|
|
removeListener(componentId: string, type: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const listeners = this.eventListeners.get(componentId) || [];
|
|
const idx = listeners.findIndex((l) => l.type === type);
|
|
if (idx >= 0) listeners.splice(idx, 1);
|
|
}
|
|
|
|
/**
|
|
* 마운트된 컴포넌트 조회
|
|
*/
|
|
getMountedComponents(): ComponentInfo[] {
|
|
return Array.from(this.mountedComponents.values());
|
|
}
|
|
|
|
/**
|
|
* 정리되지 않은 리스너 조회
|
|
*/
|
|
getOrphanedListeners(): ListenerInfo[] {
|
|
const orphaned: ListenerInfo[] = [];
|
|
for (const [compId, listeners] of this.eventListeners) {
|
|
if (!this.mountedComponents.has(compId)) {
|
|
orphaned.push(...listeners.map((l) => ({ ...l, componentId: compId })));
|
|
}
|
|
}
|
|
return orphaned;
|
|
}
|
|
|
|
/**
|
|
* 마운트 감시 등록
|
|
*/
|
|
watchMount(callback: (info: MountEvent) => void): () => void {
|
|
this.mountWatchers.add(callback);
|
|
return () => {
|
|
this.mountWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 언마운트 감시 등록
|
|
*/
|
|
watchUnmount(callback: (info: UnmountEvent) => void): () => void {
|
|
this.unmountWatchers.add(callback);
|
|
return () => {
|
|
this.unmountWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 성능 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 렌더링 추적
|
|
*/
|
|
trackRender(componentName: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const count = this.renderCounts.get(componentName) || 0;
|
|
this.renderCounts.set(componentName, count + 1);
|
|
|
|
if (this.isProfiling) {
|
|
this.profilingData.push({
|
|
type: 'render',
|
|
component: componentName,
|
|
timestamp: performance.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 바인딩 평가 추적
|
|
*/
|
|
trackBindingEval(expression?: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.bindingEvalCount++;
|
|
|
|
if (this.isProfiling && expression) {
|
|
this.profilingData.push({
|
|
type: 'binding',
|
|
expression,
|
|
timestamp: performance.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 렌더링 횟수 조회
|
|
*/
|
|
getRenderCount(): Map<string, number> {
|
|
return new Map(this.renderCounts);
|
|
}
|
|
|
|
/**
|
|
* 바인딩 평가 횟수 조회
|
|
*/
|
|
getBindingEvalCount(): number {
|
|
return this.bindingEvalCount;
|
|
}
|
|
|
|
/**
|
|
* 메모리 경고 조회
|
|
*/
|
|
getMemoryWarnings(): MemoryWarning[] {
|
|
const warnings: MemoryWarning[] = [];
|
|
|
|
// 큰 상태 객체 감지
|
|
try {
|
|
const state = this.getState();
|
|
const stateSize = JSON.stringify(state).length;
|
|
if (stateSize > 1000000) {
|
|
// 1MB
|
|
warnings.push({
|
|
type: 'large-state',
|
|
message: `상태 크기가 ${(stateSize / 1024 / 1024).toFixed(2)}MB입니다`,
|
|
suggestion: '불필요한 데이터 정리 필요',
|
|
severity: stateSize > 5000000 ? 'error' : 'warning',
|
|
});
|
|
}
|
|
} catch {
|
|
// 순환 참조 등으로 직렬화 실패 시 무시
|
|
}
|
|
|
|
// 이력 크기 감지
|
|
if (this.stateHistory.length > this.config.maxHistorySize * 0.8) {
|
|
warnings.push({
|
|
type: 'large-history',
|
|
message: `상태 이력이 ${this.stateHistory.length}개입니다`,
|
|
suggestion: 'maxHistory 설정 조정 필요',
|
|
severity: 'warning',
|
|
});
|
|
}
|
|
|
|
// 정리되지 않은 리스너 감지
|
|
const orphanedCount = this.getOrphanedListeners().length;
|
|
if (orphanedCount > 0) {
|
|
warnings.push({
|
|
type: 'orphaned-listeners',
|
|
message: `${orphanedCount}개의 정리되지 않은 이벤트 리스너`,
|
|
suggestion: 'useEffect cleanup 또는 removeEventListener 확인',
|
|
severity: orphanedCount > 10 ? 'error' : 'warning',
|
|
});
|
|
}
|
|
|
|
// 과도한 렌더링 감지
|
|
const excessiveRenders = Array.from(this.renderCounts.entries()).filter(
|
|
([, count]) => count > 50
|
|
);
|
|
if (excessiveRenders.length > 0) {
|
|
warnings.push({
|
|
type: 'excessive-renders',
|
|
message: `${excessiveRenders.length}개 컴포넌트가 50회 이상 렌더링`,
|
|
suggestion: 'React.memo, useMemo, useCallback 사용 고려',
|
|
severity: 'warning',
|
|
});
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/**
|
|
* 프로파일링 시작
|
|
*/
|
|
startProfiling(): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.isProfiling = true;
|
|
this.profilingData = [];
|
|
this.profilingStartTime = performance.now();
|
|
console.log('[G7DevTools] 프로파일링 시작');
|
|
}
|
|
|
|
/**
|
|
* 프로파일링 종료
|
|
*/
|
|
stopProfiling(): ProfileReport {
|
|
this.isProfiling = false;
|
|
const duration = performance.now() - this.profilingStartTime;
|
|
|
|
const report = this.analyzeProfile(duration);
|
|
console.log('[G7DevTools] 프로파일링 완료', report);
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* 프로파일 분석
|
|
*/
|
|
private analyzeProfile(duration: number): ProfileReport {
|
|
const renders = this.profilingData.filter((e) => e.type === 'render');
|
|
const bindings = this.profilingData.filter((e) => e.type === 'binding');
|
|
const actions = this.profilingData.filter((e) => e.type === 'action');
|
|
|
|
// 컴포넌트별 렌더링 집계
|
|
const componentRenders: Record<string, { count: number; durations: number[] }> = {};
|
|
for (const entry of renders) {
|
|
if (!entry.component) continue;
|
|
if (!componentRenders[entry.component]) {
|
|
componentRenders[entry.component] = { count: 0, durations: [] };
|
|
}
|
|
componentRenders[entry.component].count++;
|
|
if (entry.duration) {
|
|
componentRenders[entry.component].durations.push(entry.duration);
|
|
}
|
|
}
|
|
|
|
const slowestComponents = Object.entries(componentRenders)
|
|
.map(([name, data]) => ({
|
|
name,
|
|
renderCount: data.count,
|
|
avgDuration:
|
|
data.durations.length > 0
|
|
? data.durations.reduce((a, b) => a + b, 0) / data.durations.length
|
|
: 0,
|
|
}))
|
|
.sort((a, b) => b.renderCount - a.renderCount)
|
|
.slice(0, 10);
|
|
|
|
// 핫 패스 (자주 평가되는 바인딩)
|
|
const bindingCounts: Record<string, number> = {};
|
|
for (const entry of bindings) {
|
|
if (!entry.expression) continue;
|
|
bindingCounts[entry.expression] = (bindingCounts[entry.expression] || 0) + 1;
|
|
}
|
|
const hotPaths = Object.entries(bindingCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 10)
|
|
.map(([path]) => path);
|
|
|
|
return {
|
|
duration,
|
|
entries: this.profilingData,
|
|
summary: {
|
|
totalRenders: renders.length,
|
|
totalBindings: bindings.length,
|
|
totalActions: actions.length,
|
|
slowestComponents,
|
|
hotPaths,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 성능 통계 초기화
|
|
*/
|
|
resetPerformanceStats(): void {
|
|
this.renderCounts.clear();
|
|
this.bindingEvalCount = 0;
|
|
this.profilingData = [];
|
|
}
|
|
|
|
// ============================================
|
|
// 네트워크 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 요청 시작 추적
|
|
*
|
|
* @param url 전체 URL (쿼리스트링 포함)
|
|
* @param method HTTP 메서드
|
|
* @param options 추가 옵션 (requestBody, dataSourceId)
|
|
* @returns 요청 추적 ID
|
|
*/
|
|
trackRequest(
|
|
url: string,
|
|
method: string,
|
|
options?: {
|
|
requestBody?: any;
|
|
dataSourceId?: string;
|
|
}
|
|
): string {
|
|
if (!this.config.enabled) return '';
|
|
|
|
const requestId = this.generateId();
|
|
|
|
// URL에서 경로와 쿼리 파라미터 분리
|
|
const urlPath = extractPath(url);
|
|
const queryParams = parseQueryParams(url);
|
|
|
|
this.activeRequests.set(requestId, {
|
|
id: requestId,
|
|
url: urlPath,
|
|
fullUrl: url,
|
|
queryParams,
|
|
method,
|
|
startTime: Date.now(),
|
|
status: 'pending',
|
|
requestBody: options?.requestBody ? this.sanitizeObject(options.requestBody) : undefined,
|
|
dataSourceId: options?.dataSourceId,
|
|
});
|
|
|
|
if (this.isProfiling) {
|
|
this.profilingData.push({
|
|
type: 'network',
|
|
timestamp: performance.now(),
|
|
});
|
|
}
|
|
|
|
return requestId;
|
|
}
|
|
|
|
/**
|
|
* 요청 완료 추적
|
|
*/
|
|
completeRequest(requestId: string, statusCode: number, response?: any): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const req = this.activeRequests.get(requestId);
|
|
if (!req) return;
|
|
|
|
const log: RequestLog = {
|
|
...req,
|
|
status: statusCode >= 200 && statusCode < 300 ? 'success' : 'error',
|
|
statusCode,
|
|
duration: Date.now() - req.startTime,
|
|
endTime: Date.now(),
|
|
response: this.sanitizeObject(response),
|
|
};
|
|
|
|
this.requestHistory.push(log);
|
|
this.activeRequests.delete(requestId);
|
|
|
|
// 이력 크기 제한
|
|
if (this.requestHistory.length > this.config.maxHistorySize) {
|
|
this.requestHistory.shift();
|
|
}
|
|
|
|
this.notifyRequestWatchers(log);
|
|
}
|
|
|
|
/**
|
|
* 요청 실패 추적
|
|
*/
|
|
failRequest(requestId: string, error: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const req = this.activeRequests.get(requestId);
|
|
if (!req) return;
|
|
|
|
const log: RequestLog = {
|
|
...req,
|
|
status: 'error',
|
|
error,
|
|
duration: Date.now() - req.startTime,
|
|
endTime: Date.now(),
|
|
};
|
|
|
|
this.requestHistory.push(log);
|
|
this.activeRequests.delete(requestId);
|
|
|
|
this.notifyRequestWatchers(log);
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 로딩 시작 추적
|
|
*/
|
|
trackDataSource(name: string): void {
|
|
if (!this.config.enabled) return;
|
|
this.pendingDataSources.add(name);
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 로딩 완료 추적
|
|
*/
|
|
completeDataSource(name: string): void {
|
|
if (!this.config.enabled) return;
|
|
this.pendingDataSources.delete(name);
|
|
}
|
|
|
|
/**
|
|
* 활성 요청 조회
|
|
*/
|
|
getActiveRequests(): RequestInfo[] {
|
|
return Array.from(this.activeRequests.values());
|
|
}
|
|
|
|
/**
|
|
* 요청 이력 조회
|
|
*/
|
|
getRequestHistory(): RequestLog[] {
|
|
return [...this.requestHistory];
|
|
}
|
|
|
|
/**
|
|
* 대기 중인 데이터소스 조회
|
|
*/
|
|
getPendingDataSources(): string[] {
|
|
return Array.from(this.pendingDataSources);
|
|
}
|
|
|
|
/**
|
|
* 요청 감시 등록
|
|
*/
|
|
watchRequest(callback: RequestWatcherCallback): () => void {
|
|
this.requestWatchers.add(callback);
|
|
return () => {
|
|
this.requestWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 요청 감시자들에게 알림
|
|
*/
|
|
private notifyRequestWatchers(request: RequestLog): void {
|
|
this.requestWatchers.forEach((cb) => {
|
|
try {
|
|
cb(request);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Request watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// WebSocket 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* WebSocket 연결 추적
|
|
*/
|
|
trackWebSocketConnection(id: string, url: string, state: WebSocketConnectionInfo['state']): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.wsConnections.set(id, {
|
|
id,
|
|
url,
|
|
state,
|
|
connectedAt: state === 'open' ? Date.now() : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* WebSocket 메시지 추적
|
|
*/
|
|
trackWebSocketMessage(
|
|
connectionId: string,
|
|
direction: 'sent' | 'received',
|
|
type: string,
|
|
payload: any
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const message: WebSocketMessage = {
|
|
id: this.generateId(),
|
|
connectionId,
|
|
direction,
|
|
type,
|
|
payload: this.sanitizeObject(payload),
|
|
timestamp: Date.now(),
|
|
sequence: this.wsMessageHistory.length,
|
|
};
|
|
|
|
this.wsMessageHistory.push(message);
|
|
|
|
// 이력 크기 제한
|
|
if (this.wsMessageHistory.length > this.config.maxHistorySize) {
|
|
this.wsMessageHistory.shift();
|
|
}
|
|
|
|
this.notifyWebSocketWatchers(message);
|
|
}
|
|
|
|
/**
|
|
* WebSocket 연결 정보 조회
|
|
*/
|
|
getWebSocketConnections(): WebSocketConnectionInfo[] {
|
|
return Array.from(this.wsConnections.values());
|
|
}
|
|
|
|
/**
|
|
* WebSocket 메시지 이력 조회
|
|
*/
|
|
getWebSocketMessageHistory(): WebSocketMessage[] {
|
|
return [...this.wsMessageHistory];
|
|
}
|
|
|
|
/**
|
|
* WebSocket 연결 상태 조회
|
|
*/
|
|
getWebSocketConnectionState(): 'connected' | 'disconnected' | 'reconnecting' {
|
|
const connections = Array.from(this.wsConnections.values());
|
|
if (connections.some((c) => c.state === 'open')) return 'connected';
|
|
if (connections.some((c) => c.state === 'connecting')) return 'reconnecting';
|
|
return 'disconnected';
|
|
}
|
|
|
|
/**
|
|
* WebSocket 메시지 감시 등록
|
|
*/
|
|
watchWebSocketMessage(callback: WebSocketWatcherCallback): () => void {
|
|
this.wsMessageWatchers.add(callback);
|
|
return () => {
|
|
this.wsMessageWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* WebSocket 감시자들에게 알림
|
|
*/
|
|
private notifyWebSocketWatchers(message: WebSocketMessage): void {
|
|
this.wsMessageWatchers.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] WebSocket watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// 조건부 렌더링 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* if 조건 추적
|
|
*/
|
|
trackIfCondition(id: string, expression: string, value: boolean): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const existing = this.ifConditions.get(id);
|
|
const prevValue = existing?.evaluatedValue;
|
|
|
|
this.ifConditions.set(id, {
|
|
id,
|
|
expression,
|
|
evaluatedValue: value,
|
|
evaluationCount: (existing?.evaluationCount || 0) + 1,
|
|
lastEvaluated: Date.now(),
|
|
});
|
|
|
|
// 값이 변경되면 감시자 알림
|
|
if (existing && prevValue !== value) {
|
|
this.notifyConditionWatchers({
|
|
id,
|
|
expression,
|
|
oldValue: prevValue!,
|
|
newValue: value,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* iteration 추적
|
|
*/
|
|
trackIteration(
|
|
id: string,
|
|
info: {
|
|
source: string;
|
|
itemVar: string;
|
|
indexVar?: string;
|
|
sourceLength: number;
|
|
}
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.iterations.set(id, {
|
|
id,
|
|
...info,
|
|
lastRendered: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* if 조건들 조회
|
|
*/
|
|
getIfConditions(): ConditionInfo[] {
|
|
return Array.from(this.ifConditions.values());
|
|
}
|
|
|
|
/**
|
|
* iteration들 조회
|
|
*/
|
|
getIterations(): IterationInfo[] {
|
|
return Array.from(this.iterations.values());
|
|
}
|
|
|
|
/**
|
|
* 스코프 체인 조회
|
|
*/
|
|
getScopeChain(componentId: string): ScopeVariable[] {
|
|
const chain: ScopeVariable[] = [];
|
|
|
|
// _global
|
|
const state = this.getState();
|
|
chain.push({ name: '_global', value: state._global, source: 'global' });
|
|
chain.push({ name: '_local', value: state._local, source: 'local' });
|
|
chain.push({ name: '_computed', value: state._computed, source: 'computed' });
|
|
|
|
// iteration 변수는 컴포넌트별로 추적 필요 (추후 확장)
|
|
|
|
return chain;
|
|
}
|
|
|
|
/**
|
|
* 조건 변경 감시 등록
|
|
*/
|
|
watchConditionChange(callback: ConditionWatcherCallback): () => void {
|
|
this.conditionWatchers.add(callback);
|
|
return () => {
|
|
this.conditionWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 조건 감시자들에게 알림
|
|
*/
|
|
private notifyConditionWatchers(change: import('./types').ConditionChange): void {
|
|
this.conditionWatchers.forEach((cb) => {
|
|
try {
|
|
cb(change);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Condition watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Form 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Form 추적
|
|
*/
|
|
trackForm(id: string, dataKey: string, inputs: InputInfo[]): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.forms.set(id, {
|
|
id,
|
|
dataKey,
|
|
inputs,
|
|
trackedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Form 변경 추적
|
|
*/
|
|
trackFormChange(formId: string, inputName: string, value: any): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const change: FormChange = {
|
|
formId,
|
|
inputName,
|
|
value,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.notifyFormWatchers(change);
|
|
}
|
|
|
|
/**
|
|
* Form 제거
|
|
*/
|
|
untrackForm(id: string): void {
|
|
this.forms.delete(id);
|
|
}
|
|
|
|
/**
|
|
* Form들 조회
|
|
*/
|
|
getForms(): FormInfo[] {
|
|
return Array.from(this.forms.values());
|
|
}
|
|
|
|
/**
|
|
* 특정 Form 상태 조회
|
|
*/
|
|
getFormState(dataKey: string): Record<string, any> {
|
|
const state = this.getState();
|
|
return this.getNestedValue(state, dataKey) || {};
|
|
}
|
|
|
|
/**
|
|
* Input 바인딩 경로 조회
|
|
*/
|
|
getBindingPath(formDataKey: string, inputName: string): string {
|
|
return `${formDataKey}.${inputName}`;
|
|
}
|
|
|
|
/**
|
|
* Form 변경 감시 등록
|
|
*/
|
|
watchFormChange(callback: FormWatcherCallback): () => void {
|
|
this.formWatchers.add(callback);
|
|
return () => {
|
|
this.formWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Form 감시자들에게 알림
|
|
*/
|
|
private notifyFormWatchers(change: FormChange): void {
|
|
this.formWatchers.forEach((cb) => {
|
|
try {
|
|
cb(change);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Form watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Form 바인딩 검증 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Form 바인딩 이슈 추적
|
|
*/
|
|
trackFormBindingIssue(
|
|
type: FormBindingIssueType,
|
|
severity: 'info' | 'warning' | 'error',
|
|
details: {
|
|
formId?: string;
|
|
formDataKey?: string;
|
|
inputInfo?: {
|
|
name?: string;
|
|
type?: string;
|
|
componentId?: string;
|
|
};
|
|
contextInfo?: {
|
|
hasParentFormContext: boolean;
|
|
parentFormContextProp?: string;
|
|
isInsideSortable: boolean;
|
|
isInsideModal: boolean;
|
|
depth: number;
|
|
};
|
|
description: string;
|
|
suggestion: string;
|
|
docLink?: string;
|
|
}
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const issue: FormBindingIssue = {
|
|
id: `form-issue-${++this.formBindingIssueIdCounter}`,
|
|
timestamp: Date.now(),
|
|
type,
|
|
severity,
|
|
formId: details.formId,
|
|
formDataKey: details.formDataKey,
|
|
inputInfo: details.inputInfo,
|
|
contextInfo: details.contextInfo,
|
|
description: details.description,
|
|
suggestion: details.suggestion,
|
|
docLink: details.docLink,
|
|
};
|
|
|
|
this.formBindingIssues.push(issue);
|
|
|
|
// 이력 크기 제한
|
|
if (this.formBindingIssues.length > this.maxFormBindingIssues) {
|
|
this.formBindingIssues.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Form 바인딩 검증 수행
|
|
*/
|
|
validateFormBinding(
|
|
formId: string,
|
|
dataKey: string | undefined,
|
|
inputs: Array<{ name: string; type: string; value?: any }>,
|
|
contextInfo: {
|
|
hasParentContext: boolean;
|
|
parentContextProp?: string;
|
|
isContextBroken: boolean;
|
|
breakReason?: string;
|
|
}
|
|
): FormBindingValidationInfo {
|
|
const inputBindings = inputs.map(input => {
|
|
const issues: string[] = [];
|
|
let isValid = true;
|
|
|
|
if (!input.name) {
|
|
issues.push('Input에 name 속성이 없습니다');
|
|
isValid = false;
|
|
this.trackFormBindingIssue('missing-input-name', 'error', {
|
|
formId,
|
|
formDataKey: dataKey,
|
|
inputInfo: { name: input.name, type: input.type },
|
|
description: 'Input에 name 속성이 없어 자동 바인딩이 동작하지 않습니다',
|
|
suggestion: 'Input에 name 속성을 추가하세요',
|
|
docLink: 'troubleshooting-components-form.md',
|
|
});
|
|
}
|
|
|
|
if (!dataKey) {
|
|
issues.push('Form에 dataKey가 없습니다');
|
|
isValid = false;
|
|
}
|
|
|
|
return {
|
|
inputName: input.name || '(unnamed)',
|
|
inputType: input.type,
|
|
bindingPath: dataKey && input.name ? `${dataKey}.${input.name}` : '-',
|
|
isValid,
|
|
currentValue: input.value,
|
|
issues,
|
|
};
|
|
});
|
|
|
|
// dataKey 누락 이슈
|
|
if (!dataKey) {
|
|
this.trackFormBindingIssue('missing-datakey', 'error', {
|
|
formId,
|
|
description: 'Form에 dataKey가 설정되지 않아 자동 바인딩이 동작하지 않습니다',
|
|
suggestion: 'Form 컴포넌트에 dataKey props를 설정하세요',
|
|
docLink: 'troubleshooting-components-form.md',
|
|
});
|
|
}
|
|
|
|
// 컨텍스트 단절 이슈
|
|
if (contextInfo.isContextBroken) {
|
|
const issueType: FormBindingIssueType = contextInfo.breakReason?.includes('sortable')
|
|
? 'sortable-context-break'
|
|
: contextInfo.breakReason?.includes('modal')
|
|
? 'modal-context-isolation'
|
|
: 'context-not-propagated';
|
|
|
|
this.trackFormBindingIssue(issueType, 'warning', {
|
|
formId,
|
|
formDataKey: dataKey,
|
|
contextInfo: {
|
|
hasParentFormContext: contextInfo.hasParentContext,
|
|
parentFormContextProp: contextInfo.parentContextProp,
|
|
isInsideSortable: contextInfo.breakReason?.includes('sortable') || false,
|
|
isInsideModal: contextInfo.breakReason?.includes('modal') || false,
|
|
depth: 0,
|
|
},
|
|
description: contextInfo.breakReason || 'Form 컨텍스트가 제대로 전파되지 않았습니다',
|
|
suggestion: 'parentFormContextProp={undefined}를 확인하거나 중첩 구조를 검토하세요',
|
|
docLink: 'troubleshooting-components-form.md',
|
|
});
|
|
}
|
|
|
|
const validation: FormBindingValidationInfo = {
|
|
formId,
|
|
dataKey,
|
|
timestamp: Date.now(),
|
|
contextPropagation: contextInfo,
|
|
inputBindings,
|
|
isValid: inputBindings.every(b => b.isValid) && !!dataKey && !contextInfo.isContextBroken,
|
|
issueCount: inputBindings.filter(b => !b.isValid).length + (dataKey ? 0 : 1) + (contextInfo.isContextBroken ? 1 : 0),
|
|
};
|
|
|
|
// 기존 검증 결과 업데이트
|
|
const existingIndex = this.formBindingValidations.findIndex(v => v.formId === formId);
|
|
if (existingIndex >= 0) {
|
|
this.formBindingValidations[existingIndex] = validation;
|
|
} else {
|
|
this.formBindingValidations.push(validation);
|
|
}
|
|
|
|
return validation;
|
|
}
|
|
|
|
/**
|
|
* Form 바인딩 검증 추적 정보 반환
|
|
*/
|
|
getFormBindingValidationTrackingInfo(): FormBindingValidationTrackingInfo {
|
|
const stats = this.getFormBindingValidationStats();
|
|
|
|
return {
|
|
issues: [...this.formBindingIssues],
|
|
validations: [...this.formBindingValidations],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Form 바인딩 검증 통계 반환
|
|
*/
|
|
private getFormBindingValidationStats(): FormBindingValidationStats {
|
|
const byIssueType: Record<FormBindingIssueType, number> = {
|
|
'missing-datakey': 0,
|
|
'missing-input-name': 0,
|
|
'context-not-propagated': 0,
|
|
'sortable-context-break': 0,
|
|
'modal-context-isolation': 0,
|
|
'duplicate-input-name': 0,
|
|
'value-type-mismatch': 0,
|
|
'binding-path-invalid': 0,
|
|
};
|
|
|
|
const bySeverity: Record<string, number> = {
|
|
info: 0,
|
|
warning: 0,
|
|
error: 0,
|
|
};
|
|
|
|
for (const issue of this.formBindingIssues) {
|
|
byIssueType[issue.type]++;
|
|
bySeverity[issue.severity]++;
|
|
}
|
|
|
|
// 자주 발생하는 이슈 계산
|
|
const issueTypeCounts = Object.entries(byIssueType)
|
|
.filter(([, count]) => count > 0)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([type, count]) => ({
|
|
type: type as FormBindingIssueType,
|
|
count,
|
|
description: this.getIssueTypeDescription(type as FormBindingIssueType),
|
|
}));
|
|
|
|
return {
|
|
totalFormsValidated: this.formBindingValidations.length,
|
|
validForms: this.formBindingValidations.filter(v => v.isValid).length,
|
|
formsWithIssues: this.formBindingValidations.filter(v => !v.isValid).length,
|
|
totalIssues: this.formBindingIssues.length,
|
|
byIssueType,
|
|
bySeverity,
|
|
topIssues: issueTypeCounts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이슈 유형 설명 반환
|
|
*/
|
|
private getIssueTypeDescription(type: FormBindingIssueType): string {
|
|
const descriptions: Record<FormBindingIssueType, string> = {
|
|
'missing-datakey': 'Form에 dataKey 누락',
|
|
'missing-input-name': 'Input에 name 속성 누락',
|
|
'context-not-propagated': 'Form 컨텍스트 전파 실패',
|
|
'sortable-context-break': 'Sortable 컨테이너에서 컨텍스트 단절',
|
|
'modal-context-isolation': '모달에서 부모 Form 컨텍스트 격리',
|
|
'duplicate-input-name': '동일 Form 내 중복 Input name',
|
|
'value-type-mismatch': '값 타입과 Input 타입 불일치',
|
|
'binding-path-invalid': '바인딩 경로 유효하지 않음',
|
|
};
|
|
return descriptions[type] || type;
|
|
}
|
|
|
|
/**
|
|
* Form 바인딩 이슈 초기화
|
|
*/
|
|
clearFormBindingIssues(): void {
|
|
this.formBindingIssues = [];
|
|
this.formBindingValidations = [];
|
|
this.formBindingIssueIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// Computed 의존성 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Computed 속성 등록
|
|
*/
|
|
trackComputedProperty(
|
|
name: string,
|
|
expression: string,
|
|
dependencies: Array<{ type: ComputedDependencyType; path: string; value?: any }>,
|
|
value: any,
|
|
computationTime: number,
|
|
componentId?: string,
|
|
error?: string
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const id = `computed-${name}-${componentId || 'global'}`;
|
|
|
|
const property: ComputedPropertyInfo = {
|
|
id,
|
|
name,
|
|
expression,
|
|
componentId,
|
|
dependencies,
|
|
currentValue: this.safeClone(value),
|
|
lastComputedAt: Date.now(),
|
|
computationTime,
|
|
error,
|
|
};
|
|
|
|
this.computedProperties.set(id, property);
|
|
}
|
|
|
|
/**
|
|
* Computed 재계산 추적
|
|
*/
|
|
trackComputedRecalc(
|
|
computedName: string,
|
|
trigger: ComputedRecalcTrigger,
|
|
previousValue: any,
|
|
newValue: any,
|
|
computationTime: number,
|
|
triggeredBy?: {
|
|
type: ComputedDependencyType;
|
|
path: string;
|
|
oldValue?: any;
|
|
newValue?: any;
|
|
},
|
|
componentId?: string
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const computedId = `computed-${computedName}-${componentId || 'global'}`;
|
|
const valueChanged = JSON.stringify(previousValue) !== JSON.stringify(newValue);
|
|
|
|
const log: ComputedRecalcLog = {
|
|
id: `recalc-${++this.computedRecalcIdCounter}`,
|
|
computedId,
|
|
computedName,
|
|
timestamp: Date.now(),
|
|
trigger,
|
|
triggeredBy,
|
|
previousValue: this.safeClone(previousValue),
|
|
newValue: this.safeClone(newValue),
|
|
valueChanged,
|
|
computationTime,
|
|
cascadeCount: 0, // 연쇄 재계산 수는 나중에 분석에서 계산
|
|
};
|
|
|
|
this.computedRecalcLogs.push(log);
|
|
|
|
// 이력 크기 제한
|
|
if (this.computedRecalcLogs.length > this.maxComputedRecalcLogs) {
|
|
this.computedRecalcLogs.shift();
|
|
}
|
|
|
|
// Computed 속성 정보 업데이트
|
|
const property = this.computedProperties.get(computedId);
|
|
if (property) {
|
|
property.currentValue = this.safeClone(newValue);
|
|
property.lastComputedAt = Date.now();
|
|
property.computationTime = computationTime;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computed 의존성 체인 분석
|
|
*/
|
|
analyzeComputedDependencyChain(computedName: string, componentId?: string): ComputedDependencyChain {
|
|
const visited = new Set<string>();
|
|
const path: string[] = [];
|
|
let hasCycle = false;
|
|
let cyclePath: string[] | undefined;
|
|
let maxDepth = 0;
|
|
|
|
const buildTree = (name: string, depth: number): ComputedDependencyNode => {
|
|
maxDepth = Math.max(maxDepth, depth);
|
|
|
|
if (visited.has(name)) {
|
|
hasCycle = true;
|
|
cyclePath = [...path, name];
|
|
return {
|
|
name,
|
|
type: 'computed',
|
|
children: [],
|
|
depth,
|
|
};
|
|
}
|
|
|
|
visited.add(name);
|
|
path.push(name);
|
|
|
|
const prop = this.computedProperties.get(`computed-${name}-${componentId || 'global'}`);
|
|
const children: ComputedDependencyNode[] = [];
|
|
|
|
if (prop) {
|
|
for (const dep of prop.dependencies) {
|
|
if (dep.type === 'computed') {
|
|
children.push(buildTree(dep.path, depth + 1));
|
|
} else {
|
|
children.push({
|
|
name: dep.path,
|
|
type: dep.type,
|
|
children: [],
|
|
depth: depth + 1,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
path.pop();
|
|
visited.delete(name);
|
|
|
|
return {
|
|
name,
|
|
type: 'computed',
|
|
children,
|
|
depth,
|
|
};
|
|
};
|
|
|
|
const tree = buildTree(computedName, 0);
|
|
|
|
return {
|
|
root: computedName,
|
|
tree,
|
|
hasCycle,
|
|
cyclePath,
|
|
maxDepth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computed 의존성 추적 정보 반환
|
|
*/
|
|
getComputedDependencyTrackingInfo(): ComputedDependencyTrackingInfo {
|
|
const properties = Array.from(this.computedProperties.values());
|
|
const stats = this.getComputedDependencyStats();
|
|
|
|
// 모든 computed에 대해 의존성 체인 분석
|
|
const dependencyChains: ComputedDependencyChain[] = [];
|
|
for (const prop of properties) {
|
|
const chain = this.analyzeComputedDependencyChain(prop.name, prop.componentId);
|
|
dependencyChains.push(chain);
|
|
}
|
|
|
|
return {
|
|
properties,
|
|
recalcLogs: [...this.computedRecalcLogs],
|
|
dependencyChains,
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computed 의존성 통계 반환
|
|
*/
|
|
private getComputedDependencyStats(): ComputedDependencyStats {
|
|
const byTrigger: Record<ComputedRecalcTrigger, number> = {
|
|
'state-change': 0,
|
|
'datasource-update': 0,
|
|
'dependency-change': 0,
|
|
'manual': 0,
|
|
'initial': 0,
|
|
};
|
|
|
|
const recalcCounts: Record<string, number> = {};
|
|
const computationTimes: Record<string, number[]> = {};
|
|
let totalComputationTime = 0;
|
|
let unnecessaryCount = 0;
|
|
|
|
for (const log of this.computedRecalcLogs) {
|
|
byTrigger[log.trigger]++;
|
|
|
|
if (!log.valueChanged) {
|
|
unnecessaryCount++;
|
|
}
|
|
|
|
recalcCounts[log.computedName] = (recalcCounts[log.computedName] || 0) + 1;
|
|
|
|
if (!computationTimes[log.computedName]) {
|
|
computationTimes[log.computedName] = [];
|
|
}
|
|
computationTimes[log.computedName].push(log.computationTime);
|
|
totalComputationTime += log.computationTime;
|
|
}
|
|
|
|
// 가장 많이 재계산된 속성
|
|
const topRecalculated = Object.entries(recalcCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([name, count]) => ({ name, count }));
|
|
|
|
// 가장 느린 계산
|
|
const slowestComputed = Object.entries(computationTimes)
|
|
.map(([name, times]) => ({
|
|
name,
|
|
avgTime: times.reduce((a, b) => a + b, 0) / times.length,
|
|
}))
|
|
.sort((a, b) => b.avgTime - a.avgTime)
|
|
.slice(0, 5);
|
|
|
|
// 순환 의존성 감지 수
|
|
let cycleCount = 0;
|
|
for (const prop of this.computedProperties.values()) {
|
|
const chain = this.analyzeComputedDependencyChain(prop.name, prop.componentId);
|
|
if (chain.hasCycle) {
|
|
cycleCount++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalComputed: this.computedProperties.size,
|
|
totalRecalculations: this.computedRecalcLogs.length,
|
|
unnecessaryRecalculations: unnecessaryCount,
|
|
avgComputationTime: this.computedRecalcLogs.length > 0
|
|
? totalComputationTime / this.computedRecalcLogs.length
|
|
: 0,
|
|
topRecalculated,
|
|
slowestComputed,
|
|
cycleDetectionCount: cycleCount,
|
|
byTrigger,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computed 추적 초기화
|
|
*/
|
|
clearComputedTracking(): void {
|
|
this.computedProperties.clear();
|
|
this.computedRecalcLogs = [];
|
|
this.computedRecalcIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 모달 상태 스코프 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 모달 열림 추적
|
|
*/
|
|
trackModalOpen(options: {
|
|
modalId: string;
|
|
modalName: string;
|
|
scopeType?: ModalStateScopeType;
|
|
parentModalId?: string;
|
|
componentId?: string;
|
|
initialState?: Record<string, any>;
|
|
isolatedStateKeys?: string[];
|
|
sharedStateKeys?: string[];
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const modalInfo: ModalStateInfo = {
|
|
modalId: options.modalId,
|
|
modalName: options.modalName,
|
|
openedAt: Date.now(),
|
|
closedAt: null,
|
|
scopeType: options.scopeType || 'isolated',
|
|
parentModalId: options.parentModalId,
|
|
componentId: options.componentId,
|
|
initialState: options.initialState ? { ...options.initialState } : {},
|
|
currentState: options.initialState ? { ...options.initialState } : {},
|
|
stateChangeCount: 0,
|
|
isolatedStateKeys: options.isolatedStateKeys || [],
|
|
sharedStateKeys: options.sharedStateKeys || [],
|
|
};
|
|
|
|
this.modalStates.set(options.modalId, modalInfo);
|
|
|
|
// modalStack vs 레이아웃 modals 정의 교차 검증
|
|
this.validateModalDefinitionExists(options.modalId, options.modalName);
|
|
|
|
// 부모-자식 관계 추적
|
|
if (options.parentModalId) {
|
|
const existingRelation = this.modalStateRelations.find(
|
|
r => r.parentModalId === options.parentModalId && r.childModalId === options.modalId
|
|
);
|
|
if (!existingRelation) {
|
|
this.modalStateRelations.push({
|
|
parentModalId: options.parentModalId,
|
|
childModalId: options.modalId,
|
|
sharedKeys: options.sharedStateKeys || [],
|
|
isolatedKeys: options.isolatedStateKeys || [],
|
|
relationType: 'parent-child',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모달 닫힘 추적
|
|
*/
|
|
trackModalClose(modalId: string, finalState?: Record<string, any>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const modalInfo = this.modalStates.get(modalId);
|
|
if (!modalInfo) return;
|
|
|
|
modalInfo.closedAt = Date.now();
|
|
if (finalState) {
|
|
modalInfo.currentState = { ...finalState };
|
|
}
|
|
|
|
// 상태 유출 검사
|
|
this.detectStateLeakage(modalInfo);
|
|
}
|
|
|
|
/**
|
|
* modalStack에 열린 모달 ID가 레이아웃 modals 섹션에 정의되어 있는지 검증
|
|
*
|
|
* @since engine-v1.22.0
|
|
*/
|
|
private validateModalDefinitionExists(modalId: string, modalName: string): void {
|
|
const layout = this.currentLayout;
|
|
if (!layout?.layoutJson) return;
|
|
|
|
const modals = layout.layoutJson.modals;
|
|
|
|
// modals 섹션 자체가 없거나 빈 배열/객체
|
|
if (!modals) {
|
|
this.recordModalStateIssue({
|
|
type: 'missing-definition',
|
|
modalId,
|
|
modalName,
|
|
severity: 'error',
|
|
description: `모달 "${modalId}"이(가) modalStack에 추가되었으나, 현재 레이아웃에 modals 섹션이 없습니다. 레이아웃 경로: ${layout.layoutPath}`,
|
|
affectedStateKeys: [],
|
|
expectedValue: `modals 섹션에 id="${modalId}" 정의 존재`,
|
|
actualValue: 'modals 섹션 없음',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// modals가 배열인 경우 (정상 형식)
|
|
const modalArray = Array.isArray(modals) ? modals : Object.values(modals);
|
|
const found = modalArray.some(
|
|
(m: any) => m?.id === modalId || m?.props?.id === modalId
|
|
);
|
|
|
|
if (!found) {
|
|
this.recordModalStateIssue({
|
|
type: 'missing-definition',
|
|
modalId,
|
|
modalName,
|
|
severity: 'error',
|
|
description: `모달 "${modalId}"이(가) modalStack에 추가되었으나, 렌더링된 레이아웃의 modals 섹션에 해당 ID의 정의가 없습니다. partial 로딩 실패, 레이아웃 병합 문제, extends 상속 누락 등을 확인하세요. 레이아웃 경로: ${layout.layoutPath}`,
|
|
affectedStateKeys: [],
|
|
expectedValue: `modals 배열에 id="${modalId}" 항목 존재`,
|
|
actualValue: `modals 배열에 ${modalArray.length}개 모달 정의 (${modalArray.map((m: any) => m?.id || 'unknown').join(', ')})`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모달 내 상태 변경 추적
|
|
*/
|
|
trackModalStateChange(options: {
|
|
modalId: string;
|
|
stateKey: string;
|
|
previousValue: any;
|
|
newValue: any;
|
|
changeSource?: 'user-action' | 'api-response' | 'parent-sync' | 'init' | 'cleanup';
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const modalInfo = this.modalStates.get(options.modalId);
|
|
if (!modalInfo) return;
|
|
|
|
modalInfo.stateChangeCount++;
|
|
modalInfo.currentState[options.stateKey] = options.newValue;
|
|
|
|
// 격리 위반 검사
|
|
const violatesIsolation = this.checkIsolationViolation(modalInfo, options.stateKey);
|
|
|
|
const changeLog: ModalStateChangeLog = {
|
|
id: `modal-change-${++this.modalStateChangeLogIdCounter}`,
|
|
modalId: options.modalId,
|
|
modalName: modalInfo.modalName,
|
|
timestamp: Date.now(),
|
|
stateKey: options.stateKey,
|
|
previousValue: options.previousValue,
|
|
newValue: options.newValue,
|
|
changeSource: options.changeSource || 'user-action',
|
|
violatesIsolation,
|
|
};
|
|
|
|
this.modalStateChangeLogs.push(changeLog);
|
|
|
|
// 크기 제한
|
|
if (this.modalStateChangeLogs.length > this.maxModalStateChangeLogs) {
|
|
this.modalStateChangeLogs.shift();
|
|
}
|
|
|
|
// 격리 위반 시 이슈 기록
|
|
if (violatesIsolation) {
|
|
this.recordModalStateIssue({
|
|
type: 'isolation-violation',
|
|
modalId: options.modalId,
|
|
modalName: modalInfo.modalName,
|
|
severity: 'warning',
|
|
description: `모달 '${modalInfo.modalName}'에서 격리된 상태 키 '${options.stateKey}'가 변경됨`,
|
|
affectedStateKeys: [options.stateKey],
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상태 유출 감지
|
|
*/
|
|
private detectStateLeakage(modalInfo: ModalStateInfo): void {
|
|
// isolated 스코프의 모달에서 상태가 유출되었는지 확인
|
|
if (modalInfo.scopeType !== 'isolated') return;
|
|
|
|
for (const key of modalInfo.isolatedStateKeys) {
|
|
const initialValue = modalInfo.initialState[key];
|
|
const currentValue = modalInfo.currentState[key];
|
|
|
|
// 값이 변경되었고, 이게 부모 상태에 영향을 줄 수 있는지 확인
|
|
if (initialValue !== currentValue) {
|
|
// 부모 모달이 있는 경우 해당 키가 부모에서 사용되는지 확인
|
|
if (modalInfo.parentModalId) {
|
|
const parentModal = this.modalStates.get(modalInfo.parentModalId);
|
|
if (parentModal && parentModal.currentState.hasOwnProperty(key)) {
|
|
this.recordModalStateIssue({
|
|
type: 'state-leakage',
|
|
modalId: modalInfo.modalId,
|
|
modalName: modalInfo.modalName,
|
|
severity: 'error',
|
|
description: `모달 닫힘 후 상태 '${key}'가 부모 모달로 유출될 수 있음`,
|
|
affectedStateKeys: [key],
|
|
leakedValue: currentValue,
|
|
expectedValue: initialValue,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 격리 위반 검사
|
|
*/
|
|
private checkIsolationViolation(modalInfo: ModalStateInfo, _stateKey: string): boolean {
|
|
// shared 스코프가 아닌데 공유 키가 변경되면 위반
|
|
if (modalInfo.scopeType === 'isolated') {
|
|
// 격리 키에 포함된 상태가 외부에서 변경 시도되면 위반
|
|
// _stateKey는 향후 상세 격리 위반 검사 시 사용 예정
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 모달 상태 이슈 기록
|
|
*/
|
|
private recordModalStateIssue(options: {
|
|
type: ModalStateIssueType;
|
|
modalId: string;
|
|
modalName: string;
|
|
severity: 'warning' | 'error';
|
|
description: string;
|
|
affectedStateKeys: string[];
|
|
leakedValue?: any;
|
|
expectedValue?: any;
|
|
actualValue?: any;
|
|
stackInfo?: string;
|
|
}): void {
|
|
const issue: ModalStateIssue = {
|
|
id: `modal-issue-${++this.modalStateIssueIdCounter}`,
|
|
type: options.type,
|
|
modalId: options.modalId,
|
|
modalName: options.modalName,
|
|
timestamp: Date.now(),
|
|
severity: options.severity,
|
|
description: options.description,
|
|
affectedStateKeys: options.affectedStateKeys,
|
|
leakedValue: options.leakedValue,
|
|
expectedValue: options.expectedValue,
|
|
actualValue: options.actualValue,
|
|
stackInfo: options.stackInfo,
|
|
};
|
|
|
|
this.modalStateIssues.push(issue);
|
|
|
|
// 크기 제한
|
|
if (this.modalStateIssues.length > this.maxModalStateIssues) {
|
|
this.modalStateIssues.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모달 상태 스코프 추적 정보 반환
|
|
*/
|
|
getModalStateScopeTrackingInfo(): ModalStateScopeTrackingInfo {
|
|
const modals = Array.from(this.modalStates.values());
|
|
const stats = this.getModalStateScopeStats();
|
|
|
|
return {
|
|
modals,
|
|
issues: [...this.modalStateIssues],
|
|
relations: [...this.modalStateRelations],
|
|
changeLogs: [...this.modalStateChangeLogs],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 모달 상태 스코프 통계 반환
|
|
*/
|
|
private getModalStateScopeStats(): ModalStateScopeStats {
|
|
const modals = Array.from(this.modalStates.values());
|
|
|
|
const issuesBySeverity: Record<'warning' | 'error', number> = {
|
|
warning: 0,
|
|
error: 0,
|
|
};
|
|
|
|
const issuesByType: Record<ModalStateIssueType, number> = {
|
|
'state-leakage': 0,
|
|
'isolation-violation': 0,
|
|
'parent-mutation': 0,
|
|
'orphaned-state': 0,
|
|
'scope-mismatch': 0,
|
|
'cleanup-failure': 0,
|
|
'missing-definition': 0,
|
|
};
|
|
|
|
for (const issue of this.modalStateIssues) {
|
|
issuesBySeverity[issue.severity]++;
|
|
issuesByType[issue.type]++;
|
|
}
|
|
|
|
const byScope: Record<ModalStateScopeType, number> = {
|
|
isolated: 0,
|
|
shared: 0,
|
|
inherited: 0,
|
|
};
|
|
|
|
for (const modal of modals) {
|
|
byScope[modal.scopeType]++;
|
|
}
|
|
|
|
return {
|
|
totalModals: modals.length,
|
|
openModals: modals.filter(m => m.closedAt === null).length,
|
|
nestedModals: modals.filter(m => m.parentModalId !== undefined).length,
|
|
totalIssues: this.modalStateIssues.length,
|
|
issuesBySeverity,
|
|
issuesByType,
|
|
byScope,
|
|
leakageDetectionCount: issuesByType['state-leakage'],
|
|
cleanupFailureCount: issuesByType['cleanup-failure'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 모달 상태 스코프 추적 초기화
|
|
*/
|
|
clearModalStateScopeTracking(): void {
|
|
this.modalStates.clear();
|
|
this.modalStateIssues = [];
|
|
this.modalStateRelations = [];
|
|
this.modalStateChangeLogs = [];
|
|
this.modalStateIssueIdCounter = 0;
|
|
this.modalStateChangeLogIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// Named Actions 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 현재 레이아웃의 named_actions 정의를 등록합니다.
|
|
*
|
|
* @param definitions named_actions 정의 맵
|
|
*/
|
|
setNamedActionDefinitions(definitions: Record<string, any>): void {
|
|
if (!this.config.enabled) return;
|
|
this.namedActionDefinitions = definitions || {};
|
|
}
|
|
|
|
/**
|
|
* actionRef 해석 이력을 기록합니다.
|
|
*
|
|
* @param log actionRef 해석 정보
|
|
*/
|
|
trackNamedActionRef(log: Omit<NamedActionRefLog, 'id'>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const entry: NamedActionRefLog = {
|
|
...log,
|
|
id: `named_action_ref_${++this.namedActionRefIdCounter}`,
|
|
};
|
|
|
|
this.namedActionRefLogs.push(entry);
|
|
if (this.namedActionRefLogs.length > this.maxNamedActionRefLogs) {
|
|
this.namedActionRefLogs = this.namedActionRefLogs.slice(-this.maxNamedActionRefLogs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Named Actions 추적 정보를 반환합니다.
|
|
*/
|
|
getNamedActionTrackingInfo(): NamedActionTrackingInfo {
|
|
// 각 named action별 참조 횟수 통계
|
|
const refCountByName: Record<string, number> = {};
|
|
for (const log of this.namedActionRefLogs) {
|
|
refCountByName[log.actionRefName] = (refCountByName[log.actionRefName] || 0) + 1;
|
|
}
|
|
|
|
// 미사용 정의 감지
|
|
const unusedDefinitions = Object.keys(this.namedActionDefinitions).filter(
|
|
name => !refCountByName[name]
|
|
);
|
|
|
|
return {
|
|
definitions: this.namedActionDefinitions,
|
|
refLogs: [...this.namedActionRefLogs],
|
|
stats: {
|
|
totalDefinitions: Object.keys(this.namedActionDefinitions).length,
|
|
totalRefs: this.namedActionRefLogs.length,
|
|
refCountByName,
|
|
unusedDefinitions,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Named Actions 추적 초기화
|
|
*/
|
|
clearNamedActionTracking(): void {
|
|
this.namedActionDefinitions = {};
|
|
this.namedActionRefLogs = [];
|
|
this.namedActionRefIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 표현식 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 표현식 평가 추적
|
|
*
|
|
* DataBindingEngine에서 표현식을 평가할 때 호출됩니다.
|
|
*/
|
|
trackExpressionEval(info: Omit<ExpressionEvalInfo, 'id' | 'timestamp' | 'warning'>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const evalInfo: ExpressionEvalInfo = {
|
|
...info,
|
|
id: `expr-${++this.expressionIdCounter}`,
|
|
timestamp: Date.now(),
|
|
warning: this.detectExpressionWarning(info),
|
|
};
|
|
|
|
this.expressionHistory.push(evalInfo);
|
|
|
|
// 이력 크기 제한 (최근 500개만 유지)
|
|
if (this.expressionHistory.length > 500) {
|
|
this.expressionHistory.shift();
|
|
}
|
|
|
|
// 프로파일링 중이면 기록
|
|
if (this.isProfiling) {
|
|
this.profilingData.push({
|
|
type: 'binding',
|
|
expression: info.expression,
|
|
timestamp: performance.now(),
|
|
duration: info.duration,
|
|
});
|
|
}
|
|
|
|
// 감시자 알림
|
|
this.notifyExpressionWatchers(evalInfo);
|
|
}
|
|
|
|
/**
|
|
* 표현식 경고 감지
|
|
*/
|
|
private detectExpressionWarning(info: Omit<ExpressionEvalInfo, 'id' | 'timestamp' | 'warning'>): ExpressionWarning | undefined {
|
|
const { expression, result, resultType, method, duration } = info;
|
|
|
|
// 1. undefined 결과 감지
|
|
if (resultType === 'undefined' && !expression.includes('??') && !expression.includes('||')) {
|
|
// 단순 경로 표현식인 경우 경고
|
|
if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(expression.replace(/{{|}}/g, '').trim())) {
|
|
return {
|
|
type: 'undefined-result',
|
|
message: `표현식 결과가 undefined입니다`,
|
|
suggestion: `데이터가 로드되었는지 확인하거나 fallback 값을 추가하세요: ${expression} ?? ''`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 2. null 결과 감지
|
|
if (resultType === 'null') {
|
|
return {
|
|
type: 'null-result',
|
|
message: `표현식 결과가 null입니다`,
|
|
suggestion: `nullish coalescing 연산자를 사용하세요: ${expression} ?? ''`,
|
|
};
|
|
}
|
|
|
|
// 3. 배열이 문자열로 변환됨 (resolveBindings 사용 시)
|
|
if (method === 'resolveBindings' && resultType === 'string') {
|
|
const resultStr = String(result);
|
|
if (resultStr.includes('[object Object]') || /^\[.*\]$/.test(resultStr)) {
|
|
return {
|
|
type: 'array-to-string',
|
|
message: `배열/객체가 문자열로 변환되었습니다`,
|
|
suggestion: `evaluateExpression()을 사용하여 원본 타입을 유지하세요`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 4. 객체가 문자열로 변환됨
|
|
if (method === 'resolveBindings' && resultType === 'string' && String(result).includes('[object Object]')) {
|
|
return {
|
|
type: 'object-to-string',
|
|
message: `객체가 [object Object]로 변환되었습니다`,
|
|
suggestion: `evaluateExpression()을 사용하거나 특정 속성에 접근하세요`,
|
|
};
|
|
}
|
|
|
|
// 5. Optional chaining 누락 의심
|
|
if (resultType === 'undefined' && expression.includes('.') && !expression.includes('?.')) {
|
|
const parts = expression.replace(/{{|}}/g, '').trim().split('.');
|
|
if (parts.length >= 2) {
|
|
return {
|
|
type: 'missing-optional-chain',
|
|
message: `Optional chaining(?.) 누락 가능성`,
|
|
suggestion: `안전한 접근을 위해 ?. 사용을 고려하세요: ${parts.join('?.')}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 6. 잘못된 iteration 변수명 감지
|
|
if (expression.includes('{{item.') || expression.includes('{{index}}')) {
|
|
// iteration 내부에서 item/index를 직접 사용하는 경우
|
|
const iterationVars = Array.from(this.iterations.values());
|
|
const hasCustomVar = iterationVars.some(i => i.itemVar !== 'item');
|
|
if (hasCustomVar) {
|
|
return {
|
|
type: 'wrong-iteration-var',
|
|
message: `'item'/'index' 대신 iteration에서 정의한 변수명을 사용해야 합니다`,
|
|
suggestion: `iteration의 item_var/index_var에 정의된 변수명을 확인하세요`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 7. 느린 평가 감지 (10ms 이상)
|
|
if (duration && duration > 10) {
|
|
return {
|
|
type: 'slow-evaluation',
|
|
message: `표현식 평가에 ${duration.toFixed(2)}ms 소요됨`,
|
|
suggestion: `복잡한 표현식을 단순화하거나 computed 값으로 분리하세요`,
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 표현식 이력 조회
|
|
*/
|
|
getExpressions(): ExpressionEvalInfo[] {
|
|
return [...this.expressionHistory];
|
|
}
|
|
|
|
/**
|
|
* 경고가 있는 표현식만 조회
|
|
*/
|
|
getExpressionWarnings(): ExpressionEvalInfo[] {
|
|
return this.expressionHistory.filter(e => e.warning != null);
|
|
}
|
|
|
|
/**
|
|
* 특정 표현식 검색
|
|
*/
|
|
searchExpressions(query: string): ExpressionEvalInfo[] {
|
|
const lowerQuery = query.toLowerCase();
|
|
return this.expressionHistory.filter(e =>
|
|
e.expression.toLowerCase().includes(lowerQuery) ||
|
|
e.componentName?.toLowerCase().includes(lowerQuery) ||
|
|
e.propName?.toLowerCase().includes(lowerQuery)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 표현식 통계 조회
|
|
*/
|
|
getExpressionStats(): ExpressionStats {
|
|
const expressions = this.expressionHistory;
|
|
const uniqueExprs = new Set(expressions.map(e => e.expression));
|
|
const warnings = expressions.filter(e => e.warning != null);
|
|
const cached = expressions.filter(e => e.fromCache);
|
|
const durations = expressions.filter(e => e.duration != null).map(e => e.duration!);
|
|
|
|
// 결과 타입별 집계
|
|
const byType: Record<string, number> = {};
|
|
for (const expr of expressions) {
|
|
byType[expr.resultType] = (byType[expr.resultType] || 0) + 1;
|
|
}
|
|
|
|
// 경고 유형별 집계
|
|
const byWarning: Record<string, number> = {};
|
|
for (const expr of warnings) {
|
|
if (expr.warning) {
|
|
byWarning[expr.warning.type] = (byWarning[expr.warning.type] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalEvaluations: expressions.length,
|
|
uniqueExpressions: uniqueExprs.size,
|
|
warningCount: warnings.length,
|
|
cacheHitRate: expressions.length > 0 ? cached.length / expressions.length : 0,
|
|
averageDuration: durations.length > 0
|
|
? durations.reduce((a, b) => a + b, 0) / durations.length
|
|
: 0,
|
|
byType,
|
|
byWarning,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 표현식 이력 초기화
|
|
*/
|
|
clearExpressions(): void {
|
|
this.expressionHistory = [];
|
|
this.expressionIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 표현식 감시 등록
|
|
*/
|
|
watchExpressions(callback: ExpressionWatcherCallback): () => void {
|
|
this.expressionWatchers.add(callback);
|
|
return () => {
|
|
this.expressionWatchers.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 표현식 감시자들에게 알림
|
|
*/
|
|
private notifyExpressionWatchers(info: ExpressionEvalInfo): void {
|
|
this.expressionWatchers.forEach((cb) => {
|
|
try {
|
|
cb(info);
|
|
} catch (e) {
|
|
console.error('[G7DevTools] Expression watcher error:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// 설정 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 로그 레벨 설정
|
|
*/
|
|
setLogLevel(level: 'debug' | 'info' | 'warn' | 'error'): void {
|
|
this.config.logLevel = level;
|
|
}
|
|
|
|
/**
|
|
* 최대 이력 크기 설정
|
|
*/
|
|
setMaxHistory(count: number): void {
|
|
this.config.maxHistorySize = Math.max(10, Math.min(1000, count));
|
|
}
|
|
|
|
/**
|
|
* 서버 엔드포인트 설정
|
|
*/
|
|
setServerEndpoint(endpoint: string): void {
|
|
this.config.serverEndpoint = endpoint;
|
|
}
|
|
|
|
/**
|
|
* 현재 설정 조회
|
|
*/
|
|
getConfig(): DevToolsConfig {
|
|
return { ...this.config };
|
|
}
|
|
|
|
// ============================================
|
|
// 유틸리티 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 고유 ID 생성
|
|
*/
|
|
generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* 객체 직렬화 (순환 참조 제거)
|
|
*/
|
|
private sanitizeObject(obj: any, seen = new WeakSet()): any {
|
|
if (obj === null || typeof obj !== 'object') {
|
|
return obj;
|
|
}
|
|
|
|
if (seen.has(obj)) {
|
|
return '[Circular]';
|
|
}
|
|
|
|
seen.add(obj);
|
|
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item) => this.sanitizeObject(item, seen));
|
|
}
|
|
|
|
const result: Record<string, any> = {};
|
|
for (const key of Object.keys(obj)) {
|
|
try {
|
|
result[key] = this.sanitizeObject(obj[key], seen);
|
|
} catch {
|
|
result[key] = '[Unable to serialize]';
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 중첩 객체에서 값 가져오기
|
|
*/
|
|
private getNestedValue(obj: any, path: string): any {
|
|
if (!path) return obj;
|
|
|
|
const parts = path.split('.');
|
|
let current = obj;
|
|
|
|
for (const part of parts) {
|
|
if (current == null) return undefined;
|
|
current = current[part];
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
// ============================================
|
|
// 진단 엔진용 Getter 메서드들
|
|
// ============================================
|
|
|
|
/**
|
|
* 라이프사이클 정보 반환
|
|
*/
|
|
getLifecycleInfo(): LifecycleInfo {
|
|
return {
|
|
mountedComponents: Array.from(this.mountedComponents.values()),
|
|
orphanedListeners: this.getOrphanedListeners(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 성능 정보 반환
|
|
*/
|
|
getPerformanceInfo(): PerformanceInfo {
|
|
return {
|
|
renderCounts: this.renderCounts,
|
|
bindingEvalCount: this.bindingEvalCount,
|
|
memoryWarnings: this.getMemoryWarnings(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 네트워크 정보 반환
|
|
*/
|
|
getNetworkInfo(): NetworkInfo {
|
|
return {
|
|
activeRequests: Array.from(this.activeRequests.values()),
|
|
requestHistory: [...this.requestHistory],
|
|
pendingDataSources: Array.from(this.pendingDataSources),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 조건부 렌더링 정보 반환
|
|
*/
|
|
getConditionalInfo(): ConditionalInfo {
|
|
return {
|
|
ifConditions: Array.from(this.ifConditions.values()),
|
|
iterations: Array.from(this.iterations.values()),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Form 정보 반환
|
|
*/
|
|
getFormInfo(): FormInfo[] {
|
|
return Array.from(this.forms.values());
|
|
}
|
|
|
|
/**
|
|
* WebSocket 정보 반환
|
|
*/
|
|
getWebSocketInfo(): WebSocketInfo {
|
|
return {
|
|
connections: Array.from(this.wsConnections.values()),
|
|
messageHistory: [...this.wsMessageHistory],
|
|
connectionState: this.getWebSocketConnectionState(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 특정 시점의 상태 반환
|
|
*
|
|
* @param timestamp 조회할 시점
|
|
* @returns 해당 시점의 상태 또는 undefined
|
|
*/
|
|
getStateAtTime(timestamp: number): StateView | undefined {
|
|
// 해당 시점 이전의 가장 최근 스냅샷 찾기
|
|
const snapshot = [...this.stateHistory]
|
|
.reverse()
|
|
.find(s => s.timestamp <= timestamp);
|
|
|
|
if (!snapshot) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
_global: snapshot.next,
|
|
_local: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 모든 데이터 초기화
|
|
*/
|
|
reset(): void {
|
|
this.stateHistory = [];
|
|
this.actionHistory = [];
|
|
this.cacheStats = { hits: 0, misses: 0, entries: 0 };
|
|
// 캐시 결정 추적 초기화
|
|
this.cacheDecisions = [];
|
|
this.cacheDecisionIdCounter = 0;
|
|
this.currentRenderCycleId = null;
|
|
this.isInActionExecution = false;
|
|
this.isInIteration = false;
|
|
this.mountedComponents.clear();
|
|
this.eventListeners.clear();
|
|
this.renderCounts.clear();
|
|
this.bindingEvalCount = 0;
|
|
this.profilingData = [];
|
|
this.activeRequests.clear();
|
|
this.requestHistory = [];
|
|
this.pendingDataSources.clear();
|
|
this.wsConnections.clear();
|
|
this.wsMessageHistory = [];
|
|
this.ifConditions.clear();
|
|
this.iterations.clear();
|
|
this.forms.clear();
|
|
this.expressionHistory = [];
|
|
this.expressionIdCounter = 0;
|
|
this.dataSources.clear();
|
|
// 데이터 경로 변환 초기화
|
|
this.dataPathTransforms = [];
|
|
// Nested Context 초기화
|
|
this.nestedContexts = [];
|
|
this.nestedContextIdCounter = 0;
|
|
// Form 바인딩 검증 초기화
|
|
this.formBindingIssues = [];
|
|
this.formBindingValidations = [];
|
|
this.formBindingIssueIdCounter = 0;
|
|
// Computed 의존성 추적 초기화
|
|
this.computedProperties.clear();
|
|
this.computedRecalcLogs = [];
|
|
this.computedRecalcIdCounter = 0;
|
|
// 모달 상태 스코프 추적 초기화
|
|
this.modalStates.clear();
|
|
this.modalStateIssues = [];
|
|
this.modalStateRelations = [];
|
|
this.modalStateChangeLogs = [];
|
|
this.modalStateIssueIdCounter = 0;
|
|
this.modalStateChangeLogIdCounter = 0;
|
|
// 로그 초기화
|
|
this.logHistory = [];
|
|
this.logIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 표현식 정보 반환 (진단 엔진용)
|
|
*/
|
|
getExpressionInfo(): { expressions: ExpressionEvalInfo[]; stats: ExpressionStats } {
|
|
return {
|
|
expressions: this.getExpressions(),
|
|
stats: this.getExpressionStats(),
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 로그 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 로그 추적
|
|
*
|
|
* Logger에서 호출되어 로그를 DevTools에 기록합니다.
|
|
*/
|
|
trackLog(level: LogLevel, prefix: string, args: unknown[]): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 메시지 직렬화
|
|
const message = args
|
|
.map(arg => {
|
|
if (typeof arg === 'string') return arg;
|
|
try {
|
|
return JSON.stringify(arg);
|
|
} catch {
|
|
return String(arg);
|
|
}
|
|
})
|
|
.join(' ');
|
|
|
|
const entry: LogEntry = {
|
|
id: `log-${++this.logIdCounter}`,
|
|
level,
|
|
prefix,
|
|
message,
|
|
args: this.sanitizeObject(args),
|
|
timestamp: Date.now(),
|
|
stack: level === 'error' ? new Error().stack : undefined,
|
|
};
|
|
|
|
this.logHistory.push(entry);
|
|
|
|
// 이력 크기 제한
|
|
if (this.logHistory.length > this.maxLogHistory) {
|
|
this.logHistory.shift();
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Debounce 액션 및 부분 업데이트 추적
|
|
// ============================================
|
|
|
|
/** debounce 액션 이력 */
|
|
private debounceActionHistory: Array<{
|
|
id: string;
|
|
handler: string;
|
|
type: string;
|
|
status: 'pending' | 'success' | 'error' | 'cancelled';
|
|
params?: Record<string, any>;
|
|
debounce?: {
|
|
delay: number;
|
|
status: 'pending' | 'executed' | 'cancelled';
|
|
scheduledAt?: number;
|
|
executedAt?: number;
|
|
mode?: 'leading' | 'trailing';
|
|
};
|
|
timestamp: number;
|
|
}> = [];
|
|
|
|
/** 데이터소스 부분 업데이트 이력 */
|
|
private dataSourceUpdateHistory: Array<{
|
|
id: string;
|
|
dataSourceId: string;
|
|
updateType: 'full' | 'partial';
|
|
itemPath?: string;
|
|
itemId?: string | number;
|
|
updates?: Record<string, any>;
|
|
timestamp: number;
|
|
}> = [];
|
|
|
|
private debounceActionIdCounter = 0;
|
|
private dataSourceUpdateIdCounter = 0;
|
|
|
|
/**
|
|
* debounce 액션 추적
|
|
*
|
|
* debounce 옵션이 있는 액션의 실행 상태를 추적합니다.
|
|
*/
|
|
trackAction(info: {
|
|
handler: string;
|
|
type: string;
|
|
status: 'pending' | 'success' | 'error' | 'cancelled';
|
|
params?: Record<string, any>;
|
|
debounce?: {
|
|
delay: number;
|
|
status: 'pending' | 'executed' | 'cancelled';
|
|
scheduledAt?: number;
|
|
executedAt?: number;
|
|
mode?: 'leading' | 'trailing';
|
|
};
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const entry = {
|
|
id: `debounce-action-${++this.debounceActionIdCounter}`,
|
|
...info,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.debounceActionHistory.push(entry);
|
|
|
|
// 이력 크기 제한 (최대 100개)
|
|
if (this.debounceActionHistory.length > 100) {
|
|
this.debounceActionHistory.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 부분 업데이트 추적
|
|
*
|
|
* G7Core.dataSource.updateItem() 호출을 추적합니다.
|
|
*/
|
|
trackDataSourceUpdate(info: {
|
|
dataSourceId: string;
|
|
updateType: 'full' | 'partial';
|
|
itemPath?: string;
|
|
itemId?: string | number;
|
|
updates?: Record<string, any>;
|
|
timestamp: number;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const entry = {
|
|
id: `ds-update-${++this.dataSourceUpdateIdCounter}`,
|
|
...info,
|
|
};
|
|
|
|
this.dataSourceUpdateHistory.push(entry);
|
|
|
|
// 이력 크기 제한 (최대 100개)
|
|
if (this.dataSourceUpdateHistory.length > 100) {
|
|
this.dataSourceUpdateHistory.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* debounce 액션 이력 조회
|
|
*/
|
|
getDebounceActionHistory(options?: {
|
|
handler?: string;
|
|
status?: 'pending' | 'success' | 'error' | 'cancelled';
|
|
limit?: number;
|
|
}): typeof this.debounceActionHistory {
|
|
let history = [...this.debounceActionHistory];
|
|
|
|
if (options?.handler) {
|
|
history = history.filter(h => h.handler.includes(options.handler!));
|
|
}
|
|
|
|
if (options?.status) {
|
|
history = history.filter(h => h.status === options.status);
|
|
}
|
|
|
|
if (options?.limit) {
|
|
history = history.slice(-options.limit);
|
|
}
|
|
|
|
return history;
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 업데이트 이력 조회
|
|
*/
|
|
getDataSourceUpdateHistory(options?: {
|
|
dataSourceId?: string;
|
|
updateType?: 'full' | 'partial';
|
|
limit?: number;
|
|
}): typeof this.dataSourceUpdateHistory {
|
|
let history = [...this.dataSourceUpdateHistory];
|
|
|
|
if (options?.dataSourceId) {
|
|
history = history.filter(h => h.dataSourceId === options.dataSourceId);
|
|
}
|
|
|
|
if (options?.updateType) {
|
|
history = history.filter(h => h.updateType === options.updateType);
|
|
}
|
|
|
|
if (options?.limit) {
|
|
history = history.slice(-options.limit);
|
|
}
|
|
|
|
return history;
|
|
}
|
|
|
|
/**
|
|
* 로그 이력 조회
|
|
*/
|
|
getLogs(options?: LogFilterOptions): LogEntry[] {
|
|
let logs = [...this.logHistory];
|
|
|
|
if (options?.level) {
|
|
const levels = Array.isArray(options.level)
|
|
? options.level
|
|
: [options.level];
|
|
logs = logs.filter(log => levels.includes(log.level));
|
|
}
|
|
|
|
if (options?.prefix) {
|
|
const prefixLower = options.prefix.toLowerCase();
|
|
logs = logs.filter(log =>
|
|
log.prefix.toLowerCase().includes(prefixLower)
|
|
);
|
|
}
|
|
|
|
if (options?.search) {
|
|
const searchLower = options.search.toLowerCase();
|
|
logs = logs.filter(log =>
|
|
log.message.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
if (options?.since) {
|
|
logs = logs.filter(log => log.timestamp >= options.since!);
|
|
}
|
|
|
|
if (options?.limit) {
|
|
logs = logs.slice(-options.limit);
|
|
}
|
|
|
|
return logs;
|
|
}
|
|
|
|
/**
|
|
* 로그 통계 조회
|
|
*/
|
|
getLogStats(): LogStats {
|
|
const now = Date.now();
|
|
const oneMinuteAgo = now - 60000;
|
|
|
|
const byLevel: Record<LogLevel, number> = {
|
|
log: 0,
|
|
warn: 0,
|
|
error: 0,
|
|
debug: 0,
|
|
info: 0,
|
|
};
|
|
|
|
const byPrefix: Record<string, number> = {};
|
|
let recentErrors = 0;
|
|
let recentWarnings = 0;
|
|
|
|
for (const entry of this.logHistory) {
|
|
// 레벨별 집계
|
|
byLevel[entry.level]++;
|
|
|
|
// Prefix별 집계
|
|
byPrefix[entry.prefix] = (byPrefix[entry.prefix] || 0) + 1;
|
|
|
|
// 최근 에러/경고 집계
|
|
if (entry.timestamp >= oneMinuteAgo) {
|
|
if (entry.level === 'error') recentErrors++;
|
|
if (entry.level === 'warn') recentWarnings++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalLogs: this.logHistory.length,
|
|
byLevel,
|
|
byPrefix,
|
|
recentErrors,
|
|
recentWarnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 로그 이력 초기화
|
|
*/
|
|
clearLogs(): void {
|
|
this.logHistory = [];
|
|
this.logIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 로그 정보 반환 (상태 덤프용)
|
|
*
|
|
* entries와 stats가 동일한 logHistory 전체를 기반으로 계산됩니다.
|
|
* limit을 제거하여 stats와 entries 간 불일치를 방지합니다.
|
|
*/
|
|
getLogInfo(): LogInfo {
|
|
return {
|
|
entries: this.getLogs(),
|
|
stats: this.getLogStats(),
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 데이터소스 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 데이터소스 등록
|
|
*
|
|
* DataSourceManager에서 데이터소스 정의 시 호출됩니다.
|
|
*/
|
|
trackDataSourceDefinition(info: Omit<DataSourceInfo, 'status' | 'lastLoadedAt'>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.dataSources.set(info.id, {
|
|
...info,
|
|
status: 'idle',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 로딩 시작
|
|
*/
|
|
trackDataSourceLoading(id: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const existing = this.dataSources.get(id);
|
|
if (existing) {
|
|
this.dataSources.set(id, { ...existing, status: 'loading' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 로드 완료
|
|
*/
|
|
trackDataSourceLoaded(id: string, data: any, dataPath?: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const existing = this.dataSources.get(id);
|
|
if (!existing) return;
|
|
|
|
// 데이터 구조 분석
|
|
let itemCount: number | undefined;
|
|
let keys: string[] | undefined;
|
|
let resolvedDataPath = dataPath;
|
|
|
|
// 데이터 경로 자동 감지
|
|
if (data) {
|
|
if (Array.isArray(data)) {
|
|
itemCount = data.length;
|
|
keys = data.length > 0 && typeof data[0] === 'object' ? Object.keys(data[0]) : undefined;
|
|
} else if (typeof data === 'object') {
|
|
// 일반적인 API 응답 구조 감지 (data.data, data.items 등)
|
|
if (Array.isArray(data.data)) {
|
|
resolvedDataPath = resolvedDataPath || 'data';
|
|
itemCount = data.data.length;
|
|
keys = data.data.length > 0 && typeof data.data[0] === 'object' ? Object.keys(data.data[0]) : undefined;
|
|
} else if (Array.isArray(data.items)) {
|
|
resolvedDataPath = resolvedDataPath || 'items';
|
|
itemCount = data.items.length;
|
|
keys = data.items.length > 0 && typeof data.items[0] === 'object' ? Object.keys(data.items[0]) : undefined;
|
|
} else {
|
|
keys = Object.keys(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.dataSources.set(id, {
|
|
...existing,
|
|
status: 'loaded',
|
|
dataPath: resolvedDataPath,
|
|
itemCount,
|
|
keys,
|
|
lastLoadedAt: Date.now(),
|
|
error: undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 로드 실패
|
|
*/
|
|
trackDataSourceError(id: string, error: string): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const existing = this.dataSources.get(id);
|
|
if (existing) {
|
|
this.dataSources.set(id, {
|
|
...existing,
|
|
status: 'error',
|
|
error,
|
|
lastLoadedAt: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 제거
|
|
*/
|
|
untrackDataSource(id: string): void {
|
|
this.dataSources.delete(id);
|
|
}
|
|
|
|
/**
|
|
* 모든 데이터소스 정보 조회
|
|
*/
|
|
getDataSources(): DataSourceInfo[] {
|
|
return Array.from(this.dataSources.values());
|
|
}
|
|
|
|
/**
|
|
* 특정 데이터소스 정보 조회
|
|
*/
|
|
getDataSource(id: string): DataSourceInfo | undefined {
|
|
return this.dataSources.get(id);
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 초기화
|
|
*/
|
|
clearDataSources(): void {
|
|
this.dataSources.clear();
|
|
}
|
|
|
|
// ============================================
|
|
// 데이터 경로 변환 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 데이터 경로 변환 추적
|
|
*
|
|
* API 응답 → actualData → initGlobal/initLocal 과정을 추적합니다.
|
|
*/
|
|
trackDataPathTransform(info: {
|
|
dataSourceId: string;
|
|
step: import('./types').DataPathTransformStep;
|
|
inputPath: string;
|
|
inputValue: any;
|
|
outputPath: string;
|
|
outputValue: any;
|
|
config?: {
|
|
path?: string;
|
|
initGlobal?: any;
|
|
initLocal?: any;
|
|
};
|
|
warning?: string;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 기존 변환 정보 찾기 또는 새로 생성
|
|
let transform = this.dataPathTransforms.find(t => t.dataSourceId === info.dataSourceId);
|
|
|
|
if (!transform) {
|
|
transform = {
|
|
dataSourceId: info.dataSourceId,
|
|
timestamp: Date.now(),
|
|
transformSteps: [],
|
|
warnings: [],
|
|
};
|
|
this.dataPathTransforms.push(transform);
|
|
|
|
// 최대 크기 제한
|
|
if (this.dataPathTransforms.length > this.maxDataPathTransforms) {
|
|
this.dataPathTransforms.shift();
|
|
}
|
|
}
|
|
|
|
// 변환 단계 추가
|
|
transform.transformSteps.push({
|
|
step: info.step,
|
|
inputPath: info.inputPath,
|
|
inputValue: this.safeClone(info.inputValue),
|
|
outputPath: info.outputPath,
|
|
outputValue: this.safeClone(info.outputValue),
|
|
config: info.config,
|
|
});
|
|
|
|
// 경고 추가
|
|
if (info.warning) {
|
|
transform.warnings.push(info.warning);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터 경로 변환 최종 바인딩 설정
|
|
*/
|
|
setDataPathFinalBinding(dataSourceId: string, binding: {
|
|
expression: string;
|
|
resolvedPath: string;
|
|
value: any;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const transform = this.dataPathTransforms.find(t => t.dataSourceId === dataSourceId);
|
|
if (transform) {
|
|
transform.finalBinding = {
|
|
expression: binding.expression,
|
|
resolvedPath: binding.resolvedPath,
|
|
value: this.safeClone(binding.value),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터 경로 변환 추적 정보 반환
|
|
*/
|
|
getDataPathTransformTrackingInfo(): import('./types').DataPathTransformTrackingInfo {
|
|
const stats = this.getDataPathTransformStats();
|
|
|
|
return {
|
|
transforms: [...this.dataPathTransforms],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 데이터 경로 변환 통계 계산
|
|
*/
|
|
private getDataPathTransformStats(): import('./types').DataPathTransformStats {
|
|
const transforms = this.dataPathTransforms;
|
|
|
|
// 데이터소스별 개수
|
|
const byDataSource: Record<string, number> = {};
|
|
let warningsCount = 0;
|
|
const warningCounts: Record<string, number> = {};
|
|
|
|
for (const t of transforms) {
|
|
byDataSource[t.dataSourceId] = (byDataSource[t.dataSourceId] || 0) + 1;
|
|
warningsCount += t.warnings.length;
|
|
|
|
for (const w of t.warnings) {
|
|
warningCounts[w] = (warningCounts[w] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
// 자주 발생하는 경고
|
|
const commonWarnings = Object.entries(warningCounts)
|
|
.map(([warning, count]) => ({ warning, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 10);
|
|
|
|
return {
|
|
totalTransforms: transforms.length,
|
|
byDataSource,
|
|
warningsCount,
|
|
commonWarnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 특정 데이터소스의 변환 추적 정보 조회
|
|
*/
|
|
getDataPathTransformForDataSource(dataSourceId: string): import('./types').DataPathTransformInfo | undefined {
|
|
return this.dataPathTransforms.find(t => t.dataSourceId === dataSourceId);
|
|
}
|
|
|
|
/**
|
|
* 데이터 경로 변환 데이터 초기화
|
|
*/
|
|
clearDataPathTransforms(): void {
|
|
this.dataPathTransforms = [];
|
|
}
|
|
|
|
// ============================================
|
|
// Nested Context 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* Nested Context 추적
|
|
*
|
|
* expandChildren, cellChildren, iteration, modal 등 중첩 렌더링 컨텍스트를 추적합니다.
|
|
*/
|
|
trackNestedContext(info: {
|
|
componentId: string;
|
|
componentType: import('./types').NestedContextType;
|
|
parentContext: {
|
|
available: string[];
|
|
values: Record<string, any>;
|
|
};
|
|
ownContext: {
|
|
added: string[];
|
|
values: Record<string, any>;
|
|
};
|
|
depth: number;
|
|
parentId?: string;
|
|
}): string {
|
|
if (!this.config.enabled) return '';
|
|
|
|
const id = `nested-${++this.nestedContextIdCounter}`;
|
|
|
|
// 병합된 컨텍스트 계산
|
|
const mergedAll = [...new Set([...info.parentContext.available, ...info.ownContext.added])];
|
|
const mergedValues = { ...info.parentContext.values, ...info.ownContext.values };
|
|
|
|
const context: import('./types').NestedContextInfo = {
|
|
id,
|
|
timestamp: Date.now(),
|
|
componentId: info.componentId,
|
|
componentType: info.componentType,
|
|
parentContext: {
|
|
available: info.parentContext.available,
|
|
values: this.safeClone(info.parentContext.values),
|
|
},
|
|
ownContext: {
|
|
added: info.ownContext.added,
|
|
values: this.safeClone(info.ownContext.values),
|
|
},
|
|
mergedContext: {
|
|
all: mergedAll,
|
|
values: this.safeClone(mergedValues),
|
|
},
|
|
accessAttempts: [],
|
|
depth: info.depth,
|
|
parentId: info.parentId,
|
|
};
|
|
|
|
this.nestedContexts.push(context);
|
|
|
|
// 최대 크기 제한
|
|
if (this.nestedContexts.length > this.maxNestedContexts) {
|
|
this.nestedContexts.shift();
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Nested Context 접근 시도 기록
|
|
*/
|
|
trackNestedContextAccess(contextId: string, access: {
|
|
path: string;
|
|
found: boolean;
|
|
value?: any;
|
|
error?: string;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const context = this.nestedContexts.find(c => c.id === contextId);
|
|
if (context) {
|
|
context.accessAttempts.push({
|
|
path: access.path,
|
|
found: access.found,
|
|
value: access.found ? this.safeClone(access.value) : undefined,
|
|
error: access.error,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nested Context 추적 정보 반환
|
|
*/
|
|
getNestedContextTrackingInfo(): import('./types').NestedContextTrackingInfo {
|
|
const stats = this.getNestedContextStats();
|
|
|
|
return {
|
|
contexts: [...this.nestedContexts],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Nested Context 통계 계산
|
|
*/
|
|
private getNestedContextStats(): import('./types').NestedContextStats {
|
|
const contexts = this.nestedContexts;
|
|
|
|
// 타입별 개수
|
|
const byType: Record<import('./types').NestedContextType, number> = {
|
|
expandChildren: 0,
|
|
cellChildren: 0,
|
|
iteration: 0,
|
|
modal: 0,
|
|
slot: 0,
|
|
};
|
|
|
|
let maxDepth = 0;
|
|
let failedAccessCount = 0;
|
|
const failedPaths: Record<string, number> = {};
|
|
|
|
for (const c of contexts) {
|
|
byType[c.componentType]++;
|
|
if (c.depth > maxDepth) maxDepth = c.depth;
|
|
|
|
for (const attempt of c.accessAttempts) {
|
|
if (!attempt.found) {
|
|
failedAccessCount++;
|
|
failedPaths[attempt.path] = (failedPaths[attempt.path] || 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 자주 실패하는 경로
|
|
const commonFailedPaths = Object.entries(failedPaths)
|
|
.map(([path, count]) => ({ path, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 10);
|
|
|
|
return {
|
|
totalContexts: contexts.length,
|
|
byType,
|
|
maxDepth,
|
|
failedAccessCount,
|
|
commonFailedPaths,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 특정 컴포넌트의 Nested Context 조회
|
|
*/
|
|
getNestedContextForComponent(componentId: string): import('./types').NestedContextInfo | undefined {
|
|
return this.nestedContexts.find(c => c.componentId === componentId);
|
|
}
|
|
|
|
/**
|
|
* Nested Context 데이터 초기화
|
|
*/
|
|
clearNestedContexts(): void {
|
|
this.nestedContexts = [];
|
|
this.nestedContextIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 핸들러 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 핸들러 등록 추적
|
|
*
|
|
* @param name 핸들러 이름
|
|
* @param category 핸들러 카테고리
|
|
* @param description 핸들러 설명
|
|
* @param source 핸들러 소스 (모듈명 등)
|
|
*/
|
|
trackHandlerRegistration(
|
|
name: string,
|
|
category: HandlerInfo['category'] = 'custom',
|
|
description?: string,
|
|
source?: string
|
|
): void {
|
|
if (!this.isEnabled) return;
|
|
|
|
this.handlers.set(name, {
|
|
name,
|
|
category,
|
|
description,
|
|
registeredAt: Date.now(),
|
|
source,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 핸들러 해제 추적
|
|
*
|
|
* @param name 핸들러 이름
|
|
*/
|
|
trackHandlerUnregistration(name: string): void {
|
|
if (!this.isEnabled) return;
|
|
this.handlers.delete(name);
|
|
}
|
|
|
|
/**
|
|
* 모든 핸들러 정보 조회
|
|
*/
|
|
getHandlers(): HandlerInfo[] {
|
|
return Array.from(this.handlers.values());
|
|
}
|
|
|
|
/**
|
|
* 특정 핸들러 정보 조회
|
|
*/
|
|
getHandler(name: string): HandlerInfo | undefined {
|
|
return this.handlers.get(name);
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 핸들러 조회
|
|
*/
|
|
getHandlersByCategory(category: HandlerInfo['category']): HandlerInfo[] {
|
|
return Array.from(this.handlers.values()).filter(h => h.category === category);
|
|
}
|
|
|
|
/**
|
|
* 핸들러 초기화
|
|
*/
|
|
clearHandlers(): void {
|
|
this.handlers.clear();
|
|
}
|
|
|
|
// ============================================
|
|
// 컴포넌트 이벤트 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 이벤트 구독 추적
|
|
*
|
|
* @param eventName 이벤트 이름
|
|
*/
|
|
trackEventSubscribe(eventName: string): void {
|
|
if (!this.isEnabled) return;
|
|
|
|
const existing = this.componentEventSubscriptions.get(eventName);
|
|
const now = Date.now();
|
|
|
|
if (existing) {
|
|
existing.subscriberCount++;
|
|
existing.lastSubscribedAt = now;
|
|
} else {
|
|
this.componentEventSubscriptions.set(eventName, {
|
|
eventName,
|
|
subscriberCount: 1,
|
|
firstSubscribedAt: now,
|
|
lastSubscribedAt: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이벤트 구독 해제 추적
|
|
*
|
|
* @param eventName 이벤트 이름
|
|
*/
|
|
trackEventUnsubscribe(eventName: string): void {
|
|
if (!this.isEnabled) return;
|
|
|
|
const existing = this.componentEventSubscriptions.get(eventName);
|
|
if (existing) {
|
|
existing.subscriberCount--;
|
|
if (existing.subscriberCount <= 0) {
|
|
this.componentEventSubscriptions.delete(eventName);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 이벤트 발생 추적
|
|
*
|
|
* @param eventName 이벤트 이름
|
|
* @param data 전달 데이터
|
|
* @param listenerCount 리스너 수
|
|
* @param results 리스너 결과
|
|
* @param error 에러 (있는 경우)
|
|
*/
|
|
trackEventEmit(
|
|
eventName: string,
|
|
data: any,
|
|
listenerCount: number,
|
|
results?: any[],
|
|
error?: Error
|
|
): void {
|
|
if (!this.isEnabled) return;
|
|
|
|
const log: ComponentEventEmitLog = {
|
|
id: `evt_${++this.componentEventIdCounter}`,
|
|
eventName,
|
|
data: this.sanitizeForLogging(data),
|
|
timestamp: Date.now(),
|
|
listenerCount,
|
|
results: results ? this.sanitizeForLogging(results) : undefined,
|
|
hasError: !!error,
|
|
errorMessage: error?.message,
|
|
};
|
|
|
|
this.componentEventEmitHistory.push(log);
|
|
|
|
// 최대 이력 개수 제한
|
|
if (this.componentEventEmitHistory.length > this.maxComponentEventHistory) {
|
|
this.componentEventEmitHistory = this.componentEventEmitHistory.slice(-this.maxComponentEventHistory);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 이벤트의 모든 리스너 제거 추적
|
|
*
|
|
* @param eventName 이벤트 이름
|
|
*/
|
|
trackEventOff(eventName: string): void {
|
|
if (!this.isEnabled) return;
|
|
this.componentEventSubscriptions.delete(eventName);
|
|
}
|
|
|
|
/**
|
|
* 모든 이벤트 리스너 제거 추적
|
|
*/
|
|
trackEventClear(): void {
|
|
if (!this.isEnabled) return;
|
|
this.componentEventSubscriptions.clear();
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 이벤트 정보 조회
|
|
*/
|
|
getComponentEventInfo(): ComponentEventInfo {
|
|
const subscriptions = Array.from(this.componentEventSubscriptions.values());
|
|
return {
|
|
subscriptions,
|
|
emitHistory: [...this.componentEventEmitHistory],
|
|
totalSubscribers: subscriptions.reduce((sum, s) => sum + s.subscriberCount, 0),
|
|
totalEmits: this.componentEventEmitHistory.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이벤트 emit 이력 조회
|
|
*/
|
|
getEventEmitHistory(): ComponentEventEmitLog[] {
|
|
return [...this.componentEventEmitHistory];
|
|
}
|
|
|
|
/**
|
|
* 이벤트 구독 목록 조회
|
|
*/
|
|
getEventSubscriptions(): ComponentEventSubscription[] {
|
|
return Array.from(this.componentEventSubscriptions.values());
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 이벤트 초기화
|
|
*/
|
|
clearComponentEvents(): void {
|
|
this.componentEventSubscriptions.clear();
|
|
this.componentEventEmitHistory = [];
|
|
this.componentEventIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 로깅용 데이터 정제 (순환 참조 제거)
|
|
*/
|
|
private sanitizeForLogging(data: any): any {
|
|
if (data === null || data === undefined) return data;
|
|
if (typeof data !== 'object') return data;
|
|
|
|
try {
|
|
return JSON.parse(JSON.stringify(data));
|
|
} catch {
|
|
return '[Circular or Non-serializable]';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 상태-렌더링 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 상태 변경 시작 추적
|
|
*
|
|
* setState 호출 시 ActionDispatcher에서 호출됩니다.
|
|
*
|
|
* @param statePath 변경된 상태 경로
|
|
* @param oldValue 이전 값
|
|
* @param newValue 새 값
|
|
* @param trigger 트리거 정보 (액션 ID, 핸들러 타입 등)
|
|
* @returns setState ID (렌더링 추적에 사용)
|
|
*/
|
|
startStateChange(
|
|
statePath: string,
|
|
oldValue: any,
|
|
newValue: any,
|
|
trigger?: { actionId?: string; handlerType?: string; source?: string }
|
|
): string {
|
|
if (!this.config.enabled) return '';
|
|
|
|
const setStateId = `setState_${++this.stateRenderingIdCounter}`;
|
|
|
|
this.currentStateChangeContext = {
|
|
setStateId,
|
|
startTime: performance.now(),
|
|
changedPath: statePath,
|
|
trigger: trigger || {},
|
|
renderedComponents: [],
|
|
};
|
|
|
|
// 상태 변경 로그 생성 (렌더링 완료 시 업데이트됨)
|
|
const log: StateRenderingLog = {
|
|
id: `sr_${this.stateRenderingIdCounter}`,
|
|
setStateId,
|
|
statePath,
|
|
oldValue: this.sanitizeForLogging(oldValue),
|
|
newValue: this.sanitizeForLogging(newValue),
|
|
timestamp: Date.now(),
|
|
triggeredBy: trigger || {},
|
|
renderedComponents: [],
|
|
totalRenderDuration: 0,
|
|
affectedBindingsCount: 0,
|
|
};
|
|
|
|
this.stateRenderingLogs.push(log);
|
|
|
|
// 로그 크기 제한
|
|
if (this.stateRenderingLogs.length > this.maxStateRenderingLogs) {
|
|
this.stateRenderingLogs.shift();
|
|
}
|
|
|
|
return setStateId;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 렌더링 추적 (상태 변경 컨텍스트 내에서)
|
|
*
|
|
* 상태 변경으로 인해 컴포넌트가 렌더링될 때 호출됩니다.
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param componentName 컴포넌트 이름
|
|
* @param renderDuration 렌더링 소요 시간
|
|
* @param accessedStatePaths 접근한 상태 경로들
|
|
* @param evaluatedBindings 평가된 바인딩 표현식들
|
|
* @param parentId 부모 컴포넌트 ID
|
|
*/
|
|
trackComponentRender(
|
|
componentId: string,
|
|
componentName: string,
|
|
renderDuration: number,
|
|
accessedStatePaths: string[] = [],
|
|
evaluatedBindings: string[] = [],
|
|
parentId?: string
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 컴포넌트별 렌더링 횟수 증가
|
|
const count = this.componentRenderCounts.get(componentName) || 0;
|
|
this.componentRenderCounts.set(componentName, count + 1);
|
|
|
|
// 상태 경로 → 컴포넌트 매핑 업데이트
|
|
for (const path of accessedStatePaths) {
|
|
if (!this.stateToComponentMap.has(path)) {
|
|
this.stateToComponentMap.set(path, new Set());
|
|
}
|
|
this.stateToComponentMap.get(path)!.add(componentName);
|
|
}
|
|
|
|
// 현재 상태 변경 컨텍스트가 있으면 렌더링 정보 추가
|
|
if (this.currentStateChangeContext) {
|
|
const renderInfo: RenderedComponentInfo = {
|
|
componentId,
|
|
componentName,
|
|
renderDuration,
|
|
accessedStatePaths,
|
|
evaluatedBindings,
|
|
renderOrder: this.currentStateChangeContext.renderedComponents.length,
|
|
parentId,
|
|
};
|
|
|
|
this.currentStateChangeContext.renderedComponents.push(renderInfo);
|
|
}
|
|
|
|
// 기본 렌더링 추적도 유지
|
|
this.trackRender(componentName);
|
|
}
|
|
|
|
/**
|
|
* 상태 변경 완료 추적
|
|
*
|
|
* 상태 변경으로 인한 모든 렌더링이 완료된 후 호출됩니다.
|
|
*
|
|
* @param setStateId 시작 시 반환받은 setState ID
|
|
*/
|
|
completeStateChange(setStateId: string): void {
|
|
if (!this.config.enabled || !this.currentStateChangeContext) return;
|
|
if (this.currentStateChangeContext.setStateId !== setStateId) return;
|
|
|
|
const context = this.currentStateChangeContext;
|
|
const endTime = performance.now();
|
|
const totalDuration = endTime - context.startTime;
|
|
|
|
// 해당 로그 찾아서 업데이트
|
|
const logIndex = this.stateRenderingLogs.findIndex(
|
|
log => log.setStateId === setStateId
|
|
);
|
|
|
|
if (logIndex >= 0) {
|
|
const log = this.stateRenderingLogs[logIndex];
|
|
log.renderedComponents = context.renderedComponents;
|
|
log.totalRenderDuration = totalDuration;
|
|
log.affectedBindingsCount = context.renderedComponents.reduce(
|
|
(sum, c) => sum + c.evaluatedBindings.length,
|
|
0
|
|
);
|
|
}
|
|
|
|
// 컨텍스트 초기화
|
|
this.currentStateChangeContext = null;
|
|
}
|
|
|
|
/**
|
|
* 현재 상태 변경 컨텍스트 조회
|
|
*/
|
|
getCurrentStateChangeContext(): StateChangeContext | null {
|
|
return this.currentStateChangeContext;
|
|
}
|
|
|
|
/**
|
|
* 상태-렌더링 로그 조회
|
|
*/
|
|
getStateRenderingLogs(): StateRenderingLog[] {
|
|
return [...this.stateRenderingLogs];
|
|
}
|
|
|
|
/**
|
|
* 상태-렌더링 정보 전체 조회
|
|
*/
|
|
getStateRenderingInfo(): StateRenderingInfo {
|
|
const logs = this.stateRenderingLogs;
|
|
const componentCounts = Object.fromEntries(this.componentRenderCounts);
|
|
const stateToComponent: Record<string, string[]> = {};
|
|
|
|
for (const [path, components] of this.stateToComponentMap) {
|
|
stateToComponent[path] = Array.from(components);
|
|
}
|
|
|
|
return {
|
|
logs,
|
|
componentRenderCounts: componentCounts,
|
|
stateToComponentMap: stateToComponent,
|
|
stats: this.calculateStateRenderingStats(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 상태-렌더링 통계 계산
|
|
*/
|
|
private calculateStateRenderingStats(): StateRenderingStats {
|
|
const logs = this.stateRenderingLogs;
|
|
|
|
if (logs.length === 0) {
|
|
return {
|
|
totalStateChanges: 0,
|
|
totalRenders: 0,
|
|
avgRenderDuration: 0,
|
|
avgComponentsPerChange: 0,
|
|
topRenderedComponents: [],
|
|
topInfluentialPaths: [],
|
|
};
|
|
}
|
|
|
|
// 총 렌더링 수
|
|
const totalRenders = logs.reduce(
|
|
(sum, log) => sum + log.renderedComponents.length,
|
|
0
|
|
);
|
|
|
|
// 평균 렌더링 시간
|
|
const avgRenderDuration =
|
|
logs.reduce((sum, log) => sum + log.totalRenderDuration, 0) / logs.length;
|
|
|
|
// 상태 변경당 평균 컴포넌트 수
|
|
const avgComponentsPerChange = totalRenders / logs.length;
|
|
|
|
// 가장 많이 렌더링된 컴포넌트 TOP 5
|
|
const sortedComponents = Array.from(this.componentRenderCounts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([name, count]) => ({ name, count }));
|
|
|
|
// 가장 영향력 있는 상태 경로 TOP 5
|
|
const sortedPaths = Array.from(this.stateToComponentMap.entries())
|
|
.sort((a, b) => b[1].size - a[1].size)
|
|
.slice(0, 5)
|
|
.map(([path, components]) => ({
|
|
path,
|
|
affectedComponents: components.size,
|
|
}));
|
|
|
|
return {
|
|
totalStateChanges: logs.length,
|
|
totalRenders,
|
|
avgRenderDuration,
|
|
avgComponentsPerChange,
|
|
topRenderedComponents: sortedComponents,
|
|
topInfluentialPaths: sortedPaths,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 특정 상태 경로에 영향받는 컴포넌트 조회
|
|
*/
|
|
getComponentsAffectedByState(statePath: string): string[] {
|
|
const components = this.stateToComponentMap.get(statePath);
|
|
return components ? Array.from(components) : [];
|
|
}
|
|
|
|
/**
|
|
* 특정 컴포넌트의 렌더링 이력 조회
|
|
*/
|
|
getComponentRenderHistory(componentName: string): StateRenderingLog[] {
|
|
return this.stateRenderingLogs.filter(log =>
|
|
log.renderedComponents.some(c => c.componentName === componentName)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 상태-렌더링 추적 데이터 초기화
|
|
*/
|
|
clearStateRenderingLogs(): void {
|
|
this.stateRenderingLogs = [];
|
|
this.componentRenderCounts.clear();
|
|
this.stateToComponentMap.clear();
|
|
this.currentStateChangeContext = null;
|
|
this.stateRenderingIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 상태 계층 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 컴포넌트의 상태 소스 추적
|
|
*
|
|
* 컴포넌트가 어떤 상태 소스에서 데이터를 읽는지 추적합니다.
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param componentName 컴포넌트 이름
|
|
* @param stateSource 상태 소스 정보
|
|
* @param stateProvider 상태 제공자 정보
|
|
*/
|
|
trackComponentStateSource(
|
|
componentId: string,
|
|
componentName: string,
|
|
stateSource: {
|
|
global: string[];
|
|
local: string[];
|
|
context: string[];
|
|
},
|
|
stateProvider: {
|
|
type: 'globalState' | 'dynamicState' | 'componentContext';
|
|
hasComponentContext: boolean;
|
|
parentId?: string;
|
|
}
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.componentStateSources.set(componentId, {
|
|
componentId,
|
|
componentName,
|
|
stateSource,
|
|
stateProvider,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* dynamicState 업데이트 추적
|
|
*
|
|
* 컴포넌트의 dynamicState가 업데이트될 때 호출됩니다.
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param dynamicState 동적 상태 값
|
|
*/
|
|
trackDynamicState(componentId: string, dynamicState: Record<string, any>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
this.dynamicStates.set(componentId, dynamicState);
|
|
}
|
|
|
|
/**
|
|
* componentContext 흐름 추적
|
|
*
|
|
* 컴포넌트가 context를 받고 전달하는 과정을 추적합니다.
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param componentName 컴포넌트 이름
|
|
* @param contextReceived context를 받았는지
|
|
* @param passedToChildren 자식에게 전달했는지
|
|
* @param usedInRender 렌더링에 사용했는지
|
|
* @param parentId 부모 컴포넌트 ID
|
|
*/
|
|
trackContextFlow(
|
|
componentId: string,
|
|
componentName: string,
|
|
contextReceived: boolean,
|
|
passedToChildren: boolean,
|
|
usedInRender: boolean,
|
|
parentId?: string
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const node: ContextFlowNode = {
|
|
component: componentName,
|
|
componentId,
|
|
contextReceived,
|
|
passedToChildren,
|
|
usedInRender,
|
|
};
|
|
|
|
this.contextFlowNodes.set(componentId, node);
|
|
|
|
// 부모 노드가 있으면 자식으로 연결
|
|
if (parentId) {
|
|
const parentNode = this.contextFlowNodes.get(parentId);
|
|
if (parentNode) {
|
|
if (!parentNode.children) {
|
|
parentNode.children = [];
|
|
}
|
|
parentNode.children.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상태 계층 정보 조회
|
|
*
|
|
* 현재 상태 계층, 충돌 감지, 컴포넌트별 상태 소스 정보를 반환합니다.
|
|
* 덤프 시에는 간소화된 버전을 사용하여 파일 크기를 줄입니다.
|
|
*/
|
|
getStateHierarchyInfo(summarize = true): StateHierarchyInfo {
|
|
const layers = this.buildStateHierarchyLayers();
|
|
const conflicts = this.detectStateConflicts(layers);
|
|
const componentStateSources = Array.from(this.componentStateSources.values());
|
|
|
|
// 덤프 시 간소화: 큰 values 객체를 요약
|
|
const summarizedLayers = summarize
|
|
? layers.map(layer => ({
|
|
...layer,
|
|
values: this.summarizeValues(layer.values, 2),
|
|
}))
|
|
: layers;
|
|
|
|
return {
|
|
layers: summarizedLayers,
|
|
conflicts,
|
|
componentStateSources,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 값을 요약하여 덤프 크기 감소
|
|
*
|
|
* - 배열: 처음 3개 항목 + 길이 정보
|
|
* - 객체: 지정된 깊이까지만 전개
|
|
* - 문자열: 100자 제한
|
|
*/
|
|
private summarizeValues(obj: any, maxDepth: number, currentDepth = 0): any {
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
// 기본 타입
|
|
if (typeof obj !== 'object') {
|
|
if (typeof obj === 'string' && obj.length > 100) {
|
|
return obj.substring(0, 100) + `... (${obj.length}자)`;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
// 깊이 제한 도달
|
|
if (currentDepth >= maxDepth) {
|
|
if (Array.isArray(obj)) {
|
|
return `[Array(${obj.length})]`;
|
|
}
|
|
const keys = Object.keys(obj);
|
|
return `{${keys.slice(0, 5).join(', ')}${keys.length > 5 ? `, ... +${keys.length - 5}` : ''}}`;
|
|
}
|
|
|
|
// 배열 처리
|
|
if (Array.isArray(obj)) {
|
|
if (obj.length <= 3) {
|
|
return obj.map(item => this.summarizeValues(item, maxDepth, currentDepth + 1));
|
|
}
|
|
return {
|
|
__type: 'array',
|
|
__length: obj.length,
|
|
__preview: obj.slice(0, 3).map(item => this.summarizeValues(item, maxDepth, currentDepth + 1)),
|
|
};
|
|
}
|
|
|
|
// 객체 처리
|
|
const result: Record<string, any> = {};
|
|
const keys = Object.keys(obj);
|
|
|
|
for (const key of keys) {
|
|
result[key] = this.summarizeValues(obj[key], maxDepth, currentDepth + 1);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 상태 계층 레이어 빌드
|
|
*/
|
|
private buildStateHierarchyLayers(): StateHierarchyLayer[] {
|
|
const layers: StateHierarchyLayer[] = [];
|
|
|
|
try {
|
|
const g7Core = (window as any).G7Core;
|
|
const state = g7Core?.state?.get?.();
|
|
|
|
if (!state) return layers;
|
|
|
|
// 1. Global _local 레이어
|
|
if (state._local) {
|
|
layers.push({
|
|
name: 'Global _local',
|
|
type: 'global',
|
|
path: '_local',
|
|
values: state._local,
|
|
priority: 1,
|
|
});
|
|
}
|
|
|
|
// 2. Global _global 레이어
|
|
if (state._global) {
|
|
layers.push({
|
|
name: 'Global _global',
|
|
type: 'global',
|
|
path: '_global',
|
|
values: state._global,
|
|
priority: 1,
|
|
});
|
|
}
|
|
|
|
// 3. DynamicState 레이어들 (우선순위 높음)
|
|
for (const [componentId, dynamicState] of this.dynamicStates) {
|
|
if (dynamicState && Object.keys(dynamicState).length > 0) {
|
|
layers.push({
|
|
name: `DynamicState (${componentId})`,
|
|
type: 'dynamicState',
|
|
componentId,
|
|
values: dynamicState,
|
|
priority: 2,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 4. Effective (병합된) 상태 계산
|
|
const effectiveLocal = this.computeEffectiveState(state._local || {});
|
|
layers.push({
|
|
name: 'Effective _local (merged)',
|
|
type: 'effective',
|
|
values: effectiveLocal,
|
|
priority: 3,
|
|
});
|
|
|
|
} catch (error) {
|
|
console.warn('[G7DevTools] 상태 계층 빌드 실패:', error);
|
|
}
|
|
|
|
return layers;
|
|
}
|
|
|
|
/**
|
|
* 실제 적용되는 상태 계산
|
|
*
|
|
* global _local과 dynamicState를 병합한 실제 상태를 계산합니다.
|
|
*/
|
|
private computeEffectiveState(globalLocal: Record<string, any>): Record<string, any> {
|
|
const effective = { ...globalLocal };
|
|
|
|
// 모든 dynamicState를 병합 (우선순위 높음)
|
|
for (const [, dynamicState] of this.dynamicStates) {
|
|
if (dynamicState._local) {
|
|
Object.assign(effective, dynamicState._local);
|
|
}
|
|
}
|
|
|
|
return effective;
|
|
}
|
|
|
|
/**
|
|
* 상태 충돌 감지
|
|
*
|
|
* 여러 레이어에서 동일한 상태 경로가 다른 값을 가지는 경우를 감지합니다.
|
|
*/
|
|
private detectStateConflicts(layers: StateHierarchyLayer[]): StateConflict[] {
|
|
const conflicts: StateConflict[] = [];
|
|
|
|
// Global _local 레이어 찾기
|
|
const globalLocalLayer = layers.find(l => l.type === 'global' && l.path === '_local');
|
|
if (!globalLocalLayer) return conflicts;
|
|
|
|
// 각 dynamicState와 비교
|
|
const dynamicLayers = layers.filter(l => l.type === 'dynamicState');
|
|
|
|
for (const dynamicLayer of dynamicLayers) {
|
|
const dynamicLocal = dynamicLayer.values._local || dynamicLayer.values;
|
|
|
|
for (const [key, dynamicValue] of Object.entries(dynamicLocal)) {
|
|
const globalValue = globalLocalLayer.values[key];
|
|
|
|
// 값이 다르면 충돌
|
|
if (JSON.stringify(globalValue) !== JSON.stringify(dynamicValue)) {
|
|
// 이 상태를 사용하는/못하는 컴포넌트 찾기
|
|
const usedBy: string[] = [];
|
|
const notUsedBy: string[] = [];
|
|
|
|
for (const source of this.componentStateSources.values()) {
|
|
const usesLocal = source.stateSource.local.some(
|
|
p => p === `_local.${key}` || p === key
|
|
);
|
|
|
|
if (usesLocal) {
|
|
if (source.stateProvider.type === 'dynamicState') {
|
|
usedBy.push(source.componentName);
|
|
} else if (source.stateProvider.type === 'globalState') {
|
|
notUsedBy.push(source.componentName);
|
|
}
|
|
}
|
|
}
|
|
|
|
conflicts.push({
|
|
path: `_local.${key}`,
|
|
globalValue,
|
|
dynamicStateValue: dynamicValue,
|
|
effectiveValue: dynamicValue, // dynamicState가 우선
|
|
usedBy,
|
|
notUsedBy,
|
|
severity: notUsedBy.length > 0 ? 'warning' : 'info',
|
|
description: notUsedBy.length > 0
|
|
? `${notUsedBy.join(', ')}은(는) globalState._local을 읽어 dynamicState 값을 사용하지 못함`
|
|
: 'dynamicState 값이 globalState와 다르지만 모든 컴포넌트가 dynamicState를 사용',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/**
|
|
* componentContext 흐름 정보 조회
|
|
*/
|
|
getContextFlowInfo(): ContextFlowInfo {
|
|
// 루트 노드 찾기 (부모가 없는 노드)
|
|
const rootNodes: ContextFlowNode[] = [];
|
|
const allIds = new Set(this.contextFlowNodes.keys());
|
|
|
|
for (const node of this.contextFlowNodes.values()) {
|
|
if (node.children) {
|
|
for (const child of node.children) {
|
|
allIds.delete(child.componentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 남은 ID가 루트 노드
|
|
for (const id of allIds) {
|
|
const node = this.contextFlowNodes.get(id);
|
|
if (node) {
|
|
rootNodes.push(node);
|
|
}
|
|
}
|
|
|
|
return {
|
|
rootComponent: rootNodes.length > 0 ? rootNodes[0].component : 'Unknown',
|
|
contextFlow: rootNodes,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 상태 계층 추적 데이터 초기화
|
|
*/
|
|
clearStateHierarchyData(): void {
|
|
this.componentStateSources.clear();
|
|
this.contextFlowNodes.clear();
|
|
this.dynamicStates.clear();
|
|
}
|
|
|
|
// ============================================
|
|
// CSS 스타일 검증 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 컴포넌트 스타일 정보 추적
|
|
*/
|
|
trackComponentStyle(
|
|
componentId: string,
|
|
componentName: string,
|
|
classes: string[],
|
|
computedStyles: ComponentStyleInfo['computedStyles']
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// Tailwind 클래스 분석
|
|
const tailwindAnalysis = this.analyzeTailwindClasses(classes);
|
|
|
|
const styleInfo: ComponentStyleInfo = {
|
|
componentId,
|
|
componentName,
|
|
classes,
|
|
computedStyles,
|
|
tailwindAnalysis,
|
|
};
|
|
|
|
this.componentStyles.set(componentId, styleInfo);
|
|
|
|
// 스타일 이슈 감지
|
|
this.detectStyleIssues(styleInfo);
|
|
}
|
|
|
|
/**
|
|
* Tailwind 클래스 분석
|
|
*/
|
|
private analyzeTailwindClasses(classes: string[]): ComponentStyleInfo['tailwindAnalysis'] {
|
|
const darkClasses: string[] = [];
|
|
const responsiveClasses: string[] = [];
|
|
const dynamicClasses: string[] = [];
|
|
const usedClasses: string[] = [];
|
|
|
|
const responsivePrefixes = ['sm:', 'md:', 'lg:', 'xl:', '2xl:'];
|
|
const dynamicPatterns = [/bg-\w+-\d+/, /text-\w+-\d+/, /border-\w+-\d+/];
|
|
|
|
for (const cls of classes) {
|
|
usedClasses.push(cls);
|
|
|
|
if (cls.startsWith('dark:')) {
|
|
darkClasses.push(cls);
|
|
}
|
|
|
|
for (const prefix of responsivePrefixes) {
|
|
if (cls.startsWith(prefix)) {
|
|
responsiveClasses.push(cls);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 동적 클래스 감지 (숫자로 끝나는 컬러 클래스)
|
|
for (const pattern of dynamicPatterns) {
|
|
if (pattern.test(cls)) {
|
|
dynamicClasses.push(cls);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
usedClasses,
|
|
darkClasses,
|
|
responsiveClasses,
|
|
dynamicClasses,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 스타일 이슈 감지
|
|
*/
|
|
private detectStyleIssues(styleInfo: ComponentStyleInfo): void {
|
|
const { componentId, componentName, computedStyles, tailwindAnalysis } = styleInfo;
|
|
|
|
// 1. 보이지 않는 요소 감지
|
|
if (
|
|
computedStyles.opacity === '0' ||
|
|
computedStyles.visibility === 'hidden' ||
|
|
computedStyles.display === 'none' ||
|
|
(computedStyles.width === '0px' && computedStyles.height === '0px')
|
|
) {
|
|
this.addStyleIssue({
|
|
id: this.generateId(),
|
|
type: 'invisible-element',
|
|
componentId,
|
|
componentName,
|
|
property: computedStyles.opacity === '0' ? 'opacity' :
|
|
computedStyles.visibility === 'hidden' ? 'visibility' :
|
|
computedStyles.display === 'none' ? 'display' : 'width/height',
|
|
currentValue: computedStyles.opacity === '0' ? '0' :
|
|
computedStyles.visibility === 'hidden' ? 'hidden' :
|
|
computedStyles.display === 'none' ? 'none' : '0px',
|
|
severity: 'warning',
|
|
description: '요소가 화면에 보이지 않습니다.',
|
|
suggestion: 'opacity, visibility, display, width/height 값을 확인하세요.',
|
|
});
|
|
}
|
|
|
|
// 2. 다크 모드 클래스 누락 감지 (배경/텍스트 컬러에 대해)
|
|
const bgClasses = tailwindAnalysis.usedClasses.filter(c => c.startsWith('bg-') && !c.startsWith('dark:'));
|
|
const darkBgClasses = tailwindAnalysis.darkClasses.filter(c => c.includes('bg-'));
|
|
|
|
if (bgClasses.length > 0 && darkBgClasses.length === 0) {
|
|
this.addStyleIssue({
|
|
id: this.generateId(),
|
|
type: 'dark-mode-missing',
|
|
componentId,
|
|
componentName,
|
|
property: 'background-color',
|
|
currentValue: bgClasses.join(', '),
|
|
expectedValue: 'dark:bg-* 클래스 필요',
|
|
severity: 'info',
|
|
description: '다크 모드 배경색 클래스가 누락되었습니다.',
|
|
suggestion: `${bgClasses[0]}에 대응하는 dark: 클래스를 추가하세요.`,
|
|
});
|
|
}
|
|
|
|
// 3. 동적 클래스 경고 (Tailwind purging 가능성)
|
|
if (tailwindAnalysis.dynamicClasses.length > 0) {
|
|
this.addStyleIssue({
|
|
id: this.generateId(),
|
|
type: 'tailwind-purging',
|
|
componentId,
|
|
componentName,
|
|
property: 'class',
|
|
currentValue: tailwindAnalysis.dynamicClasses.join(', '),
|
|
severity: 'info',
|
|
description: '동적으로 생성된 Tailwind 클래스가 있습니다.',
|
|
suggestion: 'tailwind.config.js의 safelist에 추가하거나 전체 클래스명을 상수로 정의하세요.',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스타일 이슈 추가
|
|
*/
|
|
private addStyleIssue(issue: StyleIssue): void {
|
|
// 중복 방지
|
|
const exists = this.styleIssues.some(
|
|
i => i.componentId === issue.componentId &&
|
|
i.type === issue.type &&
|
|
i.property === issue.property
|
|
);
|
|
|
|
if (!exists) {
|
|
this.styleIssues.push(issue);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스타일 검증 정보 반환
|
|
*/
|
|
getStyleValidationInfo(): StyleValidationInfo {
|
|
const componentStylesArray = Array.from(this.componentStyles.values());
|
|
|
|
return {
|
|
issues: [...this.styleIssues],
|
|
componentStyles: componentStylesArray,
|
|
stats: {
|
|
totalComponents: componentStylesArray.length,
|
|
invisibleCount: this.styleIssues.filter(i => i.type === 'invisible-element').length,
|
|
tailwindIssueCount: this.styleIssues.filter(i => i.type === 'tailwind-purging').length,
|
|
darkModeIssueCount: this.styleIssues.filter(i => i.type === 'dark-mode-missing').length,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 스타일 검증 데이터 초기화
|
|
*/
|
|
clearStyleValidationData(): void {
|
|
this.styleIssues = [];
|
|
this.componentStyles.clear();
|
|
}
|
|
|
|
// ============================================
|
|
// 인증 디버깅 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 인증 이벤트 로깅
|
|
*/
|
|
trackAuthEvent(
|
|
type: AuthEventType,
|
|
success: boolean,
|
|
error?: string,
|
|
details?: Record<string, any>
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const event: AuthEventLog = {
|
|
id: `auth_${++this.authEventIdCounter}`,
|
|
type,
|
|
timestamp: Date.now(),
|
|
success,
|
|
error,
|
|
details,
|
|
};
|
|
|
|
this.authEvents.push(event);
|
|
|
|
// 히스토리 제한
|
|
if (this.authEvents.length > this.maxAuthEventHistory) {
|
|
this.authEvents = this.authEvents.slice(-this.maxAuthEventHistory);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* API 인증 헤더 분석 추적
|
|
*/
|
|
trackAuthHeader(
|
|
url: string,
|
|
hasAuthHeader: boolean,
|
|
headerType?: string,
|
|
tokenValid?: boolean,
|
|
responseStatus?: number
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const analysis: AuthHeaderAnalysis = {
|
|
url,
|
|
hasAuthHeader,
|
|
headerType,
|
|
tokenValid: tokenValid ?? false,
|
|
responseStatus,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.authHeaderHistory.push(analysis);
|
|
|
|
// 히스토리 제한
|
|
if (this.authHeaderHistory.length > this.maxAuthEventHistory) {
|
|
this.authHeaderHistory = this.authHeaderHistory.slice(-this.maxAuthEventHistory);
|
|
}
|
|
|
|
// 401 응답 시 자동으로 이벤트 로깅
|
|
if (responseStatus === 401) {
|
|
this.trackAuthEvent('api-unauthorized', false, `401 Unauthorized: ${url}`, { url });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인증 상태 정보 가져오기
|
|
*/
|
|
private getAuthState(): AuthStateInfo {
|
|
try {
|
|
const g7Core = (window as any).G7Core;
|
|
const apiClient = g7Core?.api;
|
|
|
|
// AuthManager 인스턴스 가져오기 (G7Core.auth가 없으면 AuthManager.getInstance() 사용)
|
|
let auth = g7Core?.auth;
|
|
if (!auth && g7Core?.AuthManager?.getInstance) {
|
|
auth = g7Core.AuthManager.getInstance();
|
|
}
|
|
|
|
// 토큰 정보 확인 (ApiClient의 TOKEN_KEY = 'auth_token')
|
|
const accessToken = apiClient?.getToken?.() ||
|
|
auth?.getAccessToken?.() ||
|
|
localStorage.getItem('auth_token') ||
|
|
localStorage.getItem('access_token');
|
|
|
|
const refreshToken = auth?.getRefreshToken?.() ||
|
|
localStorage.getItem('refresh_token');
|
|
|
|
// 사용자 정보 소스 추적
|
|
let source: 'AuthManager' | 'G7Core.state' | 'unknown' = 'unknown';
|
|
|
|
// 사용자 정보 - AuthManager의 getUser() 먼저 확인
|
|
let user = auth?.getUser?.();
|
|
if (user) {
|
|
source = 'AuthManager';
|
|
}
|
|
|
|
// AuthManager에 user가 없으면 G7Core state에서 확인
|
|
if (!user) {
|
|
const state = g7Core?.state?.get?.();
|
|
// user 또는 currentUser 필드 확인 (Admin: user, User: currentUser)
|
|
user = state?._global?.user || state?._global?.currentUser || state?.user || state?.currentUser;
|
|
if (user) {
|
|
source = 'G7Core.state';
|
|
}
|
|
}
|
|
|
|
// 여전히 없으면 AuthManager의 isAuthenticated() 상태 확인
|
|
const isAuthManagerAuthenticated = auth?.isAuthenticated?.() ?? false;
|
|
|
|
// storage 위치 확인
|
|
let storage: 'localStorage' | 'sessionStorage' | 'memory' = 'memory';
|
|
if (localStorage.getItem('auth_token') || localStorage.getItem('access_token')) {
|
|
storage = 'localStorage';
|
|
} else if (sessionStorage.getItem('auth_token') || sessionStorage.getItem('access_token')) {
|
|
storage = 'sessionStorage';
|
|
}
|
|
|
|
// 인증 여부: 토큰이 있고 (user가 있거나 AuthManager가 인증됨)
|
|
const isAuthenticated = !!accessToken && (!!user || isAuthManagerAuthenticated);
|
|
|
|
// 토큰 미리보기 (앞 10자 + ... + 뒤 6자)
|
|
let accessTokenPreview: string | undefined;
|
|
if (accessToken && typeof accessToken === 'string' && accessToken.length > 20) {
|
|
accessTokenPreview = `${accessToken.substring(0, 10)}...${accessToken.substring(accessToken.length - 6)}`;
|
|
} else if (accessToken) {
|
|
accessTokenPreview = '***';
|
|
}
|
|
|
|
// 사용자 정보 상세 구성
|
|
const userInfo = user ? {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
// 역할 정보 상세
|
|
roles: user.roles?.map((r: any) => {
|
|
if (typeof r === 'string') {
|
|
return { name: r };
|
|
}
|
|
return {
|
|
id: r.id,
|
|
name: r.name,
|
|
guard_name: r.guard_name,
|
|
};
|
|
}),
|
|
// 권한 정보 상세
|
|
permissions: user.permissions?.map((p: any) => {
|
|
if (typeof p === 'string') {
|
|
return { name: p };
|
|
}
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
guard_name: p.guard_name,
|
|
};
|
|
}),
|
|
// 추가 사용자 정보
|
|
avatar: user.avatar || user.profile_photo_url || user.profile_image,
|
|
created_at: user.created_at,
|
|
updated_at: user.updated_at,
|
|
last_login_at: user.last_login_at,
|
|
email_verified_at: user.email_verified_at,
|
|
// 기타 필드 (동적)
|
|
...(user.phone && { phone: user.phone }),
|
|
...(user.nickname && { nickname: user.nickname }),
|
|
...(user.status && { status: user.status }),
|
|
...(user.locale && { locale: user.locale }),
|
|
} : undefined;
|
|
|
|
return {
|
|
isAuthenticated,
|
|
user: userInfo,
|
|
tokens: {
|
|
hasAccessToken: !!accessToken,
|
|
hasRefreshToken: !!refreshToken,
|
|
storage,
|
|
accessTokenPreview,
|
|
},
|
|
lastActivity: Date.now(),
|
|
source,
|
|
};
|
|
} catch {
|
|
return {
|
|
isAuthenticated: false,
|
|
tokens: {
|
|
hasAccessToken: false,
|
|
hasRefreshToken: false,
|
|
storage: 'memory',
|
|
},
|
|
source: 'unknown',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인증 디버깅 정보 반환
|
|
*/
|
|
getAuthDebugInfo(): AuthDebugInfo {
|
|
const events = [...this.authEvents];
|
|
|
|
return {
|
|
state: this.getAuthState(),
|
|
events,
|
|
headerAnalysis: [...this.authHeaderHistory],
|
|
stats: {
|
|
loginAttempts: events.filter(e => e.type === 'login').length,
|
|
successfulLogins: events.filter(e => e.type === 'login' && e.success).length,
|
|
failedLogins: events.filter(e => e.type === 'login' && !e.success).length,
|
|
tokenRefreshes: events.filter(e => e.type === 'token-refresh').length,
|
|
unauthorizedResponses: events.filter(e => e.type === 'api-unauthorized').length,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 인증 디버깅 데이터 초기화
|
|
*/
|
|
clearAuthData(): void {
|
|
this.authEvents = [];
|
|
this.authHeaderHistory = [];
|
|
this.authEventIdCounter = 0;
|
|
}
|
|
|
|
// ============================================
|
|
// 레이아웃 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 레이아웃 로드 추적
|
|
*
|
|
* LayoutLoader에서 레이아웃 로드 완료 시 호출됩니다.
|
|
* 현재 렌더링 중인 레이아웃의 전체 JSON을 저장합니다.
|
|
*
|
|
* @param layoutPath 레이아웃 경로 (예: "admin/admin_dashboard")
|
|
* @param templateId 템플릿 ID (예: "sirsoft-admin_basic")
|
|
* @param layoutJson 전체 레이아웃 JSON 데이터
|
|
* @param source 로드 소스 ('cache' | 'api')
|
|
*/
|
|
trackLayoutLoad(
|
|
layoutPath: string,
|
|
templateId: string,
|
|
layoutJson: LayoutJsonData,
|
|
source: 'cache' | 'api' = 'api'
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const now = Date.now();
|
|
|
|
// 현재 레이아웃 저장
|
|
this.currentLayout = {
|
|
layoutPath,
|
|
templateId,
|
|
layoutJson: this.sanitizeObject(layoutJson),
|
|
loadedAt: now,
|
|
version: layoutJson.version,
|
|
layoutName: layoutJson.layout_name,
|
|
source,
|
|
};
|
|
|
|
// 이력에 추가
|
|
const historyEntry: LayoutHistoryEntry = {
|
|
id: `layout-${++this.layoutIdCounter}`,
|
|
layoutPath,
|
|
templateId,
|
|
loadedAt: now,
|
|
source,
|
|
version: layoutJson.version,
|
|
};
|
|
|
|
this.layoutHistory.push(historyEntry);
|
|
|
|
// 이력 크기 제한
|
|
if (this.layoutHistory.length > this.maxLayoutHistory) {
|
|
this.layoutHistory.shift();
|
|
}
|
|
|
|
// 통계 업데이트
|
|
this.layoutStats.totalLoads++;
|
|
if (source === 'cache') {
|
|
this.layoutStats.cacheHits++;
|
|
} else {
|
|
this.layoutStats.apiLoads++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 레이아웃 조회
|
|
*
|
|
* @returns 현재 렌더링 중인 레이아웃 정보 또는 null
|
|
*/
|
|
getCurrentLayout(): CurrentLayoutInfo | null {
|
|
return this.currentLayout;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 이력 조회
|
|
*
|
|
* @returns 레이아웃 로드 이력
|
|
*/
|
|
getLayoutHistory(): LayoutHistoryEntry[] {
|
|
return [...this.layoutHistory];
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 디버깅 정보 반환 (상태 덤프용)
|
|
*
|
|
* @returns 레이아웃 디버깅 정보
|
|
*/
|
|
getLayoutDebugInfo(): LayoutDebugInfo {
|
|
return {
|
|
current: this.currentLayout,
|
|
history: [...this.layoutHistory],
|
|
stats: { ...this.layoutStats },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 초기화
|
|
*/
|
|
clearLayoutData(): void {
|
|
this.currentLayout = null;
|
|
this.layoutHistory = [];
|
|
this.layoutIdCounter = 0;
|
|
this.layoutStats = { totalLoads: 0, cacheHits: 0, apiLoads: 0 };
|
|
}
|
|
|
|
// ============================================
|
|
// 변경 감지 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 핸들러 실행 시작 기록
|
|
*
|
|
* @param handlerName 핸들러 이름
|
|
* @returns 실행 ID (종료 시 사용)
|
|
*/
|
|
startHandlerExecution(handlerName: string): string {
|
|
if (!this.config.enabled) return '';
|
|
|
|
const executionId = `exec_${++this.executionIdCounter}_${Date.now()}`;
|
|
const detail: HandlerExecutionDetail = {
|
|
handlerName,
|
|
executionId,
|
|
startTime: Date.now(),
|
|
stateChanges: [],
|
|
dataSourceChanges: [],
|
|
alerts: [],
|
|
};
|
|
|
|
this.executionDetails.set(executionId, detail);
|
|
|
|
// 오래된 항목 정리
|
|
if (this.executionDetails.size > this.maxExecutionDetails) {
|
|
const keys = Array.from(this.executionDetails.keys());
|
|
const toRemove = keys.slice(0, keys.length - this.maxExecutionDetails);
|
|
toRemove.forEach(key => this.executionDetails.delete(key));
|
|
}
|
|
|
|
return executionId;
|
|
}
|
|
|
|
/**
|
|
* 핸들러 실행 종료 기록
|
|
*
|
|
* @param executionId 실행 ID
|
|
* @param exitReason 종료 사유
|
|
* @param exitLocation 종료 위치 (선택)
|
|
* @param exitDescription 종료 설명 (선택)
|
|
*/
|
|
endHandlerExecution(
|
|
executionId: string,
|
|
exitReason: HandlerExitReason,
|
|
exitLocation?: string,
|
|
exitDescription?: string
|
|
): void {
|
|
if (!this.config.enabled || !executionId) return;
|
|
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (!detail) return;
|
|
|
|
detail.endTime = Date.now();
|
|
detail.duration = detail.endTime - detail.startTime;
|
|
detail.exitReason = exitReason;
|
|
detail.exitLocation = exitLocation;
|
|
detail.exitDescription = exitDescription;
|
|
|
|
// 기대 변경 확인 및 알림 생성
|
|
const expectation = this.changeExpectations.get(executionId);
|
|
if (expectation) {
|
|
detail.expectedChanges = expectation;
|
|
this.checkExpectations(executionId, detail, expectation);
|
|
this.changeExpectations.delete(executionId);
|
|
}
|
|
|
|
// 상태 변경 없이 성공 종료된 경우 알림
|
|
if (exitReason === 'normal' && detail.stateChanges.length === 0 && detail.dataSourceChanges.length === 0) {
|
|
this.addChangeAlert(executionId, {
|
|
type: 'no-state-change',
|
|
severity: 'warning',
|
|
message: `핸들러 "${detail.handlerName}"이(가) 상태 변경 없이 완료됨`,
|
|
description: '핸들러가 성공적으로 완료되었지만 상태나 데이터소스 변경이 없습니다. 의도한 동작인지 확인하세요.',
|
|
handlerName: detail.handlerName,
|
|
suggestion: '핸들러 내부 조건문을 확인하거나 DevTools의 "변경감지" 탭에서 exitLocation을 확인하세요.',
|
|
docLink: '.claude/docs/frontend/troubleshooting-state.md',
|
|
});
|
|
}
|
|
|
|
// early return 감지 시 알림
|
|
if (exitReason === 'early-return-condition' || exitReason === 'early-return-validation') {
|
|
this.addChangeAlert(executionId, {
|
|
type: 'early-return-detected',
|
|
severity: 'info',
|
|
message: `핸들러 "${detail.handlerName}"이(가) early return으로 종료됨`,
|
|
description: exitDescription || `종료 위치: ${exitLocation || '알 수 없음'}`,
|
|
handlerName: detail.handlerName,
|
|
suggestion: exitReason === 'early-return-validation'
|
|
? '검증 실패로 인한 early return입니다. 입력 데이터를 확인하세요.'
|
|
: '조건부 early return입니다. 조건문의 평가 결과를 확인하세요.',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 종료 사유 기록 (핸들러 내부에서 호출)
|
|
*
|
|
* @param executionId 실행 ID
|
|
* @param exitReason 종료 사유
|
|
* @param location 종료 위치
|
|
* @param description 상세 설명
|
|
*/
|
|
recordExitReason(
|
|
executionId: string,
|
|
exitReason: HandlerExitReason,
|
|
location: string,
|
|
description?: string
|
|
): void {
|
|
if (!this.config.enabled || !executionId) return;
|
|
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (!detail) return;
|
|
|
|
detail.exitReason = exitReason;
|
|
detail.exitLocation = location;
|
|
detail.exitDescription = description;
|
|
}
|
|
|
|
/**
|
|
* 상태 변경 기록
|
|
*
|
|
* @param executionId 관련 핸들러 실행 ID (선택)
|
|
* @param path 상태 경로
|
|
* @param changeType 변경 유형
|
|
* @param oldValue 이전 값
|
|
* @param newValue 새 값
|
|
*/
|
|
recordStateChange(
|
|
executionId: string | undefined,
|
|
path: string,
|
|
changeType: StateChangeRecord['changeType'],
|
|
oldValue: any,
|
|
newValue: any
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const record: StateChangeRecord = {
|
|
id: `sc_${++this.stateChangeIdCounter}`,
|
|
path,
|
|
changeType,
|
|
oldValue: this.safeClone(oldValue),
|
|
newValue: this.safeClone(newValue),
|
|
timestamp: Date.now(),
|
|
comparison: this.compareValues(oldValue, newValue),
|
|
executionId,
|
|
};
|
|
|
|
this.stateChangeHistory.push(record);
|
|
|
|
// 핸들러 실행과 연결
|
|
if (executionId) {
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (detail) {
|
|
detail.stateChanges.push(record);
|
|
}
|
|
}
|
|
|
|
// 히스토리 크기 제한
|
|
if (this.stateChangeHistory.length > this.maxStateChangeHistory) {
|
|
this.stateChangeHistory.splice(0, this.stateChangeHistory.length - this.maxStateChangeHistory);
|
|
}
|
|
|
|
// 객체 참조가 동일한 경우 알림
|
|
if (record.comparison.isDeepEqual && typeof oldValue === 'object' && oldValue !== null) {
|
|
this.addChangeAlert(executionId || '', {
|
|
type: 'object-reference-same',
|
|
severity: 'warning',
|
|
message: `상태 "${path}"가 변경되었으나 값이 동일함`,
|
|
description: '객체 참조가 동일하거나 깊은 비교 결과 변경이 없습니다. 변경 감지가 실패할 수 있습니다.',
|
|
handlerName: executionId ? this.executionDetails.get(executionId)?.handlerName || 'unknown' : 'unknown',
|
|
statePath: path,
|
|
suggestion: '불변성을 유지하여 새 객체를 생성하세요. 예: { ...oldObject, newField: value }',
|
|
docLink: '.claude/docs/frontend/troubleshooting-state.md#object-mutation',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 변경 기록
|
|
*
|
|
* @param executionId 관련 핸들러 실행 ID (선택)
|
|
* @param dataSourceId 데이터소스 ID
|
|
* @param changeType 변경 유형
|
|
* @param previousStatus 이전 상태
|
|
* @param newStatus 새 상태
|
|
*/
|
|
recordDataSourceChange(
|
|
executionId: string | undefined,
|
|
dataSourceId: string,
|
|
changeType: DataSourceChangeRecord['changeType'],
|
|
previousStatus: DataSourceChangeRecord['previousStatus'],
|
|
newStatus: DataSourceChangeRecord['newStatus']
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const record: DataSourceChangeRecord = {
|
|
dataSourceId,
|
|
changeType,
|
|
timestamp: Date.now(),
|
|
previousStatus,
|
|
newStatus,
|
|
executionId,
|
|
};
|
|
|
|
this.dataSourceChangeHistory.push(record);
|
|
|
|
// 핸들러 실행과 연결
|
|
if (executionId) {
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (detail) {
|
|
detail.dataSourceChanges.push(record);
|
|
}
|
|
}
|
|
|
|
// 히스토리 크기 제한
|
|
if (this.dataSourceChangeHistory.length > this.maxStateChangeHistory) {
|
|
this.dataSourceChangeHistory.splice(0, this.dataSourceChangeHistory.length - this.maxStateChangeHistory);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 변경 기대치 설정 (핸들러가 변경을 예고)
|
|
*
|
|
* @param executionId 실행 ID
|
|
* @param expectedStatePaths 기대되는 상태 변경 경로 목록
|
|
* @param expectedDataSources 기대되는 데이터소스 갱신 목록
|
|
*/
|
|
expectChange(
|
|
executionId: string,
|
|
expectedStatePaths: string[],
|
|
expectedDataSources: string[]
|
|
): void {
|
|
if (!this.config.enabled || !executionId) return;
|
|
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (!detail) return;
|
|
|
|
const expectation: ChangeExpectation = {
|
|
expectedStatePaths,
|
|
expectedDataSources,
|
|
setAt: Date.now(),
|
|
source: detail.handlerName,
|
|
};
|
|
|
|
this.changeExpectations.set(executionId, expectation);
|
|
}
|
|
|
|
/**
|
|
* 기대 변경 확인 및 알림 생성
|
|
*/
|
|
private checkExpectations(
|
|
executionId: string,
|
|
detail: HandlerExecutionDetail,
|
|
expectation: ChangeExpectation
|
|
): void {
|
|
const actualStatePaths = new Set(detail.stateChanges.map(sc => sc.path));
|
|
const actualDataSources = new Set(detail.dataSourceChanges.map(dc => dc.dataSourceId));
|
|
|
|
// 기대한 상태 변경이 발생하지 않은 경우
|
|
for (const expectedPath of expectation.expectedStatePaths) {
|
|
if (!actualStatePaths.has(expectedPath)) {
|
|
this.addChangeAlert(executionId, {
|
|
type: 'expected-not-fulfilled',
|
|
severity: 'error',
|
|
message: `기대한 상태 변경 "${expectedPath}"가 발생하지 않음`,
|
|
description: `핸들러 "${detail.handlerName}"이(가) "${expectedPath}" 변경을 예고했지만 실제로 변경되지 않았습니다.`,
|
|
handlerName: detail.handlerName,
|
|
statePath: expectedPath,
|
|
suggestion: '핸들러 내부 로직을 확인하세요. early return이나 조건부 분기로 인해 변경이 생략되었을 수 있습니다.',
|
|
docLink: '.claude/docs/frontend/troubleshooting-state.md',
|
|
});
|
|
}
|
|
}
|
|
|
|
// 기대한 데이터소스 갱신이 발생하지 않은 경우
|
|
for (const expectedDs of expectation.expectedDataSources) {
|
|
if (!actualDataSources.has(expectedDs)) {
|
|
this.addChangeAlert(executionId, {
|
|
type: 'expected-not-fulfilled',
|
|
severity: 'error',
|
|
message: `기대한 데이터소스 갱신 "${expectedDs}"가 발생하지 않음`,
|
|
description: `핸들러 "${detail.handlerName}"이(가) "${expectedDs}" 갱신을 예고했지만 실제로 갱신되지 않았습니다.`,
|
|
handlerName: detail.handlerName,
|
|
dataSourceId: expectedDs,
|
|
suggestion: 'refetchDataSource 호출이 실행되었는지 확인하세요.',
|
|
docLink: '.claude/docs/frontend/data-sources.md',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 변경 알림 추가
|
|
*/
|
|
private addChangeAlert(
|
|
executionId: string,
|
|
alertData: Omit<ChangeAlert, 'id' | 'timestamp'>
|
|
): void {
|
|
const alert: ChangeAlert = {
|
|
...alertData,
|
|
id: `alert_${++this.alertIdCounter}`,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.changeAlerts.push(alert);
|
|
|
|
// 핸들러 실행과 연결
|
|
if (executionId) {
|
|
const detail = this.executionDetails.get(executionId);
|
|
if (detail) {
|
|
detail.alerts.push(alert);
|
|
}
|
|
}
|
|
|
|
// 알림 크기 제한
|
|
if (this.changeAlerts.length > this.maxChangeAlerts) {
|
|
this.changeAlerts.splice(0, this.changeAlerts.length - this.maxChangeAlerts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 값 비교
|
|
*/
|
|
private compareValues(oldValue: any, newValue: any): StateChangeRecord['comparison'] {
|
|
const oldType = this.getValueType(oldValue);
|
|
const newType = this.getValueType(newValue);
|
|
const typeChanged = oldType !== newType;
|
|
|
|
let isDeepEqual = false;
|
|
let changedKeys: string[] | undefined;
|
|
|
|
if (!typeChanged && (oldType === 'object' || oldType === 'array')) {
|
|
isDeepEqual = this.deepEqual(oldValue, newValue);
|
|
if (!isDeepEqual && oldType === 'object') {
|
|
changedKeys = this.getChangedKeys(oldValue, newValue);
|
|
}
|
|
} else {
|
|
isDeepEqual = oldValue === newValue;
|
|
}
|
|
|
|
return {
|
|
typeChanged,
|
|
oldType,
|
|
newType,
|
|
isDeepEqual,
|
|
changedKeys,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 값의 타입 반환
|
|
*/
|
|
private getValueType(value: any): string {
|
|
if (value === null) return 'null';
|
|
if (value === undefined) return 'undefined';
|
|
if (Array.isArray(value)) return 'array';
|
|
return typeof value;
|
|
}
|
|
|
|
/**
|
|
* 깊은 비교
|
|
*/
|
|
private deepEqual(a: any, b: any): boolean {
|
|
if (a === b) return true;
|
|
if (typeof a !== typeof b) return false;
|
|
if (a === null || b === null) return a === b;
|
|
if (typeof a !== 'object') return a === b;
|
|
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
|
|
if (keysA.length !== keysB.length) return false;
|
|
|
|
for (const key of keysA) {
|
|
if (!keysB.includes(key)) return false;
|
|
if (!this.deepEqual(a[key], b[key])) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 변경된 키 목록 반환
|
|
*/
|
|
private getChangedKeys(oldObj: Record<string, any>, newObj: Record<string, any>): string[] {
|
|
const changedKeys: string[] = [];
|
|
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
|
|
for (const key of allKeys) {
|
|
if (!this.deepEqual(oldObj[key], newObj[key])) {
|
|
changedKeys.push(key);
|
|
}
|
|
}
|
|
|
|
return changedKeys;
|
|
}
|
|
|
|
/**
|
|
* 변경 감지 정보 반환 (상태 덤프용)
|
|
*/
|
|
getChangeDetectionInfo(): ChangeDetectionInfo {
|
|
const details = Array.from(this.executionDetails.values());
|
|
|
|
// 통계 계산
|
|
const stats: ChangeDetectionStats = {
|
|
totalExecutions: details.length,
|
|
executionsWithStateChange: details.filter(d => d.stateChanges.length > 0).length,
|
|
executionsWithoutStateChange: details.filter(d => d.stateChanges.length === 0 && d.exitReason === 'normal').length,
|
|
earlyReturnCount: details.filter(d =>
|
|
d.exitReason === 'early-return-condition' || d.exitReason === 'early-return-validation'
|
|
).length,
|
|
alertCount: this.changeAlerts.length,
|
|
alertsByType: this.getAlertsByType(),
|
|
};
|
|
|
|
return {
|
|
executionDetails: details,
|
|
stateChangeHistory: [...this.stateChangeHistory],
|
|
dataSourceChangeHistory: [...this.dataSourceChangeHistory],
|
|
alerts: [...this.changeAlerts],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 알림 유형별 개수 반환
|
|
*/
|
|
private getAlertsByType(): Record<ChangeAlertType, number> {
|
|
const result: Record<ChangeAlertType, number> = {
|
|
'no-state-change': 0,
|
|
'no-datasource-change': 0,
|
|
'expected-not-fulfilled': 0,
|
|
'object-reference-same': 0,
|
|
'early-return-detected': 0,
|
|
'async-timing-issue': 0,
|
|
};
|
|
|
|
for (const alert of this.changeAlerts) {
|
|
result[alert.type]++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 변경 감지 데이터 초기화
|
|
*/
|
|
clearChangeDetectionData(): void {
|
|
this.executionDetails.clear();
|
|
this.stateChangeHistory = [];
|
|
this.dataSourceChangeHistory = [];
|
|
this.changeAlerts = [];
|
|
this.changeExpectations.clear();
|
|
this.executionIdCounter = 0;
|
|
this.stateChangeIdCounter = 0;
|
|
this.alertIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 최근 알림 반환
|
|
*
|
|
* @param limit 반환할 최대 개수
|
|
* @param severity 필터링할 심각도 (선택)
|
|
*/
|
|
getRecentAlerts(limit = 10, severity?: ChangeAlert['severity']): ChangeAlert[] {
|
|
let alerts = [...this.changeAlerts];
|
|
|
|
if (severity) {
|
|
alerts = alerts.filter(a => a.severity === severity);
|
|
}
|
|
|
|
return alerts.slice(-limit);
|
|
}
|
|
|
|
/**
|
|
* 특정 핸들러의 실행 상세 반환
|
|
*
|
|
* @param handlerName 핸들러 이름
|
|
* @param limit 반환할 최대 개수
|
|
*/
|
|
getHandlerExecutions(handlerName: string, limit = 10): HandlerExecutionDetail[] {
|
|
const details = Array.from(this.executionDetails.values())
|
|
.filter(d => d.handlerName === handlerName)
|
|
.slice(-limit);
|
|
|
|
return details;
|
|
}
|
|
|
|
// ============================================
|
|
// Sequence 실행 추적 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 스택에서 sequenceId로 실행 정보 찾기 (헬퍼 메서드)
|
|
*/
|
|
private findExecutionInStack(sequenceId: string): SequenceExecutionInfo | undefined {
|
|
return this.sequenceExecutionStack.find(e => e.sequenceId === sequenceId);
|
|
}
|
|
|
|
/**
|
|
* Sequence 실행 시작
|
|
*
|
|
* @param trigger 트리거 정보
|
|
* @returns 생성된 sequenceId
|
|
*/
|
|
startSequenceExecution(trigger?: SequenceExecutionInfo['trigger']): string {
|
|
if (!this.config.enabled) return '';
|
|
|
|
const sequenceId = `seq_${++this.sequenceIdCounter}_${Date.now()}`;
|
|
|
|
const execution: SequenceExecutionInfo = {
|
|
sequenceId,
|
|
startTime: Date.now(),
|
|
totalDuration: 0,
|
|
trigger,
|
|
actions: [],
|
|
status: 'running',
|
|
};
|
|
|
|
// 스택에 push (중첩 sequence 지원)
|
|
this.sequenceExecutionStack.push(execution);
|
|
|
|
return sequenceId;
|
|
}
|
|
|
|
/**
|
|
* Sequence 내 액션 실행 전 상태 캡처
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param actionIndex 액션 인덱스
|
|
* @param handler 핸들러 이름
|
|
* @param params 액션 파라미터
|
|
* @param currentState 현재 상태
|
|
*/
|
|
captureSequenceActionBefore(
|
|
sequenceId: string,
|
|
actionIndex: number,
|
|
handler: string,
|
|
params: Record<string, any>,
|
|
currentState: {
|
|
_global: Record<string, any>;
|
|
_local: Record<string, any>;
|
|
_isolated?: Record<string, any>;
|
|
}
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 스택에서 해당 sequenceId 찾기
|
|
const execution = this.findExecutionInStack(sequenceId);
|
|
if (!execution) return;
|
|
|
|
const actionInfo: SequenceActionInfo = {
|
|
index: actionIndex,
|
|
handler,
|
|
params: this.safeClone(params),
|
|
stateBeforeAction: {
|
|
_global: this.safeClone(currentState._global),
|
|
_local: this.safeClone(currentState._local),
|
|
_isolated: currentState._isolated ? this.safeClone(currentState._isolated) : undefined,
|
|
},
|
|
stateAfterAction: {
|
|
_global: {},
|
|
_local: {},
|
|
},
|
|
duration: 0,
|
|
status: 'success',
|
|
};
|
|
|
|
execution.actions.push(actionInfo);
|
|
}
|
|
|
|
/**
|
|
* Sequence 내 액션 실행 후 상태 캡처
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param actionIndex 액션 인덱스
|
|
* @param currentState 현재 상태
|
|
* @param duration 소요 시간
|
|
* @param result 액션 결과
|
|
* @param error 에러 (있는 경우)
|
|
*/
|
|
captureSequenceActionAfter(
|
|
sequenceId: string,
|
|
actionIndex: number,
|
|
currentState: {
|
|
_global: Record<string, any>;
|
|
_local: Record<string, any>;
|
|
_isolated?: Record<string, any>;
|
|
},
|
|
duration: number,
|
|
result?: any,
|
|
error?: Error
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 스택에서 해당 sequenceId 찾기
|
|
const execution = this.findExecutionInStack(sequenceId);
|
|
if (!execution) return;
|
|
|
|
const actionInfo = execution.actions.find((a: SequenceActionInfo) => a.index === actionIndex);
|
|
if (!actionInfo) return;
|
|
|
|
actionInfo.stateAfterAction = {
|
|
_global: this.safeClone(currentState._global),
|
|
_local: this.safeClone(currentState._local),
|
|
_isolated: currentState._isolated ? this.safeClone(currentState._isolated) : undefined,
|
|
};
|
|
actionInfo.duration = duration;
|
|
actionInfo.result = this.safeClone(result);
|
|
|
|
// 상태 diff 계산
|
|
actionInfo.stateDiff = {
|
|
global: this.computeStateDiff(actionInfo.stateBeforeAction._global, actionInfo.stateAfterAction._global),
|
|
local: this.computeStateDiff(actionInfo.stateBeforeAction._local, actionInfo.stateAfterAction._local),
|
|
};
|
|
|
|
if (actionInfo.stateBeforeAction._isolated && actionInfo.stateAfterAction._isolated) {
|
|
actionInfo.stateDiff.isolated = this.computeStateDiff(
|
|
actionInfo.stateBeforeAction._isolated,
|
|
actionInfo.stateAfterAction._isolated
|
|
);
|
|
}
|
|
|
|
// pending state 캡처
|
|
const pendingLocal = (window as any).__g7PendingLocalState;
|
|
if (pendingLocal) {
|
|
actionInfo.pendingState = this.safeClone(pendingLocal);
|
|
}
|
|
|
|
if (error) {
|
|
actionInfo.status = 'error';
|
|
actionInfo.error = {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sequence 실행 완료
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
* @param error 전체 에러 (있는 경우)
|
|
*/
|
|
endSequenceExecution(sequenceId: string, error?: Error): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
// 스택에서 해당 sequenceId 찾기 (인덱스 필요)
|
|
const stackIndex = this.sequenceExecutionStack.findIndex(e => e.sequenceId === sequenceId);
|
|
if (stackIndex === -1) return;
|
|
|
|
const execution = this.sequenceExecutionStack[stackIndex];
|
|
execution.endTime = Date.now();
|
|
execution.totalDuration = execution.endTime - execution.startTime;
|
|
|
|
if (error) {
|
|
execution.status = 'error';
|
|
execution.error = {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
};
|
|
// 실패한 액션 인덱스 찾기
|
|
const failedAction = execution.actions.find((a: SequenceActionInfo) => a.status === 'error');
|
|
if (failedAction) {
|
|
execution.failedAtIndex = failedAction.index;
|
|
}
|
|
} else {
|
|
execution.status = 'success';
|
|
}
|
|
|
|
// 이력에 추가
|
|
this.sequenceExecutions.push(execution);
|
|
|
|
// 최대 크기 제한
|
|
if (this.sequenceExecutions.length > this.maxSequenceExecutions) {
|
|
this.sequenceExecutions.shift();
|
|
}
|
|
|
|
// 스택에서 제거
|
|
this.sequenceExecutionStack.splice(stackIndex, 1);
|
|
}
|
|
|
|
/**
|
|
* 상태 diff 계산
|
|
*/
|
|
private computeStateDiff(before: Record<string, any>, after: Record<string, any>): StateDiff {
|
|
const added: string[] = [];
|
|
const removed: string[] = [];
|
|
const changed: Array<{ path: string; oldValue: any; newValue: any }> = [];
|
|
|
|
const beforeKeys = new Set(Object.keys(before));
|
|
const afterKeys = new Set(Object.keys(after));
|
|
|
|
// 추가된 키
|
|
for (const key of afterKeys) {
|
|
if (!beforeKeys.has(key)) {
|
|
added.push(key);
|
|
}
|
|
}
|
|
|
|
// 제거된 키
|
|
for (const key of beforeKeys) {
|
|
if (!afterKeys.has(key)) {
|
|
removed.push(key);
|
|
}
|
|
}
|
|
|
|
// 변경된 키
|
|
for (const key of beforeKeys) {
|
|
if (afterKeys.has(key)) {
|
|
const oldVal = before[key];
|
|
const newVal = after[key];
|
|
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
changed.push({
|
|
path: key,
|
|
oldValue: oldVal,
|
|
newValue: newVal,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { added, removed, changed };
|
|
}
|
|
|
|
/**
|
|
* Sequence 추적 정보 반환
|
|
*/
|
|
getSequenceTrackingInfo(): SequenceTrackingInfo {
|
|
const stats = this.getSequenceStats();
|
|
|
|
// 현재 실행 중인 sequence들 (스택의 최상위)
|
|
const currentExecution = this.sequenceExecutionStack.length > 0
|
|
? this.sequenceExecutionStack[this.sequenceExecutionStack.length - 1]
|
|
: undefined;
|
|
|
|
return {
|
|
executions: [...this.sequenceExecutions],
|
|
currentExecution,
|
|
stats,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sequence 통계 계산
|
|
*/
|
|
private getSequenceStats(): SequenceStats {
|
|
const executions = this.sequenceExecutions;
|
|
const totalExecutions = executions.length;
|
|
const successCount = executions.filter(e => e.status === 'success').length;
|
|
const errorCount = executions.filter(e => e.status === 'error').length;
|
|
const totalActions = executions.reduce((sum, e) => sum + e.actions.length, 0);
|
|
const avgDuration = totalExecutions > 0
|
|
? executions.reduce((sum, e) => sum + e.totalDuration, 0) / totalExecutions
|
|
: 0;
|
|
|
|
// 핸들러 사용 빈도 계산
|
|
const handlerCounts: Record<string, number> = {};
|
|
for (const execution of executions) {
|
|
for (const action of execution.actions) {
|
|
handlerCounts[action.handler] = (handlerCounts[action.handler] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const topHandlers = Object.entries(handlerCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 5)
|
|
.map(([handler, count]) => ({ handler, count }));
|
|
|
|
return {
|
|
totalExecutions,
|
|
successCount,
|
|
errorCount,
|
|
totalActions,
|
|
avgDuration,
|
|
topHandlers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sequence 데이터 초기화
|
|
*/
|
|
clearSequenceData(): void {
|
|
this.sequenceExecutions = [];
|
|
this.sequenceExecutionStack = [];
|
|
this.sequenceIdCounter = 0;
|
|
}
|
|
|
|
/**
|
|
* 특정 Sequence 실행 정보 반환
|
|
*
|
|
* @param sequenceId Sequence ID
|
|
*/
|
|
getSequenceExecution(sequenceId: string): SequenceExecutionInfo | undefined {
|
|
// 스택에서 먼저 찾기 (실행 중인 sequence)
|
|
const inStack = this.findExecutionInStack(sequenceId);
|
|
if (inStack) {
|
|
return inStack;
|
|
}
|
|
// 완료된 이력에서 찾기
|
|
return this.sequenceExecutions.find(e => e.sequenceId === sequenceId);
|
|
}
|
|
|
|
/**
|
|
* 최근 Sequence 실행 목록 반환
|
|
*
|
|
* @param limit 반환할 최대 개수
|
|
*/
|
|
getRecentSequences(limit = 10): SequenceExecutionInfo[] {
|
|
return this.sequenceExecutions.slice(-limit);
|
|
}
|
|
|
|
// ============================================
|
|
// Stale Closure 감지 메서드
|
|
// ============================================
|
|
|
|
/**
|
|
* 상태 캡처 등록 (비동기 핸들러 시작 시 호출)
|
|
*
|
|
* @param handlerId 핸들러 ID
|
|
* @param statePaths 캡처할 상태 경로들
|
|
* @param stateValues 현재 상태 값들
|
|
*/
|
|
registerStateCaptureForHandler(
|
|
handlerId: string,
|
|
statePaths: string[],
|
|
stateValues: Record<string, any>
|
|
): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const captureMap = new Map<string, { value: any; capturedAt: number }>();
|
|
const now = Date.now();
|
|
|
|
for (const path of statePaths) {
|
|
captureMap.set(path, {
|
|
value: this.safeClone(stateValues[path]),
|
|
capturedAt: now,
|
|
});
|
|
}
|
|
|
|
this.stateCaptureRegistry.set(handlerId, captureMap);
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 감지 (비동기 작업 완료 후 호출)
|
|
*
|
|
* @param handlerId 핸들러 ID
|
|
* @param location 발생 위치
|
|
* @param currentState 현재 상태
|
|
* @param warningType 경고 유형
|
|
* @param actionId 관련 액션 ID
|
|
*/
|
|
detectStaleClosure(
|
|
handlerId: string,
|
|
location: string,
|
|
currentState: Record<string, any>,
|
|
warningType: StaleClosureWarningType,
|
|
actionId?: string
|
|
): StaleClosureWarning[] {
|
|
if (!this.config.enabled) return [];
|
|
|
|
const captureMap = this.stateCaptureRegistry.get(handlerId);
|
|
if (!captureMap) return [];
|
|
|
|
const now = Date.now();
|
|
const warnings: StaleClosureWarning[] = [];
|
|
|
|
for (const [path, captured] of captureMap) {
|
|
const currentValue = this.getValueByPath(currentState, path);
|
|
const timeDiff = now - captured.capturedAt;
|
|
|
|
// 값이 다르고, 일정 시간이 지났으면 경고 생성
|
|
if (!this.isDeepEqual(captured.value, currentValue) && timeDiff > 0) {
|
|
const warning = this.createStaleClosureWarning(
|
|
warningType,
|
|
location,
|
|
path,
|
|
captured.value,
|
|
captured.capturedAt,
|
|
currentValue,
|
|
now,
|
|
timeDiff,
|
|
actionId
|
|
);
|
|
warnings.push(warning);
|
|
this.addStaleClosureWarning(warning);
|
|
}
|
|
}
|
|
|
|
// 사용 후 캡처 레지스트리 정리
|
|
this.stateCaptureRegistry.delete(handlerId);
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 경고 직접 추가
|
|
*
|
|
* @param info 경고 정보
|
|
*/
|
|
trackStaleClosureWarning(info: {
|
|
type: StaleClosureWarningType;
|
|
location: string;
|
|
capturedPath: string;
|
|
capturedValue: any;
|
|
capturedAt: number;
|
|
currentValue: any;
|
|
actionId?: string;
|
|
stackTrace?: string;
|
|
}): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const now = Date.now();
|
|
const warning = this.createStaleClosureWarning(
|
|
info.type,
|
|
info.location,
|
|
info.capturedPath,
|
|
info.capturedValue,
|
|
info.capturedAt,
|
|
info.currentValue,
|
|
now,
|
|
now - info.capturedAt,
|
|
info.actionId,
|
|
info.stackTrace
|
|
);
|
|
|
|
this.addStaleClosureWarning(warning);
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 경고 생성
|
|
*/
|
|
private createStaleClosureWarning(
|
|
type: StaleClosureWarningType,
|
|
location: string,
|
|
path: string,
|
|
capturedValue: any,
|
|
capturedAt: number,
|
|
currentValue: any,
|
|
retrievedAt: number,
|
|
timeDiff: number,
|
|
actionId?: string,
|
|
stackTrace?: string
|
|
): StaleClosureWarning {
|
|
const severity = this.getStaleClosureSeverity(type, timeDiff);
|
|
const { description, suggestion, docLink } = this.getStaleClosureMessages(type, path, timeDiff);
|
|
|
|
return {
|
|
id: `stale_${++this.staleClosureIdCounter}_${Date.now()}`,
|
|
timestamp: Date.now(),
|
|
type,
|
|
location,
|
|
capturedState: {
|
|
path,
|
|
capturedValue: this.safeClone(capturedValue),
|
|
capturedAt,
|
|
},
|
|
currentState: {
|
|
path,
|
|
currentValue: this.safeClone(currentValue),
|
|
retrievedAt,
|
|
},
|
|
timeDiff,
|
|
severity,
|
|
description,
|
|
suggestion,
|
|
docLink,
|
|
actionId,
|
|
stackTrace,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 경고 심각도 결정
|
|
*/
|
|
private getStaleClosureSeverity(
|
|
type: StaleClosureWarningType,
|
|
timeDiff: number
|
|
): 'info' | 'warning' | 'error' {
|
|
// 시간 차이가 크면 더 심각
|
|
if (timeDiff > 5000) return 'error';
|
|
if (timeDiff > 1000) return 'warning';
|
|
|
|
// 유형별 기본 심각도
|
|
switch (type) {
|
|
case 'async-state-capture':
|
|
case 'sequence-state-mismatch':
|
|
return 'warning';
|
|
case 'callback-state-capture':
|
|
case 'timeout-state-capture':
|
|
return 'info';
|
|
case 'event-handler-stale':
|
|
return 'warning';
|
|
default:
|
|
return 'info';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 경고 메시지 생성
|
|
*/
|
|
private getStaleClosureMessages(
|
|
type: StaleClosureWarningType,
|
|
path: string,
|
|
timeDiff: number
|
|
): { description: string; suggestion: string; docLink?: string } {
|
|
const timeStr = timeDiff < 1000 ? `${timeDiff}ms` : `${(timeDiff / 1000).toFixed(1)}s`;
|
|
|
|
switch (type) {
|
|
case 'async-state-capture':
|
|
return {
|
|
description: `await 후 ${timeStr} 경과 시점에 '${path}' 상태가 변경됨. 캡처된 상태 대신 최신 상태를 사용해야 함`,
|
|
suggestion: `await 이후에는 G7Core.state.get()으로 최신 상태를 다시 조회하거나, useRef + getter 패턴을 사용하세요`,
|
|
docLink: 'troubleshooting-state-closure.md',
|
|
};
|
|
case 'callback-state-capture':
|
|
return {
|
|
description: `콜백에서 ${timeStr} 전에 캡처된 '${path}' 상태를 사용 중. 최신 상태와 다름`,
|
|
suggestion: `콜백에서 상태를 참조할 때는 stateRef.current 패턴 또는 G7Core.state.get()를 사용하세요`,
|
|
docLink: 'troubleshooting-state-closure.md',
|
|
};
|
|
case 'timeout-state-capture':
|
|
return {
|
|
description: `setTimeout/setInterval 콜백에서 '${path}' 상태가 ${timeStr} 전 값 사용 중`,
|
|
suggestion: `타이머 콜백 내에서는 G7Core.state.get()으로 최신 상태를 조회하세요`,
|
|
docLink: 'troubleshooting-state-closure.md',
|
|
};
|
|
case 'sequence-state-mismatch':
|
|
return {
|
|
description: `sequence 내 '${path}' 상태가 이전 액션 결과와 불일치. context.state 대신 캡처된 값 사용 의심`,
|
|
suggestion: `sequence 내에서는 context.state 또는 $prev를 사용하여 최신 상태를 참조하세요`,
|
|
docLink: 'troubleshooting-state-setstate.md',
|
|
};
|
|
case 'event-handler-stale':
|
|
return {
|
|
description: `이벤트 핸들러에서 ${timeStr} 전에 바인딩된 '${path}' 상태 사용 중`,
|
|
suggestion: `이벤트 핸들러에서 상태를 참조할 때는 useRef 패턴 또는 G7Core.state.get()를 사용하세요`,
|
|
docLink: 'troubleshooting-state-closure.md',
|
|
};
|
|
default:
|
|
return {
|
|
description: `'${path}' 상태에서 stale closure 감지됨 (${timeStr} 경과)`,
|
|
suggestion: `상태 참조 시 최신 값을 사용하는지 확인하세요`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 경고 추가
|
|
*/
|
|
private addStaleClosureWarning(warning: StaleClosureWarning): void {
|
|
this.staleClosureWarnings.push(warning);
|
|
|
|
// 최대 크기 제한
|
|
if (this.staleClosureWarnings.length > this.maxStaleClosureWarnings) {
|
|
this.staleClosureWarnings.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 경로로 값 가져오기
|
|
*/
|
|
private getValueByPath(obj: Record<string, any>, path: string): any {
|
|
const parts = path.split('.');
|
|
let current = obj;
|
|
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) return undefined;
|
|
current = current[part];
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* 깊은 비교
|
|
*/
|
|
private isDeepEqual(a: any, b: any): boolean {
|
|
if (a === b) return true;
|
|
if (a === null || b === null) return a === b;
|
|
if (typeof a !== typeof b) return false;
|
|
if (typeof a !== 'object') return a === b;
|
|
|
|
try {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 추적 정보 반환
|
|
*/
|
|
getStaleClosureTrackingInfo(): StaleClosureTrackingInfo {
|
|
const stats = this.getStaleClosureStats();
|
|
|
|
return {
|
|
warnings: [...this.staleClosureWarnings],
|
|
stats,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 통계 계산
|
|
*/
|
|
private getStaleClosureStats(): StaleClosureStats {
|
|
const warnings = this.staleClosureWarnings;
|
|
|
|
// 유형별 개수
|
|
const warningsByType: Record<StaleClosureWarningType, number> = {
|
|
'async-state-capture': 0,
|
|
'callback-state-capture': 0,
|
|
'timeout-state-capture': 0,
|
|
'sequence-state-mismatch': 0,
|
|
'event-handler-stale': 0,
|
|
};
|
|
for (const w of warnings) {
|
|
warningsByType[w.type]++;
|
|
}
|
|
|
|
// 심각도별 개수
|
|
const warningsBySeverity: Record<string, number> = { info: 0, warning: 0, error: 0 };
|
|
for (const w of warnings) {
|
|
warningsBySeverity[w.severity]++;
|
|
}
|
|
|
|
// 위치별 개수
|
|
const locationCounts: Record<string, number> = {};
|
|
for (const w of warnings) {
|
|
locationCounts[w.location] = (locationCounts[w.location] || 0) + 1;
|
|
}
|
|
const topLocations = Object.entries(locationCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 5)
|
|
.map(([location, count]) => ({ location, count }));
|
|
|
|
// 상태 경로별 개수
|
|
const pathCounts: Record<string, number> = {};
|
|
for (const w of warnings) {
|
|
pathCounts[w.capturedState.path] = (pathCounts[w.capturedState.path] || 0) + 1;
|
|
}
|
|
const topAffectedPaths = Object.entries(pathCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 5)
|
|
.map(([path, count]) => ({ path, count }));
|
|
|
|
return {
|
|
totalWarnings: warnings.length,
|
|
warningsByType,
|
|
warningsBySeverity,
|
|
topLocations,
|
|
topAffectedPaths,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stale Closure 데이터 초기화
|
|
*/
|
|
clearStaleClosureData(): void {
|
|
this.staleClosureWarnings = [];
|
|
this.staleClosureIdCounter = 0;
|
|
this.stateCaptureRegistry.clear();
|
|
}
|
|
|
|
/**
|
|
* 최근 Stale Closure 경고 반환
|
|
*
|
|
* @param limit 반환할 최대 개수
|
|
* @param severity 심각도 필터
|
|
*/
|
|
getRecentStaleClosureWarnings(
|
|
limit = 10,
|
|
severity?: 'info' | 'warning' | 'error'
|
|
): StaleClosureWarning[] {
|
|
let warnings = [...this.staleClosureWarnings];
|
|
|
|
if (severity) {
|
|
warnings = warnings.filter(w => w.severity === severity);
|
|
}
|
|
|
|
return warnings.slice(-limit);
|
|
}
|
|
|
|
/**
|
|
* 안전한 객체 복사 (순환 참조 처리)
|
|
*/
|
|
private safeClone(obj: any): any {
|
|
if (obj === null || obj === undefined) return obj;
|
|
if (typeof obj !== 'object') return obj;
|
|
|
|
try {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
} catch {
|
|
// 순환 참조 등으로 직렬화 실패 시 간단한 복사
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(item => {
|
|
try {
|
|
return JSON.parse(JSON.stringify(item));
|
|
} catch {
|
|
return '[Unserializable]';
|
|
}
|
|
});
|
|
}
|
|
const result: Record<string, any> = {};
|
|
for (const key of Object.keys(obj)) {
|
|
try {
|
|
result[key] = JSON.parse(JSON.stringify(obj[key]));
|
|
} catch {
|
|
result[key] = '[Unserializable]';
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default G7DevToolsCore;
|