Mutate ?? and ?. in TypeScript and JavaScript, including Express handlers.

This commit is contained in:
Robert C. Martin
2026-10-03 08:49:10 -05:00
parent 72b7581a37
commit 1bad4b908a
5 changed files with 91 additions and 9 deletions
+1 -1
View File
@@ -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. |
| Java | `true`/`false`, `==`/`!=`, `<`/`<=`, `>`/`>=`, `+`/`-`, `*`/`/`, `&&`/`||`, delete `!` and unary `-`, `0`/`1`. Constructors are not entries, matching crapper. |
| 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 `-`). |
| Python | the Java set, spelled `and`/`or`, `not`, and `True`/`False`. |
+22 -4
View File
@@ -115,11 +115,29 @@ def project_functions(source: str, path: Path, root: Path):
)
def _owner(functions, line: int):
matches = [fn for fn in functions if fn.start_line <= line <= fn.end_line]
def _contains(fn, line: int, byte: int | None) -> bool:
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:
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]:
@@ -133,7 +151,7 @@ def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Si
}
found: list[Site] = []
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:
continue
private = privacy[id(owner)]
+33 -4
View File
@@ -2,7 +2,9 @@
Clojure follows clj-mutate's symbol rules. Go follows mutate4go, including
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
@@ -24,6 +26,7 @@ _BINARY = {
"!==": "===",
"&&": "||",
"||": "&&",
"??": "||",
"and": "or",
"or": "and",
}
@@ -100,8 +103,12 @@ class RawSite:
def _grammar(language: str, path: str) -> str:
if language == "typescript" and path.endswith(".tsx"):
return "tsx"
if language == "typescript":
if path.endswith(".tsx"):
return "tsx"
if path.endswith((".js", ".jsx", ".mjs", ".cjs")):
return "javascript"
return "typescript"
if language == "go":
return "go"
return language
@@ -118,7 +125,7 @@ def _category(token: str) -> str:
return "comparison"
if token in {"==", "!=", "===", "!=="}:
return "equality"
if token in {"&&", "||", "and", "or"}:
if token in {"&&", "||", "??", "and", "or"}:
return "logical"
return "arithmetic"
@@ -126,9 +133,27 @@ def _category(token: str) -> str:
def _binary_mutant(language: str, token: str) -> str | None:
if language == "go" and token == "/":
return None
if token == "??" and language != "typescript":
return None
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:
if mutant == original:
return
@@ -217,6 +242,10 @@ def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None:
mutant = _binary_mutant(language, token)
if mutant is not None:
_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"}:
_add(found, node, token, "", "unary")
if token == "-" and parent in _UNARY_PARENTS:
+4
View File
@@ -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):
assert _grammar("typescript", "src/app.tsx") == "tsx"
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"
sites, pairs = _pairs(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn place []\n [inc])\n")
+31
View File
@@ -163,3 +163,34 @@ def test_python_private_and_class_namespace(tmp_path):
def test_string_operators_are_not_sites(tmp_path):
source = 'def place():\n return "x > 0 and true"\n'
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) == []