blob: 4fcd03b21c72b482faeac1f2fd7d818df05cad2a [file] [edit]
#!/usr/bin/env python3
# Copyright 2026 Google LLC
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""Compares compile_commands.json between GN and CMake builds."""
import argparse
import json
from pathlib import Path
import re
import signal
import sys
# Ignore SIGPIPE on Linux (this happens when piping the output to head/tail)
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
DEFAULT_DIRS = ["src", "bench", "test", "models", "eval", "tools", "litert"]
UNSET = "<unset>"
EXIT_SUCCESS, EXIT_DIFF, EXIT_ERR = 0, 1, 255
DEFINE_RE = re.compile(
r'(?:^|\s)(?:"[-/]D((?:\\.|[^"\\])*)"|\'[-/]D((?:\\.|[^\'\\])*)\'|[-/]D((?:\\'
r" |[^ \t\r\n])+))"
)
def find_repo_root(start: Path) -> Path:
"""Finds repo root by checking ancestors for markers."""
p = start.resolve()
p = p.parent if p.is_file() else p
for parent in [p, *p.parents]:
if (parent / "include" / "xnnpack.h").exists() or (
parent / "CMakeLists.txt"
).exists():
return parent
for parent in [p, *p.parents]:
if (parent / ".git").exists() or (parent / "BUILD.gn").exists():
return parent
return p
def parse_define(d: str) -> tuple[str, str]:
"""Parses a define token into (name, value)."""
name, sep, val = d.partition("=")
if not sep:
return name, "1"
if val.startswith(('"', "'")) and val.endswith(('"', "'")) and len(val) >= 2:
val = val[1:-1]
return name, val.replace('\\"', '"')
def extract_defines(
cmd: str | list[str],
include_re: re.Pattern[str],
ignore_res: list[re.Pattern[str]],
) -> dict[str, str]:
"""Extracts define flags from a command string or argument list."""
raw = (
[t[2:] for t in cmd if t.startswith(("-D", "/D")) and len(t) > 2]
if isinstance(cmd, list)
else [m[0] or m[1] or m[2] for m in DEFINE_RE.findall(cmd)]
)
defs = {}
for d in raw:
name, val = parse_define(d)
if any(r.search(name) for r in ignore_res):
continue
if include_re.search(name):
defs[name] = val
return defs
def is_known_source(rel_posix: str, include_dirs: tuple[str, ...]) -> bool:
return rel_posix.startswith(include_dirs)
def load_commands(
path: Path,
repo_root: Path,
include_re: re.Pattern[str],
ignore_res: list[re.Pattern[str]],
include_dirs: tuple[str, ...],
) -> dict[str, dict[str, str]]:
"""Loads compile_commands.json and returns file -> defines mapping."""
if not path.is_file():
sys.stderr.write(f"Error: File not found: '{path}'\n")
sys.exit(EXIT_ERR)
try:
entries = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
sys.stderr.write(f"Error reading '{path}': {e}\n")
sys.exit(EXIT_ERR)
if not isinstance(entries, list):
sys.stderr.write(f"Error: '{path}' is not a valid JSON list.\n")
sys.exit(EXIT_ERR)
result: dict[str, dict[str, str]] = {}
for e in entries:
if not isinstance(e, dict) or not (f := e.get("file")):
continue
abs_p = (Path(e.get("directory", repo_root)) / f).resolve()
try:
rel_p = abs_p.relative_to(repo_root).as_posix()
except ValueError:
rel_p = abs_p.as_posix()
if is_known_source(rel_p, include_dirs) and rel_p not in result:
cmd = e.get("command") or e.get("arguments", [])
result[rel_p] = extract_defines(cmd, include_re, ignore_res)
return result
def compare(
data1: dict[str, dict[str, str]],
data2: dict[str, dict[str, str]],
pedantic: bool,
):
"""Compares two compile command maps."""
files1, files2 = set(data1), set(data2)
only1, only2 = sorted(files1 - files2), sorted(files2 - files1)
diffs, match_count = [], 0
for f in sorted(files1 & files2):
d1, d2 = data1[f], data2[f]
file_diffs = []
for k in sorted(set(d1) | set(d2)):
v1, v2 = d1.get(k), d2.get(k)
if v1 == v2 or (not pedantic and {v1, v2} <= {"0", None}):
continue
file_diffs.append((k, v1 or UNSET, v2 or UNSET))
if file_diffs:
diffs.append((f, file_diffs))
else:
match_count += 1
return only1, only2, diffs, match_count
def print_report(
label1: str,
label2: str,
path1: Path,
path2: Path,
only1: list[str],
only2: list[str],
diffs: list[tuple[str, list[tuple[str, str, str]]]],
match_count: int,
total1: int,
total2: int,
) -> None:
"""Prints text comparison summary."""
print(
f"Comparing compile commands:\n {label1}: {path1} ({total1} files)\n "
f" {label2}: {path2} ({total2} files)\n{'=' * 80}"
)
if only1:
print(
f"\nMissing in {label2} (only in {label1}) [{len(only1)} files]:\n"
+ "\n".join(f" - {f}" for f in only1)
)
if only2:
print(
f"\nMissing in {label1} (only in {label2}) [{len(only2)} files]:\n"
+ "\n".join(f" - {f}" for f in only2)
)
if diffs:
print(f"\nDefine differences [{len(diffs)} files]:")
for f, f_diffs in diffs:
print(f" {f}:")
for d, v1, v2 in f_diffs:
print(f" {d}: {label1}={v1}, {label2}={v2}")
print(f"\n{'=' * 80}\nSummary:")
print(
f" {label1} files analyzed: {total1}\n {label2} files analyzed:"
f" {total2}"
)
print(
f" Files only in {label1}: {len(only1)}\n Files only in {label2}:"
f" {len(only2)}"
)
print(
f" Files with define differences: {len(diffs)}\n Identically matching"
f" files: {match_count}"
)
print(
"\n** SUCCESS: All files and defines match identically **"
if not (only1 or only2 or diffs)
else "\n** DIFFERENCES DETECTED **"
)
def output_jsonl(
label1: str,
label2: str,
path1: Path,
path2: Path,
only1: list[str],
only2: list[str],
diffs: list[tuple[str, list[tuple[str, str, str]]]],
match_count: int,
total1: int,
total2: int,
) -> None:
"""Outputs JSON Lines (JSONL) formatted comparison result."""
print(
json.dumps({
"type": "summary",
"label1": label1,
"label2": label2,
"path1": str(path1),
"path2": str(path2),
"total_files_1": total1,
"total_files_2": total2,
"only_in_1_count": len(only1),
"only_in_2_count": len(only2),
"files_with_define_differences_count": len(diffs),
"matching_files_count": match_count,
"has_differences": bool(only1 or only2 or diffs),
})
)
for f in only1:
print(json.dumps({"type": "only_in_1", "file": f}))
for f in only2:
print(json.dumps({"type": "only_in_2", "file": f}))
for f, f_diffs in diffs:
print(
json.dumps({
"type": "define_difference",
"file": f,
"differences": [
{"define": d, "val1": v1, "val2": v2} for d, v1, v2 in f_diffs
],
})
)
def main() -> None:
parser = argparse.ArgumentParser(
"compare-compile-commands",
description="Compares compile_commands.json between GN and CMake builds.",
)
parser.add_argument(
"compile_commands_1",
type=Path,
help="Path to first compile_commands.json",
)
parser.add_argument(
"compile_commands_2",
type=Path,
help="Path to second compile_commands.json",
)
parser.add_argument(
"--include-define",
default="^XNN_",
help="Regex pattern of define macro(s) to include (default: '^XNN_')",
)
parser.add_argument(
"--ignore-define",
action="append",
default=[],
help="Regex pattern of define macro(s) to ignore (can be repeated)",
)
parser.add_argument(
"--include-directories",
action="append",
help=(
"Directory prefix to include (can be repeated or comma-separated,"
" default: src,bench,test,models,eval,tools,litert)"
),
)
parser.add_argument(
"--pedantic", action="store_true", help="Treat <unset> as distinct from 0"
)
parser.add_argument("--repo-root", type=Path, help="Explicit repository root")
parser.add_argument(
"--jsonl", action="store_true", help="Output in JSON Lines (JSONL) format"
)
args = parser.parse_args()
p1, p2 = args.compile_commands_1, args.compile_commands_2
repo_root = args.repo_root or find_repo_root(p1)
include_re = re.compile(args.include_define)
ignore_res = [re.compile(pat) for pat in args.ignore_define]
raw_dirs = (
[
d.strip()
for item in args.include_directories
for d in item.split(",")
if d.strip()
]
if args.include_directories
else DEFAULT_DIRS
)
include_dirs = tuple(
d.strip("/") + "/" if d.strip("/.") else "" for d in raw_dirs
)
b1, b2 = p1.name, p2.name
label1, label2 = (f"{b1} (first)", f"{b2} (second)") if b1 == b2 else (b1, b2)
data1 = load_commands(p1, repo_root, include_re, ignore_res, include_dirs)
data2 = load_commands(p2, repo_root, include_re, ignore_res, include_dirs)
only1, only2, diffs, match_count = compare(data1, data2, args.pedantic)
if args.jsonl:
output_jsonl(
label1,
label2,
p1,
p2,
only1,
only2,
diffs,
match_count,
len(data1),
len(data2),
)
else:
print_report(
label1,
label2,
p1,
p2,
only1,
only2,
diffs,
match_count,
len(data1),
len(data2),
)
sys.exit(EXIT_DIFF if (only1 or only2 or diffs) else EXIT_SUCCESS)
if __name__ == "__main__":
main()