Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 127 additions & 12 deletions probhub/mutation_parser_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,38 +163,153 @@ def span(self, node):
}


def _parse_cpp(source_bytes):
def _new_cpp_parser():
try:
from tree_sitter import Parser
from tree_sitter import Language
import tree_sitter_cpp

language = Language(tree_sitter_cpp.language())
tree = Parser(language).parse(source_bytes)
return Parser(language)
except ImportError as exc:
raise RuntimeError(f"mutation parser dependencies are unavailable: {exc}") from exc
except (TypeError, ValueError, RuntimeError) as exc:
raise RuntimeError(f"C++ mutation parser failed: {exc}") from exc


def _parse_tree(parser, source_bytes):
try:
tree = parser.parse(source_bytes)
except (TypeError, ValueError, RuntimeError) as exc:
raise RuntimeError(f"C++ mutation parser failed: {exc}") from exc
if tree is None or tree.root_node is None:
raise RuntimeError("C++ mutation parser returned no syntax tree")
if tree.root_node.has_error:
error = _first_syntax_error(tree.root_node)
point = error.start_point if error is not None else tree.root_node.start_point
raise SyntaxError(
"accepted C++ source contains syntax the mutation parser cannot locate safely "
f"at line {point.row + 1}, byte column {point.column + 1}"
)
return tree


def _first_syntax_error(node):
def _syntax_issues(node):
issues = []
stack = [node]
while stack:
current = stack.pop()
if current.type == "ERROR" or current.is_missing:
return current
issues.append(current)
stack.extend(reversed(current.children))
return None
return issues


def _raise_syntax_error(root):
issues = _syntax_issues(root)
error = issues[0] if issues else root
point = error.start_point
raise SyntaxError(
"accepted C++ source contains syntax the mutation parser cannot locate safely "
f"at line {point.row + 1}, byte column {point.column + 1}"
)


def _enclosing_typeid_argument(issue, source_bytes):
argument = issue
while argument is not None:
if argument.type == "argument_list" and argument.parent is not None:
call = argument.parent
if call.type == "call_expression":
function = call.child_by_field_name("function")
arguments = call.child_by_field_name("arguments")
if (
function is not None
and function.type == "identifier"
and arguments is not None
and arguments.id == argument.id
and source_bytes[function.start_byte:function.end_byte] == b"typeid"
):
break
argument = argument.parent
if argument is None:
return None
children = argument.children
if len(children) < 2:
return None
opening = children[0]
closing = children[-1]
if (
opening.type != "("
or closing.type != ")"
or opening.is_missing
or closing.is_missing
or opening.start_byte != argument.start_byte
or closing.end_byte != argument.end_byte
or source_bytes[opening.start_byte:opening.end_byte] != b"("
or source_bytes[closing.start_byte:closing.end_byte] != b")"
):
return None
return opening.end_byte, closing.start_byte


def _valid_type_id(parser, type_id):
# The pinned grammar parses aliases correctly even when it rejects the
# same type-id inside typeid(...), so use a single-declaration probe.
prefix = b"using __probhub_type = "
probe = prefix + type_id + b";"
tree = _parse_tree(parser, probe)
if tree.root_node.has_error:
return False
meaningful = [
child for child in tree.root_node.named_children
if child.type != "comment"
]
if len(meaningful) != 1 or meaningful[0].type != "alias_declaration":
return False
declaration = meaningful[0]
name = declaration.child_by_field_name("name")
descriptor = declaration.child_by_field_name("type")
return (
declaration.start_byte == 0
and declaration.end_byte == len(probe)
and name is not None
and probe[name.start_byte:name.end_byte] == b"__probhub_type"
and descriptor is not None
and len(prefix) <= descriptor.start_byte
and descriptor.start_byte < descriptor.end_byte
and descriptor.end_byte <= len(prefix) + len(type_id)
)


def _recover_typeid_type_errors(parser, tree, source_bytes):
spans = set()
for issue in _syntax_issues(tree.root_node):
span = _enclosing_typeid_argument(issue, source_bytes)
if span is None or not _valid_type_id(parser, source_bytes[span[0]:span[1]]):
return None
spans.add(span)
if not spans:
return None

# Preserve every byte offset and line break while replacing only the
# verified type-id with an expression the grammar accepts.
repaired = bytearray(source_bytes)
for start, end in sorted(spans):
marker_written = False
for index in range(start, end):
if repaired[index] in {10, 13}:
continue
repaired[index] = ord("0") if not marker_written else ord(" ")
marker_written = True
if not marker_written:
return None
recovered = _parse_tree(parser, bytes(repaired))
return recovered if not recovered.root_node.has_error else None


def _parse_cpp(source_bytes):
parser = _new_cpp_parser()
tree = _parse_tree(parser, source_bytes)
if not tree.root_node.has_error:
return tree
recovered = _recover_typeid_type_errors(parser, tree, source_bytes)
if recovered is None:
_raise_syntax_error(tree.root_node)
return recovered


def _iter_nodes(root):
Expand Down
4 changes: 3 additions & 1 deletion references/mutation-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
| `boolean-negation` | 删除 `!` 或取反 `if`/`while` 条件 | 检查布尔分支覆盖 |
| `integer-boundary` | 只对十进制比较边界的常量尝试 `+1`/`-1` | 检查常量边界附近数据 |

