#!/usr/bin/env python3 """yida_jsx_pipeline.py — JSX 全链路处理流水线(transform + field_check + lint)。 三块能力一站式聚合(按 region 分段维护): 1) [region: transform] JSX 源码 → React.createElement 调用(纯 Python,零依赖)。 2) [region: field_check] 发布前字段 ID 对账(拉表单 schema 比对 JSX 引用)。 3) [region: lint] 宜搭专属运行时静态检查(30 条规则)。 公开 API(被 yida_custom_page_update.py 直接 import): - transform_jsx(source) -> str - field_check(code, app, *, schema_fetcher=None) -> dict - lint_yida_source(source, filename=None) -> dict - lint_check(source, filename=None) -> dict - extract_form_uuids / extract_field_ids / fetch_form_fields (field_check 辅助) - EVENT_NAME_ALIASES / THEN_CALLBACK_LINE_LIMIT / CALLBACK_SCAN_LINE_LIMIT (lint 常量) CLI: python yida_jsx_pipeline.py transform python yida_jsx_pipeline.py field-check --app APP_X --code-file page.jsx python yida_jsx_pipeline.py lint page.jsx [--json] [--stdin] """ from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path from typing import Callable, Iterable # =========================================================================== # region: transform —— JSX → React.createElement # =========================================================================== _VOID_ELEMENTS = frozenset([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]) _JSX_TAG_START = re.compile(r'<([A-Za-z_][A-Za-z0-9_.]*|>)') def transform_jsx(source): """将 JSX 源码中的 JSX 表达式转换为 React.createElement 调用。 Args: source: 包含 JSX 的 JavaScript 源码字符串 Returns: 转换后不含 JSX 的 JavaScript 源码 """ transformer = _JsxTransformer(source) return transformer.transform() class _JsxTransformer: """递归下降 JSX 转换器""" def __init__(self, source): self.source = source self.pos = 0 self.output = [] def transform(self): while self.pos < len(self.source): if self._at_jsx_start(): jsx_code = self._parse_jsx_element() self.output.append(jsx_code) else: self.output.append(self.source[self.pos]) self.pos += 1 return ''.join(self.output) def _peek(self, offset=0): idx = self.pos + offset if idx < len(self.source): return self.source[idx] return '' def _remaining(self): return self.source[self.pos:] def _at_jsx_start(self): """判断当前 < 是否是 JSX 标签开始(而非比较运算符)""" if self._peek() != '<': return False next_char = self._peek(1) if next_char == '>' or next_char == '/': return self._is_jsx_context() if next_char.isalpha() or next_char == '_': return self._is_jsx_context() return False def _is_jsx_context(self): """通过前文判断当前 < 是否处于 JSX 可出现的上下文""" preceding = ''.join(self.output).rstrip() if not preceding: # 在递归调用中(如表达式容器 {
}),output 为空但来源是合法 JSX # 检查 source 前面是否刚从表达式容器开始 if self.pos == 0: return True return False last_char = preceding[-1] # JSX 可出现在 return/( 之后、赋值/逗号/冒号/三元之后、逻辑运算符之后 if last_char in ('(', ',', ':', '?', '=', '&', '|', '!', ';', '{', '[', '\n'): return True # return '): return True if last_char == '>': return False return False def _skip_whitespace(self): while self.pos < len(self.source) and self.source[self.pos] in ' \t\n\r': self.pos += 1 def _parse_jsx_element(self): """解析一个完整的 JSX 元素,返回 createElement 调用字符串""" assert self.source[self.pos] == '<' self.pos += 1 # skip < # Fragment: <>... if self._peek() == '>': self.pos += 1 # skip > children = self._parse_jsx_children('') # expect if self.source[self.pos:self.pos + 3] == '': self.pos += 3 return self._emit_create_element('React.Fragment', 'null', children) # 解析标签名 tag_name = self._parse_tag_name() # 解析属性 attrs = self._parse_attributes() # 自闭合 /> 或 > self._skip_whitespace() if self.source[self.pos:self.pos + 2] == '/>': self.pos += 2 props_str = self._attrs_to_props(attrs) return self._emit_create_element(self._tag_ref(tag_name), props_str, []) assert self.source[self.pos] == '>', f"Expected > at pos {self.pos}, got: {self.source[self.pos:self.pos+20]}" self.pos += 1 # skip > # 解析子元素 children = self._parse_jsx_children(tag_name) # 解析闭合标签 self._parse_closing_tag(tag_name) props_str = self._attrs_to_props(attrs) return self._emit_create_element(self._tag_ref(tag_name), props_str, children) def _parse_tag_name(self): start = self.pos while self.pos < len(self.source) and (self.source[self.pos].isalnum() or self.source[self.pos] in '_.'): self.pos += 1 return self.source[start:self.pos] def _parse_attributes(self): """解析 JSX 属性列表,返回 [(name, value_str), ...]""" attrs = [] while self.pos < len(self.source): self._skip_whitespace() ch = self._peek() if ch == '/' or ch == '>': break attr_name = self._parse_attribute_name() if not attr_name: break self._skip_whitespace() if self._peek() == '=': self.pos += 1 # skip = self._skip_whitespace() value = self._parse_attribute_value() attrs.append((attr_name, value)) else: # boolean attribute attrs.append((attr_name, 'true')) return attrs def _parse_attribute_name(self): start = self.pos while self.pos < len(self.source): ch = self.source[self.pos] if ch.isalnum() or ch in '_-': self.pos += 1 else: break return self.source[start:self.pos] def _parse_attribute_value(self): """解析属性值,返回 JS 表达式字符串""" ch = self._peek() if ch == '"': return self._parse_string_literal('"') if ch == "'": return self._parse_string_literal("'") if ch == '{': return self._parse_jsx_expression() return 'true' def _parse_string_literal(self, quote): self.pos += 1 # skip opening quote result = [quote] while self.pos < len(self.source): ch = self.source[self.pos] if ch == '\\': result.append(ch) self.pos += 1 if self.pos < len(self.source): result.append(self.source[self.pos]) self.pos += 1 elif ch == quote: result.append(ch) self.pos += 1 break else: result.append(ch) self.pos += 1 return ''.join(result) def _parse_jsx_expression(self): """解析 { ... } 表达式容器,返回内部表达式字符串(可能含嵌套 JSX)""" assert self.source[self.pos] == '{' self.pos += 1 # skip { content = self._read_balanced_braces() # 递归转换内部 JSX inner_transformed = transform_jsx(content) return inner_transformed def _read_balanced_braces(self): """从当前位置读取到匹配 } 为止的内容(处理嵌套 {} 和字符串)""" depth = 1 result = [] while self.pos < len(self.source) and depth > 0: ch = self.source[self.pos] if ch in ('"', "'", '`'): string_content = self._consume_string_in_expr(ch) result.append(string_content) elif ch == '{': depth += 1 result.append(ch) self.pos += 1 elif ch == '}': depth -= 1 if depth > 0: result.append(ch) self.pos += 1 elif ch == '/' and self.pos + 1 < len(self.source): next_ch = self.source[self.pos + 1] if next_ch == '/': comment = self._consume_line_comment() result.append(comment) elif next_ch == '*': comment = self._consume_block_comment() result.append(comment) else: result.append(ch) self.pos += 1 else: result.append(ch) self.pos += 1 return ''.join(result) def _consume_string_in_expr(self, quote): """消费字符串字面量(含转义),返回完整字符串""" result = [quote] self.pos += 1 if quote == '`': return self._consume_template_literal(result) while self.pos < len(self.source): ch = self.source[self.pos] result.append(ch) self.pos += 1 if ch == '\\' and self.pos < len(self.source): result.append(self.source[self.pos]) self.pos += 1 elif ch == quote: break return ''.join(result) def _consume_template_literal(self, result): """消费模板字面量 `...${expr}...`""" while self.pos < len(self.source): ch = self.source[self.pos] result.append(ch) self.pos += 1 if ch == '\\' and self.pos < len(self.source): result.append(self.source[self.pos]) self.pos += 1 elif ch == '`': break elif ch == '$' and self.pos < len(self.source) and self.source[self.pos] == '{': result.append('{') self.pos += 1 # 读到匹配的 } depth = 1 while self.pos < len(self.source) and depth > 0: c = self.source[self.pos] result.append(c) self.pos += 1 if c == '{': depth += 1 elif c == '}': depth -= 1 return ''.join(result) def _consume_line_comment(self): start = self.pos while self.pos < len(self.source) and self.source[self.pos] != '\n': self.pos += 1 return self.source[start:self.pos] def _consume_block_comment(self): start = self.pos self.pos += 2 # skip /* while self.pos < len(self.source) - 1: if self.source[self.pos] == '*' and self.source[self.pos + 1] == '/': self.pos += 2 break self.pos += 1 else: self.pos = len(self.source) return self.source[start:self.pos] def _parse_jsx_children(self, parent_tag): """解析子元素直到遇到 或 """ children = [] text_buf = [] while self.pos < len(self.source): # 检查闭合标签 if parent_tag and self.source[self.pos:self.pos + 2] == '': break ch = self.source[self.pos] if ch == '{': # 先 flush text if text_buf: text = ''.join(text_buf).strip() if text: children.append(repr(text)) text_buf = [] # 表达式子元素 self.pos += 1 expr_content = self._read_balanced_braces() expr_transformed = transform_jsx(expr_content) stripped = expr_transformed.strip() # 跳过纯 JSX 注释表达式 {/* ... */} 或 {// ...} # 否则会在 React.createElement 子节点列表里产生空位导致连续逗号 ,, comment_stripped = re.sub(r'/\*[\s\S]*?\*/', '', stripped) comment_stripped = re.sub(r'//[^\n]*', '', comment_stripped).strip() if not comment_stripped: continue children.append(stripped) elif ch == '<': # 先 flush text if text_buf: text = ''.join(text_buf).strip() if text: children.append(repr(text)) text_buf = [] # 嵌套子元素 child_element = self._parse_jsx_element() children.append(child_element) else: text_buf.append(ch) self.pos += 1 # flush remaining text if text_buf: text = ''.join(text_buf).strip() if text: children.append(repr(text)) return children def _parse_closing_tag(self, expected_tag): """解析 """ if self.source[self.pos:self.pos + 2] != '': self.pos += 1 def _tag_ref(self, tag_name): """标签名转为 createElement 第一参数""" if tag_name[0].isupper(): return tag_name # 组件引用 return repr(tag_name) # HTML 元素用字符串 def _attrs_to_props(self, attrs): """将属性列表转为 props 对象字符串""" if not attrs: return 'null' pairs = [] for name, value in attrs: prop_name = self._normalize_attr_name(name) pairs.append(f'{prop_name}: {value}') return '{' + ', '.join(pairs) + '}' def _normalize_attr_name(self, name): """HTML 属性名 → JS 属性名""" mapping = { 'class': 'className', 'for': 'htmlFor', 'tabindex': 'tabIndex', 'readonly': 'readOnly', 'maxlength': 'maxLength', 'colspan': 'colSpan', 'rowspan': 'rowSpan', 'enctype': 'encType', 'contenteditable': 'contentEditable', 'crossorigin': 'crossOrigin', 'accesskey': 'accessKey', 'autocomplete': 'autoComplete', 'autofocus': 'autoFocus', 'autoplay': 'autoPlay', } normalized = mapping.get(name.lower(), name) # 带 - 的属性名需要引号 if '-' in normalized and not normalized.startswith('data-') and not normalized.startswith('aria-'): return repr(normalized) if '-' in normalized: return repr(normalized) return normalized def _emit_create_element(self, tag, props, children): """生成 React.createElement 调用""" args = [tag, props] args.extend(children) return 'React.createElement(' + ', '.join(args) + ')' # =========================================================================== # region: field_check —— 发布前字段 ID 对账 # =========================================================================== # JSX 里的表单引用:'FORM-XXX' 字面量 _FORM_UUID_RE = re.compile(r"['\"](FORM-[A-Z0-9]+)['\"]") # 字段 ID 命名规范:以 Field/SelectField/... 结尾再接下划线 + 大小写数字 # 用宽松前缀(任何字母)+ 强约束的 fieldId 命名格式(见 yida-custom-page-codegen.md) _FIELD_ID_LITERAL_RE = re.compile(r"['\"]([a-zA-Z][a-zA-Z0-9]*Field_[A-Za-z0-9]+)['\"]") def extract_form_uuids(code: str) -> set[str]: """提取 JSX 中所有 'FORM-XXX' 字面量。""" return set(_FORM_UUID_RE.findall(code)) def extract_field_ids(code: str) -> set[str]: """提取 JSX 中所有疑似字段 ID 的字符串字面量(xxxField_yyyy 形式)。""" return set(_FIELD_ID_LITERAL_RE.findall(code)) def _run_dws(args: list[str]) -> tuple[object | None, str | None]: try: result = subprocess.run(["dws"] + args, capture_output=True, text=True, timeout=120) except FileNotFoundError: return None, "找不到 'dws' 命令(请确认已安装并在 PATH)" except subprocess.TimeoutExpired: return None, "dws 调用超时" if result.returncode != 0: err = result.stderr.strip() or result.stdout.strip() return None, f"dws 失败 (exit {result.returncode}): {err[:200]}" try: return json.loads(result.stdout), None except json.JSONDecodeError as e: return None, f"输出非 JSON: {e}" _FIELD_ID_VALUE_RE = re.compile(r'\b[A-Za-z]+Field_[A-Za-z0-9]+\b') _COMPONENT_LIST_KEYS = ("components", "fields", "data", "items", "result", "children", "list") def _walk_values(obj): if isinstance(obj, dict): yield obj for value in obj.values(): yield from _walk_values(value) elif isinstance(obj, list): for item in obj: yield from _walk_values(item) def _find_nested_value(obj, keys): if isinstance(obj, dict): for key in keys: value = obj.get(key) if value not in (None, ""): return value for value in obj.values(): found = _find_nested_value(value, keys) if found not in (None, ""): return found elif isinstance(obj, list): for item in obj: found = _find_nested_value(item, keys) if found not in (None, ""): return found return None def _normalize_i18n_label(label): if isinstance(label, str): raw = label.strip() if raw.startswith("{") and raw.endswith("}"): try: parsed = json.loads(raw) except json.JSONDecodeError: return label if isinstance(parsed, dict): return (parsed.get("zh_CN") or parsed.get("zh-CN") or parsed.get("text") or parsed.get("pureEn_US") or parsed.get("en_US") or label) return label if isinstance(label, dict): return (label.get("zh_CN") or label.get("zh-CN") or label.get("text") or label.get("pureEn_US") or label.get("en_US") or "") return label or "" def _find_field_id(obj): value = _find_nested_value(obj, ("fieldId", "fieldCode", "field_id", "fieldKey")) if isinstance(value, str): m = _FIELD_ID_VALUE_RE.search(value) if m: return m.group(0) return value key_value = _find_nested_value(obj, ("key", "name", "id")) if isinstance(key_value, str): m = _FIELD_ID_VALUE_RE.search(key_value) if m: return m.group(0) text = json.dumps(obj, ensure_ascii=False) if isinstance(obj, (dict, list)) else str(obj) m = _FIELD_ID_VALUE_RE.search(text) return m.group(0) if m else None def _extract_component_items(data): if isinstance(data, dict): for key in _COMPONENT_LIST_KEYS: value = data.get(key) if isinstance(value, list): return value if isinstance(value, dict): nested = _extract_component_items(value) if nested: return nested if isinstance(data, list): return data found = [] seen = set() for item in _walk_values(data): if not isinstance(item, dict): continue fid = _find_field_id(item) if fid and fid not in seen: seen.add(fid) found.append(item) return found def fetch_form_fields(app: str, form_uuid: str) -> tuple[list[dict] | None, str | None]: """调用 dws yida form components 拿表单字段。""" data, err = _run_dws(["yida", "form", "components", "--app", app, "--form", form_uuid, "--format", "json"]) if err: return None, err return _extract_component_items(data), None def _normalize_field(comp: dict) -> dict: field_id = _find_field_id(comp) label = (_find_nested_value(comp, ("label", "title", "text", "displayName", "nameCn")) or "") label = _normalize_i18n_label(label) return { "fieldId": field_id, "label": label, "componentName": (_find_nested_value(comp, ("componentName", "type", "component")) or ""), } def field_check(code: str, app: str, *, schema_fetcher: Callable[[str], tuple[list[dict] | None, str | None]] | None = None) -> dict: """字段 ID 对账。 Args: code: JSX 源码 app: 应用 appType schema_fetcher: 可注入的字段拉取函数(测试用),签名 (form_uuid)->(fields, err) Returns: { ok: bool, errors: [{type, fieldId?, message}], warnings: [{type, message}], info: {checkedForms, referencedFieldCount, knownFieldCount, missing} } """ fetcher = schema_fetcher or (lambda fu: fetch_form_fields(app, fu)) form_uuids = extract_form_uuids(code) referenced = extract_field_ids(code) if not form_uuids: return { "ok": True, "errors": [], "warnings": [], "info": {"skipped": "no_form_uuid_in_jsx", "referencedFieldCount": len(referenced)}, } if not referenced: return { "ok": True, "errors": [], "warnings": [], "info": {"skipped": "no_field_id_referenced", "checkedForms": sorted(form_uuids)}, } all_known: dict[str, dict] = {} fetch_errors: list[dict] = [] for fu in sorted(form_uuids): fields, err = fetcher(fu) if err or fields is None: fetch_errors.append({"form": fu, "error": err or "unknown"}) continue for c in fields: f = _normalize_field(c) if f["fieldId"]: all_known[f["fieldId"]] = {**f, "form": fu} # 所有表单都拉不到 → 无法对账,作为 warning 放行(避免登录态过期把发布卡死) if fetch_errors and not all_known: return { "ok": True, "errors": [], "warnings": [{ "type": "schema_fetch_all_failed", "message": ("无法拉取任何表单 schema,跳过字段对账。" "可能原因:登录态过期 / 网络异常 / form_uuid 不属于当前应用。" f"详情:{fetch_errors}"), }], "info": {"checkedForms": sorted(form_uuids), "referencedFieldCount": len(referenced), "knownFieldCount": 0}, } missing = sorted(referenced - all_known.keys()) errors: list[dict] = [] if missing: forms_hint = ", ".join(sorted(form_uuids)) for fid in missing: errors.append({ "type": "field_id_not_found", "fieldId": fid, "message": ( f"字段 ID `{fid}` 在表单 schema 中不存在。\n" f" 已检查的表单:{forms_hint}\n" f" 修复建议:\n" f" 1) 用 `python yida_form_inspector.py --action fields-snippet " f"--app {app} --form ` 重新生成 FIELDS 常量\n" f" 2) 或核对 var FORM_UUID 是否填错" ), }) warnings: list[dict] = [] for fe in fetch_errors: warnings.append({ "type": "schema_fetch_partial", "message": (f"表单 {fe['form']} schema 拉取失败(部分对账已跳过):{fe['error']}"), }) return { "ok": len(errors) == 0, "errors": errors, "warnings": warnings, "info": { "checkedForms": sorted(form_uuids), "referencedFieldCount": len(referenced), "knownFieldCount": len(all_known), "missing": missing, }, } # =========================================================================== # region: lint —— 宜搭专属运行时静态检查(30 条规则) # =========================================================================== THEN_CALLBACK_LINE_LIMIT = 50 CALLBACK_SCAN_LINE_LIMIT = 80 EVENT_NAME_ALIASES = { 'onclick': 'onClick', 'onchange': 'onChange', 'oninput': 'onChange', 'onsubmit': 'onSubmit', 'onkeydown': 'onKeyDown', 'onkeyup': 'onKeyUp', 'onkeypress': 'onKeyPress', 'onfocus': 'onFocus', 'onblur': 'onBlur', 'onmouseenter': 'onMouseEnter', 'onmouseleave': 'onMouseLeave', 'onmousedown': 'onMouseDown', 'onmouseup': 'onMouseUp', 'onmousemove': 'onMouseMove', 'oncompositionstart': 'onCompositionStart', 'oncompositionend': 'onCompositionEnd', } # ---- helpers ---- def _is_in_comment_or_string(line: str, match_index: int) -> bool: """判断行内某偏移是否在 // 注释或字符串中(简化版)。""" if match_index is None or match_index < 0: return False before = line[:match_index] if '//' in before: return True quotes = sum(1 for ch in before if ch in ("'", '"')) return quotes % 2 != 0 def _strip_comments_and_strings(source: str) -> str: """剥离 /**/ // 块/行注释 和 ' " ` 字符串,便于纯结构扫描。""" out = re.sub(r'/\*[\s\S]*?\*/', '', source) out = re.sub(r'//.*$', '', out, flags=re.M) out = re.sub(r"'(?:\\.|[^'\\])*'", "''", out) out = re.sub(r'"(?:\\.|[^"\\])*"', '""', out) out = re.sub(r'`(?:\\.|[^`\\])*`', '``', out) return out def _extract_function_body(lines: list[str], start_line_index: int, function_index: int) -> str: """从 lines[start_line_index] 起,找到第一个 `{` 并按 brace 深度抽出函数体(最多扫 80 行)。""" end_line_index = min(len(lines), start_line_index + CALLBACK_SCAN_LINE_LIMIT) source = '\n'.join(lines[start_line_index:end_line_index]) open_brace_index = source.find('{', function_index) if open_brace_index < 0: return '' brace_depth = 0 quote: str | None = None in_line_comment = False in_block_comment = False i = open_brace_index n = len(source) while i < n: ch = source[i] nxt = source[i + 1] if i + 1 < n else '' if in_line_comment: if ch == '\n': in_line_comment = False i += 1 continue if in_block_comment: if ch == '*' and nxt == '/': in_block_comment = False i += 2 continue i += 1 continue if quote: if ch == '\\': i += 2 continue if ch == quote: quote = None i += 1 continue if ch == '/' and nxt == '/': in_line_comment = True i += 2 continue if ch == '/' and nxt == '*': in_block_comment = True i += 2 continue if ch in ("'", '"', '`'): quote = ch i += 1 continue if ch == '{': brace_depth += 1 elif ch == '}': brace_depth -= 1 if brace_depth == 0: return source[open_brace_index + 1:i] i += 1 return source[open_brace_index + 1:] def _function_callback_uses_this(lines: list[str], line_index: int, match_index: int) -> bool: line = lines[line_index] fn_idx = line.find('function', match_index) if fn_idx < 0: return False body = _extract_function_body(lines, line_index, fn_idx) return bool(re.search(r'\bthis\b', _strip_comments_and_strings(body))) def _extract_named_export_function_body(source: str, name: str) -> tuple[int, str] | None: """Extract body of `export function name(...) { ... }` from full source.""" m = re.search(r'\bexport\s+function\s+' + re.escape(name) + r'\s*\(', source) if not m: return None open_brace = source.find('{', m.end()) if open_brace < 0: return None brace_depth = 0 quote: str | None = None in_line_comment = False in_block_comment = False i = open_brace n = len(source) while i < n: ch = source[i] nxt = source[i + 1] if i + 1 < n else '' if in_line_comment: if ch == '\n': in_line_comment = False i += 1 continue if in_block_comment: if ch == '*' and nxt == '/': in_block_comment = False i += 2 continue i += 1 continue if quote: if ch == '\\': i += 2 continue if ch == quote: quote = None i += 1 continue if ch == '/' and nxt == '/': in_line_comment = True i += 2 continue if ch == '/' and nxt == '*': in_block_comment = True i += 2 continue if ch in ("'", '"', '`'): quote = ch i += 1 continue if ch == '{': brace_depth += 1 elif ch == '}': brace_depth -= 1 if brace_depth == 0: start_line = source.count('\n', 0, open_brace) + 1 return start_line, source[open_brace + 1:i] i += 1 start_line = source.count('\n', 0, open_brace) + 1 return start_line, source[open_brace + 1:] # ---- disable map ---- _DISABLE_LINE_RE = re.compile( r'(?:dws|openyida)-lint-disable-line(?:\s+([a-z0-9_,\-\s]+))?', re.I) _DISABLE_NEXT_RE = re.compile( r'(?:dws|openyida)-lint-disable-next-line(?:\s+([a-z0-9_,\-\s]+))?', re.I) def _parse_disable_rules(raw: str | None) -> list[str]: if not raw or not raw.strip(): return ['*'] return [r.strip() for r in re.split(r'[,\s]+', raw) if r.strip()] def _build_disable_map(lines: list[str]) -> dict[int, set[str]]: dmap: dict[int, set[str]] = {} for idx, line in enumerate(lines): line_no = idx + 1 m = _DISABLE_LINE_RE.search(line) if m: dmap.setdefault(line_no, set()).update(_parse_disable_rules(m.group(1))) m2 = _DISABLE_NEXT_RE.search(line) if m2: dmap.setdefault(line_no + 1, set()).update(_parse_disable_rules(m2.group(1))) return dmap def _is_rule_disabled(dmap: dict[int, set[str]], line: int, rule: str) -> bool: rules = dmap.get(line) if not rules: return False return '*' in rules or rule in rules def _push_issue(lst: list[dict], line: int, rule: str, message: str, dmap: dict[int, set[str]]) -> None: if _is_rule_disabled(dmap, line, rule): return for it in lst: if it['line'] == line and it['rule'] == rule and it['message'] == message: return lst.append({'line': line, 'rule': rule, 'message': message}) # ---- aux detectors ---- _THEN_START_RE = re.compile( r'\.then\s*\(\s*(?:function\s*\(|(?:\([^)]*\)|[a-zA-Z_$]\w*)\s*=>)') def _detect_large_then_callbacks(lines: list[str]) -> list[dict]: results: list[dict] = [] in_then = False brace_depth = 0 then_start_line = 0 then_body_start_line = 0 for i, line in enumerate(lines): trimmed = line.strip() if trimmed.startswith('//') or trimmed.startswith('*') or trimmed.startswith('/*'): continue if not in_then: m = _THEN_START_RE.search(line) if m and not _is_in_comment_or_string(line, m.start()): in_then = True then_start_line = i + 1 brace_depth = 0 after = line[m.start():] for ch in after: if ch == '{': brace_depth += 1 elif ch == '}': brace_depth -= 1 then_body_start_line = i + 1 else: for ch in line: if ch == '{': brace_depth += 1 elif ch == '}': brace_depth -= 1 if brace_depth <= 0: callback_line_count = (i + 1) - then_body_start_line if callback_line_count > THEN_CALLBACK_LINE_LIMIT: results.append({'line': then_start_line, 'lineCount': callback_line_count}) in_then = False return results _YIDA_CALL_RE = re.compile(r'this\.utils\.yida\.[A-Za-z_$][\w$]*\s*\(') def _detect_yida_calls_without_catch(source: str, warnings: list[dict], dmap: dict[int, set[str]]) -> None: for m in _YIDA_CALL_RE.finditer(source): line = source.count('\n', 0, m.start()) + 1 statement = source[m.start():m.start() + 600] if '.catch(' not in statement: _push_issue(warnings, line, 'yida-api-catch', '调用 this.utils.yida.* 应跟 .catch() 防止 Promise 异常吞没', dmap) _LABEL_FORMATTER_RE = re.compile( r'\blabel\s*:\s*\{[\s\S]{0,1200}?\bformatter\s*:\s*function\b') def _detect_echarts_rich_label_formatter(source: str, warnings: list[dict], dmap: dict[int, set[str]]) -> None: for m in _LABEL_FORMATTER_RE.finditer(source): line = source.count('\n', 0, m.start()) + 1 block = source[m.start():m.start() + 1600] uses_rich = bool(re.search(r'\brich\s*:', block) or re.search(r"return\s+['\"`][\s\S]{0,300}?\{[A-Za-z0-9_]+\|", block)) if uses_rich: _push_issue(warnings, line, 'echarts-rich-label-formatter', 'echarts label.formatter 使用 rich 模板时建议改用对象写法或 string formatter', dmap) # ---- lifecycle scan (replaces AST ExportNamedDeclaration / ClassMethod) ---- _EXPORT_FN_RE = re.compile(r'\bexport\s+function\s+([A-Za-z][\w]*)\s*\(') _LIFECYCLE_DECL_RE = re.compile( r'(?:^|[\s;{])(componentDidMount|componentWillUnmount)\s*\(') def _scan_lifecycle(source: str, errors: list[dict], dmap: dict[int, set[str]]) -> None: for m in _EXPORT_FN_RE.finditer(source): name = m.group(1) line = source.count('\n', 0, m.start()) + 1 if name.lower() == 'didmount' and name != 'didMount': _push_issue(errors, line, 'lifecycle-case', f'生命周期函数大小写错误:{name} 应为 didMount', dmap) elif name.lower() == 'didunmount' and name != 'didUnmount': _push_issue(errors, line, 'lifecycle-case', f'生命周期函数大小写错误:{name} 应为 didUnmount', dmap) if name in ('componentDidMount', 'componentWillUnmount'): expected = 'didMount' if name == 'componentDidMount' else 'didUnmount' _push_issue(errors, line, 'react-lifecycle-method', f'宜搭页面不使用 React 生命周期:{name} 应为 {expected}', dmap) # 类方法 / 对象方法形式(避免重复行) for m in _LIFECYCLE_DECL_RE.finditer(source): name = m.group(1) line = source.count('\n', 0, m.start()) + 1 expected = 'didMount' if name == 'componentDidMount' else 'didUnmount' _push_issue(errors, line, 'react-lifecycle-method', f'宜搭页面不使用 React 生命周期:{name} 应为 {expected}', dmap) # ---- jsx element scan (replaces AST JSXOpeningElement) ---- # JSX 起始标签匹配:必须自己处理 brace/quote 平衡,避免 `=>` 中的 `>` 被误当作 # 标签结束符(这是 [^<>] 简化正则的固有缺陷)。 _JSX_TAG_NAME_RE = re.compile(r'<([a-zA-Z][\w.\-]*)') # 在属性串内找事件属性 onXxx= _EVENT_ATTR_RE = re.compile(r'\b(on[A-Za-z]\w*)\s*=\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|"[^"]*"|\'[^\']*\')') _LOWER_EVENT_RE = re.compile(r'\bon[a-z]\w+\b') def _iter_jsx_open_tags(source: str): """逐个 yield JSX 起始标签 `(start_pos, tag_name, attr_str)`。 自己跟踪 `{}` 深度和字符串边界,避免 `() => x` 中的 `>` 被误当作标签结束。 """ n = len(source) i = 0 while i < n: m = _JSX_TAG_NAME_RE.match(source, i) if not m: i += 1 continue tag_name = m.group(1) j = m.end() brace = 0 quote: str | None = None end_pos = -1 while j < n: ch = source[j] nxt = source[j + 1] if j + 1 < n else '' if quote: if ch == '\\': j += 2 continue if ch == quote: quote = None j += 1 continue if ch in ("'", '"', '`'): quote = ch j += 1 continue if ch == '{': brace += 1 elif ch == '}': brace -= 1 elif ch == '<' and brace == 0: # 嵌套标签,提前结束当前扫描 break elif ch == '>' and brace == 0: end_pos = j break j += 1 if end_pos < 0: i = m.end() continue attr_str = source[m.end():end_pos] # 兼容自闭合: 的最后 / 落在 attr_str 末尾,去掉 if attr_str.endswith('/'): attr_str = attr_str[:-1] yield (m.start(), tag_name, attr_str) i = end_pos + 1 def _attrs_split(attr_str: str) -> dict[str, str]: """粗略拆解属性串,返回 {name: rawValue}。仅用于 button 缺 handler 检测。""" if not attr_str: return {} result: dict[str, str] = {} # 匹配 name="..." name='...' name={...} name (布尔属性) pattern = re.compile( r'([a-zA-Z][\w-]*)(?:\s*=\s*("[^"]*"|\'[^\']*\'|\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}))?') for m in pattern.finditer(attr_str): name = m.group(1) value = m.group(2) or '' result[name] = value return result def _is_interactive_button(attrs: dict[str, str]) -> bool: if 'disabled' in attrs or 'aria-disabled' in attrs: return True btn_type = attrs.get('type', '').strip('"\'').lower() if btn_type == 'submit': return True for k in ('onClick', 'onMouseDown', 'onKeyDown'): if k in attrs: return True return False def _canonical_event_name(name: str) -> str: if name in EVENT_NAME_ALIASES: return EVENT_NAME_ALIASES[name] if not name or len(name) <= 2: return name return 'on' + name[2].upper() + name[3:] _BARE_HANDLER_IDENT_RE = re.compile(r'^(?:handle|on)[A-Z]\w*$') _THIS_OR_SELF_MEMBER_RE = re.compile(r'^(?:this|self)\.[A-Za-z_$][\w$]*$') _BIND_THIS_RE = re.compile(r'\.bind\s*\(\s*this\s*\)\s*$') def _expr_is_bare_handler_ref(expr: str) -> bool: expr = expr.strip() if _THIS_OR_SELF_MEMBER_RE.match(expr): return True if _BARE_HANDLER_IDENT_RE.match(expr): return True return False def _arrow_returns_bare_handler(value: str) -> bool: """识别 {() => this.foo} / {(a) => handleX} / {() => { this.foo; }} 这类无效箭头。""" inner = value.strip() if inner.startswith('{') and inner.endswith('}'): inner = inner[1:-1].strip() # 匹配箭头函数:(...) => body 或 ident => body m = re.match(r'(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*(.+)$', inner, re.S) if not m: return False body = m.group(1).strip() if body.startswith('{') and body.endswith('}'): body = body[1:-1].strip().rstrip(';').strip() # 多语句:取最后一个表达式判断 parts = [p.strip() for p in re.split(r';|\n', body) if p.strip()] return any(_expr_is_bare_handler_ref(p) for p in parts) return _expr_is_bare_handler_ref(body) # 复用行扫描的 message 模板,确保和 _DIRECT_METHOD_RE/_BIND_INLINE_RE 触发的告警 # 内容完全一致 → _push_issue 的 (line, rule, message) 去重生效。 _MSG_DIRECT_METHOD = 'onXxx={this.foo} 会导致 this 上下文丢失,请改用箭头函数' _MSG_BIND_THIS = 'onXxx={fn.bind(this)} 在宜搭运行时不稳定,请改用箭头函数' def _scan_jsx_elements(source: str, errors: list[dict], warnings: list[dict], dmap: dict[int, set[str]]) -> None: for start_pos, tag, attr_str in _iter_jsx_open_tags(source): line = source.count('\n', 0, start_pos) + 1 is_lower_tag = tag[0].islower() attrs = _attrs_split(attr_str) if is_lower_tag and tag == 'button' and not _is_interactive_button(attrs): _push_issue(errors, line, 'button-missing-handler', '