Files
2026-04-01 10:30:52 +09:00

899 lines
29 KiB
TypeScript

/**
* G7 DevTools 진단 엔진
*
* 증상 기반으로 템플릿 엔진 문제를 자동 진단하는 규칙 엔진
*
* @module DiagnosticEngine
*/
import type {
DiagnosticRule,
DiagnosticCategory,
DiagnosticContext,
DiagnosisResult,
FixSuggestion,
CommonIssue,
StateSnapshot,
ActionLog,
CacheStats,
StateView,
LifecycleInfo,
PerformanceInfo,
NetworkInfo,
ConditionalInfo,
FormInfo,
WebSocketInfo,
} from './types';
import { G7DevToolsCore } from './G7DevToolsCore';
/**
* 진단 규칙 정의
*
* 트러블슈팅 가이드의 주요 이슈를 규칙으로 정의
*/
const diagnosticRules: DiagnosticRule[] = [
// ============================================
// 상태 관련 문제
// ============================================
{
id: 'stale-closure',
name: 'Stale Closure',
category: 'state',
symptoms: [
'이전 값이 전송됨',
'저장 시 오래된 데이터',
'API 호출 시 첫 값만 전송',
'클릭할 때마다 같은 값',
],
detector: (ctx) => {
// 액션 이력에서 동일한 값이 반복되는 패턴 감지
const recentActions = ctx.actionHistory.slice(-10);
const apiCalls = recentActions.filter(a => a.type === 'apiCall');
if (apiCalls.length < 2) return false;
// 연속된 API 호출에서 동일한 파라미터가 사용되는지 확인
for (let i = 1; i < apiCalls.length; i++) {
const prevParams = JSON.stringify(apiCalls[i - 1].params);
const currParams = JSON.stringify(apiCalls[i].params);
if (prevParams === currParams && apiCalls[i].params) {
return true;
}
}
return false;
},
probability: 0.85,
solution: 'G7Core.state.get()._global 사용하여 최신 상태 참조',
docLink: 'troubleshooting-state.md#stale-closure',
codeExample: `
// ❌ 문제: 클로저가 이전 상태 캡처
"body": { "email": "{{_global.email}}" }
// ✅ 해결: 최신 상태 참조
"body": { "email": "{{G7Core.state.get()._global.email}}" }
`,
},
{
id: 'sequence-merge',
name: 'Sequence setState 병합 충돌',
category: 'state',
symptoms: [
'sequence 내 setState 일부 누락',
'여러 setState 중 마지막만 적용',
'상태 업데이트 순서 문제',
],
detector: (ctx) => {
const sequenceActions = ctx.actionHistory.filter(a => a.type === 'sequence');
return sequenceActions.some(seq =>
seq.children?.filter(c => c.type === 'setState').length! > 1
);
},
probability: 0.75,
solution: 'currentState 추적 패턴 사용 또는 단일 setState로 병합',
docLink: 'troubleshooting-state.md#사례3',
codeExample: `
// ❌ 문제: sequence 내 여러 setState
"actions": [
{ "handler": "setState", "params": { "a": 1 } },
{ "handler": "setState", "params": { "b": 2 } }
]
// ✅ 해결: 단일 setState로 병합
"actions": [
{ "handler": "setState", "params": { "a": 1, "b": 2 } }
]
`,
},
{
id: 'init-actions-timing',
name: 'init_actions 타이밍 이슈',
category: 'timing',
symptoms: [
'init_actions 값이 렌더링에 반영 안됨',
'초기 상태가 undefined',
'첫 클릭 시 초기값 손실',
],
detector: (ctx) => {
// 첫 번째 스냅샷이 init_actions이고 이후 상태가 비어있는지 확인
if (ctx.stateHistory.length === 0) return false;
const firstSnapshot = ctx.stateHistory[0];
return firstSnapshot?.source === 'init_actions' &&
Object.keys(firstSnapshot.next || {}).length === 0;
},
probability: 0.70,
solution: 'init_actions 후 _global 상태 병합 확인',
docLink: 'troubleshooting-state.md#init_actions-관련-이슈',
},
{
id: 'datakey-global-init',
name: 'dataKey="_global.xxx" 초기화 누락',
category: 'form',
symptoms: [
'Form dataKey가 전역 상태에 바인딩 안됨',
'Input 값이 _global에 저장 안됨',
'Form 제출 시 빈 값 전송',
],
detector: (ctx) => {
// Form이 _global.으로 시작하는 dataKey를 가지고 있고
// init_actions에서 초기화되지 않은 경우
const formsWithGlobalKey = ctx.form.filter(f =>
f.dataKey?.startsWith('_global.')
);
if (formsWithGlobalKey.length === 0) return false;
// init_actions 스냅샷이 없으면 문제
return !ctx.stateHistory.some(s => s.source === 'init_actions');
},
probability: 0.95,
solution: 'init_actions에서 _global 상태 초기화 필수',
docLink: 'troubleshooting-state.md#datakey-자동-바인딩-이슈',
codeExample: `
// init_actions에서 초기화 필요
"init_actions": [
{
"handler": "setState",
"params": {
"target": "global",
"loginForm": { "email": "", "password": "" }
}
}
]
`,
},
// ============================================
// 캐시 관련 문제
// ============================================
{
id: 'cache-iteration',
name: '캐시 반복 렌더링 문제',
category: 'cache',
symptoms: [
'모든 행이 같은 값',
'iteration에서 첫 항목만 표시',
'DataGrid 데이터 중복',
],
detector: (ctx) => {
// 캐시 히트율이 매우 높고 iteration이 있는 경우
const hitRate = ctx.cacheStats.hitRate ?? 0;
return hitRate > 0.9 && ctx.conditional.iterations.length > 0;
},
probability: 0.90,
solution: 'skipCache: true 추가',
docLink: 'troubleshooting-cache.md#사례4',
codeExample: `
// ❌ 문제: 캐시된 값이 모든 행에 적용
"cellChildren": [{ "props": { "value": "{{item.name}}" } }]
// ✅ 해결: 렌더러에서 skipCache 옵션 사용
// 또는 ActionDispatcher에서 skipCache: true로 호출
`,
},
{
id: 'cache-dynamic-query',
name: '동적 쿼리 파라미터 캐싱',
category: 'cache',
symptoms: [
'페이지네이션이 동작 안함',
'필터 변경해도 같은 결과',
'정렬이 적용 안됨',
],
detector: (ctx) => {
// 네트워크 요청이 반복되지만 같은 결과를 받는 경우
const requests = ctx.network.requestHistory.slice(-5);
if (requests.length < 2) return false;
// 같은 URL에 대한 요청이 있는지 확인
const urls = requests.map(r => r.url.split('?')[0]);
return new Set(urls).size < urls.length;
},
probability: 0.85,
solution: '데이터소스 params에 skipCache: true 추가',
docLink: 'troubleshooting-cache.md#사례2',
},
// ============================================
// 라이프사이클 문제
// ============================================
{
id: 'orphaned-listener',
name: '정리되지 않은 이벤트 리스너',
category: 'lifecycle',
symptoms: [
'메모리 사용량 증가',
'이벤트가 여러 번 실행됨',
'페이지 이동 후에도 이벤트 발생',
],
detector: (ctx) => {
return ctx.lifecycle.orphanedListeners.length > 0;
},
probability: 0.80,
solution: 'useEffect cleanup 또는 removeEventListener 추가',
docLink: 'troubleshooting-components.md#lifecycle',
},
{
id: 'unmount-state-update',
name: '언마운트 후 상태 업데이트',
category: 'lifecycle',
symptoms: [
'Cannot update state on unmounted component',
'비동기 작업 완료 후 에러',
'모달 닫힘 후 에러',
],
detector: (ctx) => {
return ctx.actionHistory.some(a =>
a.status === 'error' &&
a.error?.message?.includes('unmounted')
);
},
probability: 0.90,
solution: 'AbortController 또는 isMounted 플래그 사용',
docLink: 'troubleshooting-state.md#unmount-update',
},
// ============================================
// 성능 문제
// ============================================
{
id: 'excessive-rerenders',
name: '과도한 리렌더링',
category: 'performance',
symptoms: [
'UI가 느림',
'스크롤 버벅임',
'입력 지연',
],
detector: (ctx) => {
// 렌더링 횟수가 50회 이상인 컴포넌트가 있는지
for (const count of ctx.performance.renderCounts.values()) {
if (count > 50) return true;
}
return false;
},
probability: 0.75,
solution: 'React.memo, useMemo, useCallback 사용',
docLink: 'troubleshooting-components.md#performance',
},
{
id: 'binding-eval-flood',
name: '바인딩 평가 과다',
category: 'performance',
symptoms: [
'페이지 로드 느림',
'CPU 사용량 높음',
],
detector: (ctx) => {
return ctx.performance.bindingEvalCount > 1000;
},
probability: 0.85,
solution: '불필요한 {{}} 바인딩 제거, 정적 값 사용',
docLink: 'troubleshooting-cache.md#binding-performance',
},
{
id: 'memory-leak',
name: '잠재적 메모리 누수',
category: 'performance',
symptoms: [
'시간이 지남에 따라 느려짐',
'브라우저 탭 메모리 증가',
'오래 사용 시 크래시',
],
detector: (ctx) => {
return ctx.performance.memoryWarnings.length > 0;
},
probability: 0.70,
solution: '클린업 함수 확인, WeakMap 사용',
docLink: 'troubleshooting-state.md#memory-leak',
},
// ============================================
// 네트워크 문제
// ============================================
{
id: 'request-timeout',
name: 'API 요청 타임아웃',
category: 'network',
symptoms: [
'데이터가 로딩 안됨',
'무한 로딩 스피너',
'타임아웃 에러',
],
detector: (ctx) => {
return ctx.network.requestHistory.some(r =>
r.status === 'timeout' || (r.duration && r.duration > 30000)
);
},
probability: 0.90,
solution: '서버 상태 확인, 타임아웃 설정 조정',
docLink: 'troubleshooting-backend.md#timeout',
},
{
id: 'duplicate-requests',
name: '중복 API 요청',
category: 'network',
symptoms: [
'같은 요청이 여러 번 실행',
'네트워크 탭에 동일 요청 반복',
'불필요한 서버 부하',
],
detector: (ctx) => {
const history = ctx.network.requestHistory;
const recent = history.slice(-20);
const urls = recent.map(r => `${r.method}:${r.url}`);
return urls.length !== new Set(urls).size;
},
probability: 0.85,
solution: 'debounce 추가, 데이터소스 의존성 확인',
docLink: 'troubleshooting-cache.md#duplicate-request',
},
{
id: 'datasource-not-loading',
name: '데이터소스 미로딩',
category: 'network',
symptoms: [
'데이터가 undefined',
'빈 테이블/목록',
'API 호출이 안됨',
],
detector: (ctx) => {
return ctx.network.pendingDataSources.length > 0 &&
ctx.network.activeRequests.length === 0;
},
probability: 0.80,
solution: '데이터소스 strategy 확인, 의존성 확인',
docLink: 'troubleshooting-backend.md#datasource',
},
// ============================================
// 권한/인증 문제
// ============================================
{
id: 'permission-denied',
name: '레이아웃 권한 거부 (403)',
category: 'network',
symptoms: [
'403 Forbidden',
'권한이 없습니다',
'레이아웃 로드 실패',
'페이지 접근 불가',
'빈 화면',
],
detector: (ctx) => {
return ctx.network.requestHistory.some(r => r.statusCode === 403);
},
probability: 0.90,
solution: '사용자 권한 확인 또는 레이아웃 permissions 설정 확인',
docLink: 'frontend/layout-json.md#permissions',
codeExample: `
// 레이아웃 JSON의 permissions 필드 확인
{
"permissions": ["core.products.read"] // 필요 권한
}
// 권한 없이 공개로 설정하려면:
{
"permissions": [] // 빈 배열 = 공개 레이아웃
}
`,
},
{
id: 'auth-required',
name: '인증 필요 (401)',
category: 'network',
symptoms: [
'401 Unauthorized',
'로그인 필요',
'토큰 만료',
'인증되지 않음',
'Unauthenticated',
],
detector: (ctx) => {
return ctx.network.requestHistory.some(r => r.statusCode === 401);
},
probability: 0.95,
solution: '로그인 상태 확인 또는 토큰 갱신 필요',
docLink: 'frontend/auth-system.md',
},
// ============================================
// 무한 스크롤 문제
// ============================================
{
id: 'infinite-scroll-state-reset',
name: '무한 스크롤 상태 미초기화',
category: 'state',
symptoms: [
'삭제 후 스크롤 안됨',
'무한 스크롤이 멈춤',
'hasMore가 false로 유지',
'더 이상 데이터 로드 안됨',
],
detector: (ctx) => {
// infiniteScroll.hasMore가 false이고 refetchDataSource 액션이 있는 경우
const state = ctx.currentState;
const hasRefetch = ctx.actionHistory.some(a => a.type === 'refetchDataSource');
return hasRefetch && state?._global?.infiniteScroll?.hasMore === false;
},
probability: 0.85,
solution: 'refetchDataSource 전에 infiniteScroll 상태 초기화 필요',
docLink: 'frontend/actions-handlers.md#appendDataSource',
codeExample: `
// 삭제 성공 시 상태 초기화 후 refetch
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"infiniteScroll": {
"currentPage": 1,
"hasMore": true,
"isLoadingMore": false
}
}
},
{ "handler": "refetchDataSource", "params": { "id": "list" } }
]
`,
},
{
id: 'scroll-event-not-configured',
name: '스크롤 이벤트 미설정',
category: 'lifecycle',
symptoms: [
'스크롤해도 데이터 로드 안됨',
'무한 스크롤 작동 안함',
'스크롤 이벤트 안잡힘',
],
detector: (ctx) => {
// scroll 타입 액션이 없고 appendDataSource도 없는 경우
const hasScrollAction = ctx.actionHistory.some(a => a.type === 'scroll');
const hasAppendAction = ctx.actionHistory.some(a => a.type === 'appendDataSource');
// 스크롤 액션과 append 액션 모두 없으면 미설정 가능성
return !hasScrollAction && !hasAppendAction && ctx.actionHistory.length > 5;
},
probability: 0.70,
solution: 'scroll 이벤트 핸들러 설정 및 컨테이너 overflow 속성 확인',
docLink: 'frontend/actions.md#scroll-event',
codeExample: `
// 무한 스크롤 컨테이너 설정 예시
{
"type": "Div",
"props": {
"className": "overflow-y-auto h-full"
},
"actions": {
"scroll": [
{
"handler": "appendDataSource",
"params": {
"id": "list",
"dataPath": "data",
"sourcePath": "{{_global.infiniteScroll.currentPage}}"
}
}
]
}
}
`,
},
{
id: 'append-datasource-wrong-path',
name: 'appendDataSource 경로 오류',
category: 'state',
symptoms: [
'무한 스크롤 시 데이터 덮어씀',
'기존 데이터가 사라짐',
'데이터가 병합 안됨',
],
detector: (ctx) => {
// appendDataSource 액션 후 데이터가 줄어드는 패턴
const appendActions = ctx.actionHistory.filter(a => a.type === 'appendDataSource');
// 연속된 append 후 데이터 수가 줄어들면 문제
return appendActions.length >= 2 && appendActions.some(a => a.status === 'error');
},
probability: 0.75,
solution: 'dataPath와 sourcePath 설정 확인, 데이터소스 dataPath가 병합 대상인지 확인',
docLink: 'frontend/actions-handlers.md#appendDataSource',
},
// ============================================
// WebSocket 문제
// ============================================
{
id: 'websocket-disconnected',
name: 'WebSocket 연결 끊김',
category: 'websocket',
symptoms: [
'실시간 업데이트 안됨',
'알림이 오지 않음',
],
detector: (ctx) => {
return ctx.websocket.connectionState === 'disconnected';
},
probability: 0.90,
solution: '네트워크 상태 확인, 재연결 로직 확인',
docLink: 'troubleshooting-backend.md#websocket',
},
// ============================================
// 조건부 렌더링 문제
// ============================================
{
id: 'if-condition-always-false',
name: 'if 조건 항상 false',
category: 'conditional',
symptoms: [
'컴포넌트가 표시 안됨',
'조건부 영역이 비어있음',
],
detector: (ctx) => {
return ctx.conditional.ifConditions.some(c =>
c.evaluatedValue === false && c.evaluationCount > 5
);
},
probability: 0.85,
solution: 'if 조건식 확인, 상태값 확인',
docLink: 'troubleshooting-state.md#if-condition',
},
{
id: 'iteration-empty',
name: 'iteration 소스 비어있음',
category: 'conditional',
symptoms: [
'반복 영역이 비어있음',
'목록이 표시 안됨',
],
detector: (ctx) => {
return ctx.conditional.iterations.some(i => i.sourceLength === 0);
},
probability: 0.80,
solution: 'iteration source 표현식 확인, 데이터 로딩 확인',
docLink: 'troubleshooting-guide.md#iteration',
},
{
id: 'iteration-item-var',
name: 'iteration 변수명 오류',
category: 'conditional',
symptoms: [
'반복 항목 내 바인딩 빈값',
'올바른 개수는 표시되나 내용 없음',
],
detector: (ctx) => {
// item 또는 index가 변수명으로 사용되면 문제 가능성
return ctx.conditional.iterations.some(i =>
i.itemVar === 'item' || i.indexVar === 'index'
);
},
probability: 0.95,
solution: 'item_var, index_var 사용 (item, index 아님)',
docLink: 'troubleshooting-guide.md#iteration-바인딩-문제',
codeExample: `
// ❌ 잘못된 속성명
"iteration": {
"source": "{{data}}",
"item": "file", // 잘못됨!
"index": "fileIdx" // 잘못됨!
}
// ✅ 올바른 속성명
"iteration": {
"source": "{{data}}",
"item_var": "file",
"index_var": "fileIdx"
}
`,
},
// ============================================
// Form 문제
// ============================================
{
id: 'form-no-datakey',
name: 'Form dataKey 미설정',
category: 'form',
symptoms: [
'Input 값이 저장 안됨',
'Form 제출 시 빈 값',
],
detector: (ctx) => {
return ctx.form.some(f => !f.dataKey);
},
probability: 0.90,
solution: 'Form에 dataKey prop 추가',
docLink: 'troubleshooting-components.md#form-datakey',
},
{
id: 'input-no-name',
name: 'Input name 미설정',
category: 'form',
symptoms: [
'자동 바인딩 안됨',
'Input 값 변경이 상태에 반영 안됨',
],
detector: (ctx) => {
return ctx.form.some(f =>
f.inputs.some(i => !i.name)
);
},
probability: 0.95,
solution: 'Input에 name prop 추가',
docLink: 'troubleshooting-components.md#input-name',
},
// ============================================
// i18n 문제
// ============================================
{
id: 'i18n-key-missing',
name: '번역 키 누락',
category: 'i18n',
symptoms: [
'$t:key가 그대로 표시됨',
'번역되지 않은 텍스트',
],
detector: (_ctx) => {
// DOM에서 $t: 패턴 검색
if (typeof document !== 'undefined') {
return document.body.innerHTML.includes('$t:');
}
return false;
},
probability: 0.95,
solution: '번역 파일에 키 추가',
docLink: 'troubleshooting-components.md#i18n',
},
];
/**
* 진단 엔진 클래스
*
* 증상을 기반으로 템플릿 엔진 문제를 자동 진단
*/
export class DiagnosticEngine {
private devTools: G7DevToolsCore;
constructor() {
this.devTools = G7DevToolsCore.getInstance();
}
/**
* 증상을 분석하여 진단 결과 반환
*
* @param symptoms 사용자가 보고한 증상 목록
* @returns 진단 결과 (신뢰도 순 정렬)
*/
analyze(symptoms: string[]): DiagnosisResult[] {
const context = this.buildContext();
const matches: DiagnosisResult[] = [];
for (const rule of diagnosticRules) {
// 증상 매칭 확인
const symptomMatch = symptoms.some(s =>
rule.symptoms.some(rs =>
rs.toLowerCase().includes(s.toLowerCase()) ||
s.toLowerCase().includes(rs.toLowerCase())
)
);
// 자동 감지 실행
let detectorMatch = false;
try {
detectorMatch = rule.detector(context);
} catch {
// 감지기 실행 실패 시 무시
}
if (symptomMatch || detectorMatch) {
matches.push({
rule,
confidence: this.calculateConfidence(rule, symptoms, symptomMatch, detectorMatch),
evidence: this.collectEvidence(rule, context),
});
}
}
// 신뢰도 순 정렬
return matches.sort((a, b) => b.confidence - a.confidence);
}
/**
* 진단 결과에 대한 해결 제안 반환
*
* @param diagnosis 진단 결과
* @returns 해결 제안
*/
suggestFix(diagnosis: DiagnosisResult): FixSuggestion {
return {
title: diagnosis.rule.name,
description: diagnosis.rule.solution,
codeExample: diagnosis.rule.codeExample,
docLink: diagnosis.rule.docLink,
};
}
/**
* 자주 발생하는 이슈 목록 반환
*
* @returns 빈도순 정렬된 이슈 목록
*/
getCommonIssues(): CommonIssue[] {
// 현재 컨텍스트에서 감지된 이슈 수집
const context = this.buildContext();
const detected: { rule: DiagnosticRule; count: number }[] = [];
for (const rule of diagnosticRules) {
try {
if (rule.detector(context)) {
detected.push({ rule, count: 1 });
}
} catch {
// 무시
}
}
return detected
.sort((a, b) => b.count - a.count)
.map(d => ({
id: d.rule.id,
name: d.rule.name,
description: d.rule.solution,
frequency: d.count,
}));
}
/**
* 특정 카테고리의 규칙만 반환
*
* @param category 진단 카테고리
* @returns 해당 카테고리의 규칙 목록
*/
getRulesByCategory(category: DiagnosticCategory): DiagnosticRule[] {
return diagnosticRules.filter(r => r.category === category);
}
/**
* 모든 진단 규칙 반환
*/
getAllRules(): DiagnosticRule[] {
return [...diagnosticRules];
}
/**
* 진단 컨텍스트 구축
*/
private buildContext(): DiagnosticContext {
return {
stateHistory: this.devTools.getStateHistory(),
actionHistory: this.devTools.getActionHistory(),
cacheStats: this.devTools.getCacheStats(),
currentState: this.devTools.getState(),
lifecycle: this.devTools.getLifecycleInfo(),
performance: this.devTools.getPerformanceInfo(),
network: this.devTools.getNetworkInfo(),
conditional: this.devTools.getConditionalInfo(),
form: this.devTools.getFormInfo(),
websocket: this.devTools.getWebSocketInfo(),
stateAtTime: (timestamp: number) => this.devTools.getStateAtTime(timestamp),
layoutHas: (prop: string, value: string) => this.checkLayoutHas(prop, value),
};
}
/**
* 신뢰도 계산
*/
private calculateConfidence(
rule: DiagnosticRule,
symptoms: string[],
symptomMatch: boolean,
detectorMatch: boolean
): number {
let confidence = rule.probability;
// 증상 매칭 시 신뢰도 증가
if (symptomMatch) {
const matchCount = symptoms.filter(s =>
rule.symptoms.some(rs =>
rs.toLowerCase().includes(s.toLowerCase()) ||
s.toLowerCase().includes(rs.toLowerCase())
)
).length;
confidence += matchCount * 0.05;
}
// 자동 감지 시 신뢰도 증가
if (detectorMatch) {
confidence += 0.1;
}
// 최대 0.99로 제한
return Math.min(confidence, 0.99);
}
/**
* 증거 수집
*/
private collectEvidence(rule: DiagnosticRule, context: DiagnosticContext): string[] {
const evidence: string[] = [];
switch (rule.category) {
case 'state':
if (context.stateHistory.length > 0) {
evidence.push(`상태 변경 이력: ${context.stateHistory.length}개`);
}
break;
case 'cache':
evidence.push(`캐시 히트율: ${((context.cacheStats.hitRate ?? 0) * 100).toFixed(1)}%`);
break;
case 'lifecycle':
if (context.lifecycle.orphanedListeners.length > 0) {
evidence.push(`정리되지 않은 리스너: ${context.lifecycle.orphanedListeners.length}개`);
}
break;
case 'performance':
evidence.push(`바인딩 평가 횟수: ${context.performance.bindingEvalCount}`);
break;
case 'network':
evidence.push(`진행 중인 요청: ${context.network.activeRequests.length}개`);
break;
case 'form':
evidence.push(`추적 중인 Form: ${context.form.length}개`);
break;
}
return evidence;
}
/**
* 레이아웃에서 특정 속성/값 존재 확인
*/
private checkLayoutHas(prop: string, value: string): boolean {
// 현재 레이아웃 JSON에서 특정 패턴 검색
// 실제 구현에서는 레이아웃 데이터 접근 필요
try {
const g7Core = (window as any).G7Core;
const layoutData = g7Core?.getCurrentLayout?.();
if (!layoutData) return false;
const jsonStr = JSON.stringify(layoutData);
return jsonStr.includes(`"${prop}":"${value}`) ||
jsonStr.includes(`"${prop}": "${value}`);
} catch {
return false;
}
}
}
/**
* DiagnosticEngine 싱글톤 인스턴스
*/
let instance: DiagnosticEngine | null = null;
/**
* DiagnosticEngine 싱글톤 인스턴스 반환
*/
export function getDiagnosticEngine(): DiagnosticEngine {
if (!instance) {
instance = new DiagnosticEngine();
}
return instance;
}