diff --git a/probhub/mutation_parser_worker.py b/probhub/mutation_parser_worker.py index 33b8e21..62dc2a8 100644 --- a/probhub/mutation_parser_worker.py +++ b/probhub/mutation_parser_worker.py @@ -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): diff --git a/references/mutation-testing.md b/references/mutation-testing.md index 9b88523..4d24110 100644 --- a/references/mutation-testing.md +++ b/references/mutation-testing.md @@ -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`,不会把旧执行分类与新候选计划混用。 diff --git a/tests/test_mutation_syntax.py b/tests/test_mutation_syntax.py index 290da95..f5aeab5 100644 --- a/tests/test_mutation_syntax.py +++ b/tests/test_mutation_syntax.py @@ -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 \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 \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 \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 \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(