Files
2026-09-02 11:44:52 +08:00

1589 lines
61 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <jsx_file>
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:
# 在递归调用中(如表达式容器 {<div>}),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 <xxx
if preceding.endswith('return'):
return True
# 箭头函数 => <xxx
if preceding.endswith('=>'):
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)
# 解析闭合标签 </tagName>
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):
"""解析子元素直到遇到 </parent_tag> 或 </>"""
children = []
text_buf = []
while self.pos < len(self.source):
# 检查闭合标签
if parent_tag and self.source[self.pos:self.pos + 2] == '</':
break
if not parent_tag and self.source[self.pos:self.pos + 3] == '</>':
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):
"""解析 </tagName>"""
if self.source[self.pos:self.pos + 2] != '</':
return
self.pos += 2 # skip </
tag = self._parse_tag_name()
self._skip_whitespace()
if self.pos < len(self.source) and self.source[self.pos] == '>':
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 <FORM-XXX>` 重新生成 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]
# 兼容自闭合:<tag .../> 的最后 / 落在 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',
'<button> 必须挂载 onClick / onMouseDown / onKeyDown 之一,'
'或显式 disabled / type="submit"', dmap)
# 事件属性扫描
for evt_match in _EVENT_ATTR_RE.finditer(attr_str):
attr_name = evt_match.group(1)
attr_value = evt_match.group(2) or ''
attr_line_offset = attr_str[:evt_match.start()].count('\n')
attr_line = line + attr_line_offset
# event-lowercase: onclick / onchange 等小写形式
if _LOWER_EVENT_RE.fullmatch(attr_name) and attr_name[2:3].islower():
canon = _canonical_event_name(attr_name)
_push_issue(errors, attr_line, 'event-lowercase',
f'JSX 事件属性大小写错误:{attr_name} 应为 {canon}', dmap)
continue
# 仅处理 onXxx 形式
if not re.match(r'^on[A-Z]', attr_name):
continue
# 字符串值(非大括号包裹):event-call-result
if attr_value.startswith('"') or attr_value.startswith("'"):
_push_issue(errors, attr_line, 'event-call-result',
f'{attr_name} 必须传函数引用,不能是字符串', dmap)
continue
if not (attr_value.startswith('{') and attr_value.endswith('}')):
continue
inner = attr_value[1:-1].strip()
# event-direct-method: this.xxx 或 self.xxx 单独成员引用
if _THIS_OR_SELF_MEMBER_RE.match(inner):
_push_issue(errors, attr_line, 'event-direct-method',
_MSG_DIRECT_METHOD, dmap)
continue
# event-bind-this: xxx.bind(this)
if _BIND_THIS_RE.search(inner):
_push_issue(errors, attr_line, 'event-bind-this',
_MSG_BIND_THIS, dmap)
continue
# event-call-result: 形如 fn(args) 的调用结果
if (re.search(r'\)\s*$', inner) and '=>' not in inner
and not inner.startswith('function')
and not inner.startswith('(')):
if re.match(r'^[A-Za-z_$][\w$.]*\s*\(', inner):
_push_issue(errors, attr_line, 'event-call-result',
f'{attr_name}={{fn()}} 是把调用结果当 handler,请去掉括号',
dmap)
continue
# event-noop-arrow: () => this.foo 或 () => { this.foo }
if '=>' in inner and _arrow_returns_bare_handler(attr_value):
_push_issue(errors, attr_line, 'event-noop-arrow',
f'{attr_name}={{() => this.foo}} 没有真正调用 handler'
f'请改成 () => this.foo()', dmap)
# ---- main lint ----
_RENDERJSX_RE = re.compile(r'export\s+function\s+renderJsx\s*\(')
_REACT_HOOKS_RE = re.compile(r'\buse(State|Effect|Memo|Callback|Ref|Reducer|Context)\s*\(')
_UNSUPPORTED_HOOKS_RE = re.compile(r'\buse(Memo|Callback|Ref|Reducer|Context)\s*\(')
_USE_STATE_RE = re.compile(r'\buseState\s*\(')
_SUPPORTED_USE_STATE_PREFIX_RE = re.compile(
r'\b(?:var|let|const)\s+\[\s*[A-Za-z_$][\w$]*\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*$')
_EXPORT_DEFAULT_RE = re.compile(r'export\s+default\b')
_EXPORT_DEFAULT_PAGE_RE = re.compile(r'export\s+default\s+function\s+Page\s*\(')
_HAS_JSX_TAG_RE = re.compile(r'<[A-Za-z][\w.-]*(\s|>)')
_IMPORT_REQUIRE_RE = re.compile(r'^\s*import\s+|\brequire\s*\(')
_SUPPORTED_MODERN_IMPORT_RE = re.compile(
r'^\s*import\b[\s\S]*?\bfrom\s+[\'"](?:react|react-dom)[\'"]\s*;?\s*$'
r'|^\s*import\s+[\'"](?:react|react-dom)[\'"]\s*;?\s*$')
_LEGACY_ECHARTS_MAP_RE = re.compile(
r'(?:echarts(?:\.min)?\.js/map/js/china|echarts/map/js/china|map/js/china(?:\.js)?)',
re.I)
_EVENT_FUNCTION_RE = re.compile(r'on[A-Z]\w+=\{function\b')
_DIRECT_METHOD_RE = re.compile(r'on[A-Z]\w+=\{this\.[A-Za-z_$][\w$]*\s*\}')
_BIND_INLINE_RE = re.compile(r'on[A-Z]\w+=\{[^}]*\.bind\(this\)[^}]*\}')
_CONST_LET_RE = re.compile(r'\b(const|let)\s+')
_COMPUTED_RE = re.compile(r'\{\s*\[[^\]]+\]\s*:')
_PAD_METHOD_RE = re.compile(r'\.(padStart|padEnd)\s*\(')
_MAP_FILTER_FN_RE = re.compile(r'\.(map|filter)\s*\(\s*function\b')
_FOREACH_FN_RE = re.compile(r'\.forEach\s*\(\s*function\b')
_CONTROLLED_INPUT_RE = re.compile(r'<input\b[^>]*\bvalue=')
_NATIVE_SELECT_RE = re.compile(r'<select\b')
_IFRAME_NAV_RE = re.compile(
r'<a\b(?=[^>]*(?:aliwork\.com|yidaapps\.com|/preview/|/workbench))'
r'(?![^>]*\btarget=([\'"]?)_top\1)'
r'(?![^>]*\btarget=([\'"]?)_blank\2)[^>]*>',
re.I)
_TOP_LOCATION_RE = re.compile(
r'window\.location\.href\s*=\s*[^;\n]*(?:aliwork\.com|yidaapps\.com|/preview/|/workbench)',
re.I)
_PAGE_SIZE_RE = re.compile(r'\bpageSize\s*:\s*(\d+)')
_CUSTOM_STATE_DECL_RE = re.compile(r'\bvar\s+_customState\b')
_CUSTOM_STATE_REF_RE = re.compile(r'(?<![.\w$])_customState\b')
_SELF_METHOD_ASSIGN_RE = re.compile(r'\b(?:self|this)\.[A-Za-z_$][\w$]*\s*=\s*function\s*\(')
_RENDER_YIDA_API_RE = re.compile(r'\b(?:Yida\.api|(?:self|this)\.utils\.yida)\b')
_DID_MOUNT_EMULATION_RE = re.compile(r'\bdidMountCalled\b')
def _has_unsupported_use_state_pattern(source: str) -> bool:
"""Return True if any useState call is not compiler-lowerable.
The page compiler currently lowers only declaration-form hooks:
var [state, setState] = useState(initialValue);
It supports multiline initial values, but the declaration prefix itself must
be on the same line as useState so that the generated state name is stable.
"""
for match in _USE_STATE_RE.finditer(source):
line_start = source.rfind('\n', 0, match.start()) + 1
prefix = source[line_start:match.start()]
if not _SUPPORTED_USE_STATE_PREFIX_RE.search(prefix):
return True
return False
def _scan_render_side_effects(source: str, errors: list[dict],
dmap: dict[int, set[str]]) -> None:
render = _extract_named_export_function_body(source, 'renderJsx')
if render is None:
return
start_line, body = render
clean_body = _strip_comments_and_strings(body)
for m in _SELF_METHOD_ASSIGN_RE.finditer(clean_body):
line = start_line + clean_body.count('\n', 0, m.start())
_push_issue(errors, line, 'method-defined-in-render',
'不要在 renderJsx 内写 self.xxx = function()。'
'宜搭运行时方法必须提升为 export function xxx()'
'renderJsx 只负责渲染', dmap)
for m in _DID_MOUNT_EMULATION_RE.finditer(clean_body):
line = start_line + clean_body.count('\n', 0, m.start())
_push_issue(errors, line, 'lifecycle-emulated-in-render',
'不要在 renderJsx 内用 didMountCalled 模拟生命周期。'
'初始化加载请写 export function didMount()', dmap)
for m in _RENDER_YIDA_API_RE.finditer(clean_body):
line = start_line + clean_body.count('\n', 0, m.start())
_push_issue(errors, line, 'api-call-in-render',
'renderJsx 中不要直接调用 Yida API。'
'异步加载放到 didMount 或事件触发的 export function 中', dmap)
def lint_yida_source(source: str, filename: str | None = None) -> dict:
"""对 JSX 源码做静态检查,返回 {'errors': [...], 'warnings': [...]}。
每条记录形如:{'line': int, 'rule': str, 'message': str}。
rule 命名采用稳定字符串,便于错误码映射与 disable 注释引用。
"""
errors: list[dict] = []
warnings: list[dict] = []
lines = source.split('\n')
dmap = _build_disable_map(lines)
has_render_jsx = bool(_RENDERJSX_RE.search(source))
is_modern_authoring = bool(_EXPORT_DEFAULT_PAGE_RE.search(source))
if not has_render_jsx and not is_modern_authoring:
_push_issue(errors, 1, 'missing-render-jsx',
'页面入口必须为 export function renderJsx() {...}'
'或使用可编译的 export default function Page() authoring 模式', dmap)
if _UNSUPPORTED_HOOKS_RE.search(source):
_push_issue(errors, 1, 'unsupported-hooks',
'当前 Python compiler 仅支持 useState 和 useEffect(..., [])'
'不支持 useMemo/useCallback/useRef/useReducer/useContext', dmap)
elif _REACT_HOOKS_RE.search(source) and not is_modern_authoring:
_push_issue(errors, 1, 'react-hooks',
'Hooks 只能出现在 export default function Page() authoring 模式中,'
'发布前由 Python compiler 降级为 _customState + didMount', dmap)
if is_modern_authoring and _has_unsupported_use_state_pattern(source):
_push_issue(errors, 1, 'unsupported-use-state-pattern',
'当前 Python compiler 仅支持 var/let/const [state, setState] = useState(init) '
'这种解构声明写法', dmap)
if _EXPORT_DEFAULT_RE.search(source) and not is_modern_authoring:
_push_issue(errors, 1, 'export-default',
'仅允许 export default function Page() authoring 模式;'
'否则请使用具名 export function renderJsx()', dmap)
if filename and filename.lower().endswith('.js') and has_render_jsx \
and _HAS_JSX_TAG_RE.search(source):
_push_issue(warnings, 1, 'jsx-extension',
'文件含 JSX 元素,建议把扩展名从 .js 改为 .jsx', dmap)
code_without_strings = _strip_comments_and_strings(source)
if _CUSTOM_STATE_REF_RE.search(code_without_strings) \
and not _CUSTOM_STATE_DECL_RE.search(code_without_strings):
_push_issue(errors, 1, 'missing-custom-state',
'代码引用了 _customState 但未声明 var _customState = {...}; '
'发布后会报 ReferenceError', dmap)
_scan_render_side_effects(source, errors, dmap)
for idx, line in enumerate(lines):
line_no = idx + 1
trimmed = line.strip()
if trimmed.startswith('//') or trimmed.startswith('*') or trimmed.startswith('/*'):
continue
m = _IMPORT_REQUIRE_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()) \
and not (is_modern_authoring and _SUPPORTED_MODERN_IMPORT_RE.search(line)):
_push_issue(errors, line_no, 'import-require',
'宜搭自定义页面不允许 import / require'
'请直接使用 React / this.utils.* 等运行时全局对象', dmap)
m = _LEGACY_ECHARTS_MAP_RE.search(line)
if m:
_push_issue(errors, line_no, 'echarts-legacy-map-china',
'禁用 echarts/map/js/china,请改用 echarts/map/json/* 数据加载方式', dmap)
m = _EVENT_FUNCTION_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()) \
and _function_callback_uses_this(lines, idx, m.start()):
_push_issue(errors, line_no, 'event-function',
'JSX 事件用 function(){...} 且内部含 this,会丢失 this 指向,'
'请改用箭头函数', dmap)
m = _DIRECT_METHOD_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(errors, line_no, 'event-direct-method',
_MSG_DIRECT_METHOD, dmap)
m = _BIND_INLINE_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(errors, line_no, 'event-bind-this',
_MSG_BIND_THIS, dmap)
m = _CONST_LET_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(warnings, line_no, 'const-let',
'宜搭页面 Babel 输出 ES5,建议用 var 而非 const/let 以避免编译问题', dmap)
m = _COMPUTED_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(errors, line_no, 'computed-property',
'对象不允许使用 ES6 computed key{[xxx]: ...}),'
'请改用 var obj = {}; obj[xxx] = ...', dmap)
m = _PAD_METHOD_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(warnings, line_no, 'pad-method',
f'{m.group(1)} 是 ES2017,宜搭运行时不一定支持,'
f'请改用手写补零', dmap)
m = _MAP_FILTER_FN_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()) \
and _function_callback_uses_this(lines, idx, m.start()):
_push_issue(errors, line_no, 'array-callback-function',
f'{m.group(1)}(function(){{...}}) 内部含 this 会丢失上下文,'
f'请改用箭头函数', dmap)
m = _FOREACH_FN_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()) \
and _function_callback_uses_this(lines, idx, m.start()):
_push_issue(warnings, line_no, 'foreach-callback-function',
'forEach(function(){...}) 内部含 this 建议改用箭头函数', dmap)
m = _CONTROLLED_INPUT_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(errors, line_no, 'controlled-input',
'<input value={...}> 在宜搭运行时表现异常,'
'请改用 defaultValue 或宜搭 Input 组件', dmap)
m = _NATIVE_SELECT_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(warnings, line_no, 'native-select-ui',
'原生 <select> 样式与宜搭风格不一致,建议用 Select 组件', dmap)
m = _IFRAME_NAV_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()):
_push_issue(warnings, line_no, 'iframe-self-navigation',
'<a> 跳转宜搭内部链接时建议加 target="_top" 或 "_blank"'
'否则在 iframe 内会卡住', dmap)
m = _TOP_LOCATION_RE.search(line)
if m and not _is_in_comment_or_string(line, m.start()) \
and 'window.top.location' not in line:
_push_issue(warnings, line_no, 'iframe-self-navigation',
'window.location.href 跳转宜搭内部链接时建议改用 window.top.location', dmap)
m = _PAGE_SIZE_RE.search(line)
if m and int(m.group(1)) > 100 \
and not _is_in_comment_or_string(line, m.start()):
_push_issue(errors, line_no, 'page-size-limit',
f'pageSize={m.group(1)} 超过宜搭限制 100,请分页拉取', dmap)
for r in _detect_large_then_callbacks(lines):
_push_issue(warnings, r['line'], 'large-then-callback',
f'.then() 回调超过 {THEN_CALLBACK_LINE_LIMIT} 行(实际 {r["lineCount"]} 行),'
f'建议拆分为独立函数', dmap)
_detect_yida_calls_without_catch(source, warnings, dmap)
_detect_echarts_rich_label_formatter(source, warnings, dmap)
_scan_lifecycle(source, errors, dmap)
_scan_jsx_elements(source, errors, warnings, dmap)
errors.sort(key=lambda x: (x['line'], x['rule']))
warnings.sort(key=lambda x: (x['line'], x['rule']))
return {'errors': errors, 'warnings': warnings}
def lint_check(source: str, filename: str | None = None) -> dict:
"""与 field_check 同款返回结构。
Returns: {'ok': bool, 'errors': [...], 'warnings': [...], 'info': {...}}
"""
result = lint_yida_source(source, filename)
return {
'ok': len(result['errors']) == 0,
'errors': result['errors'],
'warnings': result['warnings'],
'info': {
'errorCount': len(result['errors']),
'warningCount': len(result['warnings']),
},
}
# ===========================================================================
# region: unified CLItransform / field-check / lint
# ===========================================================================
def _print_lint_human(result: dict) -> int:
errs = result['errors']
warns = result['warnings']
if not errs and not warns:
print('[OK] lint 通过(30 条规则)')
return 0
if errs:
print(f'[FAIL] {len(errs)} 个错误:', file=sys.stderr)
for e in errs:
print(f" L{e['line']:>4} [{e['rule']}] {e['message']}", file=sys.stderr)
if warns:
print(f'[WARN] {len(warns)} 个警告:')
for w in warns:
print(f" L{w['line']:>4} [{w['rule']}] {w['message']}")
return 1 if errs else 0
def _cli_transform(args: argparse.Namespace) -> int:
src = Path(args.source).expanduser().read_text(encoding='utf-8')
out = transform_jsx(src)
if args.output:
Path(args.output).expanduser().write_text(out, encoding='utf-8')
else:
sys.stdout.write(out)
return 0
def _cli_field_check(args: argparse.Namespace) -> int:
if args.code_file:
code = Path(args.code_file).expanduser().read_text(encoding='utf-8')
elif args.code:
code = args.code
else:
print('错误: 必须提供 --code-file 或 --code', file=sys.stderr)
return 1
result = field_check(code, args.app)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result['ok'] else 1
def _cli_lint(args: argparse.Namespace) -> int:
if args.stdin:
src = sys.stdin.read()
fname = args.filename
elif args.source:
path = Path(args.source).expanduser().resolve()
if not path.exists():
print(f'错误: 文件不存在 {path}', file=sys.stderr)
return 1
src = path.read_text(encoding='utf-8')
fname = str(path)
else:
print('错误: 必须提供 source 路径或 --stdin', file=sys.stderr)
return 1
result = lint_yida_source(src, fname)
if args.json:
print(json.dumps({'ok': len(result['errors']) == 0, **result},
ensure_ascii=False, indent=2))
return 0 if not result['errors'] else 1
return _print_lint_human(result)
def main() -> int:
ap = argparse.ArgumentParser(
description='宜搭自定义页面 JSX 流水线(transform + field-check + lint')
sub = ap.add_subparsers(dest='cmd', required=True)
sp_t = sub.add_parser('transform', help='JSX → React.createElement')
sp_t.add_argument('source', help='JSX 源文件路径')
sp_t.add_argument('--output', help='输出文件(默认 stdout')
sp_fc = sub.add_parser('field-check', help='发布前字段 ID 对账')
sp_fc.add_argument('--app', required=True, help='应用 appType')
sp_fc.add_argument('--code-file', help='JSX 源文件')
sp_fc.add_argument('--code', help='JSX 内联字符串')
sp_l = sub.add_parser('lint', help='宜搭专属 30 条规则静态检查')
sp_l.add_argument('source', nargs='?', help='JSX 源文件路径(与 --stdin 二选一)')
sp_l.add_argument('--stdin', action='store_true', help='从 stdin 读取源码')
sp_l.add_argument('--filename', help='用于 jsx-extension 规则的文件名(仅 --stdin 模式有用)')
sp_l.add_argument('--json', action='store_true', help='输出 JSON 而非文本')
args = ap.parse_args()
if args.cmd == 'transform':
return _cli_transform(args)
if args.cmd == 'field-check':
return _cli_field_check(args)
if args.cmd == 'lint':
return _cli_lint(args)
ap.error(f'未知命令: {args.cmd}')
return 1
if __name__ == '__main__':
sys.exit(main())