mirror of
https://github.com/unclebob/mutator.git
synced 2026-11-06 18:23:50 +00:00
Mutate ?? and ?. in TypeScript and JavaScript, including Express handlers.
This commit is contained in:
@@ -95,7 +95,7 @@ Tokens inside strings, comments, and quoted Clojure forms are left alone. A repl
|
|||||||
| Clojure | clj-mutate's head symbols: `+`/`-`, `*` to `/`, `inc`/`dec`, comparisons, `=`/`not=`, `if`/`if-not`, `when`/`when-not`, `and`/`or`, and the seq, predicate, and coercion pairs. `true`/`false` and `0`/`1` anywhere. |
|
| Clojure | clj-mutate's head symbols: `+`/`-`, `*` to `/`, `inc`/`dec`, comparisons, `=`/`not=`, `if`/`if-not`, `when`/`when-not`, `and`/`or`, and the seq, predicate, and coercion pairs. `true`/`false` and `0`/`1` anywhere. |
|
||||||
| Java | `true`/`false`, `==`/`!=`, `<`/`<=`, `>`/`>=`, `+`/`-`, `*`/`/`, `&&`/`||`, delete `!` and unary `-`, `0`/`1`. Constructors are not entries, matching crapper. |
|
| Java | `true`/`false`, `==`/`!=`, `<`/`<=`, `>`/`>=`, `+`/`-`, `*`/`/`, `&&`/`||`, delete `!` and unary `-`, `0`/`1`. Constructors are not entries, matching crapper. |
|
||||||
| Go | mutate4go: `+`/`-`, `*` to `/` only, comparisons, `==`/`!=`, `true`/`false`, `&&`/`||`, `0`/`1`. |
|
| Go | mutate4go: `+`/`-`, `*` to `/` only, comparisons, `==`/`!=`, `true`/`false`, `&&`/`||`, `0`/`1`. |
|
||||||
| TypeScript | the Java set, plus `===`/`!==`. |
|
| TypeScript | the Java set, plus `===`/`!==`, `??` to `||`, and `?.` to `.`. A call or index drops `?.` (`a?.()` becomes `a()`). `.js`, `.jsx`, `.mjs`, and `.cjs` use these rules. |
|
||||||
| Rust | the Java set (`&&`/`||`, `!`, unary `-`). |
|
| Rust | the Java set (`&&`/`||`, `!`, unary `-`). |
|
||||||
| Python | the Java set, spelled `and`/`or`, `not`, and `True`/`False`. |
|
| Python | the Java set, spelled `and`/`or`, `not`, and `True`/`False`. |
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,29 @@ def project_functions(source: str, path: Path, root: Path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _owner(functions, line: int):
|
def _contains(fn, line: int, byte: int | None) -> bool:
|
||||||
matches = [fn for fn in functions if fn.start_line <= line <= fn.end_line]
|
if fn.start_line > line or fn.end_line < line:
|
||||||
|
return False
|
||||||
|
start = getattr(fn, "start_byte", -1)
|
||||||
|
end = getattr(fn, "end_byte", -1)
|
||||||
|
if byte is None or start < 0 or end < 0:
|
||||||
|
return True
|
||||||
|
return start <= byte < end
|
||||||
|
|
||||||
|
|
||||||
|
def _tightness(fn) -> int:
|
||||||
|
start = getattr(fn, "start_byte", -1)
|
||||||
|
end = getattr(fn, "end_byte", -1)
|
||||||
|
if start >= 0 and end >= start:
|
||||||
|
return end - start
|
||||||
|
return (fn.end_line - fn.start_line) * 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _owner(functions, line: int, byte: int | None = None):
|
||||||
|
matches = [fn for fn in functions if _contains(fn, line, byte)]
|
||||||
if not matches:
|
if not matches:
|
||||||
return None
|
return None
|
||||||
return min(matches, key=lambda fn: (fn.end_line - fn.start_line, fn.start_line))
|
return min(matches, key=lambda fn: (_tightness(fn), fn.start_line))
|
||||||
|
|
||||||
|
|
||||||
def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Site]:
|
def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Site]:
|
||||||
@@ -133,7 +151,7 @@ def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Si
|
|||||||
}
|
}
|
||||||
found: list[Site] = []
|
found: list[Site] = []
|
||||||
for raw in discover_raw(source, language, path.as_posix()):
|
for raw in discover_raw(source, language, path.as_posix()):
|
||||||
owner = _owner(functions, raw.line)
|
owner = _owner(functions, raw.line, raw.start)
|
||||||
if owner is None:
|
if owner is None:
|
||||||
continue
|
continue
|
||||||
private = privacy[id(owner)]
|
private = privacy[id(owner)]
|
||||||
|
|||||||
+33
-4
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Clojure follows clj-mutate's symbol rules. Go follows mutate4go, including
|
Clojure follows clj-mutate's symbol rules. Go follows mutate4go, including
|
||||||
one-way `*` to `/`. Java follows mutate4java. TypeScript, Rust, and Python
|
one-way `*` to `/`. Java follows mutate4java. TypeScript, Rust, and Python
|
||||||
use the same decisions as Java, spelled in that language.
|
use the same decisions as Java, spelled in that language. TypeScript also
|
||||||
|
mutates `??` to `||` and `?.` to `.` (removed on a call or index). JavaScript
|
||||||
|
files use the TypeScript rules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,6 +26,7 @@ _BINARY = {
|
|||||||
"!==": "===",
|
"!==": "===",
|
||||||
"&&": "||",
|
"&&": "||",
|
||||||
"||": "&&",
|
"||": "&&",
|
||||||
|
"??": "||",
|
||||||
"and": "or",
|
"and": "or",
|
||||||
"or": "and",
|
"or": "and",
|
||||||
}
|
}
|
||||||
@@ -100,8 +103,12 @@ class RawSite:
|
|||||||
|
|
||||||
|
|
||||||
def _grammar(language: str, path: str) -> str:
|
def _grammar(language: str, path: str) -> str:
|
||||||
if language == "typescript" and path.endswith(".tsx"):
|
if language == "typescript":
|
||||||
return "tsx"
|
if path.endswith(".tsx"):
|
||||||
|
return "tsx"
|
||||||
|
if path.endswith((".js", ".jsx", ".mjs", ".cjs")):
|
||||||
|
return "javascript"
|
||||||
|
return "typescript"
|
||||||
if language == "go":
|
if language == "go":
|
||||||
return "go"
|
return "go"
|
||||||
return language
|
return language
|
||||||
@@ -118,7 +125,7 @@ def _category(token: str) -> str:
|
|||||||
return "comparison"
|
return "comparison"
|
||||||
if token in {"==", "!=", "===", "!=="}:
|
if token in {"==", "!=", "===", "!=="}:
|
||||||
return "equality"
|
return "equality"
|
||||||
if token in {"&&", "||", "and", "or"}:
|
if token in {"&&", "||", "??", "and", "or"}:
|
||||||
return "logical"
|
return "logical"
|
||||||
return "arithmetic"
|
return "arithmetic"
|
||||||
|
|
||||||
@@ -126,9 +133,27 @@ def _category(token: str) -> str:
|
|||||||
def _binary_mutant(language: str, token: str) -> str | None:
|
def _binary_mutant(language: str, token: str) -> str | None:
|
||||||
if language == "go" and token == "/":
|
if language == "go" and token == "/":
|
||||||
return None
|
return None
|
||||||
|
if token == "??" and language != "typescript":
|
||||||
|
return None
|
||||||
return _BINARY.get(token)
|
return _BINARY.get(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_mutant(node) -> str | None:
|
||||||
|
"""`a?.b` becomes `a.b`. A call or index drops `?.`, so `a?.()` becomes `a()`."""
|
||||||
|
|
||||||
|
parent = node.parent
|
||||||
|
if parent is None:
|
||||||
|
return None
|
||||||
|
host = parent.parent if parent.type == "optional_chain" else parent
|
||||||
|
if host is None:
|
||||||
|
return None
|
||||||
|
if host.type == "member_expression":
|
||||||
|
return "."
|
||||||
|
if host.type in {"call_expression", "subscript_expression"}:
|
||||||
|
return ""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _add(found: list[RawSite], node, original: str, mutant: str, category: str) -> None:
|
def _add(found: list[RawSite], node, original: str, mutant: str, category: str) -> None:
|
||||||
if mutant == original:
|
if mutant == original:
|
||||||
return
|
return
|
||||||
@@ -217,6 +242,10 @@ def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None:
|
|||||||
mutant = _binary_mutant(language, token)
|
mutant = _binary_mutant(language, token)
|
||||||
if mutant is not None:
|
if mutant is not None:
|
||||||
_add(found, node, token, mutant, _category(token))
|
_add(found, node, token, mutant, _category(token))
|
||||||
|
if language == "typescript" and token == "?.":
|
||||||
|
optional = _optional_mutant(node)
|
||||||
|
if optional is not None:
|
||||||
|
_add(found, node, "?.", optional, "optional")
|
||||||
if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}:
|
if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}:
|
||||||
_add(found, node, token, "", "unary")
|
_add(found, node, token, "", "unary")
|
||||||
if token == "-" and parent in _UNARY_PARENTS:
|
if token == "-" and parent in _UNARY_PARENTS:
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ def test_privacy_headers_digests_owners_and_mutation_fields(tmp_path):
|
|||||||
def test_grammar_guards_and_non_head_clojure_symbols(tmp_path):
|
def test_grammar_guards_and_non_head_clojure_symbols(tmp_path):
|
||||||
assert _grammar("typescript", "src/app.tsx") == "tsx"
|
assert _grammar("typescript", "src/app.tsx") == "tsx"
|
||||||
assert _grammar("typescript", "src/app.ts") == "typescript"
|
assert _grammar("typescript", "src/app.ts") == "typescript"
|
||||||
|
assert _grammar("typescript", "src/app.js") == "javascript"
|
||||||
|
assert _grammar("typescript", "src/app.mjs") == "javascript"
|
||||||
|
assert _grammar("typescript", "src/app.cjs") == "javascript"
|
||||||
|
assert _grammar("typescript", "src/app.jsx") == "javascript"
|
||||||
assert _grammar("python", "src/app.py") == "python"
|
assert _grammar("python", "src/app.py") == "python"
|
||||||
|
|
||||||
sites, pairs = _pairs(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn place []\n [inc])\n")
|
sites, pairs = _pairs(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn place []\n [inc])\n")
|
||||||
|
|||||||
@@ -163,3 +163,34 @@ def test_python_private_and_class_namespace(tmp_path):
|
|||||||
def test_string_operators_are_not_sites(tmp_path):
|
def test_string_operators_are_not_sites(tmp_path):
|
||||||
source = 'def place():\n return "x > 0 and true"\n'
|
source = 'def place():\n return "x > 0 and true"\n'
|
||||||
assert _sites(tmp_path, "src/demo/app.py", source) == []
|
assert _sites(tmp_path, "src/demo/app.py", source) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_typescript_mutates_nullish_and_optional_chaining(tmp_path):
|
||||||
|
source = """
|
||||||
|
export function place(a, b) {
|
||||||
|
return a ?? b?.c ?? b?.[0] ?? b?.();
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
sites = _sites(tmp_path, "src/demo/app.ts", source)
|
||||||
|
assert sum(site.original == "??" and site.mutant == "||" for site in sites) == 3
|
||||||
|
assert {site.mutant for site in sites if site.original == "?."} == {".", ""}
|
||||||
|
assert {site.namespace for site in sites} == {"demo.app"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_javascript_express_handler_owns_its_nullish_site(tmp_path):
|
||||||
|
source = """
|
||||||
|
export function mount(app) {
|
||||||
|
app.get("/users", (req, res) => {
|
||||||
|
return req.body ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
sites = _sites(tmp_path, "src/demo/routes.js", source)
|
||||||
|
assert {site.namespace for site in sites} == {"demo.routes"}
|
||||||
|
assert ("GET /users", "??", "||") in {(site.name, site.original, site.mutant) for site in sites}
|
||||||
|
assert all(site.name != "mount" for site in sites)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_quoted_nullish_operator_is_not_a_site(tmp_path):
|
||||||
|
source = 'export function place() {\n return "a ?? b?.c";\n}\n'
|
||||||
|
assert _sites(tmp_path, "src/demo/app.ts", source) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user