Files
HeuJung 953616552f fix(core,engine,ecommerce,admin_basic): 렌더 경로 판정·파서 단일화로 표현식 비대칭 일괄 해소
표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이
갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상
대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다.

판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은
회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다.

- 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 /
 _computed stale 전파 / 원본 타입 소실)
- 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설
- 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인
- BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환
- iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬
- 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임
- 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지
 못하던 결함 수정
- SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다
- audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소,
 신규 룰 5종 등재
2026-08-04 09:05:00 +09:00

294 lines
10 KiB
TypeScript

/**
* 바인딩 식의 **형태 판정** 단일 출처
*
* "이 문자열이 단일 바인딩인가", "이 식이 경로인가 표현식인가 파이프인가" 를 판정하는
* 로직은 엔진 곳곳에 복제되어 있었고, 복사본마다 인식 문자 집합이 달라 **같은 식이
* 어느 렌더 경로를 타느냐에 따라 다른 결과**를 냈다. 판정은 이 모듈 하나로 모은다.
*
* 통일하는 것은 **판정뿐**이다. 캐시 정책·예외 처리·DevTools 추적·후처리(번역, raw 마커)는
* 지점마다 정당한 이유로 다르므로 각 호출 지점이 그대로 유지한다.
*
* 순수 함수만 두고 런타임 의존은 `PipeRegistry`(파이프 스캐너) 하나로 제한한다 —
* `DataBindingEngine` 은 `import type` 으로만 참조해 순환 import 를 만들지 않는다.
*
* @since engine-v1.55.0
*/
import { hasPipes } from './PipeRegistry';
import type { BindingContext, BindingOptions, DataBindingEngine } from './DataBindingEngine';
/**
* JavaScript 리터럴 값 매핑
*
* 경로 탐색(`resolve`)이 `true`/`null` 같은 리터럴을 컨텍스트 키로 오인하는 것을 막는다.
* `undefined` 는 `Map.get()` 의 기본 반환값과 구분되지 않으므로 `has()` 로 판정한다.
*/
export const LITERALS = new Map<string, unknown>([
['true', true],
['false', false],
['null', null],
['undefined', undefined],
]);
/**
* 바인딩 식의 형태
*
* - `empty`: `{{}}` — 식이 없음 (평가 대상 아님)
* - `literal`: `true` / `false` / `null` / `undefined`
* - `pipe`: 파이프(`|`)가 포함된 식
* - `expression`: 연산자·괄호·리터럴 등이 포함된 JS 표현식
* - `path`: 점/대괄호 경로 (`user.profile.name`, `items[0]`)
*/
export type ExpressionShape = 'empty' | 'literal' | 'pipe' | 'expression' | 'path';
/**
* 문자열 전체가 단일 `{{ }}` 바인딩인지 판정하고 내부 식을 반환
*
* 판정 기준(정본):
* - 따옴표(`'`, `"`, `` ` ``) 안의 중괄호는 세지 않는다 — `{{a['}']}}` 같은 식이 잘리지 않는다.
* - 따옴표 안의 백슬래시 이스케이프(`'\''`)는 다음 한 글자를 소비한다.
* - 중괄호 균형을 추적해, 외곽 바인딩이 중간에 닫히면(`"{{a}} {{b}}"`) 단일 바인딩이 아니다.
* - 균형이 맞지 않으면(`{{a} }}`) 단일 바인딩으로 보지 않는다.
* - 결과는 trim 한다.
*
* `{{}}` 는 빈 문자열을 반환한다 — "바인딩이긴 하나 식이 없음"이며, 호출 지점은
* `classifyExpression` 이 돌려주는 `'empty'` 로 이 상태를 구분해 처리한다.
*
* @param value 검사할 문자열
* @returns 단일 바인딩이면 내부 식(trim), 아니면 null
*/
export function extractSingleBinding(value: string): string | null {
if (typeof value !== 'string') return null;
if (!value.startsWith('{{') || !value.endsWith('}}') || value.length < 4) return null;
const inner = value.slice(2, -2);
let quote: string | null = null;
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (quote !== null) {
if (char === '\\') {
i++; // 이스케이프된 문자 1개 소비
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '{') {
depth++;
continue;
}
if (char === '}') {
if (depth > 0) {
depth--;
continue;
}
// 깊이 0 에서의 `}` → 외곽 바인딩이 중간에 닫혔거나 균형이 맞지 않는다.
return null;
}
}
return depth === 0 ? inner.trim() : null;
}
/**
* 문자열에서 찾은 `{{ }}` 바인딩 한 건
*/
export interface ScannedBinding {
/** 원본 문자열에서 `{{` 가 시작하는 인덱스 */
start: number;
/** 원본 문자열에서 `}}` 다음 인덱스 (slice 종료 지점) */
end: number;
/** `{{ }}` 를 벗긴 내부 식 (trim 하지 않은 원본) */
expr: string;
}
/**
* 문자열 안의 모든 `{{ }}` 바인딩 위치를 스캔
*
* 정규식 `\{\{([^}]+)\}\}` 은 식 안에 `}` 가 들어가면(`{{(row.meta ?? {}) | json}}`)
* 매칭에 실패한다. `String.replace` 는 매칭이 없으면 입력을 그대로 돌려주므로,
* 그 결과는 예외도 경고도 없이 **원본 `{{...}}` 문자열이 화면에 노출**되는 것이었다.
*
* 이 스캐너는 따옴표와 중괄호 깊이를 추적해 바인딩의 끝을 정확히 찾는다.
* 닫히지 않은 `{{` 는 바인딩으로 보지 않고 리터럴로 남긴다(종전 정규식과 같은 결과).
*
* @param template 검사할 문자열
* @returns 발견된 바인딩 목록 (등장 순서)
*
* @since engine-v1.55.1
*/
export function scanBindings(template: string): ScannedBinding[] {
const found: ScannedBinding[] = [];
if (typeof template !== 'string') return found;
for (let i = 0; i < template.length - 1; i++) {
if (template[i] !== '{' || template[i + 1] !== '{') continue;
let quote: string | null = null;
let depth = 0;
let closed = -1;
for (let j = i + 2; j < template.length; j++) {
const char = template[j];
if (quote !== null) {
if (char === '\\') {
j++;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '{') {
depth++;
continue;
}
if (char === '}') {
if (depth > 0) {
depth--;
continue;
}
if (template[j + 1] === '}') {
closed = j;
break;
}
// 깊이 0 에서 짝이 맞지 않는 `}` — 바인딩이 아니다.
break;
}
}
if (closed === -1) continue; // 닫히지 않음 → 리터럴로 취급
found.push({ start: i, end: closed + 2, expr: template.slice(i + 2, closed) });
i = closed + 1; // 루프의 i++ 와 합쳐 바인딩 다음으로 이동
}
return found;
}
/**
* 복잡한 표현식 여부 판정
*
* true 면 `evaluateExpression`(JS 평가)으로, false 면 `resolve`(경로 탐색)로 보낸다.
*
* 인식 문자 집합은 종전 6개 방언 중 **최대집합**을 정본으로 삼는다. 좁은 방언은
* 배열/객체 리터럴(`['a','b']`)이나 따옴표 키 인덱싱(`query['sales_status[]']`)을
* "단순 경로" 로 오판해 경로 탐색으로 보냈고, 그 결과는 조용한 `undefined` 였다.
*
* `|`(파이프)도 집합에 포함되지만, 분류 순서상 파이프가 먼저 걸러지므로
* 파이프 식이 이 판정에 도달하지 않는다(`classifyExpression` 참조).
*
* @param expr 판정할 식 (`{{ }}` 제거된 상태)
* @returns 복잡한 표현식 여부
*/
export function isComplexExpression(expr: string): boolean {
return /[?:|&!+\-*/<>=()[\]{}]/.test(expr);
}
/**
* 바인딩 식의 형태 분류
*
* 분류 순서가 곧 라우팅 우선순위다: 빈 식 → 리터럴 → 파이프 → 표현식 → 경로.
* 리터럴을 파이프보다 먼저 보는 이유는 `true`/`null` 이 경로로 해석되면 안 되기 때문이고,
* 리터럴 문자열 자체에는 파이프가 들어갈 수 없어 순서 충돌이 없다.
*
* @param expr 분류할 식 (`{{ }}` 제거된 상태)
* @returns 식의 형태
*/
export function classifyExpression(expr: string): ExpressionShape {
const trimmed = typeof expr === 'string' ? expr.trim() : '';
if (trimmed === '') return 'empty';
if (LITERALS.has(trimmed)) return 'literal';
if (hasPipes(trimmed)) return 'pipe';
if (isComplexExpression(trimmed)) return 'expression';
return 'path';
}
/**
* 단일 바인딩 값 해석 옵션
*/
export interface ResolveSingleBindingOptions {
/** 캐시 사용 안 함 (반복 렌더 등) */
skipCache?: boolean;
/** DevTools 표현식 추적 정보 */
trackingInfo?: { componentId?: string; componentName?: string; propName?: string };
/**
* 평가 실패 시 호출된다. 반환값이 해석 결과가 된다.
* 미지정이면 예외를 그대로 전파한다 — 실패 정책은 호출 지점의 몫이다.
*/
onError?: (error: unknown, expr: string) => unknown;
/**
* 빈 바인딩(`{{}}`)을 만났을 때 호출된다. 미지정이면 조용히 `undefined` 를 반환한다.
*/
onEmpty?: (expr: string) => void;
// `format` 옵션은 두지 않는다.
//
// 이 함수는 **판정과 라우팅**만 담당하고 결과를 원본 타입으로 돌려준다. 서식은
// 호출 지점마다 규칙이 다르다 — `text` 노드는 문자열이 필요하고, prop 은 배열/객체/
// boolean 을 그대로 넘겨야 한다(원본 타입 소실이 engine-v1.54.9 에서 고친 결함이다).
// 여기에 서식을 얹으면 그 결함을 옵션 형태로 되살리는 셈이라, 서식은 호출 지점에 남긴다.
}
/**
* 단일 바인딩 식을 형태에 따라 알맞은 평가기로 보내 값을 해석
*
* 형태 판정(`classifyExpression`)과 라우팅만 담당하고, 실행은 전달받은 엔진에 위임한다.
* 결과는 **원본 타입**이다 — 문자열 서식이 필요한 호출 지점이 스스로 적용한다.
*
* @param expr 단일 바인딩 내부 식 (`{{ }}` 와 `raw:` 접두사가 제거된 상태)
* @param context 데이터 컨텍스트
* @param engine DataBindingEngine 인스턴스
* @param options 해석 옵션
* @returns 해석된 값 (빈 바인딩이면 undefined)
*/
export function resolveSingleBindingValue(
expr: string,
context: BindingContext,
engine: DataBindingEngine,
options?: ResolveSingleBindingOptions,
): unknown {
const trimmed = typeof expr === 'string' ? expr.trim() : '';
const shape = classifyExpression(trimmed);
const bindingOptions: BindingOptions | undefined = options?.skipCache
? { skipCache: true }
: undefined;
if (shape === 'empty') {
options?.onEmpty?.(expr);
return undefined;
}
if (shape === 'literal') {
return LITERALS.get(trimmed);
}
try {
if (shape === 'pipe') {
return engine.evaluatePipeExpression(trimmed, context, bindingOptions, options?.trackingInfo);
}
if (shape === 'expression') {
return engine.evaluateExpression(trimmed, context, options?.trackingInfo);
}
return engine.resolve(trimmed, context, bindingOptions, options?.trackingInfo);
} catch (error) {
if (options?.onError) return options.onError(error, trimmed);
throw error;
}
}