源码使用固定的 `tree-sitter==0.26.0` 与 `tree-sitter-cpp==0.23.4` 构造 C++ 语法树,只定位函数或 lambda 复合语句体内的真实表达式。模板尖括号、运算符声明、`<=>`、预处理宏、concept/requires、`case` 标签、`static_assert`、`sizeof` / `decltype` / `noexcept` 等非执行或未求值上下文不会生成候选;注释、字符串和字符字面量也不进入语法候选。native binding 只在独立 worker 中加载;父进程通过版本化 JSON 协议核对解析器版本、源码哈希、位置与数量。worker 默认受 30 秒、512 MiB、4 MiB stdout/stderr 共享预算和 8 个进程限制,并响应 mutation 总 deadline 与取消请求。解析超时、资源超限、native 崩溃、畸形响应或可报告的语法失败都会结构化终止、清理完整进程树,不回退到 Token 猜测,也不覆盖上一份成功 evidence。当前 `tree-sitter-cpp` 对 `typeid(type)` 等少数合法语法的支持不完整,此时命令保守失败而不猜测位置。
源码使用固定的 `tree-sitter==0.26.0` 与 `tree-sitter-cpp==0.23.4` 构造 C++ 语法树,只定位函数或 lambda 复合语句体内的真实表达式。模板尖括号、运算符声明、`<=>`、预处理宏、concept/requires、`case` 标签、`static_assert`、`sizeof` / `decltype` / `noexcept` / `typeid` 等非执行或未求值上下文不会生成候选;注释、字符串和字符字面量也不进入语法候选。native binding 只在独立 worker 中加载;父进程通过版本化 JSON 协议核对解析器版本、源码哈希、位置与数量。worker 默认受 30 秒、512 MiB、4 MiB stdout/stderr 共享预算和 8 个进程限制,并响应 mutation 总 deadline 与取消请求。解析超时、资源超限、native 崩溃、畸形响应或可报告的语法失败都会结构化终止、清理完整进程树,不回退到 Token 猜测,也不覆盖上一份成功 evidence。

固定版本会把部分合法 `typeid(type-id)` 误解析为调用表达式。worker 只在全部语法错误都位于括号完整的 `typeid(...)` 参数内、参数内容可独立解析为单个 C++ type-id、并且等长替换该参数后的完整源码无其他语法错误时恢复定位;基础类型、限定类型、指针、数组、函数指针、elaborated type 与 `decltype(...)` 均覆盖定向测试。恢复只用于确认外围语法,整个 `typeid(...)` 仍是阻断上下文;缺括号、参数内语句注入、非法 type-id 或任何邻接错误继续以 `mutation_syntax_invalid` 失败闭锁。

每个变异继续使用稳定的 `cpp-token-v1` ID,计划由源码、`tree-sitter-cpp-v1` locator、算子列表、人工排除记录和上限共同决定。仍然有效的旧 ID 保持不变;旧 Token 扫描器产生但语法树不再接受的误报 ID 会成为 `unmatched`,需要作者复核后移除。解析器切换会使旧 evidence 显示 `stale`,不会把旧执行分类与新候选计划混用。

Expand Down
73 changes: 66 additions & 7 deletions tests/test_mutation_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,72 @@ def test_invalid_syntax_fails_closed_without_token_fallback(self):
self.assertEqual(raised.exception.code, "mutation_syntax_invalid")
self.assertIn("line", str(raised.exception))

def test_unsupported_typeid_type_form_fails_closed(self):
with self.assertRaises(ProbHubError) as raised:
plan_mutations(
"#include <typeinfo>\n"
"int f(int x) { return typeid(x < 3) == typeid(bool); }\n"
)
self.assertEqual(raised.exception.code, "mutation_syntax_invalid")
def test_typeid_type_forms_are_recovered_without_mutating_unevaluated_content(self):
type_ids = (
"bool",
"const bool",
"bool*",
"bool[3]",
"unsigned long long",
"struct S",
"struct 类型",
"decltype(x < 3)",
"void(*)(int)",
)
for type_id in type_ids:
with self.subTest(type_id=type_id):
source = (
"#include <typeinfo>\n"
"struct S {};\n"
"int f(int x) {\n"
f" (void)typeid({type_id});\n"
" return x < 4;\n"
"}\n"
)
records = mutation_records(plan_mutations(source))
self.assertEqual(
[(item["line"], item["column"], item["operator"], item["original"]) for item in records],
[
(5, 12, "comparison-boundary", "<"),
(5, 14, "integer-boundary", "4"),
(5, 14, "integer-boundary", "4"),
],
)

def test_typeid_type_recovery_preserves_existing_runtime_mutation_ids(self):
expression_source = (
"#include <typeinfo>\n"
"int f(int x) {\n"
" (void)typeid(x);\n"
" return x <= 3;\n"
"}\n"
)
type_source = expression_source.replace("typeid(x)", "typeid(int)")
expression_ids = [item.id for item in plan_mutations(expression_source)["mutations"]]
type_ids = [item.id for item in plan_mutations(type_source)["mutations"]]
self.assertEqual(type_ids, expression_ids)

def test_typeid_type_recovery_keeps_only_the_outer_runtime_comparison(self):
records = mutation_records(plan_mutations(
"#include <typeinfo>\n"
"int f(int x) { return typeid(x < 3) == typeid(bool); }\n"
))
self.assertEqual(
[(item["line"], item["column"], item["operator"], item["original"]) for item in records],
[(2, 37, "comparison-boundary", "==")],
)

def test_typeid_type_recovery_rejects_malformed_or_unrelated_errors(self):
sources = (
"int f(int x) { (void)typeid(bool +); return x < 4; }\n",
"int f(int x) { (void)typeid(bool; int y); return x < 4; }\n",
"int f(int x) { (void)typeid(bool); return x < ; }\n",
"int f(int x) { (void)typeid(bool; return x < 4; }\n",
)
for source in sources:
with self.subTest(source=source), self.assertRaises(ProbHubError) as raised:
plan_mutations(source)
self.assertEqual(raised.exception.code, "mutation_syntax_invalid")

def test_missing_or_mismatched_parser_is_structured(self):
with patch(
Expand Down
Loading