From 278db1485271c8b23ee8f88b44c39195c93ca515 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 11:34:38 +0200 Subject: [PATCH 01/92] C++: move consistency queries into the open-source repo These queries live next to the C++ QL tests they check, so that `codeql test run --consistency-queries` can find them without an internal checkout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cpp/ql/consistency-queries/badLocations.ql | 9 +++++++++ cpp/ql/consistency-queries/nullInToString.ql | 5 +++++ cpp/ql/consistency-queries/qlpack.yml | 5 +++++ cpp/ql/consistency-queries/unusedLocations.ql | 10 ++++++++++ .../variableDeclarationsWithoutTypes.ql | 5 +++++ cpp/ql/consistency-queries/variablesWithoutTypes.ql | 5 +++++ 6 files changed, 39 insertions(+) create mode 100644 cpp/ql/consistency-queries/badLocations.ql create mode 100644 cpp/ql/consistency-queries/nullInToString.ql create mode 100644 cpp/ql/consistency-queries/qlpack.yml create mode 100644 cpp/ql/consistency-queries/unusedLocations.ql create mode 100644 cpp/ql/consistency-queries/variableDeclarationsWithoutTypes.ql create mode 100644 cpp/ql/consistency-queries/variablesWithoutTypes.ql diff --git a/cpp/ql/consistency-queries/badLocations.ql b/cpp/ql/consistency-queries/badLocations.ql new file mode 100644 index 000000000000..385d3d92fe6a --- /dev/null +++ b/cpp/ql/consistency-queries/badLocations.ql @@ -0,0 +1,9 @@ +import cpp + +// Locations should either be :0:0:0:0 locations (UnknownLocation, or +// a whole file), or all 4 fields should be positive. +from Location l +where + [l.getStartLine(), l.getEndLine(), l.getStartColumn(), l.getEndColumn()] != 0 and + [l.getStartLine(), l.getEndLine(), l.getStartColumn(), l.getEndColumn()] < 1 +select l diff --git a/cpp/ql/consistency-queries/nullInToString.ql b/cpp/ql/consistency-queries/nullInToString.ql new file mode 100644 index 000000000000..4a6385b519ab --- /dev/null +++ b/cpp/ql/consistency-queries/nullInToString.ql @@ -0,0 +1,5 @@ +import cpp + +from Element e +where e.toString().matches("%(null)%") +select e diff --git a/cpp/ql/consistency-queries/qlpack.yml b/cpp/ql/consistency-queries/qlpack.yml new file mode 100644 index 000000000000..fed0e22e17ba --- /dev/null +++ b/cpp/ql/consistency-queries/qlpack.yml @@ -0,0 +1,5 @@ +name: codeql/cpp-consistency-queries +groups: [cpp, test, consistency-queries] +dependencies: + codeql/cpp-all: ${workspace} +extractor: cpp diff --git a/cpp/ql/consistency-queries/unusedLocations.ql b/cpp/ql/consistency-queries/unusedLocations.ql new file mode 100644 index 000000000000..875c60ba3251 --- /dev/null +++ b/cpp/ql/consistency-queries/unusedLocations.ql @@ -0,0 +1,10 @@ +import cpp + +from Location l +where + not any(Element e).getLocation() = l and + not any(LambdaCapture lc).getLocation() = l and + not any(MacroAccess ma).getActualLocation() = l and + not any(NamespaceDeclarationEntry nde).getBodyLocation() = l and + not any(XmlLocatable xml).getLocation() = l +select l diff --git a/cpp/ql/consistency-queries/variableDeclarationsWithoutTypes.ql b/cpp/ql/consistency-queries/variableDeclarationsWithoutTypes.ql new file mode 100644 index 000000000000..2573d660defd --- /dev/null +++ b/cpp/ql/consistency-queries/variableDeclarationsWithoutTypes.ql @@ -0,0 +1,5 @@ +import cpp + +from VariableDeclarationEntry i +where not exists(i.getType()) +select i diff --git a/cpp/ql/consistency-queries/variablesWithoutTypes.ql b/cpp/ql/consistency-queries/variablesWithoutTypes.ql new file mode 100644 index 000000000000..d004c175abd0 --- /dev/null +++ b/cpp/ql/consistency-queries/variablesWithoutTypes.ql @@ -0,0 +1,5 @@ +import cpp + +from Variable i +where not exists(i.getType()) +select i From def1184da73b2b9ca5c55030137cbb5f25e312ef Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 11:34:39 +0200 Subject: [PATCH 02/92] Rust: allow formatting without linting, fix codegen runfiles path `lint.py --format-only` gives the upcoming `just format` verb a way to reformat without failing on pre-existing lint findings. codegen.sh looked up its runfiles via `external/ql+`, which only resolves in a main-repository layout. `../ql+` works from both, so codegen keeps working when the repository is consumed as a bazel dependency. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/codegen/codegen.sh | 2 +- rust/lint.py | 47 +++++++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/rust/codegen/codegen.sh b/rust/codegen/codegen.sh index 2d415009aed8..726ff138db78 100755 --- a/rust/codegen/codegen.sh +++ b/rust/codegen/codegen.sh @@ -2,7 +2,7 @@ set -eu -source misc/bazel/runfiles.sh 2>/dev/null || source external/ql+/misc/bazel/runfiles.sh +source misc/bazel/runfiles.sh 2>/dev/null || source ../ql+/misc/bazel/runfiles.sh ast_generator="$(rlocation "$1")" grammar_file="$(rlocation "$2")" diff --git a/rust/lint.py b/rust/lint.py index 600a888649e9..3ace1667a464 100755 --- a/rust/lint.py +++ b/rust/lint.py @@ -4,6 +4,15 @@ import pathlib import shutil import sys +import argparse + + +def options(): + parser = argparse.ArgumentParser(description="lint rust language pack code") + parser.add_argument( + "--format-only", action="store_true", help="Only apply formatting" + ) + return parser.parse_args() def tool(name): @@ -12,27 +21,33 @@ def tool(name): return ret -this_dir = pathlib.Path(__file__).resolve().parent +def main(): + args = options() + this_dir = pathlib.Path(__file__).resolve().parent + + cargo = tool("cargo") + bazel = tool("bazel") -cargo = tool("cargo") -bazel = tool("bazel") + runs = [] -runs = [] + def run(tool, args, *, cwd=this_dir): + print("+", tool, args) + runs.append(subprocess.run([tool] + args.split(), cwd=cwd)) -def run(tool, args, *, cwd=this_dir): - print("+", tool, args) - runs.append(subprocess.run([tool] + args.split(), cwd=cwd)) + # make sure bazel-provided sources are put in tree for `cargo` to work with them + run(bazel, "run ast-generator:inject-sources") + run(cargo, "fmt --all --quiet") + if not args.format_only: + for manifest in this_dir.rglob("Cargo.toml"): + if not manifest.is_relative_to(this_dir / "ql") and not manifest.is_relative_to(this_dir / "integration-tests"): + run(cargo, + "clippy --fix --allow-dirty --allow-staged --quiet -- -D warnings", + cwd=manifest.parent) -# make sure bazel-provided sources are put in tree for `cargo` to work with them -run(bazel, "run ast-generator:inject-sources") -run(cargo, "fmt --all --quiet") + return max(r.returncode for r in runs) -for manifest in this_dir.rglob("Cargo.toml"): - if not manifest.is_relative_to(this_dir / "ql") and not manifest.is_relative_to(this_dir / "integration-tests"): - run(cargo, - "clippy --fix --allow-dirty --allow-staged --quiet -- -D warnings", - cwd=manifest.parent) -sys.exit(max(r.returncode for r in runs)) +if __name__ == "__main__": + sys.exit(main()) From c870c147704dd6c35cdbe477312d330faef5ab20 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 11:35:18 +0200 Subject: [PATCH 03/92] Just: add the shared machinery behind the common verbs Introduces a small set of verbs (`test`, `build`, `generate`, `format`, `lint`) that work the same from anywhere in the tree, so that contributors do not have to remember a different incantation per language. Running a verb from the root forwards it to whichever justfile actually implements it for the given paths; running it in a language directory uses that language's definition directly. Everything language-specific stays in the per-language justfiles added next; this commit only provides the vocabulary they share: - `misc/just/forward.just` and `forward_command.py` resolve a verb plus a set of paths to the justfiles that implement it, grouping paths per justfile. - `misc/just/lib.just` exposes `_codeql_test`, `_language_tests` and `_integration_test` for the per-language justfiles to build on. - `codeql_test_run.py` turns test flags into a `codeql test run` invocation, resolving `RAM_PER_THREAD`/`CPUS` from arguments, environment, then platform defaults. - `misc/just/defs.just` holds the settings and generic helpers, including the internal-checkout detection that lets the same justfiles work in both repos. Arguments are passed around as just lists (`set lists`), so values containing spaces survive intact rather than being re-split by the helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- justfile | 4 + lib.just | 1 + misc/just/README.md | 41 +++++++++ misc/just/build.just | 21 +++++ misc/just/codeql_test_run.py | 155 ++++++++++++++++++++++++++++++++ misc/just/defs.just | 61 +++++++++++++ misc/just/format.just | 13 +++ misc/just/forward.just | 30 +++++++ misc/just/forward_command.py | 113 +++++++++++++++++++++++ misc/just/justfile | 2 + misc/just/language_tests.py | 66 ++++++++++++++ misc/just/lib.just | 31 +++++++ misc/just/semmle-code-stub.just | 1 + 13 files changed, 539 insertions(+) create mode 100644 justfile create mode 100644 lib.just create mode 100644 misc/just/README.md create mode 100644 misc/just/build.just create mode 100755 misc/just/codeql_test_run.py create mode 100644 misc/just/defs.just create mode 100644 misc/just/format.just create mode 100644 misc/just/forward.just create mode 100644 misc/just/forward_command.py create mode 100644 misc/just/justfile create mode 100755 misc/just/language_tests.py create mode 100644 misc/just/lib.just create mode 100644 misc/just/semmle-code-stub.just diff --git a/justfile b/justfile new file mode 100644 index 000000000000..94cf7d2f4bb3 --- /dev/null +++ b/justfile @@ -0,0 +1,4 @@ +# see misc/just/README.md for an overview + +import 'lib.just' +import 'misc/just/forward.just' diff --git a/lib.just b/lib.just new file mode 100644 index 000000000000..0ddd926bcda5 --- /dev/null +++ b/lib.just @@ -0,0 +1 @@ +import "misc/just/lib.just" diff --git a/misc/just/README.md b/misc/just/README.md new file mode 100644 index 000000000000..e53b400c6536 --- /dev/null +++ b/misc/just/README.md @@ -0,0 +1,41 @@ +This directory contains an infrastructure for [`just`](https://github.com/casey/just) +recipes that can be used throughout this and the internal repository. In particular we +have common verbs (`build`, `test`, `format`, `lint`, `generate`) that individual parts +of the project can implement, and some common functionality that can be used to that +effect. + +# Forwarding + +The core of the functionality is given by forwarding. The idea is that: + +- if you are in the directory where a verb is implemented, you will get that as per + standard `just` behaviour (possibly using fallback). +- if on the other hand you are above it, and you run something like + `just test ql/rust/ql/test/{a,b}`, then a forwarder script finds a common justfile + implementing the verb for all the positional arguments passed there, and then retries + calling `just test` from there. So if `test` is implemented beneath that (in that case, + it is in `rust/ql/test`), it uses that recipe. +- even if there isn't a recipe that is common to all the positional arguments, the + forwarder will still group the arguments in batches using the same recipe. So + `just build ql/rust ql/java`, or + `just test ql/rust/ql/test/some/language/test ql/rust/ql/integration-test/some/integration/test` + will also work, with corresponding recipes run sequentially. + +Another point is how launching QL tests can be tweaked: + +- by default, the corresponding CLI is built from the internal repo (nothing is done if + working in `codeql` standalone), and no additional database or consistency checks are + made +- `--codeql=built` can be passed to skip the build step (if no changes were made to the + CLI/extractors). This is consistent with the same pytest option +- you can add the additional checks that CI does with `--all-checks` or the `+` + abbreviation. These additional checks are configured in justfiles per language, and + correspond to all the additional checks that CI adds (but that a dev might not want to + run by default). + +Test arguments are passed around as `just` lists (`set lists`), so they reach the +underlying runner already split and arguments containing spaces survive intact. + +One caveat: when running different recipes for the same verb, non-positional arguments +need to be supported by all recipes involved. For example, this will work ok for +`--learn` or `--codeql` options in language and integration tests. diff --git a/misc/just/build.just b/misc/just/build.just new file mode 100644 index 000000000000..f564c4aa9041 --- /dev/null +++ b/misc/just/build.just @@ -0,0 +1,21 @@ +# Helper build recipes + +import "defs.just" + +# Build the given language-specific CLI distribution +_build_dist LANGUAGE: _require_semmle_code (_maybe_build_dist LANGUAGE) + +# Build the language-specific distribution if we are in an internal repository checkout +# Otherwise, do nothing +[no-exit-message] +_maybe_build_dist LANGUAGE: (_if_in_semmle_code (f'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel test //language-packs:intree-{{ LANGUAGE }}-as-test --test_output=all') '# using codeql from PATH, if any') + +# Call bazel. Uses our official bazel wrapper if we are in an internal repository checkout +[no-cd] +[no-exit-message] +_bazel *ARGS: (_if_in_semmle_code 'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel' 'bazel' ARGS) + +# Call sembuild (requires an internal repository checkout) +[no-cd] +[no-exit-message] +_sembuild *ARGS: (_run_in_semmle_code (['./build'] ++ ARGS)) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py new file mode 100755 index 000000000000..5bda2d5b86eb --- /dev/null +++ b/misc/just/codeql_test_run.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Run CodeQL tests with appropriate configuration. + +Called from just recipes as: + python3 codeql_test_run.py LANGUAGE [ARG...] + +Arguments are already split by `just` (see `set lists`), so each one is taken verbatim. +`--all-checks=FLAG` contributes FLAG to the set of extra checks that `--all-checks` (or +its `+` abbreviation) turns on. +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +JUST = os.environ.get("JUST_EXECUTABLE", "just") +ERROR = os.environ.get("JUST_ERROR", "error: ") +CMD_BEGIN = os.environ.get("CMD_BEGIN", "") +CMD_END = os.environ.get("CMD_END", "") +SEMMLE_CODE = os.environ.get("SEMMLE_CODE") + +ALL_CHECKS_PREFIX = "--all-checks=" +ENV_RE = re.compile(r"^[A-Z_][A-Z_0-9]*=.*$") + + +def invoke(invocation, *, cwd=None, log_prefix=""): + prefix = f"{log_prefix} " if log_prefix else "" + print(f"{CMD_BEGIN}{prefix}{' '.join(invocation)}{CMD_END}") + try: + subprocess.run(invocation, check=True, cwd=cwd) + except subprocess.CalledProcessError as e: + return e.returncode + return 0 + + +def error(message): + print(f"{ERROR}{message}", file=sys.stderr) + + +def parse_args(args, argv): + """Sort arguments into tests, flags and environment assignments.""" + for arg in argv: + if not arg: + # an empty argument can come from a caller interpolating an unset variable + continue + if arg.startswith(ALL_CHECKS_PREFIX): + args["all_checks"].append(arg[len(ALL_CHECKS_PREFIX) :]) + elif arg.startswith("--codeql="): + args["codeql"] = arg.split("=", 1)[1] + elif arg in ("+", "--all-checks"): + args["all"] = True + elif arg.startswith("-"): + args["flags"].append(arg) + elif ENV_RE.match(arg): + args["env"].append(arg) + else: + args["tests"].append(arg) + + +def env_value(args, name, default): + """Resolve a setting from test arguments, then the environment, then a default.""" + for assignment in reversed(args["env"]): + key, _, value = assignment.partition("=") + if key == name and value: + return value + return os.environ.get(name) or default + + +def main(): + argv = sys.argv[1:] + if not argv: + error("Usage: codeql_test_run.py LANGUAGE [ARG...]") + return 1 + + language, *rest = argv + + args = { + "tests": [], + "flags": [], + "env": [], + "all_checks": [], + "codeql": "build" if SEMMLE_CODE else "host", + "all": False, + } + parse_args(args, rest) + if args["all"]: + parse_args(args, args["all_checks"]) + + if not SEMMLE_CODE and args["codeql"] in ("build", "built"): + error( + "Using `--codeql=build` or `--codeql=built` requires working " + "with the internal repository" + ) + return 1 + + if not args["tests"]: + args["tests"].append(".") + + # Resolve these only once all arguments are known, so that a `RAM_PER_THREAD=` test + # argument can lower the default on memory-heavy suites. + default_ram = 3000 if sys.platform == "linux" else 2048 + ram_per_thread = int(env_value(args, "RAM_PER_THREAD", default_ram)) + cpus = int(env_value(args, "CPUS", os.cpu_count() or 1)) + args["flags"][:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] + + if args["codeql"] == "build": + if invoke([JUST, language, "build"], cwd=SEMMLE_CODE) != 0: + return 1 + + if args["codeql"] != "host": + # Disable the default implicit config file, but keep an explicit one. + # Same behavior wrt --codeql as the integration test runner. + os.environ.setdefault("CODEQL_CONFIG_FILE", ".") + + for env_var in args["env"]: + key, _, value = env_var.partition("=") + if not key: + error(f"Invalid environment variable assignment: {env_var}") + return 1 + os.environ[key] = value + + # Resolve codeql executable + if args["codeql"] in ("built", "build"): + codeql = Path(SEMMLE_CODE, "target", "intree", f"codeql-{language}", "codeql") + elif args["codeql"] == "host": + codeql = Path("codeql") + else: + codeql = Path(args["codeql"]) + + if codeql.is_dir(): + codeql = codeql / "codeql" + + # On Windows, prefer codeql.exe over the Unix shell wrapper + if sys.platform == "win32" and codeql.suffix != ".exe": + exe = codeql.with_suffix(".exe") + if exe.exists(): + codeql = exe + + if args["codeql"] != "host" and not codeql.exists(): + error(f"CodeQL executable not found: {codeql}") + return 1 + + return invoke( + [str(codeql), "test", "run", *args["flags"], "--", *args["tests"]], + log_prefix=" ".join(args["env"]), + ) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(128 + 2) diff --git a/misc/just/defs.just b/misc/just/defs.just new file mode 100644 index 000000000000..47cedd124b44 --- /dev/null +++ b/misc/just/defs.just @@ -0,0 +1,61 @@ +import? '../../../semmle-code.just' # internal repo just file, if present +import 'semmle-code-stub.just' + +# `set lists` is what lets recipes forward argument lists without encoding them as +# whitespace separated strings. It is still unstable as of just 1.58. +set unstable +set lists +set fallback +set allow-duplicate-recipes +set allow-duplicate-variables + +export PATH_SEP := if os() == "windows" { ";" } else { ":" } +export JUST_EXECUTABLE := just_executable() + +error := f'{{ style("error") }}error{{ NORMAL }}: ' +cmd_sep := "\n#--------------------------------------------------------\n" +export CMD_BEGIN := style("command") + cmd_sep +export CMD_END := cmd_sep + NORMAL +export JUST_ERROR := error + +py := "python3" + +default_db_checks := ['--check-databases', '--check-diff-informed', '--fail-on-trap-errors'] + +[no-exit-message] +@_require_semmle_code: + {{ if SEMMLE_CODE == "" { f''' + echo "{error} running this recipe requires doing so from an internal repository checkout" >&2 + exit 1 + ''' } else { "" } }} + +[no-cd] +_run +ARGS: + {{ cmd_sep }}{{ ARGS }}{{ cmd_sep }} + +[no-cd] +_run_in DIR +ARGS: + {{ cmd_sep }}cd "{{ DIR }}"; {{ ARGS }}{{ cmd_sep }} + +[no-cd] +_run_in_semmle_code +ARGS: _require_semmle_code (_run_in "$SEMMLE_CODE" ARGS) + +[no-cd] +[no-exit-message] +[positional-arguments] +@_just +ARGS: + echo "-> just $@" + "{{ JUST_EXECUTABLE }}" "$@" + +[no-cd] +[positional-arguments] +@_if_not_on_ci_just +ARGS: + if [ "${GITHUB_ACTIONS:-}" != "true" ]; then \ + echo "-> just $@"; \ + "$JUST_EXECUTABLE" "$@"; \ + fi + +[no-cd] +[no-exit-message] +_if_in_semmle_code THEN ELSE *ARGS: + {{ cmd_sep }}{{ if SEMMLE_CODE != "" { THEN } else { ELSE } }} {{ ARGS }}{{ cmd_sep }} diff --git a/misc/just/format.just b/misc/just/format.just new file mode 100644 index 000000000000..2796a300dbe4 --- /dev/null +++ b/misc/just/format.just @@ -0,0 +1,13 @@ +import "build.just" + +[no-cd] +[no-exit-message] +_format_ql +ARGS: (_maybe_build_dist "nolang") (_if_in_semmle_code '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' 'codeql' (f"query format --in-place -v $(find {{ ARGS }} -type f -name '*.ql' -or -name '*.qll')")) + +[no-cd] +[no-exit-message] +_format_py *ARGS=".": (_if_in_semmle_code "uv run black" "black" ARGS) + +[no-cd] +[no-exit-message] +_format_cpp *ARGS=".": (_if_in_semmle_code "uv run clang-format" "clang-format" (f"-i --verbose $(find {{ ARGS }} -type f -name '*.h' -or -name '*.cpp')")) diff --git a/misc/just/forward.just b/misc/just/forward.just new file mode 100644 index 000000000000..571f5e31a806 --- /dev/null +++ b/misc/just/forward.just @@ -0,0 +1,30 @@ +# Common verbs +# See README.md in this directory for an overview. + +import "lib.just" + +# Verbs are recipe names, so each one needs its own recipe. They all delegate to the +# same forwarder, which decides where the verb is actually implemented. + +[no-cd] +[no-exit-message] +[positional-arguments] +@_forward VERB *ARGS: + {{ py }} "{{ source_dir() }}/forward_command.py" "$@" + +alias t := test +alias b := build +alias g := generate +alias gen := generate +alias f := format +alias l := lint + +test *ARGS: (_forward "test" ARGS) + +build *ARGS: (_forward "build" ARGS) + +generate *ARGS: (_forward "generate" ARGS) + +lint *ARGS: (_forward "lint" ARGS) + +format *ARGS: (_forward "format" ARGS) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py new file mode 100644 index 000000000000..84bfeb06d028 --- /dev/null +++ b/misc/just/forward_command.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Forward commands to language-specific justfiles. + +Called from just recipes as: + python3 forward_command.py COMMAND [ARGS...] +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +JUST = os.environ.get("JUST_EXECUTABLE", "just") +ERROR = os.environ.get("JUST_ERROR", "") + + +def error(message): + print(f"{ERROR}{message}", file=sys.stderr) + + +def get_just_context(justfile, cmd, flags, positional_args): + """Get the (cwd, args) for invoking just with the given justfile.""" + if ( + len(positional_args) == 1 + and justfile == Path(positional_args[0]) / "justfile" + ): + # If there's only one positional argument and it matches the justfile + # path, suppress arguments so e.g. `just build ql/rust` becomes + # `just build` in the `ql/rust` directory + return positional_args[0], [cmd, *flags] + else: + return None, ["--justfile", str(justfile), cmd, *flags, *positional_args] + + +def check_just_command(justfile, command, positional_args): + """Check if a justfile supports the given command.""" + if not justfile.exists(): + return False + cwd, args = get_just_context(justfile, command, [], positional_args) + result = subprocess.run( + [JUST, "--dry-run", *args], + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + # Avoid having the forwarder find itself + return ( + result.returncode == 0 + and "forward_command.py" not in result.stderr + ) + + +def find_justfile(command, arg): + """Search up the directory tree for a justfile supporting the command.""" + for p in [Path(arg), *Path(arg).parents]: + candidate = p / "justfile" + if check_just_command(candidate, command, [arg]): + return candidate + return None + + +def invoke_just(cwd, args): + """Run just with the given arguments.""" + try: + subprocess.run([JUST, *args], check=True, cwd=cwd) + except subprocess.CalledProcessError as e: + return e.returncode + return 0 + + +def forward(cmd, args): + """Forward a command to language-specific justfiles.""" + is_non_positional = re.compile(r"^(-.*|\+|[A-Z_][A-Z_0-9]*=.*)$") + flags = [arg for arg in args if is_non_positional.match(arg)] + positional_args = [arg for arg in args if not is_non_positional.match(arg)] + + justfiles = {} + for arg in positional_args or ["."]: + justfile = find_justfile(cmd, arg) + if not justfile: + error(f"No justfile found for {cmd} on {arg}") + return 1 + justfiles.setdefault(justfile, []).append(arg) + + invocations = [] + for justfile, pos_args in justfiles.items(): + cwd, just_args = get_just_context(justfile, cmd, flags, pos_args) + prefix = f"cd {cwd}; " if cwd else "" + print(f"-> {prefix}just {' '.join(just_args)}") + invocations.append((cwd, just_args)) + + for cwd, just_args in invocations: + if invoke_just(cwd, just_args) != 0: + return 1 + return 0 + + +def main(): + argv = sys.argv[1:] + if not argv: + error("No command provided") + return 1 + return forward(argv[0], argv[1:]) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(128 + 2) diff --git a/misc/just/justfile b/misc/just/justfile new file mode 100644 index 000000000000..bfa7bed4db2e --- /dev/null +++ b/misc/just/justfile @@ -0,0 +1,2 @@ +format *ARGS=".": + npx prettier --write {{ ARGS }} diff --git a/misc/just/language_tests.py b/misc/just/language_tests.py new file mode 100755 index 000000000000..988a77d73cf3 --- /dev/null +++ b/misc/just/language_tests.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run a whole language test suite for CI. + +Called from just recipes as: + python3 language_tests.py ROOT [ARG...] + +Arguments are already split by `just` (see `set lists`). The first one must be a test +root, which is used to locate the justfile implementing `test` for that suite. +""" + +import os +import subprocess +import sys +from pathlib import Path + + +def main(): + argv = sys.argv[1:] + if not argv: + print("Usage: language_tests.py ROOT [ARG...]", file=sys.stderr) + return 1 + + semmle_code = Path(os.environ["SEMMLE_CODE"]) + # Test roots are absolute, as justfiles build them from `source_dir()`. We run from + # the internal checkout, so relativize them there to keep command lines readable. + # Anything else (flags, environment assignments, relative paths) is passed verbatim. + args = [ + os.path.relpath(arg, semmle_code) if os.path.isabs(arg) else arg + for arg in argv + if arg + ] + + just = os.environ.get("JUST_EXECUTABLE", "just") + + # Find the nearest justfile at or above the first root + justfile_dir = Path(args[0]) + while not (semmle_code / justfile_dir / "justfile").exists(): + parent = justfile_dir.parent + if parent == justfile_dir: + print(f"No justfile found above {args[0]}", file=sys.stderr) + return 1 + justfile_dir = parent + + invocation = [ + just, + "--justfile", + str(justfile_dir / "justfile"), + "test", + "--all-checks", + "--codeql=built", + *args, + ] + + print(f"-> just {' '.join(invocation[1:])}") + try: + subprocess.run(invocation, check=True, cwd=semmle_code) + except subprocess.CalledProcessError as e: + return e.returncode + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(128 + 2) diff --git a/misc/just/lib.just b/misc/just/lib.just new file mode 100644 index 000000000000..8ce335677287 --- /dev/null +++ b/misc/just/lib.just @@ -0,0 +1,31 @@ +# Helper recipes + +import "build.just" +import "format.just" + +# Run language tests for LANGUAGE. +# +# Arguments tagged with `--all-checks=` are held back and only applied when `--all-checks` +# or `+` is passed along, which is how per-language justfiles express the extra checks CI +# runs on top of the default ones. +[no-cd] +[no-exit-message] +[positional-arguments] +@_codeql_test LANGUAGE *ARGS: + {{ py }} "{{ source_dir() }}/codeql_test_run.py" "$@" + +# Run a whole language test suite. The first argument must be a test root. This is +# intended to be called by CI +[no-cd] +[no-exit-message] +[positional-arguments] +@_language_tests *ARGS: _require_semmle_code + {{ py }} "{{ source_dir() }}/language_tests.py" "$@" + +# Run integration tests. Requires an internal repository checkout +[no-cd] +[no-exit-message] +[positional-arguments] +@_integration_test *ARGS: _require_semmle_code + echo "$CMD_BEGIN$SEMMLE_CODE/tools/pytest --codeql=build-as-test $*$CMD_END" + "$SEMMLE_CODE/tools/pytest" --codeql=build-as-test "$@" diff --git a/misc/just/semmle-code-stub.just b/misc/just/semmle-code-stub.just new file mode 100644 index 000000000000..14733ffb648e --- /dev/null +++ b/misc/just/semmle-code-stub.just @@ -0,0 +1 @@ +export SEMMLE_CODE := "" From 9f4249aee72c23a247cdb41b5198ce2c1bdd3be7 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 11:35:25 +0200 Subject: [PATCH 04/92] Just: implement the common verbs for each language Each language declares its own test flags, consistency queries and build steps, so that `just test`, `just build` and friends do the right thing wherever they are run from. The flag sets are transcribed from the internal CI definitions they replace, so behaviour is unchanged. `unified` gets the same treatment as the other languages, including the consistency queries that were previously not run anywhere. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/justfile | 9 ++++++++ actions/ql/integration-tests/justfile | 4 ++++ actions/ql/justfile | 6 ++++++ actions/ql/test/justfile | 8 ++++++++ cpp/justfile | 10 +++++++++ cpp/ql/integration-tests/justfile | 4 ++++ cpp/ql/justfile | 6 ++++++ cpp/ql/test/justfile | 8 ++++++++ csharp/justfile | 9 ++++++++ csharp/ql/integration-tests/justfile | 4 ++++ csharp/ql/justfile | 6 ++++++ csharp/ql/test/justfile | 8 ++++++++ go/justfile | 9 ++++++++ go/ql/integration-tests/justfile | 4 ++++ go/ql/justfile | 6 ++++++ go/ql/test/justfile | 8 ++++++++ java/justfile | 4 ++++ java/ql/integration-tests/justfile | 4 ++++ java/ql/justfile | 6 ++++++ java/ql/test-kotlin1/justfile | 9 ++++++++ java/ql/test-kotlin2/justfile | 9 ++++++++ java/ql/test/justfile | 10 +++++++++ javascript/justfile | 9 ++++++++ javascript/ql/integration-tests/justfile | 4 ++++ javascript/ql/justfile | 6 ++++++ javascript/ql/test/justfile | 8 ++++++++ misc/codegen/justfile | 5 +++++ python/justfile | 26 ++++++++++++++++++++++++ python/ql/integration-tests/justfile | 4 ++++ python/ql/justfile | 12 +++++++++++ python/ql/test/justfile | 8 ++++++++ ruby/justfile | 9 ++++++++ ruby/ql/integration-tests/justfile | 4 ++++ ruby/ql/justfile | 6 ++++++ ruby/ql/test/justfile | 8 ++++++++ rust/justfile | 17 ++++++++++++++++ rust/ql/integration-tests/justfile | 4 ++++ rust/ql/justfile | 6 ++++++ rust/ql/test/justfile | 8 ++++++++ swift/justfile | 18 ++++++++++++++++ swift/ql/integration-tests/justfile | 4 ++++ swift/ql/justfile | 6 ++++++ swift/ql/test/justfile | 8 ++++++++ unified/extractor/justfile | 4 ++++ unified/justfile | 14 +++++++++++++ unified/ql/justfile | 6 ++++++ unified/ql/test/justfile | 8 ++++++++ unified/swift-syntax-rs/justfile | 4 ++++ 48 files changed, 367 insertions(+) create mode 100644 actions/justfile create mode 100644 actions/ql/integration-tests/justfile create mode 100644 actions/ql/justfile create mode 100644 actions/ql/test/justfile create mode 100644 cpp/justfile create mode 100644 cpp/ql/integration-tests/justfile create mode 100644 cpp/ql/justfile create mode 100644 cpp/ql/test/justfile create mode 100644 csharp/justfile create mode 100644 csharp/ql/integration-tests/justfile create mode 100644 csharp/ql/justfile create mode 100644 csharp/ql/test/justfile create mode 100644 go/justfile create mode 100644 go/ql/integration-tests/justfile create mode 100644 go/ql/justfile create mode 100644 go/ql/test/justfile create mode 100644 java/justfile create mode 100644 java/ql/integration-tests/justfile create mode 100644 java/ql/justfile create mode 100644 java/ql/test-kotlin1/justfile create mode 100644 java/ql/test-kotlin2/justfile create mode 100644 java/ql/test/justfile create mode 100644 javascript/justfile create mode 100644 javascript/ql/integration-tests/justfile create mode 100644 javascript/ql/justfile create mode 100644 javascript/ql/test/justfile create mode 100644 misc/codegen/justfile create mode 100644 python/justfile create mode 100644 python/ql/integration-tests/justfile create mode 100644 python/ql/justfile create mode 100644 python/ql/test/justfile create mode 100644 ruby/justfile create mode 100644 ruby/ql/integration-tests/justfile create mode 100644 ruby/ql/justfile create mode 100644 ruby/ql/test/justfile create mode 100644 rust/justfile create mode 100644 rust/ql/integration-tests/justfile create mode 100644 rust/ql/justfile create mode 100644 rust/ql/test/justfile create mode 100644 swift/justfile create mode 100644 swift/ql/integration-tests/justfile create mode 100644 swift/ql/justfile create mode 100644 swift/ql/test/justfile create mode 100644 unified/extractor/justfile create mode 100644 unified/justfile create mode 100644 unified/ql/justfile create mode 100644 unified/ql/test/justfile create mode 100644 unified/swift-syntax-rs/justfile diff --git a/actions/justfile b/actions/justfile new file mode 100644 index 000000000000..b96eb20dfe26 --- /dev/null +++ b/actions/justfile @@ -0,0 +1,9 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "actions") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/actions/ql/integration-tests/justfile b/actions/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/actions/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/actions/ql/justfile b/actions/ql/justfile new file mode 100644 index 000000000000..e613c25b52c1 --- /dev/null +++ b/actions/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := "" diff --git a/actions/ql/test/justfile b/actions/ql/test/justfile new file mode 100644 index 000000000000..5ea5f794895b --- /dev/null +++ b/actions/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks + +[no-cd] +test *ARGS=".": (_codeql_test "actions" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/cpp/justfile b/cpp/justfile new file mode 100644 index 000000000000..0fc87260ebec --- /dev/null +++ b/cpp/justfile @@ -0,0 +1,10 @@ +import '../lib.just' +import? '../../cpp-coding-standards.just' + +[group('build')] +build: (_build_dist "cpp") + +roots := [source_dir() / 'ql/test', SEMMLE_CODE / 'semmlecode-cpp-tests'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/cpp/ql/integration-tests/justfile b/cpp/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/cpp/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/cpp/ql/justfile b/cpp/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/cpp/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/cpp/ql/test/justfile b/cpp/ql/test/justfile new file mode 100644 index 000000000000..4fe4f79eba67 --- /dev/null +++ b/cpp/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := ['--include-location-in-star'] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "cpp" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/csharp/justfile b/csharp/justfile new file mode 100644 index 000000000000..6f99dd8703e0 --- /dev/null +++ b/csharp/justfile @@ -0,0 +1,9 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "csharp") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/csharp/ql/integration-tests/justfile b/csharp/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/csharp/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/csharp/ql/justfile b/csharp/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/csharp/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/csharp/ql/test/justfile b/csharp/ql/test/justfile new file mode 100644 index 000000000000..e2be0fe0d9c4 --- /dev/null +++ b/csharp/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--additional-packs=ql', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "csharp" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/go/justfile b/go/justfile new file mode 100644 index 000000000000..e1ea5203166e --- /dev/null +++ b/go/justfile @@ -0,0 +1,9 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "go") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/go/ql/integration-tests/justfile b/go/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/go/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/go/ql/justfile b/go/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/go/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/go/ql/test/justfile b/go/ql/test/justfile new file mode 100644 index 000000000000..24eea13e550f --- /dev/null +++ b/go/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "go" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/java/justfile b/java/justfile new file mode 100644 index 000000000000..aba4ba7b21dd --- /dev/null +++ b/java/justfile @@ -0,0 +1,4 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "java") diff --git a/java/ql/integration-tests/justfile b/java/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/java/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/java/ql/justfile b/java/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/java/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/java/ql/test-kotlin1/justfile b/java/ql/test-kotlin1/justfile new file mode 100644 index 000000000000..6a77e14a680e --- /dev/null +++ b/java/ql/test-kotlin1/justfile @@ -0,0 +1,9 @@ +import "../justfile" + +# Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. +base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT='] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/java/ql/test-kotlin2/justfile b/java/ql/test-kotlin2/justfile new file mode 100644 index 000000000000..36f60068826e --- /dev/null +++ b/java/ql/test-kotlin2/justfile @@ -0,0 +1,9 @@ +import "../justfile" + +# Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. +base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT=', 'CODEQL_KOTLIN_LEGACY_TEST_EXTRACTION_KOTLIN2=true'] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/java/ql/test/justfile b/java/ql/test/justfile new file mode 100644 index 000000000000..3eb33887c72f --- /dev/null +++ b/java/ql/test/justfile @@ -0,0 +1,10 @@ +import "../justfile" + +# The Kotlin extractor must see the diagnostic limit set, but blank: hence the single +# trailing space. +base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT= '] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/javascript/justfile b/javascript/justfile new file mode 100644 index 000000000000..769847a380d1 --- /dev/null +++ b/javascript/justfile @@ -0,0 +1,9 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "javascript") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/javascript/ql/integration-tests/justfile b/javascript/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/javascript/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/javascript/ql/justfile b/javascript/ql/justfile new file mode 100644 index 000000000000..e613c25b52c1 --- /dev/null +++ b/javascript/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := "" diff --git a/javascript/ql/test/justfile b/javascript/ql/test/justfile new file mode 100644 index 000000000000..f78ef2da39d6 --- /dev/null +++ b/javascript/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks + +[no-cd] +test *ARGS=".": (_codeql_test "javascript" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/misc/codegen/justfile b/misc/codegen/justfile new file mode 100644 index 000000000000..a65fa16e5679 --- /dev/null +++ b/misc/codegen/justfile @@ -0,0 +1,5 @@ +import "../just/lib.just" + +test *ARGS="": (_bazel ['test', '@codeql//misc/codegen/...']) + +format *ARGS=".": (_format_py ARGS) diff --git a/python/justfile b/python/justfile new file mode 100644 index 000000000000..33580aa1e05c --- /dev/null +++ b/python/justfile @@ -0,0 +1,26 @@ +import '../lib.just' +import 'ql/justfile' + +[group('build')] +build: (_build_dist "python") + +# Long filename needed for extractor tests (too long for Git on Windows) +[no-cd] +@_ensure_long_filename: + #!/usr/bin/env bash + longfile="$SEMMLE_CODE/ql/python/ql/test/extractor-tests/long_path/really_rather_too_long_for_windows_path_length/with_unecessarily_longwinded_and_verbose_sub_folder/extremely_long_module_name_with_lots_of_digits_at_the_end_000000000000000000000000000000000000000000000000000000000000000000/test0000000000000000000000000000000000000000000000000000000.py" + mkdir -p "$(dirname "$longfile")" + touch "$longfile" + +_tests := source_dir() / 'ql/test' + +_shared_roots := [_tests / 'library-tests', _tests / 'query-tests', _tests / 'extractor-tests', _tests / 'experimental'] + +roots_2 := _shared_roots ++ [_tests / '2'] +roots_3 := _shared_roots ++ [_tests / 'modelling', _tests / '3'] + +[group('test')] +language-tests-2 *EXTRA_ARGS: _ensure_long_filename (_language_tests (roots_2 ++ _v2_env ++ EXTRA_ARGS)) + +[group('test')] +language-tests-3 *EXTRA_ARGS: _ensure_long_filename (_language_tests (roots_3 ++ _v3_env ++ EXTRA_ARGS)) diff --git a/python/ql/integration-tests/justfile b/python/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/python/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/python/ql/justfile b/python/ql/justfile new file mode 100644 index 000000000000..45ed8d733cff --- /dev/null +++ b/python/ql/justfile @@ -0,0 +1,12 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" + +python_version := env("python_version", "3") + +_v2_env := ['CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=2', 'CODEQL_PYTHON_LEGACY_TEST_EXTRACTION_VERSION=2'] +_v3_env := ['CODEQL_PYTHON_LEGACY_TEST_EXTRACTION_VERSION=3'] +_python_env := if python_version == "2" { _v2_env } else { _v3_env } diff --git a/python/ql/test/justfile b/python/ql/test/justfile new file mode 100644 index 000000000000..4ed53a3aabd1 --- /dev/null +++ b/python/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := _python_env + +all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "python" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/ruby/justfile b/ruby/justfile new file mode 100644 index 000000000000..b9cc748f169f --- /dev/null +++ b/ruby/justfile @@ -0,0 +1,9 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "ruby") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/ruby/ql/integration-tests/justfile b/ruby/ql/integration-tests/justfile new file mode 100644 index 000000000000..a4d3e54e14f2 --- /dev/null +++ b/ruby/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_integration_test ARGS) diff --git a/ruby/ql/justfile b/ruby/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/ruby/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/ruby/ql/test/justfile b/ruby/ql/test/justfile new file mode 100644 index 000000000000..4c0b48b2cffd --- /dev/null +++ b/ruby/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "ruby" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/rust/justfile b/rust/justfile new file mode 100644 index 000000000000..9877da7b9f1f --- /dev/null +++ b/rust/justfile @@ -0,0 +1,17 @@ +import '../lib.just' + +install: (_bazel ['run', '@codeql//rust:install']) + +[group('build')] +build: (_if_not_on_ci_just ['generate', source_dir()]) (_build_dist "rust") + +generate: (_bazel ['run', '@codeql//rust/codegen']) + +lint: (_run_in source_dir() ['python3', 'lint.py']) + +format: (_run_in source_dir() ['python3', 'lint.py', '--format-only']) + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) diff --git a/rust/ql/integration-tests/justfile b/rust/ql/integration-tests/justfile new file mode 100644 index 000000000000..aea016840262 --- /dev/null +++ b/rust/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_if_not_on_ci_just ['generate', source_dir()]) (_integration_test ARGS) diff --git a/rust/ql/justfile b/rust/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/rust/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/rust/ql/test/justfile b/rust/ql/test/justfile new file mode 100644 index 000000000000..442e655be938 --- /dev/null +++ b/rust/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "rust" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/swift/justfile b/swift/justfile new file mode 100644 index 000000000000..b565923cae08 --- /dev/null +++ b/swift/justfile @@ -0,0 +1,18 @@ +import '../lib.just' + +install: (_bazel ['run', '@codeql//swift:install']) + +[group('build')] +build: (_build_dist "swift") + +generate: (_bazel ['run', '@codeql//swift/codegen']) + +format *ARGS=".": (_format_cpp ARGS) + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) + +[group('test')] +extra-tests: (_sembuild "target/test/check-queries-swift") (_sembuild "target/test/check-db-upgrades-swift") (_sembuild "target/test/check-db-downgrades-swift") diff --git a/swift/ql/integration-tests/justfile b/swift/ql/integration-tests/justfile new file mode 100644 index 000000000000..5793b1ded1d4 --- /dev/null +++ b/swift/ql/integration-tests/justfile @@ -0,0 +1,4 @@ +import "../../../lib.just" + +[no-cd] +test *ARGS=".": (_just "generate") (_integration_test ARGS) diff --git a/swift/ql/justfile b/swift/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/swift/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/swift/ql/test/justfile b/swift/ql/test/justfile new file mode 100644 index 000000000000..b4c3ac4079a2 --- /dev/null +++ b/swift/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "swift" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/unified/extractor/justfile b/unified/extractor/justfile new file mode 100644 index 000000000000..f6a6417ed867 --- /dev/null +++ b/unified/extractor/justfile @@ -0,0 +1,4 @@ +import '../../lib.just' + +[group('test')] +test *BAZEL_ARGS: (_bazel (['test', '--build_tests_only', '@codeql//unified/extractor/...'] ++ BAZEL_ARGS)) diff --git a/unified/justfile b/unified/justfile new file mode 100644 index 000000000000..5ba4d7de02d0 --- /dev/null +++ b/unified/justfile @@ -0,0 +1,14 @@ +import '../lib.just' + +[group('build')] +build: (_build_dist "unified") + +roots := [source_dir() / 'ql/test'] + +[group('test')] +language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) + +[group('test')] +test *BAZEL_ARGS: (_bazel (['test', '--build_tests_only', '@codeql//unified/...'] ++ BAZEL_ARGS)) + +alias extractor-tests := test diff --git a/unified/ql/justfile b/unified/ql/justfile new file mode 100644 index 000000000000..ff0e4c1090f3 --- /dev/null +++ b/unified/ql/justfile @@ -0,0 +1,6 @@ +import "../../lib.just" + +[no-cd] +format *ARGS=".": (_format_ql ARGS) + +consistency_queries := source_dir() / "consistency-queries" diff --git a/unified/ql/test/justfile b/unified/ql/test/justfile new file mode 100644 index 000000000000..8f57b90fd9a8 --- /dev/null +++ b/unified/ql/test/justfile @@ -0,0 +1,8 @@ +import "../justfile" + +base_flags := [] + +all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] + +[no-cd] +test *ARGS=".": (_codeql_test "unified" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) diff --git a/unified/swift-syntax-rs/justfile b/unified/swift-syntax-rs/justfile new file mode 100644 index 000000000000..021bb0e9e75e --- /dev/null +++ b/unified/swift-syntax-rs/justfile @@ -0,0 +1,4 @@ +import '../../lib.just' + +[group('test')] +test *BAZEL_ARGS: (_bazel (['test', '--build_tests_only', '@codeql//unified/swift-syntax-rs/...'] ++ BAZEL_ARGS)) From 930f9b7608594651162c4e361ea6b3302d6d7b46 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 13:02:55 +0200 Subject: [PATCH 05/92] Just: let verbs find recipes below the directory they are given Pointing a verb at a directory that merely contains implementations used to fail, so `just test cpp` was an error and reaching a suite required either naming it in full or hand-writing an aggregate recipe. When nothing at or above an argument implements the verb, look below it instead. Justfiles are enumerated with `git ls-files`, which is two orders of magnitude faster than walking a checkout with build outputs in it, and probed in parallel with `just --dump`. That dump also replaces the previous trick of recognising a forwarder by a string in its stderr: a recipe that depends on `_forward` does not implement the verb, whichever repository it lives in. Upward search still wins, so naming a recipe after a verb now decides what that verb means for the whole subtree. `unified` was doing exactly that and would have hidden its own QL tests, so its bazel entry point goes back to being called `extractor-tests`. Directories that only make sense when named explicitly say so with `explicit_verbs`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/ql/integration-tests/justfile | 4 + cpp/ql/integration-tests/justfile | 4 + csharp/ql/integration-tests/justfile | 4 + go/ql/integration-tests/justfile | 4 + java/ql/integration-tests/justfile | 4 + java/ql/test-kotlin1/justfile | 4 + java/ql/test-kotlin2/justfile | 4 + javascript/ql/integration-tests/justfile | 4 + misc/just/README.md | 30 +++- misc/just/forward_command.py | 206 ++++++++++++++++++++--- python/ql/integration-tests/justfile | 4 + ruby/ql/integration-tests/justfile | 4 + rust/ql/integration-tests/justfile | 4 + swift/ql/integration-tests/justfile | 4 + unified/justfile | 4 +- 15 files changed, 261 insertions(+), 27 deletions(-) diff --git a/actions/ql/integration-tests/justfile b/actions/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/actions/ql/integration-tests/justfile +++ b/actions/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/cpp/ql/integration-tests/justfile b/cpp/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/cpp/ql/integration-tests/justfile +++ b/cpp/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/csharp/ql/integration-tests/justfile b/csharp/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/csharp/ql/integration-tests/justfile +++ b/csharp/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/go/ql/integration-tests/justfile b/go/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/go/ql/integration-tests/justfile +++ b/go/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/java/ql/integration-tests/justfile b/java/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/java/ql/integration-tests/justfile +++ b/java/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/java/ql/test-kotlin1/justfile b/java/ql/test-kotlin1/justfile index 6a77e14a680e..a9815627d15e 100644 --- a/java/ql/test-kotlin1/justfile +++ b/java/ql/test-kotlin1/justfile @@ -1,5 +1,9 @@ import "../justfile" +# These are CI shards of the Kotlin language tests, run as `just java +# kotlin-language-tests-1`, so they only run when asked for by name. +explicit_verbs := ['test'] + # Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT='] diff --git a/java/ql/test-kotlin2/justfile b/java/ql/test-kotlin2/justfile index 36f60068826e..bda00ff0ca75 100644 --- a/java/ql/test-kotlin2/justfile +++ b/java/ql/test-kotlin2/justfile @@ -1,5 +1,9 @@ import "../justfile" +# These are CI shards of the Kotlin language tests, run as `just java +# kotlin-language-tests-2`, so they only run when asked for by name. +explicit_verbs := ['test'] + # Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT=', 'CODEQL_KOTLIN_LEGACY_TEST_EXTRACTION_KOTLIN2=true'] diff --git a/javascript/ql/integration-tests/justfile b/javascript/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/javascript/ql/integration-tests/justfile +++ b/javascript/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/misc/just/README.md b/misc/just/README.md index e53b400c6536..3afe16729a23 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -20,6 +20,29 @@ The core of the functionality is given by forwarding. The idea is that: `just build ql/rust ql/java`, or `just test ql/rust/ql/test/some/language/test ql/rust/ql/integration-test/some/integration/test` will also work, with corresponding recipes run sequentially. +- finally, if nothing above an argument implements the verb, the forwarder looks + _below_ it, so that `just test ql/cpp` runs the tests defined underneath it. The + argument only says where to look in this case, so each recipe found is run on its own + directory rather than being passed the argument. Several may be found, in which case + they run sequentially: `just format ql/cpp` formats everything under `ql/cpp` that + knows how to format itself. + +Searching upwards takes precedence, so a justfile naming a verb decides what that verb +means for its whole subtree. This means a recipe should be named after a verb only if it +covers everything beneath it: an aggregate that forgets one of the directories under it +would silently shadow it. Conversely, a directory that only makes sense when named +explicitly (integration tests, or the sharded Kotlin suites that CI runs) can opt out of +being found from above: + +```just +explicit_verbs := ['test'] +``` + +This only affects the downward search. Running the verb from inside that directory, or +naming the directory on the command line, keeps working. + +Justfiles are found through `git`, so a newly written one needs to be either tracked or +untracked-but-not-ignored to be picked up. Another point is how launching QL tests can be tweaked: @@ -37,5 +60,8 @@ Test arguments are passed around as `just` lists (`set lists`), so they reach th underlying runner already split and arguments containing spaces survive intact. One caveat: when running different recipes for the same verb, non-positional arguments -need to be supported by all recipes involved. For example, this will work ok for -`--learn` or `--codeql` options in language and integration tests. +need to be supported by all recipes involved. This works fine for `--learn` or +`--codeql` across language and integration tests, but note that searching downwards can +reach recipes that have nothing to do with QL: `just test .` also finds the bazel suites +under `unified`, which do not understand `--codeql`. Such a mismatch fails rather than +being ignored, so the fix is to aim the verb at something narrower. diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 84bfeb06d028..8826c784f36f 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -5,15 +5,28 @@ python3 forward_command.py COMMAND [ARGS...] """ +import json import os import re import subprocess import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path JUST = os.environ.get("JUST_EXECUTABLE", "just") ERROR = os.environ.get("JUST_ERROR", "") +# Recipes that delegate to this one do not implement a verb, they pass it on. Skipping +# them is what stops the search from settling on a forwarder, be it this one or the root +# justfile of a nested repository. +FORWARD_RECIPE = "_forward" + +# Justfiles may list verbs that must be spelled out explicitly instead of being picked +# up by a verb aimed at one of their parent directories. +EXPLICIT_VERBS = "explicit_verbs" + +PROBE_WORKERS = 16 + def error(message): print(f"{ERROR}{message}", file=sys.stderr) @@ -33,35 +46,178 @@ def get_just_context(justfile, cmd, flags, positional_args): return None, ["--justfile", str(justfile), cmd, *flags, *positional_args] -def check_just_command(justfile, command, positional_args): - """Check if a justfile supports the given command.""" - if not justfile.exists(): - return False - cwd, args = get_just_context(justfile, command, [], positional_args) +def dump_justfile(justfile): + """Parse a justfile with `just`, returning its JSON dump or an error message.""" result = subprocess.run( - [JUST, "--dry-run", *args], - cwd=cwd, + [JUST, "--dump", "--dump-format", "json", "--justfile", str(justfile)], stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, + capture_output=True, text=True, ) - # Avoid having the forwarder find itself - return ( - result.returncode == 0 - and "forward_command.py" not in result.stderr + if result.returncode != 0: + return None, result.stderr.strip() + return json.loads(result.stdout), None + + +def list_value(assignments, name): + """Read a list literal assignment from a justfile dump.""" + value = assignments.get(name, {}).get("value") + # A literal list is dumped as ["list", element...]. Anything else is an expression + # that cannot be evaluated without running just, and counts as absent. + if isinstance(value, list) and value[:1] == ["list"]: + return value[1:] + return [] + + +def accepts(recipe, argc): + """Check whether a recipe can be called with a given number of arguments.""" + parameters = recipe["parameters"] + variadic = parameters and parameters[-1]["kind"] in ("star", "plus") + required = sum( + 1 + for parameter in parameters + if parameter["default"] is None and parameter["kind"] != "star" + ) + return required <= argc and (variadic or argc <= len(parameters)) + + +def implements(dump, command, argc, *, implicitly): + """Check whether a justfile dump provides a command taking argc arguments.""" + recipe = dump["recipes"].get(dump["aliases"].get(command, command)) + if recipe is None or recipe["private"] or not accepts(recipe, argc): + return False + if any( + dependency["recipe"] == FORWARD_RECIPE for dependency in recipe["dependencies"] + ): + return False + if implicitly and command in list_value(dump["assignments"], EXPLICIT_VERBS): + return False + return True + + +def dump_all(justfiles): + """Parse justfiles in parallel, reporting the ones that cannot be read.""" + with ThreadPoolExecutor(PROBE_WORKERS) as executor: + dumps = list(executor.map(dump_justfile, justfiles)) + parsed = [] + for justfile, (dump, failure) in zip(justfiles, dumps): + if dump is None: + error(f"could not read {justfile}:\n{failure}") + else: + parsed.append((justfile, dump)) + return parsed + + +def git(directory, *args): + """Run a git command in a directory, returning its output lines.""" + result = subprocess.run( + ["git", "-C", directory, *args], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, ) + if result.returncode != 0: + error(f"`git {' '.join(args)}` failed in {directory}:\n{result.stderr.strip()}") + return [] + return result.stdout.splitlines() + + +def submodules(directory): + """List the initialised submodules under a directory.""" + toplevel = git(directory, "rev-parse", "--show-toplevel") + if not toplevel or not (Path(toplevel[0]) / ".gitmodules").exists(): + return [] + paths = [ + Path(toplevel[0]) / line.split(" ", 1)[1] + for line in git( + toplevel[0], "config", "--file", ".gitmodules", "--get-regexp", r"\.path$" + ) + ] + within = Path(directory).resolve() + return [ + Path(directory) / os.path.relpath(path, within) + for path in paths + # An uninitialised submodule is an empty directory, with nothing to run. + if path.is_relative_to(within) and (path / ".git").exists() + ] -def find_justfile(command, arg): - """Search up the directory tree for a justfile supporting the command.""" - for p in [Path(arg), *Path(arg).parents]: - candidate = p / "justfile" - if check_just_command(candidate, command, [arg]): - return candidate +def find_justfiles(directory): + """List every justfile under a directory. + + Submodules are listed separately, as `git ls-files` can either recurse into them or + report untracked files, but not both, and a justfile that has just been written is + worth finding. + """ + justfiles = set() + for repository in [directory, *submodules(directory)]: + justfiles.update( + Path(repository) / line + for line in git( + repository, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "--", + "justfile", + "*/justfile", + ) + ) + return justfiles + + +def find_justfile_above(command, arg): + """Search up the directory tree for a justfile implementing the command.""" + candidates = [ + p / "justfile" + for p in [Path(arg), *Path(arg).parents] + if (p / "justfile").exists() + ] + for justfile, dump in dump_all(candidates): + # A justfile sitting exactly on the argument is called without it, as the + # argument would only repeat where it already is. + argc = 0 if justfile.parent == Path(arg) else 1 + if implements(dump, command, argc, implicitly=False): + return justfile return None +def find_justfiles_below(command, directory): + """Search down a directory for the outermost justfiles implementing the command.""" + # The justfile at `directory` was already ruled out by the search above it. + candidates = sorted(find_justfiles(directory) - {Path(directory) / "justfile"}) + # Each of these is called on its own directory, so without arguments. + found = [ + justfile + for justfile, dump in dump_all(candidates) + if implements(dump, command, 0, implicitly=True) + ] + # Keep only the outermost matches, so that a justfile covering a whole subtree wins + # over the ones below it. + directories = {justfile.parent for justfile in found} + return [ + justfile + for justfile in found + if not any(parent in directories for parent in justfile.parent.parents) + ] + + +def resolve(command, arg): + """Find the justfiles implementing a command for an argument. + + Returns a list of (justfile, argument) pairs. A justfile found above the argument + gets the argument itself, as that selects what to act on. One found below it gets + its own directory instead, as there the argument only said where to look. + """ + justfile = find_justfile_above(command, arg) + if justfile: + return [(justfile, arg)] + if not os.path.isdir(arg): + return [] + return [(jf, str(jf.parent)) for jf in find_justfiles_below(command, arg)] + + def invoke_just(cwd, args): """Run just with the given arguments.""" try: @@ -79,14 +235,20 @@ def forward(cmd, args): justfiles = {} for arg in positional_args or ["."]: - justfile = find_justfile(cmd, arg) - if not justfile: + resolved = resolve(cmd, arg) + if not resolved: error(f"No justfile found for {cmd} on {arg}") return 1 - justfiles.setdefault(justfile, []).append(arg) + for justfile, justfile_arg in resolved: + justfiles.setdefault(justfile, []).append(justfile_arg) invocations = [] for justfile, pos_args in justfiles.items(): + # An argument standing for the whole directory subsumes any more specific one + # that ended up on the same justfile. + whole_directory = str(justfile.parent) + if whole_directory in pos_args: + pos_args = [whole_directory] cwd, just_args = get_just_context(justfile, cmd, flags, pos_args) prefix = f"cd {cwd}; " if cwd else "" print(f"-> {prefix}just {' '.join(just_args)}") diff --git a/python/ql/integration-tests/justfile b/python/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/python/ql/integration-tests/justfile +++ b/python/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/ruby/ql/integration-tests/justfile b/ruby/ql/integration-tests/justfile index a4d3e54e14f2..8392c2b3ba40 100644 --- a/ruby/ql/integration-tests/justfile +++ b/ruby/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_integration_test ARGS) diff --git a/rust/ql/integration-tests/justfile b/rust/ql/integration-tests/justfile index aea016840262..fa96473894df 100644 --- a/rust/ql/integration-tests/justfile +++ b/rust/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_if_not_on_ci_just ['generate', source_dir()]) (_integration_test ARGS) diff --git a/swift/ql/integration-tests/justfile b/swift/ql/integration-tests/justfile index 5793b1ded1d4..097faf5baebf 100644 --- a/swift/ql/integration-tests/justfile +++ b/swift/ql/integration-tests/justfile @@ -1,4 +1,8 @@ import "../../../lib.just" +# Integration tests are slow and need an internal checkout, so they only run when +# asked for by name. +explicit_verbs := ['test'] + [no-cd] test *ARGS=".": (_just "generate") (_integration_test ARGS) diff --git a/unified/justfile b/unified/justfile index 5ba4d7de02d0..610ec84901c0 100644 --- a/unified/justfile +++ b/unified/justfile @@ -9,6 +9,4 @@ roots := [source_dir() / 'ql/test'] language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) [group('test')] -test *BAZEL_ARGS: (_bazel (['test', '--build_tests_only', '@codeql//unified/...'] ++ BAZEL_ARGS)) - -alias extractor-tests := test +extractor-tests *BAZEL_ARGS: (_bazel (['test', '--build_tests_only', '@codeql//unified/...'] ++ BAZEL_ARGS)) From 8d14da089624047076fc24bd5a6656fc36b5d4c2 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 13:46:36 +0200 Subject: [PATCH 06/92] Just: make QL test suites opt out of implicit discovery Running a whole language suite means building a CodeQL CLI and waiting a long time, which is not something `just test ` should decide to do on the user's behalf. Integration tests and the Kotlin CI shards already opted out for the same reason; the suites themselves are the bigger case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/ql/test/justfile | 4 ++++ cpp/ql/test/justfile | 4 ++++ csharp/ql/test/justfile | 4 ++++ go/ql/test/justfile | 4 ++++ java/ql/test/justfile | 4 ++++ javascript/ql/test/justfile | 4 ++++ misc/just/README.md | 36 ++++++++++++++++++++++++++++-------- python/ql/test/justfile | 4 ++++ ruby/ql/test/justfile | 4 ++++ rust/ql/test/justfile | 4 ++++ swift/ql/test/justfile | 4 ++++ unified/ql/test/justfile | 4 ++++ 12 files changed, 72 insertions(+), 8 deletions(-) diff --git a/actions/ql/test/justfile b/actions/ql/test/justfile index 5ea5f794895b..8c06f3e5c155 100644 --- a/actions/ql/test/justfile +++ b/actions/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks diff --git a/cpp/ql/test/justfile b/cpp/ql/test/justfile index 4fe4f79eba67..4ab3ef69856a 100644 --- a/cpp/ql/test/justfile +++ b/cpp/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := ['--include-location-in-star'] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--consistency-queries=' + consistency_queries] diff --git a/csharp/ql/test/justfile b/csharp/ql/test/justfile index e2be0fe0d9c4..3efa95d340ca 100644 --- a/csharp/ql/test/justfile +++ b/csharp/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--additional-packs=ql', '--consistency-queries=' + consistency_queries] diff --git a/go/ql/test/justfile b/go/ql/test/justfile index 24eea13e550f..60b5d78b053a 100644 --- a/go/ql/test/justfile +++ b/go/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] diff --git a/java/ql/test/justfile b/java/ql/test/justfile index 3eb33887c72f..53a5d2d9dee3 100644 --- a/java/ql/test/justfile +++ b/java/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + # The Kotlin extractor must see the diagnostic limit set, but blank: hence the single # trailing space. base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT= '] diff --git a/javascript/ql/test/justfile b/javascript/ql/test/justfile index f78ef2da39d6..18daff51c273 100644 --- a/javascript/ql/test/justfile +++ b/javascript/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks diff --git a/misc/just/README.md b/misc/just/README.md index 3afe16729a23..0a157132aba8 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -31,8 +31,7 @@ Searching upwards takes precedence, so a justfile naming a verb decides what tha means for its whole subtree. This means a recipe should be named after a verb only if it covers everything beneath it: an aggregate that forgets one of the directories under it would silently shadow it. Conversely, a directory that only makes sense when named -explicitly (integration tests, or the sharded Kotlin suites that CI runs) can opt out of -being found from above: +explicitly can opt out of being found from above: ```just explicit_verbs := ['test'] @@ -41,6 +40,27 @@ explicit_verbs := ['test'] This only affects the downward search. Running the verb from inside that directory, or naming the directory on the command line, keeps working. +The QL test suites use this: `test` on a language runs the whole suite, which takes a +long time and needs a CodeQL CLI, so that has to be asked for by name. Integration tests +and the sharded Kotlin suites that CI runs opt out for the same reason. What is left +discoverable from above is what is cheap enough to run without meaning to. + +Being an ordinary variable, `explicit_verbs` is inherited by justfiles importing one +that sets it. That is normally what is wanted, as importing a suite's justfile means +being the same kind of suite, down to the reason for naming it explicitly. An importer +that disagrees can reassign it, and its own value wins: + +```just +import '../some/suite/justfile' + +explicit_verbs := [] +``` + +Duplicate variables are allowed throughout (see `defs.just`), so this is silent in both +directions: assigning `explicit_verbs` without realising one was inherited overrides it +without complaint, which can put a heavy suite back within reach of a verb aimed at a +parent directory. + Justfiles are found through `git`, so a newly written one needs to be either tracked or untracked-but-not-ignored to be picked up. @@ -59,9 +79,9 @@ Another point is how launching QL tests can be tweaked: Test arguments are passed around as `just` lists (`set lists`), so they reach the underlying runner already split and arguments containing spaces survive intact. -One caveat: when running different recipes for the same verb, non-positional arguments -need to be supported by all recipes involved. This works fine for `--learn` or -`--codeql` across language and integration tests, but note that searching downwards can -reach recipes that have nothing to do with QL: `just test .` also finds the bazel suites -under `unified`, which do not understand `--codeql`. Such a mismatch fails rather than -being ignored, so the fix is to aim the verb at something narrower. +One caveat: when a verb ends up running several recipes, non-positional arguments need +to be understood by all of them. That is fine when they speak the same language, as +`--learn` or `--codeql` do across QL and integration tests. It is not when they do not: +a broad `just test .` reaches bazel and pytest suites alike, and a flag meant for one of +them will fail on the other. It fails rather than being quietly ignored, so the answer +is to aim the verb at something narrower. diff --git a/python/ql/test/justfile b/python/ql/test/justfile index 4ed53a3aabd1..f12a08176a06 100644 --- a/python/ql/test/justfile +++ b/python/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := _python_env all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] diff --git a/ruby/ql/test/justfile b/ruby/ql/test/justfile index 4c0b48b2cffd..8b671785f258 100644 --- a/ruby/ql/test/justfile +++ b/ruby/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] diff --git a/rust/ql/test/justfile b/rust/ql/test/justfile index 442e655be938..5fad8ab2d0d3 100644 --- a/rust/ql/test/justfile +++ b/rust/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] diff --git a/swift/ql/test/justfile b/swift/ql/test/justfile index b4c3ac4079a2..d4d752eed15b 100644 --- a/swift/ql/test/justfile +++ b/swift/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] diff --git a/unified/ql/test/justfile b/unified/ql/test/justfile index 8f57b90fd9a8..9367615ddf6d 100644 --- a/unified/ql/test/justfile +++ b/unified/ql/test/justfile @@ -1,5 +1,9 @@ import "../justfile" +# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when +# asked for by name. +explicit_verbs := ['test'] + base_flags := [] all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] From b289a1b55e797bbb60494ebc01fd12e702355c7d Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 13:46:44 +0200 Subject: [PATCH 07/92] Just: run every recipe a verb finds, not just the nearest one Resolution used to stop at the first justfile found walking up, and only looked downwards when that found nothing. That assumed a recipe named after a verb covers everything beneath it, which is not how these are written: `rust` formats Rust sources while `rust/ql` formats QL, so `just format rust` silently skipped the QL files. Both directions are now searched and every distinct recipe runs. Recipes reached through `import` are the same job rather than a new one, so they are recognised as already covered and run once. A cross-cutting recipe placed high up therefore composes with the ones below it rather than shadowing them, which is the point: formatting Bazel files repository-wide should add to what each directory does with its own sources, not replace it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 26 ++++----- misc/just/forward_command.py | 102 ++++++++++++++++++++++------------- 2 files changed, 80 insertions(+), 48 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 0a157132aba8..2cb9cd014c65 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -20,18 +20,20 @@ The core of the functionality is given by forwarding. The idea is that: `just build ql/rust ql/java`, or `just test ql/rust/ql/test/some/language/test ql/rust/ql/integration-test/some/integration/test` will also work, with corresponding recipes run sequentially. -- finally, if nothing above an argument implements the verb, the forwarder looks - _below_ it, so that `just test ql/cpp` runs the tests defined underneath it. The - argument only says where to look in this case, so each recipe found is run on its own - directory rather than being passed the argument. Several may be found, in which case - they run sequentially: `just format ql/cpp` formats everything under `ql/cpp` that - knows how to format itself. - -Searching upwards takes precedence, so a justfile naming a verb decides what that verb -means for its whole subtree. This means a recipe should be named after a verb only if it -covers everything beneath it: an aggregate that forgets one of the directories under it -would silently shadow it. Conversely, a directory that only makes sense when named -explicitly can opt out of being found from above: +- finally, the forwarder also looks _below_ each argument, so that `just test ql/cpp` + runs the tests defined underneath it. The argument only says where to look in this + case, so each recipe found is run on its own directory rather than being passed the + argument. Several may be found, in which case they run sequentially: `just format + ql/cpp` formats everything under `ql/cpp` that knows how to format itself. + +Both directions are searched, and every distinct recipe found runs. This matters because +a verb higher up is usually doing a different job from one further down rather than a +broader version of it: `rust` formats Rust sources while `rust/ql` formats QL, so +`just format rust` has to do both. A recipe that only arrived through `import` is the +same job, though, and runs once. + +A directory that only makes sense when named explicitly can opt out of being found from +above: ```just explicit_verbs := ['test'] diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 8826c784f36f..e12707bef019 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -82,17 +82,17 @@ def accepts(recipe, argc): def implements(dump, command, argc, *, implicitly): - """Check whether a justfile dump provides a command taking argc arguments.""" + """Return the recipe a justfile runs for a command, if it has a usable one.""" recipe = dump["recipes"].get(dump["aliases"].get(command, command)) if recipe is None or recipe["private"] or not accepts(recipe, argc): - return False + return None if any( dependency["recipe"] == FORWARD_RECIPE for dependency in recipe["dependencies"] ): - return False + return None if implicitly and command in list_value(dump["assignments"], EXPLICIT_VERBS): - return False - return True + return None + return recipe def dump_all(justfiles): @@ -167,55 +167,85 @@ def find_justfiles(directory): return justfiles -def find_justfile_above(command, arg): - """Search up the directory tree for a justfile implementing the command.""" +def find_justfiles_above(command, arg): + """Search up the directory tree for justfiles implementing the command. + + All of them are collected rather than just the nearest, because a recipe higher up + is often doing a different job from one further down rather than a broader version + of it. Returns (justfile, recipe) pairs, nearest first. + """ candidates = [ p / "justfile" for p in [Path(arg), *Path(arg).parents] if (p / "justfile").exists() ] + found = [] + seen = [] for justfile, dump in dump_all(candidates): # A justfile sitting exactly on the argument is called without it, as the # argument would only repeat where it already is. argc = 0 if justfile.parent == Path(arg) else 1 - if implements(dump, command, argc, implicitly=False): - return justfile - return None - - -def find_justfiles_below(command, directory): - """Search down a directory for the outermost justfiles implementing the command.""" - # The justfile at `directory` was already ruled out by the search above it. + recipe = implements(dump, command, argc, implicitly=False) + # These justfiles are nested, so a recipe that was seen already is one this + # one merely imported, and the nearest spelling of it has been taken. + if recipe is not None and recipe not in seen: + seen.append(recipe) + found.append((justfile, recipe)) + return found + + +def find_justfiles_below(command, directory, covered=(), *, implicitly=True): + """Search down a directory for justfiles implementing the command. + + A justfile is skipped when the recipe it would run is one an enclosing directory + already contributes, which is what `import` makes happen: the recipe is the same + job, so running it once is enough. `covered` holds the recipes already found above + the directory. + """ + # The justfile at `directory` is covered by the search above it. candidates = sorted(find_justfiles(directory) - {Path(directory) / "justfile"}) - # Each of these is called on its own directory, so without arguments. - found = [ - justfile + matches = [ + (justfile, recipe) for justfile, dump in dump_all(candidates) - if implements(dump, command, 0, implicitly=True) - ] - # Keep only the outermost matches, so that a justfile covering a whole subtree wins - # over the ones below it. - directories = {justfile.parent for justfile in found} - return [ - justfile - for justfile in found - if not any(parent in directories for parent in justfile.parent.parents) + if (recipe := implements(dump, command, 0, implicitly=implicitly)) ] + contributed = {Path(directory): list(covered)} + found = [] + # Shallowest first, so that an enclosing justfile is always decided before the ones + # it may account for. + for justfile, recipe in sorted(matches, key=lambda match: len(match[0].parts)): + if any(recipe in contributed.get(p, []) for p in justfile.parent.parents): + continue + contributed.setdefault(justfile.parent, []).append(recipe) + found.append(justfile) + return sorted(found) def resolve(command, arg): """Find the justfiles implementing a command for an argument. - Returns a list of (justfile, argument) pairs. A justfile found above the argument - gets the argument itself, as that selects what to act on. One found below it gets - its own directory instead, as there the argument only said where to look. + Returns a list of (justfile, argument) pairs, from both above and below the + argument. One found above gets the argument itself, as that selects what to act on. + One found below gets its own directory instead, as there the argument only said + where to look. """ - justfile = find_justfile_above(command, arg) - if justfile: - return [(justfile, arg)] + above = find_justfiles_above(command, arg) + resolved = [(justfile, arg) for justfile, _ in above] + if os.path.isdir(arg): + below = find_justfiles_below(command, arg, [recipe for _, recipe in above]) + resolved += [(justfile, str(justfile.parent)) for justfile in below] + return resolved + + +def report_missing(command, arg): + """Explain a command going nowhere, naming what opted out of being found.""" + error(f"No justfile found for {command} on {arg}") if not os.path.isdir(arg): - return [] - return [(jf, str(jf.parent)) for jf in find_justfiles_below(command, arg)] + return + skipped = find_justfiles_below(command, arg, implicitly=False) + if skipped: + directories = " ".join(sorted(str(jf.parent) for jf in skipped)) + error(f"these ask to be named explicitly: {directories}") def invoke_just(cwd, args): @@ -237,7 +267,7 @@ def forward(cmd, args): for arg in positional_args or ["."]: resolved = resolve(cmd, arg) if not resolved: - error(f"No justfile found for {cmd} on {arg}") + report_missing(cmd, arg) return 1 for justfile, justfile_arg in resolved: justfiles.setdefault(justfile, []).append(justfile_arg) From ea0e04aca68d94b595cb3545d12bc8b8719e54a4 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:15:30 +0200 Subject: [PATCH 08/92] Just: stop formatting from breaking on paths containing spaces `format` pasted a `find` command substitution straight into the shell, which split the result on whitespace. Hundreds of query files live under directories such as `Best Practices`, so `just format cpp` handed the formatter a nonexistent `./src/Best` and died. Arguments given on the command line were torn apart the same way, which defeats the point of `set lists`. Collecting the files in a helper rather than with `find` also avoids two portability traps: `find` on Windows is an unrelated program, and the full file list is well past the command line length limit there, so it has to be batched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 22 ++++++++-- misc/just/run_on_files.py | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 misc/just/run_on_files.py diff --git a/misc/just/format.just b/misc/just/format.just index 2796a300dbe4..1c28344bc422 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -1,13 +1,29 @@ import "build.just" +_ql_formatter := if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" } + +_py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } + +_cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } + +# `codeql query format` and `clang-format` take files rather than directories, so the +# files are collected by `run_on_files.py`. Arguments are passed positionally so that +# paths containing spaces survive, of which this repository has many. + [no-cd] [no-exit-message] -_format_ql +ARGS: (_maybe_build_dist "nolang") (_if_in_semmle_code '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' 'codeql' (f"query format --in-place -v $(find {{ ARGS }} -type f -name '*.ql' -or -name '*.qll')")) +[positional-arguments] +_format_ql +ARGS: (_maybe_build_dist "nolang") + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" .ql,.qll {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} [no-cd] [no-exit-message] -_format_py *ARGS=".": (_if_in_semmle_code "uv run black" "black" ARGS) +[positional-arguments] +_format_py *ARGS=".": + {{ cmd_sep }}{{ _py_formatter }} "$@"{{ cmd_sep }} [no-cd] [no-exit-message] -_format_cpp *ARGS=".": (_if_in_semmle_code "uv run clang-format" "clang-format" (f"-i --verbose $(find {{ ARGS }} -type f -name '*.h' -or -name '*.cpp')")) +[positional-arguments] +_format_cpp *ARGS=".": + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" .h,.cpp {{ _cpp_formatter }} -i --verbose -- "$@"{{ cmd_sep }} diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py new file mode 100644 index 000000000000..5e9b6ad49e5e --- /dev/null +++ b/misc/just/run_on_files.py @@ -0,0 +1,88 @@ +"""Run a command on the files with the given extensions below the given paths. + +This is a portable `find ... -name '*.' -exec {} +`. It exists +because `find` is an unrelated program on Windows, and because a shell command +substitution splits the file names it produces on whitespace, which mangles the many +paths in this repository that contain spaces. + +The command is run once per batch of file names rather than once per file, and the +batches are sized so that no single command line runs into a length limit. Nothing is +run at all when no file matches. + +Usage: run_on_files.py [,...] [...] -- [...] +""" + +import os +import subprocess +import sys +from pathlib import Path + +def batch_limit(): + """How many characters of file names to put on one command line. + + Windows caps a whole command line at 32767 characters. Elsewhere the cap is + `ARG_MAX`, which the environment is counted against as well, so that is taken off + along with some slack. This is worth doing rather than assuming the tightest of the + two: `ARG_MAX` is 2MB on Linux, which turns the couple of thousand QL files of a + language into a single invocation rather than several. + """ + if sys.platform == "win32": + return 30000 + try: + arg_max = os.sysconf("SC_ARG_MAX") + except (ValueError, OSError): + return 30000 + environment = sum(len(name) + len(value) + 2 for name, value in os.environ.items()) + return max(4096, arg_max - environment - 4096) + + +def files_under(paths, extensions): + """Collect the files with one of the extensions at or below each path. + + Symbolic links are not followed, which is what keeps the `bazel-*` convenience + links out of the walk. + """ + found = set() + for path in map(Path, paths): + if path.is_file(): + if path.suffix in extensions: + found.add(path) + continue + for directory, _, names in os.walk(path): + found.update( + Path(directory) / name + for name in names + if Path(name).suffix in extensions + ) + return sorted(str(path) for path in found) + + +def batched(files, limit): + """Split file names into groups that each fit on one command line.""" + batch, length = [], 0 + for file in files: + if batch and length + len(file) + 1 > limit: + yield batch + batch, length = [], 0 + batch.append(file) + length += len(file) + 1 + if batch: + yield batch + + +def main(): + extensions = set(sys.argv[1].split(",")) + rest = sys.argv[2:] + separator = rest.index("--") + command, paths = rest[:separator], rest[separator + 1 :] + + files = files_under(paths, extensions) + limit = batch_limit() - sum(len(arg) + 1 for arg in command) + status = 0 + for batch in batched(files, limit): + status = subprocess.run([*command, *batch]).returncode or status + return status + + +if __name__ == "__main__": + sys.exit(main()) From 3e5d4ced9fbc95130a67b48124319b1263a60907 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:15:30 +0200 Subject: [PATCH 09/92] Just: say which directories a verb passed over Directories opting out of discovery were only named when a verb found nothing at all. When it did find something, `just test .` looked like it had covered the tree while quietly leaving eleven test suites alone. Report them whenever they are passed over. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 4 +- misc/just/forward_command.py | 93 ++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 2cb9cd014c65..66a5bc6c6a1b 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -40,7 +40,9 @@ explicit_verbs := ['test'] ``` This only affects the downward search. Running the verb from inside that directory, or -naming the directory on the command line, keeps working. +naming the directory on the command line, keeps working. A verb that passed over such a +directory says so and names it, so that a command covering a tree does not look like it +covered more than it did. The QL test suites use this: `test` on a language runs the whole suite, which takes a long time and needs a CodeQL CLI, so that has to be asked for by name. Integration tests diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index e12707bef019..fa35ce2a6092 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -1,5 +1,24 @@ #!/usr/bin/env python3 -"""Forward commands to language-specific justfiles. +"""Forward a common verb to the justfiles that implement it. + +Verbs like `test`, `build` and `format` are spelled the same everywhere, but what they +mean is defined per language, next to the code they act on. This finds the justfiles +implementing a verb for each of its arguments and runs every one of them, so that +`just test rust` or `just format .` work without a central list of who implements what. + +Justfiles are looked for in both directions from an argument: + +- above it, where a recipe is passed the argument itself, as that says what to act on +- below it, where a recipe is passed its own directory, as there the argument only said + where to look + +Every distinct recipe found this way runs. Recipes are compared by value, so one reached +through `import` is recognised as the same job and runs once, while a cross-cutting +recipe higher up composes with the more specific ones below instead of hiding them. + +Two things keep the search useful: recipes delegating back here are skipped, so it never +settles on a forwarder, and a directory can set `explicit_verbs` to stay out of reach of +a verb aimed at one of its parents. See README.md for the whole picture. Called from just recipes as: python3 forward_command.py COMMAND [ARGS...] @@ -29,6 +48,9 @@ def error(message): + # Anything already reported on stdout belongs before this, and the two streams are + # buffered differently when they are not both a terminal. + sys.stdout.flush() print(f"{ERROR}{message}", file=sys.stderr) @@ -81,7 +103,7 @@ def accepts(recipe, argc): return required <= argc and (variadic or argc <= len(parameters)) -def implements(dump, command, argc, *, implicitly): +def implements(dump, command, argc): """Return the recipe a justfile runs for a command, if it has a usable one.""" recipe = dump["recipes"].get(dump["aliases"].get(command, command)) if recipe is None or recipe["private"] or not accepts(recipe, argc): @@ -90,11 +112,14 @@ def implements(dump, command, argc, *, implicitly): dependency["recipe"] == FORWARD_RECIPE for dependency in recipe["dependencies"] ): return None - if implicitly and command in list_value(dump["assignments"], EXPLICIT_VERBS): - return None return recipe +def opts_out(dump, command): + """Whether a justfile asks to be named rather than found by a command.""" + return command in list_value(dump["assignments"], EXPLICIT_VERBS) + + def dump_all(justfiles): """Parse justfiles in parallel, reporting the ones that cannot be read.""" with ThreadPoolExecutor(PROBE_WORKERS) as executor: @@ -185,7 +210,7 @@ def find_justfiles_above(command, arg): # A justfile sitting exactly on the argument is called without it, as the # argument would only repeat where it already is. argc = 0 if justfile.parent == Path(arg) else 1 - recipe = implements(dump, command, argc, implicitly=False) + recipe = implements(dump, command, argc) # These justfiles are nested, so a recipe that was seen already is one this # one merely imported, and the nearest spelling of it has been taken. if recipe is not None and recipe not in seen: @@ -194,21 +219,29 @@ def find_justfiles_above(command, arg): return found -def find_justfiles_below(command, directory, covered=(), *, implicitly=True): +def find_justfiles_below(command, directory, covered=()): """Search down a directory for justfiles implementing the command. A justfile is skipped when the recipe it would run is one an enclosing directory already contributes, which is what `import` makes happen: the recipe is the same job, so running it once is enough. `covered` holds the recipes already found above the directory. + + Returns the justfiles to run and, separately, the ones that implement the command + but ask to be named rather than found. """ # The justfile at `directory` is covered by the search above it. candidates = sorted(find_justfiles(directory) - {Path(directory) / "justfile"}) - matches = [ - (justfile, recipe) - for justfile, dump in dump_all(candidates) - if (recipe := implements(dump, command, 0, implicitly=implicitly)) - ] + matches = [] + opted_out = [] + for justfile, dump in dump_all(candidates): + recipe = implements(dump, command, 0) + if recipe is None: + continue + if opts_out(dump, command): + opted_out.append(justfile) + else: + matches.append((justfile, recipe)) contributed = {Path(directory): list(covered)} found = [] # Shallowest first, so that an enclosing justfile is always decided before the ones @@ -218,7 +251,7 @@ def find_justfiles_below(command, directory, covered=(), *, implicitly=True): continue contributed.setdefault(justfile.parent, []).append(recipe) found.append(justfile) - return sorted(found) + return sorted(found), sorted(opted_out) def resolve(command, arg): @@ -227,25 +260,28 @@ def resolve(command, arg): Returns a list of (justfile, argument) pairs, from both above and below the argument. One found above gets the argument itself, as that selects what to act on. One found below gets its own directory instead, as there the argument only said - where to look. + where to look. Justfiles below that asked to be named are returned separately. """ above = find_justfiles_above(command, arg) resolved = [(justfile, arg) for justfile, _ in above] + opted_out = [] if os.path.isdir(arg): - below = find_justfiles_below(command, arg, [recipe for _, recipe in above]) + below, opted_out = find_justfiles_below( + command, arg, [recipe for _, recipe in above] + ) resolved += [(justfile, str(justfile.parent)) for justfile in below] - return resolved + return resolved, opted_out + +def report_opted_out(command, justfiles): + """Name the justfiles a command passed over because they ask to be named. -def report_missing(command, arg): - """Explain a command going nowhere, naming what opted out of being found.""" - error(f"No justfile found for {command} on {arg}") - if not os.path.isdir(arg): - return - skipped = find_justfiles_below(command, arg, implicitly=False) - if skipped: - directories = " ".join(sorted(str(jf.parent) for jf in skipped)) - error(f"these ask to be named explicitly: {directories}") + Worth saying even when other recipes did run, as otherwise a command that looks + like it covered a whole directory quietly left parts of it alone. + """ + if justfiles: + directories = " ".join(sorted(str(jf.parent) for jf in set(justfiles))) + error(f"not run, as {command} must name these explicitly: {directories}") def invoke_just(cwd, args): @@ -264,10 +300,13 @@ def forward(cmd, args): positional_args = [arg for arg in args if not is_non_positional.match(arg)] justfiles = {} + opted_out = [] for arg in positional_args or ["."]: - resolved = resolve(cmd, arg) + resolved, skipped = resolve(cmd, arg) + opted_out += skipped if not resolved: - report_missing(cmd, arg) + error(f"No justfile found for {cmd} on {arg}") + report_opted_out(cmd, skipped) return 1 for justfile, justfile_arg in resolved: justfiles.setdefault(justfile, []).append(justfile_arg) @@ -284,6 +323,8 @@ def forward(cmd, args): print(f"-> {prefix}just {' '.join(just_args)}") invocations.append((cwd, just_args)) + report_opted_out(cmd, opted_out) + for cwd, just_args in invocations: if invoke_just(cwd, just_args) != 0: return 1 From 3b958f681190442f086ec96a81af204eaaf14713 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:15:30 +0200 Subject: [PATCH 10/92] Go: restore the 32-bit language test recipe `language-tests-386` was dropped when the Go justfile was ported, leaving two generated workflows invoking a recipe that no longer existed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- go/justfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/go/justfile b/go/justfile index e1ea5203166e..fa4c18266af6 100644 --- a/go/justfile +++ b/go/justfile @@ -5,5 +5,13 @@ build: (_build_dist "go") roots := [source_dir() / 'ql/test'] +# The `IncorrectIntegerConversion` query treats `math.MaxInt`/`math.MaxUint` differently on 32- and +# 64-bit targets, so we run its test under `GOARCH=386` as well. `GOOS=linux` because +# `GOOS=darwin GOARCH=386` is no longer supported. +roots_386 := [source_dir() / 'ql/test/query-tests/Security/CWE-681/IncorrectIntegerConversion.qlref'] + [group('test')] language-tests *EXTRA_ARGS: (_language_tests (roots ++ EXTRA_ARGS)) + +[group('test')] +language-tests-386 *EXTRA_ARGS: (_language_tests (roots_386 ++ ['GOOS=linux', 'GOARCH=386'] ++ EXTRA_ARGS)) From 957bb892ad97a96b163f17d6dcf9c4c85d7dcc3b Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:25:49 +0200 Subject: [PATCH 11/92] Just: do not let a skipped directory fail a successful run Naming the directories a verb passed over was reported the same way as a verb matching nothing at all, on stderr and marked as an error. A run that did everything asked of it then looked like a failure. Report it as part of the account of what ran instead, and keep the error for the case where nothing matched. List one directory per line, as a verb aimed at a repository root passes over dozens, and name the invocation that failed, as by then a verb may have fanned out widely. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 3 ++- misc/just/forward_command.py | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 66a5bc6c6a1b..74b64283b7a6 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -42,7 +42,8 @@ explicit_verbs := ['test'] This only affects the downward search. Running the verb from inside that directory, or naming the directory on the command line, keeps working. A verb that passed over such a directory says so and names it, so that a command covering a tree does not look like it -covered more than it did. +covered more than it did. That listing is part of the account of what ran and leaves the +exit status alone; only a verb that matched nothing at all fails. The QL test suites use this: `test` on a language runs the whole suite, which takes a long time and needs a CodeQL CLI, so that has to be asked for by name. Integration tests diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index fa35ce2a6092..088f13a5249e 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -273,15 +273,24 @@ def resolve(command, arg): return resolved, opted_out -def report_opted_out(command, justfiles): +def report_opted_out(command, justfiles, *, ran): """Name the justfiles a command passed over because they ask to be named. Worth saying even when other recipes did run, as otherwise a command that looks - like it covered a whole directory quietly left parts of it alone. + like it covered a whole directory quietly left parts of it alone. That case is + informational and goes to stdout with the rest of the account of what ran: the + command did what was asked of it. Only matching nothing at all is an error. """ - if justfiles: - directories = " ".join(sorted(str(jf.parent) for jf in set(justfiles))) - error(f"not run, as {command} must name these explicitly: {directories}") + if not justfiles: + return + directories = sorted(str(jf.parent) for jf in set(justfiles)) + # One per line: there can be dozens, and a single wrapped line is unreadable. + listed = "\n".join(f" {directory}" for directory in directories) + message = f"not run, as {command} must name these explicitly:\n{listed}" + if ran: + print(message) + else: + error(message) def invoke_just(cwd, args): @@ -306,7 +315,7 @@ def forward(cmd, args): opted_out += skipped if not resolved: error(f"No justfile found for {cmd} on {arg}") - report_opted_out(cmd, skipped) + report_opted_out(cmd, skipped, ran=False) return 1 for justfile, justfile_arg in resolved: justfiles.setdefault(justfile, []).append(justfile_arg) @@ -323,10 +332,13 @@ def forward(cmd, args): print(f"-> {prefix}just {' '.join(just_args)}") invocations.append((cwd, just_args)) - report_opted_out(cmd, opted_out) + report_opted_out(cmd, opted_out, ran=True) for cwd, just_args in invocations: if invoke_just(cwd, just_args) != 0: + # Say which one, as a verb can fan out over a great many directories. + where = f" in {cwd}" if cwd else "" + error(f"{cmd} failed{where}: just {' '.join(just_args)}") return 1 return 0 From e66b1c9c22890623dfbd74cc7d6bbfb5198cbd59 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:56:00 +0200 Subject: [PATCH 12/92] Just: let the file walker select by name and skip generated trees bazel files are identified by name rather than extension, and the tree of checked-in generated ones has to stay out of any sweep over them. Matching globs against the file name covers both the old extensions and those names, and exclusions keep the generated files out. Absolute names are an option because a command run through `bazel run` starts in the runfiles directory, where a relative name means nothing. Splitting on the last `--` lets such a command carry one of its own. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/run_on_files.py | 64 +++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 5e9b6ad49e5e..658fbe79d238 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -1,6 +1,6 @@ -"""Run a command on the files with the given extensions below the given paths. +"""Run a command on the files matching the given patterns below the given paths. -This is a portable `find ... -name '*.' -exec {} +`. It exists +This is a portable `find ... -name -exec {} +`. It exists because `find` is an unrelated program on Windows, and because a shell command substitution splits the file names it produces on whitespace, which mangles the many paths in this repository that contain spaces. @@ -9,12 +9,21 @@ batches are sized so that no single command line runs into a length limit. Nothing is run at all when no file matches. -Usage: run_on_files.py [,...] [...] -- [...] +Usage: run_on_files.py [option...] [,...] [...] + -- [...] + +Options: + --exclude leave out files whose path matches, repeatable + --absolute pass absolute file names, needed when the command runs elsewhere + +The command is separated from the paths by the last `--`, so that it may contain one of +its own, as `bazel run -- ...` does. """ import os import subprocess import sys +from fnmatch import fnmatch from pathlib import Path def batch_limit(): @@ -36,25 +45,31 @@ def batch_limit(): return max(4096, arg_max - environment - 4096) -def files_under(paths, extensions): - """Collect the files with one of the extensions at or below each path. +def files_under(paths, patterns, excludes=(), absolute=False): + """Collect the files matching one of the patterns at or below each path. + + Patterns are matched against the file name, as bazel files are identified by name + rather than by extension. Exclusions are matched against the whole path instead, + which is how a directory of generated files is left alone. Symbolic links are not followed, which is what keeps the `bazel-*` convenience links out of the walk. """ + + def wanted(path): + return any(fnmatch(path.name, p) for p in patterns) and not any( + fnmatch(str(path), e) for e in excludes + ) + found = set() for path in map(Path, paths): if path.is_file(): - if path.suffix in extensions: + if wanted(path): found.add(path) continue for directory, _, names in os.walk(path): - found.update( - Path(directory) / name - for name in names - if Path(name).suffix in extensions - ) - return sorted(str(path) for path in found) + found.update(p for p in map(Path(directory).joinpath, names) if wanted(p)) + return sorted(os.path.abspath(p) if absolute else str(p) for p in found) def batched(files, limit): @@ -70,13 +85,30 @@ def batched(files, limit): yield batch +def parse_options(args): + """Take the leading options off the argument list, returning what they asked for.""" + excludes, absolute = [], False + while args and args[0] != "--" and args[0].startswith("--"): + option = args.pop(0) + if option == "--absolute": + absolute = True + elif option == "--exclude": + excludes.append(args.pop(0)) + else: + sys.exit(f"run_on_files.py: unknown option {option}") + return excludes, absolute + + def main(): - extensions = set(sys.argv[1].split(",")) - rest = sys.argv[2:] - separator = rest.index("--") + args = sys.argv[1:] + excludes, absolute = parse_options(args) + patterns = set(args[0].split(",")) + rest = args[1:] + # The command may hold a `--` of its own, so the paths start after the last one. + separator = len(rest) - 1 - rest[::-1].index("--") command, paths = rest[:separator], rest[separator + 1 :] - files = files_under(paths, extensions) + files = files_under(paths, patterns, excludes, absolute) limit = batch_limit() - sum(len(arg) + 1 for arg in command) status = 0 for batch in batched(files, limit): From f87fe10252114e8d17a85311807a22db504f3afd Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:56:00 +0200 Subject: [PATCH 13/92] Just: give a forwarding justfile a way to answer a verb itself A repository root forwards every verb, and a recipe written beside the import replaces the imported one, so it had no name left to implement a verb under: adding `format` to the root broke `just format cpp` outright. Some work belongs to no single directory though, and the root is where it should live. Such a justfile now spells its own implementation `_root_`, which the forwarder looks for whenever the plain name turns out to be the forwarder's. Taking an argument, it composes with what is found below rather than shadowing it, so a verb aimed at a subdirectory still reaches only that subdirectory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 50 +++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 088f13a5249e..857ecdd3996a 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -40,6 +40,11 @@ # justfile of a nested repository. FORWARD_RECIPE = "_forward" +# A justfile that forwards a verb has already spent the plain name on the forwarder, so +# it names its own implementation of that verb `_root_`. This is how a repository +# root gets to answer a verb for itself while still dispatching it everywhere else. +ROOT_PREFIX = "_root_" + # Justfiles may list verbs that must be spelled out explicitly instead of being picked # up by a verb aimed at one of their parent directories. EXPLICIT_VERBS = "explicit_verbs" @@ -54,7 +59,7 @@ def error(message): print(f"{ERROR}{message}", file=sys.stderr) -def get_just_context(justfile, cmd, flags, positional_args): +def get_just_context(justfile, recipe, flags, positional_args): """Get the (cwd, args) for invoking just with the given justfile.""" if ( len(positional_args) == 1 @@ -63,9 +68,9 @@ def get_just_context(justfile, cmd, flags, positional_args): # If there's only one positional argument and it matches the justfile # path, suppress arguments so e.g. `just build ql/rust` becomes # `just build` in the `ql/rust` directory - return positional_args[0], [cmd, *flags] + return positional_args[0], [recipe, *flags] else: - return None, ["--justfile", str(justfile), cmd, *flags, *positional_args] + return None, ["--justfile", str(justfile), recipe, *flags, *positional_args] def dump_justfile(justfile): @@ -105,14 +110,20 @@ def accepts(recipe, argc): def implements(dump, command, argc): """Return the recipe a justfile runs for a command, if it has a usable one.""" - recipe = dump["recipes"].get(dump["aliases"].get(command, command)) - if recipe is None or recipe["private"] or not accepts(recipe, argc): + recipes = dump["recipes"] + recipe = recipes.get(dump["aliases"].get(command, command)) + if recipe is None or recipe["private"]: return None if any( dependency["recipe"] == FORWARD_RECIPE for dependency in recipe["dependencies"] ): - return None - return recipe + # Here the plain name is the forwarder's own, so it says nothing about what this + # directory does. A justfile that both forwards and answers the command itself + # spells its own answer `_root_`, the one name the two can share. + recipe = recipes.get(f"{ROOT_PREFIX}{command}") + if recipe is None: + return None + return recipe if accepts(recipe, argc) else None def opts_out(dump, command): @@ -250,26 +261,29 @@ def find_justfiles_below(command, directory, covered=()): if any(recipe in contributed.get(p, []) for p in justfile.parent.parents): continue contributed.setdefault(justfile.parent, []).append(recipe) - found.append(justfile) - return sorted(found), sorted(opted_out) + found.append((justfile, recipe)) + return sorted(found, key=lambda match: match[0]), sorted(opted_out) def resolve(command, arg): """Find the justfiles implementing a command for an argument. - Returns a list of (justfile, argument) pairs, from both above and below the - argument. One found above gets the argument itself, as that selects what to act on. - One found below gets its own directory instead, as there the argument only said + Returns a list of (justfile, argument, recipe) triples, from both above and below + the argument. One found above gets the argument itself, as that selects what to act + on. One found below gets its own directory instead, as there the argument only said where to look. Justfiles below that asked to be named are returned separately. """ above = find_justfiles_above(command, arg) - resolved = [(justfile, arg) for justfile, _ in above] + resolved = [(justfile, arg, recipe["name"]) for justfile, recipe in above] opted_out = [] if os.path.isdir(arg): below, opted_out = find_justfiles_below( command, arg, [recipe for _, recipe in above] ) - resolved += [(justfile, str(justfile.parent)) for justfile in below] + resolved += [ + (justfile, str(justfile.parent), recipe["name"]) + for justfile, recipe in below + ] return resolved, opted_out @@ -317,17 +331,17 @@ def forward(cmd, args): error(f"No justfile found for {cmd} on {arg}") report_opted_out(cmd, skipped, ran=False) return 1 - for justfile, justfile_arg in resolved: - justfiles.setdefault(justfile, []).append(justfile_arg) + for justfile, justfile_arg, recipe in resolved: + justfiles.setdefault(justfile, (recipe, []))[1].append(justfile_arg) invocations = [] - for justfile, pos_args in justfiles.items(): + for justfile, (recipe, pos_args) in justfiles.items(): # An argument standing for the whole directory subsumes any more specific one # that ended up on the same justfile. whole_directory = str(justfile.parent) if whole_directory in pos_args: pos_args = [whole_directory] - cwd, just_args = get_just_context(justfile, cmd, flags, pos_args) + cwd, just_args = get_just_context(justfile, recipe, flags, pos_args) prefix = f"cd {cwd}; " if cwd else "" print(f"-> {prefix}just {' '.join(just_args)}") invocations.append((cwd, just_args)) From 1d93d849b5ebc266c04ec11f7227d16570cde6e3 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 14:56:00 +0200 Subject: [PATCH 14/92] Just: format bazel files These are spread across the whole tree rather than gathered under a language, so they are the root's to format, and `_root_format` keeps a run aimed at a subdirectory to the bazel files under it. The buildifier bazel target cannot be driven directly: the wrapper it generates ignores the paths given to it and always sweeps the workspace. Running the binary instead means supplying the exclusion of the checked-in generated files ourselves, which buildifier has no flag for. Being a dev dependency, the target only resolves in a build rooted here; inside the internal repository its own buildifier target covers these files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- justfile | 5 +++++ misc/bazel/buildifier/BUILD.bazel | 8 ++++++++ misc/just/README.md | 19 +++++++++++++++++++ misc/just/format.just | 22 ++++++++++++++++++++-- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 94cf7d2f4bb3..6fe875f6facb 100644 --- a/justfile +++ b/justfile @@ -2,3 +2,8 @@ import 'lib.just' import 'misc/just/forward.just' + +# bazel files live all over the repository rather than under any one language, so they +# are formatted from here. `format` itself is the forwarder, hence `_root_`; see +# misc/just/README.md. +_root_format *ARGS=".": (_format_bazel ARGS) diff --git a/misc/bazel/buildifier/BUILD.bazel b/misc/bazel/buildifier/BUILD.bazel index b71712515595..ec7a152a144d 100644 --- a/misc/bazel/buildifier/BUILD.bazel +++ b/misc/bazel/buildifier/BUILD.bazel @@ -8,3 +8,11 @@ buildifier( ], lint_mode = "fix", ) + +# The binary behind the target above, which formats the paths it is given rather than +# always the whole workspace. `just format` goes through this so that formatting a +# directory formats that directory. +alias( + name = "binary", + actual = "@buildifier_prebuilt//:buildifier", +) diff --git a/misc/just/README.md b/misc/just/README.md index 74b64283b7a6..a366be317a3c 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -32,6 +32,25 @@ broader version of it: `rust` formats Rust sources while `rust/ql` formats QL, s `just format rust` has to do both. A recipe that only arrived through `import` is the same job, though, and runs once. +A repository root forwards every verb, which leaves it no way to answer one itself: a +recipe written next to the `import` overrides the imported one and takes the forwarder's +place, so `just format cpp` would stop finding anything. The root spells its own +implementation `_root_` instead, and the forwarder picks that up wherever the +plain name turns out to be the forwarder's own: + +```just +import 'misc/just/forward.just' + +_root_format *ARGS=".": (_format_bazel ARGS) +``` + +This is for work that belongs to no single directory. bazel files are the case in hand: +they sit throughout the tree rather than under any one language, so formatting them is +the root's job, and taking the argument keeps `just format cpp` to the bazel files under +`cpp`. Note that the `buildifier` bazel target is a dev dependency and so only resolves +in a build rooted in this repository; inside the internal repository the buildifier +target there covers these files instead. + A directory that only makes sense when named explicitly can opt out of being found from above: diff --git a/misc/just/format.just b/misc/just/format.just index 1c28344bc422..3570f9960ae5 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -6,6 +6,18 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } +# The `buildifier` bazel target always covers the whole workspace, so the binary behind +# it is used instead and given paths. That target is a bazel dev dependency and only +# resolves in a build rooted in this repository, so inside the internal repository this +# is left to the buildifier target there, which covers these files as well. +_bazel_formatter := if SEMMLE_CODE != "" { "" } else { "bazel run @codeql//misc/bazel/buildifier:binary --" } + +# bazel files are named rather than suffixed, and buildifier has no exclude option of its +# own, so the generated files skipped by the target above are skipped here too. +_bazel_names := "BUILD,BUILD.*,WORKSPACE,WORKSPACE.*,*.bazel,*.bzl,*.sky" + +_bazel_generated := "*misc/bazel/3rdparty/*_deps/*" + # `codeql query format` and `clang-format` take files rather than directories, so the # files are collected by `run_on_files.py`. Arguments are passed positionally so that # paths containing spaces survive, of which this repository has many. @@ -14,7 +26,7 @@ _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-f [no-exit-message] [positional-arguments] _format_ql +ARGS: (_maybe_build_dist "nolang") - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" .ql,.qll {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.ql,*.qll" {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} [no-cd] [no-exit-message] @@ -26,4 +38,10 @@ _format_py *ARGS=".": [no-exit-message] [positional-arguments] _format_cpp *ARGS=".": - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" .h,.cpp {{ _cpp_formatter }} -i --verbose -- "$@"{{ cmd_sep }} + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -i --verbose -- "$@"{{ cmd_sep }} + +[no-cd] +[no-exit-message] +[positional-arguments] +_format_bazel *ARGS=".": + {{ cmd_sep }}if [ -n '{{ _bazel_formatter }}' ]; then {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -- "$@"; fi{{ cmd_sep }} From ff32ac1db75a2e6380edd6940e449e7ab28c5271 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:04:47 +0200 Subject: [PATCH 15/92] Just: let formatting report what it rewrote, and nothing else `codeql query format` will only name the files it rewrites if it also names every file it leaves alone, so asking which files changed meant thousands of lines to find them in, and bazel was similarly talkative about building the formatter it was about to run. The file runner can now be told which lines of a command's output to hide, so the formatter is asked for everything and the lines about untouched files are dropped. It is a denylist rather than a pick of what to keep, so errors and anything unforeseen still come through, and the command's exit code is passed on unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 15 ++++-- misc/just/run_on_files.py | 109 ++++++++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 3570f9960ae5..77d5d61f2424 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -10,7 +10,10 @@ _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-f # it is used instead and given paths. That target is a bazel dev dependency and only # resolves in a build rooted in this repository, so inside the internal repository this # is left to the buildifier target there, which covers these files as well. -_bazel_formatter := if SEMMLE_CODE != "" { "" } else { "bazel run @codeql//misc/bazel/buildifier:binary --" } +# +# Building the binary has nothing to say for itself either, so bazel is told to report +# only what went wrong. +_bazel_formatter := if SEMMLE_CODE != "" { "" } else { "bazel run --noshow_progress --ui_event_filters=,+error,+fail @codeql//misc/bazel/buildifier:binary --" } # bazel files are named rather than suffixed, and buildifier has no exclude option of its # own, so the generated files skipped by the target above are skipped here too. @@ -21,12 +24,18 @@ _bazel_generated := "*misc/bazel/3rdparty/*_deps/*" # `codeql query format` and `clang-format` take files rather than directories, so the # files are collected by `run_on_files.py`. Arguments are passed positionally so that # paths containing spaces survive, of which this repository has many. +# +# The files that were rewritten are worth reporting, but `codeql query format` only +# names those once it also names every file it leaves alone, which buries them under +# thousands of lines. So it is asked for all of it and the lines about files it did not +# touch are dropped. Only those are dropped, so errors still come through, as does +# anything unforeseen. [no-cd] [no-exit-message] [positional-arguments] _format_ql +ARGS: (_maybe_build_dist "nolang") - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.ql,*.qll" {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} [no-cd] [no-exit-message] @@ -38,7 +47,7 @@ _format_py *ARGS=".": [no-exit-message] [positional-arguments] _format_cpp *ARGS=".": - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -i --verbose -- "$@"{{ cmd_sep }} + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -i -- "$@"{{ cmd_sep }} [no-cd] [no-exit-message] diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 658fbe79d238..8890d3ae905c 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -8,19 +8,11 @@ The command is run once per batch of file names rather than once per file, and the batches are sized so that no single command line runs into a length limit. Nothing is run at all when no file matches. - -Usage: run_on_files.py [option...] [,...] [...] - -- [...] - -Options: - --exclude leave out files whose path matches, repeatable - --absolute pass absolute file names, needed when the command runs elsewhere - -The command is separated from the paths by the last `--`, so that it may contain one of -its own, as `bazel run -- ...` does. """ +import argparse import os +import re import subprocess import sys from fnmatch import fnmatch @@ -85,34 +77,85 @@ def batched(files, limit): yield batch -def parse_options(args): - """Take the leading options off the argument list, returning what they asked for.""" - excludes, absolute = [], False - while args and args[0] != "--" and args[0].startswith("--"): - option = args.pop(0) - if option == "--absolute": - absolute = True - elif option == "--exclude": - excludes.append(args.pop(0)) - else: - sys.exit(f"run_on_files.py: unknown option {option}") - return excludes, absolute +def parse_args(): + """Work out what to run, on which files, and what to hide of what it says.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + usage="%(prog)s [option...] [,...] " + " [...] -- [...]", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + metavar="", + help="leave out files whose path matches, repeatable", + ) + parser.add_argument( + "--absolute", + action="store_true", + help="pass absolute file names, needed when the command runs elsewhere", + ) + parser.add_argument( + "--drop", + action="append", + default=[], + metavar="", + help="hide matching lines of the command's output, repeatable", + ) + parser.add_argument( + "patterns", + metavar="[,...]", + type=lambda patterns: set(patterns.split(",")), + help="what to match file names against", + ) + parser.add_argument( + "rest", + nargs=argparse.REMAINDER, + metavar=" [...] -- [...]", + help="the command, then the paths to search, separated by the last `--` so " + "that the command may contain one of its own", + ) + args = parser.parse_args() + if "--" not in args.rest: + parser.error("the paths must be separated from the command by `--`") + separator = len(args.rest) - 1 - args.rest[::-1].index("--") + args.command, args.paths = args.rest[:separator], args.rest[separator + 1 :] + if not args.command: + parser.error("no command given") + return args + + +def run(command, drops): + """Run the command, hiding the lines of its output that were asked to be hidden. + + Told nothing to hide, the command keeps this process' own output streams, so that + it can do as it likes with them. Otherwise its diagnostics are read a line at a + time and passed on as they arrive, which is what keeps a long run's progress + visible. Only what was named is hidden, so an unforeseen message still gets out. + + Note that these tools report on their progress over standard error rather than + standard output, which is left alone here. + """ + if not drops: + return subprocess.run(command).returncode + hidden = re.compile("|".join(drops)) + process = subprocess.Popen(command, stderr=subprocess.PIPE, text=True, bufsize=1) + for line in process.stderr: + if not hidden.search(line): + sys.stderr.write(line) + sys.stderr.flush() + return process.wait() def main(): - args = sys.argv[1:] - excludes, absolute = parse_options(args) - patterns = set(args[0].split(",")) - rest = args[1:] - # The command may hold a `--` of its own, so the paths start after the last one. - separator = len(rest) - 1 - rest[::-1].index("--") - command, paths = rest[:separator], rest[separator + 1 :] - - files = files_under(paths, patterns, excludes, absolute) - limit = batch_limit() - sum(len(arg) + 1 for arg in command) + args = parse_args() + files = files_under(args.paths, args.patterns, args.exclude, args.absolute) + limit = batch_limit() - sum(len(argument) + 1 for argument in args.command) status = 0 for batch in batched(files, limit): - status = subprocess.run([*command, *batch]).returncode or status + status = run([*args.command, *batch], args.drop) or status return status From 4f1cb307c0a611204146d847f28c76b4b3a42e6b Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:11:25 +0200 Subject: [PATCH 16/92] Just: only show the distribution install log when the install fails Building the internal distribution printed its whole log every time, so any command that needed one first said several dozen lines about unzipping a JDK before saying the one thing it was asked to say. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/build.just | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/misc/just/build.just b/misc/just/build.just index f564c4aa9041..f9739f40f397 100644 --- a/misc/just/build.just +++ b/misc/just/build.just @@ -7,8 +7,14 @@ _build_dist LANGUAGE: _require_semmle_code (_maybe_build_dist LANGUAGE) # Build the language-specific distribution if we are in an internal repository checkout # Otherwise, do nothing +# +# The install log is worth reading only when the install fails, and printing it +# regardless buried whatever was actually asked for underneath it. Note that bazel is +# not quietened any further than that here: unlike an error, a failing test's log is not +# something `--ui_event_filters` can let back through, and a build this long is one to +# see the progress of. [no-exit-message] -_maybe_build_dist LANGUAGE: (_if_in_semmle_code (f'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel test //language-packs:intree-{{ LANGUAGE }}-as-test --test_output=all') '# using codeql from PATH, if any') +_maybe_build_dist LANGUAGE: (_if_in_semmle_code (f'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel test //language-packs:intree-{{ LANGUAGE }}-as-test --test_output=errors') '# using codeql from PATH, if any') # Call bazel. Uses our official bazel wrapper if we are in an internal repository checkout [no-cd] From 9483dd3834f3405ea65e95c3782ac09acb601b2a Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:18:19 +0200 Subject: [PATCH 17/92] Just: format bazel files in the internal checkout too, and say which Guarding this to standalone checkouts made it useless, as that is not where the work happens. It was guarded because the target here is a bazel dev dependency, unreachable from a build rooted in the internal repository; but both repositories depend on the buildifier binary, each as the root module of its own checkout, so asking for it directly resolves either way. What differs is which bazel to ask and from where. The internal workspace encloses this one, and this one is itself a bazel module, so a nested invocation would take the enclosing checkout for something it is not; the file runner can now be told which directory to run from, which is also what its absolute file names were already for. Rewritten files are now named, as the QL formatter does, leaving out the accounting buildifier gives for those it did not rewrite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 4 +--- misc/just/format.just | 25 +++++++++++++++++-------- misc/just/run_on_files.py | 15 +++++++++++---- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index a366be317a3c..7acb6dd35f21 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -47,9 +47,7 @@ _root_format *ARGS=".": (_format_bazel ARGS) This is for work that belongs to no single directory. bazel files are the case in hand: they sit throughout the tree rather than under any one language, so formatting them is the root's job, and taking the argument keeps `just format cpp` to the bazel files under -`cpp`. Note that the `buildifier` bazel target is a dev dependency and so only resolves -in a build rooted in this repository; inside the internal repository the buildifier -target there covers these files instead. +`cpp`. A directory that only makes sense when named explicitly can opt out of being found from above: diff --git a/misc/just/format.just b/misc/just/format.just index 77d5d61f2424..c7276bc011bb 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -7,20 +7,29 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } # The `buildifier` bazel target always covers the whole workspace, so the binary behind -# it is used instead and given paths. That target is a bazel dev dependency and only -# resolves in a build rooted in this repository, so inside the internal repository this -# is left to the buildifier target there, which covers these files as well. -# -# Building the binary has nothing to say for itself either, so bazel is told to report -# only what went wrong. -_bazel_formatter := if SEMMLE_CODE != "" { "" } else { "bazel run --noshow_progress --ui_event_filters=,+error,+fail @codeql//misc/bazel/buildifier:binary --" } +# it is used instead and given paths. Both repositories depend on it, each as the root +# module of its own checkout, so the same label resolves either way; what differs is +# which bazel to ask and from where, as the internal repository's workspace encloses +# this one and a nested checkout would otherwise be taken for the root. + +_bazel_command := if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" } + +_bazel_workspace := if SEMMLE_CODE != "" { '"$SEMMLE_CODE"' } else { quote(parent_directory(parent_directory(source_dir()))) } + +_bazel_formatter := _bazel_command + " run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" # bazel files are named rather than suffixed, and buildifier has no exclude option of its # own, so the generated files skipped by the target above are skipped here too. +# +# As with the QL formatter, buildifier only names what it rewrote if it also accounts for +# every file it did not, so that accounting is dropped. It counts the warnings it could +# not fix there, which are left for linting to report rather than raised on every format. _bazel_names := "BUILD,BUILD.*,WORKSPACE,WORKSPACE.*,*.bazel,*.bzl,*.sky" _bazel_generated := "*misc/bazel/3rdparty/*_deps/*" +_bazel_accounting := ': applied fixes, [0-9]+ warnings left$' + # `codeql query format` and `clang-format` take files rather than directories, so the # files are collected by `run_on_files.py`. Arguments are passed positionally so that # paths containing spaces survive, of which this repository has many. @@ -53,4 +62,4 @@ _format_cpp *ARGS=".": [no-exit-message] [positional-arguments] _format_bazel *ARGS=".": - {{ cmd_sep }}if [ -n '{{ _bazel_formatter }}' ]; then {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -- "$@"; fi{{ cmd_sep }} + {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 8890d3ae905c..a28db75d62ed 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -97,6 +97,11 @@ def parse_args(): action="store_true", help="pass absolute file names, needed when the command runs elsewhere", ) + parser.add_argument( + "--chdir", + metavar="", + help="run the command from here, for one that must be run from a project root", + ) parser.add_argument( "--drop", action="append", @@ -127,7 +132,7 @@ def parse_args(): return args -def run(command, drops): +def run(command, drops, chdir=None): """Run the command, hiding the lines of its output that were asked to be hidden. Told nothing to hide, the command keeps this process' own output streams, so that @@ -139,9 +144,11 @@ def run(command, drops): standard output, which is left alone here. """ if not drops: - return subprocess.run(command).returncode + return subprocess.run(command, cwd=chdir).returncode hidden = re.compile("|".join(drops)) - process = subprocess.Popen(command, stderr=subprocess.PIPE, text=True, bufsize=1) + process = subprocess.Popen( + command, cwd=chdir, stderr=subprocess.PIPE, text=True, bufsize=1 + ) for line in process.stderr: if not hidden.search(line): sys.stderr.write(line) @@ -155,7 +162,7 @@ def main(): limit = batch_limit() - sum(len(argument) + 1 for argument in args.command) status = 0 for batch in batched(files, limit): - status = run([*args.command, *batch], args.drop) or status + status = run([*args.command, *batch], args.drop, args.chdir) or status return status From 2d54b942ce6a0b7a63a8bb1475003da64dfb6628 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:19:36 +0200 Subject: [PATCH 18/92] Just: drop the CLI from the reason a suite must be named Needing a CodeQL CLI is not a reason to hide a suite, as every QL recipe here needs one and the rest stay discoverable. Being slow is the whole of it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/ql/test/justfile | 3 +-- cpp/ql/test/justfile | 3 +-- csharp/ql/test/justfile | 3 +-- go/ql/test/justfile | 3 +-- java/ql/test/justfile | 3 +-- javascript/ql/test/justfile | 3 +-- misc/just/README.md | 6 +++--- python/ql/test/justfile | 3 +-- ruby/ql/test/justfile | 3 +-- rust/ql/test/justfile | 3 +-- swift/ql/test/justfile | 3 +-- unified/ql/test/justfile | 3 +-- 12 files changed, 14 insertions(+), 25 deletions(-) diff --git a/actions/ql/test/justfile b/actions/ql/test/justfile index 8c06f3e5c155..a824f3029972 100644 --- a/actions/ql/test/justfile +++ b/actions/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/cpp/ql/test/justfile b/cpp/ql/test/justfile index 4ab3ef69856a..7ccd81541018 100644 --- a/cpp/ql/test/justfile +++ b/cpp/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := ['--include-location-in-star'] diff --git a/csharp/ql/test/justfile b/csharp/ql/test/justfile index 3efa95d340ca..ba3e238580c5 100644 --- a/csharp/ql/test/justfile +++ b/csharp/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/go/ql/test/justfile b/go/ql/test/justfile index 60b5d78b053a..e4f9665c1773 100644 --- a/go/ql/test/justfile +++ b/go/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/java/ql/test/justfile b/java/ql/test/justfile index 53a5d2d9dee3..0c0d98652c50 100644 --- a/java/ql/test/justfile +++ b/java/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] # The Kotlin extractor must see the diagnostic limit set, but blank: hence the single diff --git a/javascript/ql/test/justfile b/javascript/ql/test/justfile index 18daff51c273..366b4e1e43dd 100644 --- a/javascript/ql/test/justfile +++ b/javascript/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/misc/just/README.md b/misc/just/README.md index 7acb6dd35f21..0999dc2d1220 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -63,9 +63,9 @@ covered more than it did. That listing is part of the account of what ran and le exit status alone; only a verb that matched nothing at all fails. The QL test suites use this: `test` on a language runs the whole suite, which takes a -long time and needs a CodeQL CLI, so that has to be asked for by name. Integration tests -and the sharded Kotlin suites that CI runs opt out for the same reason. What is left -discoverable from above is what is cheap enough to run without meaning to. +long time, so that has to be asked for by name. Integration tests and the sharded +Kotlin suites that CI runs opt out for the same reason. What is left discoverable from +above is what is cheap enough to run without meaning to. Being an ordinary variable, `explicit_verbs` is inherited by justfiles importing one that sets it. That is normally what is wanted, as importing a suite's justfile means diff --git a/python/ql/test/justfile b/python/ql/test/justfile index f12a08176a06..0f44a489e82f 100644 --- a/python/ql/test/justfile +++ b/python/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := _python_env diff --git a/ruby/ql/test/justfile b/ruby/ql/test/justfile index 8b671785f258..9673cebe0370 100644 --- a/ruby/ql/test/justfile +++ b/ruby/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/rust/ql/test/justfile b/rust/ql/test/justfile index 5fad8ab2d0d3..8d5d6c05da2d 100644 --- a/rust/ql/test/justfile +++ b/rust/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/swift/ql/test/justfile b/swift/ql/test/justfile index d4d752eed15b..6f15ac6d0723 100644 --- a/swift/ql/test/justfile +++ b/swift/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] diff --git a/unified/ql/test/justfile b/unified/ql/test/justfile index 9367615ddf6d..1ca509465bd8 100644 --- a/unified/ql/test/justfile +++ b/unified/ql/test/justfile @@ -1,7 +1,6 @@ import "../justfile" -# A whole language test suite is slow and needs a CodeQL CLI, so it only runs when -# asked for by name. +# A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] base_flags := [] From 8033c669e7636ee0027e73f049f2c677ec42acf3 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:53:54 +0200 Subject: [PATCH 19/92] Just: let each repository format its own bazel files A file named `BUILD.` that is not `BUILD.bazel` is not a bazel file: bazel knows `BUILD` and `WORKSPACE` by name and the rest by extension, so `BUILD.windows.tpl` and its kind are templates, holding placeholders that no formatter can parse. Matching them failed every format whose scope contained one, which the internal repository has and this one does not. Formatting now also asks bazel from the root of the checkout the files belong to, rather than from the enclosing one when there is one. The buildifier behind it is a dependency of whichever checkout is the root, so which one asks decides which version formats, and the files of a repository are best formatted by the version it pins and skipped by the list of generated files it keeps. Building goes the other way, as a target there needs the enclosing workspace to resolve at all, so the two no longer share a helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 7 +++++++ misc/just/format.just | 24 +++++++++++++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 0999dc2d1220..1abaffe529be 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -49,6 +49,13 @@ they sit throughout the tree rather than under any one language, so formatting t the root's job, and taking the argument keeps `just format cpp` to the bazel files under `cpp`. +Being a recipe like any other, a `_root_` is inherited by a justfile importing the +one defining it, which is how the internal repository gets this one for free. It runs +once either way, as the two spellings are the same recipe. A root that defines its own +instead replaces it, and then both run, each over the files of the repository that +defines it: bazel formatting asks bazel from the root of the checkout the files belong +to, so that a repository formats its own files with its own pin. + A directory that only makes sense when named explicitly can opt out of being found from above: diff --git a/misc/just/format.just b/misc/just/format.just index c7276bc011bb..c082e12c246a 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -7,24 +7,26 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } # The `buildifier` bazel target always covers the whole workspace, so the binary behind -# it is used instead and given paths. Both repositories depend on it, each as the root -# module of its own checkout, so the same label resolves either way; what differs is -# which bazel to ask and from where, as the internal repository's workspace encloses -# this one and a nested checkout would otherwise be taken for the root. +# it is used instead and given paths. bazel is asked from this repository's own root, +# even when it sits inside the internal one, so that the files being formatted and the +# buildifier formatting them come from the same checkout: each repository then answers +# for its own bazel files, with its own pin and its own list of what is generated. This +# is the opposite of building, where a target needs the enclosing workspace to resolve +# at all, hence `_bazel` in build.just going the other way. +_bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -_bazel_command := if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" } - -_bazel_workspace := if SEMMLE_CODE != "" { '"$SEMMLE_CODE"' } else { quote(parent_directory(parent_directory(source_dir()))) } - -_bazel_formatter := _bazel_command + " run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" +_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" # bazel files are named rather than suffixed, and buildifier has no exclude option of its -# own, so the generated files skipped by the target above are skipped here too. +# own, so the generated files skipped by the target above are skipped here too. bazel +# knows `BUILD` and `WORKSPACE` by those names and everything else by the `.bazel` +# extension, so a file named `BUILD.` else is a template or a generator's input +# rather than a bazel file, and is none of the formatter's business to parse. # # As with the QL formatter, buildifier only names what it rewrote if it also accounts for # every file it did not, so that accounting is dropped. It counts the warnings it could # not fix there, which are left for linting to report rather than raised on every format. -_bazel_names := "BUILD,BUILD.*,WORKSPACE,WORKSPACE.*,*.bazel,*.bzl,*.sky" +_bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky" _bazel_generated := "*misc/bazel/3rdparty/*_deps/*" From c5a18ad2dd68055a720b8592303bd9992f3e9b50 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:56:28 +0200 Subject: [PATCH 20/92] Just: let a repository name several sets of generated files The exclusions are one justfile variable, and a root defining its own bazel formatting has more than one directory its generators write to. Reading them the way the file name patterns are already read costs nothing and saves spelling the option twice. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 4 ++++ misc/just/run_on_files.py | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index c082e12c246a..71e6b73991f7 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -23,6 +23,10 @@ _bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fai # extension, so a file named `BUILD.` else is a template or a generator's input # rather than a bazel file, and is none of the formatter's business to parse. # +# Both lists are comma-separated, so a root defining its own `_root_format` can name +# several patterns in one variable. It has no need to repeat the ones here: the files +# they cover belong to this repository, which formats them itself. +# # As with the QL formatter, buildifier only names what it rewrote if it also accounts for # every file it did not, so that accounting is dropped. It counts the warnings it could # not fix there, which are left for linting to report rather than raised on every format. diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index a28db75d62ed..23d87c1f181b 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -77,6 +77,16 @@ def batched(files, limit): yield batch +def comma_separated(value): + """Split an option value listing several patterns. + + Patterns tend to come in groups, and a justfile passes them as one variable, so they + are spelled as one argument here rather than repeated. Repeating the option works + too, which is what lets a list be extended rather than restated. + """ + return value.split(",") + + def parse_args(): """Work out what to run, on which files, and what to hide of what it says.""" parser = argparse.ArgumentParser( @@ -87,9 +97,10 @@ def parse_args(): ) parser.add_argument( "--exclude", - action="append", + action="extend", default=[], - metavar="", + type=comma_separated, + metavar="[,...]", help="leave out files whose path matches, repeatable", ) parser.add_argument( @@ -112,7 +123,7 @@ def parse_args(): parser.add_argument( "patterns", metavar="[,...]", - type=lambda patterns: set(patterns.split(",")), + type=comma_separated, help="what to match file names against", ) parser.add_argument( From e2dd8757a5cb9017b352d806df13bad74d4cc246 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 15:56:29 +0200 Subject: [PATCH 21/92] Just: stop the overview from reformatting itself A command spanning a line break left the sentence for it to rewrap, so formatting the directory always came back with a change, and the rewrap broke out of the list it was in. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 1abaffe529be..81b04c4011ca 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -23,8 +23,9 @@ The core of the functionality is given by forwarding. The idea is that: - finally, the forwarder also looks _below_ each argument, so that `just test ql/cpp` runs the tests defined underneath it. The argument only says where to look in this case, so each recipe found is run on its own directory rather than being passed the - argument. Several may be found, in which case they run sequentially: `just format - ql/cpp` formats everything under `ql/cpp` that knows how to format itself. + argument. Several may be found, in which case they run sequentially: + `just format ql/cpp` formats everything under `ql/cpp` that knows how to format + itself. Both directions are searched, and every distinct recipe found runs. This matters because a verb higher up is usually doing a different job from one further down rather than a From e61caff13a4fd1bc731eea85d71d9eee2ee30da2 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 16:05:12 +0200 Subject: [PATCH 22/92] Just: keep a repository's bazel formatting to its own files Asking bazel from this repository's root was half of formatting its own files and not another's: the paths came from the verb rather than from the root, so a checkout enclosing this one had its files formatted here, by whichever buildifier version this repository happens to pin. The two are not interchangeable, differing in the fixes they apply, so files came out formatted by a version other than the one their own repository would use on them. Bounding the files to the root leaves each repository formatting what it owns, and a verb spanning both is answered once by each, every root implementing the verb for itself. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 14 +++++++------- misc/just/run_on_files.py | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 71e6b73991f7..8cca96c3eaa3 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -7,12 +7,12 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } # The `buildifier` bazel target always covers the whole workspace, so the binary behind -# it is used instead and given paths. bazel is asked from this repository's own root, -# even when it sits inside the internal one, so that the files being formatted and the -# buildifier formatting them come from the same checkout: each repository then answers -# for its own bazel files, with its own pin and its own list of what is generated. This -# is the opposite of building, where a target needs the enclosing workspace to resolve -# at all, hence `_bazel` in build.just going the other way. +# it is used instead and given paths. bazel is asked from the root of this repository, +# even when it sits inside another, and the files are bounded to that root as well: each +# repository then formats its own bazel files with the buildifier version it pins, and a +# verb aimed at a tree spanning both is answered once by each. This is the opposite of +# building, where a target needs the enclosing workspace to resolve at all, hence +# `_bazel` in build.just going the other way. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) _bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" @@ -68,4 +68,4 @@ _format_cpp *ARGS=".": [no-exit-message] [positional-arguments] _format_bazel *ARGS=".": - {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} + {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 23d87c1f181b..9d3190563a37 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -37,18 +37,24 @@ def batch_limit(): return max(4096, arg_max - environment - 4096) -def files_under(paths, patterns, excludes=(), absolute=False): +def files_under(paths, patterns, excludes=(), absolute=False, within=None): """Collect the files matching one of the patterns at or below each path. Patterns are matched against the file name, as bazel files are identified by name rather than by extension. Exclusions are matched against the whole path instead, which is how a directory of generated files is left alone. + A `within` directory bounds the result to the files below it, for a command that + answers for one project and may be handed a path reaching outside it. + Symbolic links are not followed, which is what keeps the `bazel-*` convenience links out of the walk. """ + boundary = Path(within).resolve() if within else None def wanted(path): + if boundary is not None and not path.resolve().is_relative_to(boundary): + return False return any(fnmatch(path.name, p) for p in patterns) and not any( fnmatch(str(path), e) for e in excludes ) @@ -113,6 +119,12 @@ def parse_args(): metavar="", help="run the command from here, for one that must be run from a project root", ) + parser.add_argument( + "--within", + metavar="", + help="leave out files outside this directory, for a command answering for one " + "project that may be handed a path reaching beyond it", + ) parser.add_argument( "--drop", action="append", @@ -169,7 +181,9 @@ def run(command, drops, chdir=None): def main(): args = parse_args() - files = files_under(args.paths, args.patterns, args.exclude, args.absolute) + files = files_under( + args.paths, args.patterns, args.exclude, args.absolute, args.within + ) limit = batch_limit() - sum(len(argument) + 1 for argument in args.command) status = 0 for batch in batched(files, limit): From aa61bde268548832a495cf8fec12a20971379eba Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 16:29:25 +0200 Subject: [PATCH 23/92] Just: size batches for the argument a command hands on, not the line it is on A single argument is capped far below the whole command line, at 128KB against 2MB on Linux, and a command that passes its arguments on through a shell arrives as one of them. Sizing batches by the line alone let a large enough tree build one argument over that cap, which fails as an `execv` error from whatever did the handing on, naming neither this file nor the files it was given. Also says how an exclusion is matched, as it is against the path the walk built rather than the one on the command line, and the natural way to name a directory only matches when the walk starts above it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 4 +++- misc/just/run_on_files.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 8cca96c3eaa3..f396d14d1edc 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -25,7 +25,9 @@ _bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fai # # Both lists are comma-separated, so a root defining its own `_root_format` can name # several patterns in one variable. It has no need to repeat the ones here: the files -# they cover belong to this repository, which formats them itself. +# they cover belong to this repository, which formats them itself. Exclusions match the +# path as walked rather than as spelled on the command line, so one naming a directory +# has to cover both the path it is reached by and the path it is walked from. # # As with the QL formatter, buildifier only names what it rewrote if it also accounts for # every file it did not, so that accounting is dropped. It counts the warnings it could diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 9d3190563a37..bed9c66b8b93 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -26,15 +26,21 @@ def batch_limit(): along with some slack. This is worth doing rather than assuming the tightest of the two: `ARG_MAX` is 2MB on Linux, which turns the couple of thousand QL files of a language into a single invocation rather than several. + + A single argument is capped far lower than the whole line, at 128KB on Linux, and a + command that hands its arguments on through a shell arrives as one of them. Batches + are kept below that too, as the resulting failure is reported by whatever did the + handing on rather than by anything naming this file. """ if sys.platform == "win32": return 30000 + single_argument = 100000 try: arg_max = os.sysconf("SC_ARG_MAX") except (ValueError, OSError): return 30000 environment = sum(len(name) + len(value) + 2 for name, value in os.environ.items()) - return max(4096, arg_max - environment - 4096) + return max(4096, min(arg_max - environment - 4096, single_argument)) def files_under(paths, patterns, excludes=(), absolute=False, within=None): @@ -42,7 +48,9 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): Patterns are matched against the file name, as bazel files are identified by name rather than by extension. Exclusions are matched against the whole path instead, - which is how a directory of generated files is left alone. + which is how a directory of generated files is left alone. That path is the one the + walk built, so an exclusion has to allow for how the paths it is given are spelled: + `*//*` does not match what is walked from `` itself. A `within` directory bounds the result to the files below it, for a command that answers for one project and may be handed a path reaching outside it. From ed1c68a37666ad260061a02fef5f3df05659fc49 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:01:59 +0200 Subject: [PATCH 24/92] Just: look above a verb that was run from a nested directory The upward search walked `Path(arg).parents`, which is empty for the default argument `.`, so a verb reached through just's fallback from a nested directory saw only what was below it. `just format` inside a language directory silently skipped the root's bazel formatting. Resolving the argument first makes `format .` from a directory agree with naming that directory from the root, which is what already happened for an absolute argument. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 857ecdd3996a..f1acbfb185ca 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -203,6 +203,13 @@ def find_justfiles(directory): return justfiles +def invocation_path(path, *, like): + """Spell an absolute path like the user spelled the argument.""" + if Path(like).is_absolute(): + return path + return Path(os.path.relpath(path, Path.cwd())) + + def find_justfiles_above(command, arg): """Search up the directory tree for justfiles implementing the command. @@ -210,9 +217,10 @@ def find_justfiles_above(command, arg): is often doing a different job from one further down rather than a broader version of it. Returns (justfile, recipe) pairs, nearest first. """ + directory = Path(arg).resolve() candidates = [ - p / "justfile" - for p in [Path(arg), *Path(arg).parents] + invocation_path(p / "justfile", like=arg) + for p in [directory, *directory.parents] if (p / "justfile").exists() ] found = [] @@ -220,7 +228,7 @@ def find_justfiles_above(command, arg): for justfile, dump in dump_all(candidates): # A justfile sitting exactly on the argument is called without it, as the # argument would only repeat where it already is. - argc = 0 if justfile.parent == Path(arg) else 1 + argc = 0 if justfile.parent.resolve() == directory else 1 recipe = implements(dump, command, argc) # These justfiles are nested, so a recipe that was seen already is one this # one merely imported, and the nearest spelling of it has been taken. From 8a77588a91386f9fa6be1691cb4875125747be60 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:01:59 +0200 Subject: [PATCH 25/92] Rust: ask for codegen without saying where Naming the directory sent the forwarder looking for a `generate` that takes one, and rust's takes none, so the integration tests stopped before they started. Without the argument, just's fallback reaches that recipe directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/ql/integration-tests/justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/ql/integration-tests/justfile b/rust/ql/integration-tests/justfile index fa96473894df..2ee4b833c128 100644 --- a/rust/ql/integration-tests/justfile +++ b/rust/ql/integration-tests/justfile @@ -5,4 +5,4 @@ import "../../../lib.just" explicit_verbs := ['test'] [no-cd] -test *ARGS=".": (_if_not_on_ci_just ['generate', source_dir()]) (_integration_test ARGS) +test *ARGS=".": (_if_not_on_ci_just ['generate']) (_integration_test ARGS) From bbdad5b72a6fc3597848de5e273609ad4211ef46 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:02:00 +0200 Subject: [PATCH 26/92] Java: say the Kotlin diagnostic limit is empty, rather than a space The space was a workaround for the old encoding, where a value that was set but empty did not survive being split out of a whitespace-separated blob. Lists make the intent writable, and the two Kotlin shard suites already spell it this way. The extractor reads the limit with `toIntOrNull`, so neither spelling ever parsed; this is about saying what was meant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- java/ql/test/justfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/java/ql/test/justfile b/java/ql/test/justfile index 0c0d98652c50..aedf78381a05 100644 --- a/java/ql/test/justfile +++ b/java/ql/test/justfile @@ -3,9 +3,8 @@ import "../justfile" # A whole language test suite is slow, so it only runs when asked for by name. explicit_verbs := ['test'] -# The Kotlin extractor must see the diagnostic limit set, but blank: hence the single -# trailing space. -base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT= '] +# Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. +base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT='] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] From bc84bc5142d64b779e894018926078b1ac113d83 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:02:00 +0200 Subject: [PATCH 27/92] Just: keep this directory's formatter to the file it was given The recipe took the argument but also let just change directory into its own, so a file named below it was looked for twice over. Interpolating the argument raw split paths containing spaces as well. The shared formatters already avoid both. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/justfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/misc/just/justfile b/misc/just/justfile index bfa7bed4db2e..679295f47801 100644 --- a/misc/just/justfile +++ b/misc/just/justfile @@ -1,2 +1,4 @@ +[no-cd] +[positional-arguments] format *ARGS=".": - npx prettier --write {{ ARGS }} + npx prettier --write "$@" From b1d5db8bf51161557e43dd3e3e64db4b75c53a35 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:15:23 +0200 Subject: [PATCH 28/92] Just: record what keeps a relative argument to the caller's directory `[no-cd]` is load-bearing and does not look it: the forwarder reaches a recipe above its argument with `--justfile`, which otherwise runs it from that justfile's directory, so the default `.` would quietly mean the whole repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/misc/just/format.just b/misc/just/format.just index f396d14d1edc..f1df04d00039 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -47,6 +47,11 @@ _bazel_accounting := ': applied fixes, [0-9]+ warnings left$' # thousands of lines. So it is asked for all of it and the lines about files it did not # touch are dropped. Only those are dropped, so errors still come through, as does # anything unforeseen. +# +# `[no-cd]` is what keeps a relative argument meaning the directory the caller is in. +# The forwarder reaches a recipe above its argument with `--justfile`, which otherwise +# runs it from that justfile's own directory: dropping the attribute would silently turn +# the default `.` into the whole repository, and the only symptom would be slowness. [no-cd] [no-exit-message] From 92c5699b0455df8fa75294c986b5b1379a960783 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 17:55:43 +0200 Subject: [PATCH 29/92] Just: say why a root recipe delegates rather than doing the work The shape is easy to extend by adding a body, which is where a relative argument stops meaning the caller's directory. Cheaper to say so where the pattern is taught than to leave the next one to find out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/misc/just/README.md b/misc/just/README.md index 81b04c4011ca..b513d5067e99 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -50,6 +50,12 @@ they sit throughout the tree rather than under any one language, so formatting t the root's job, and taking the argument keeps `just format cpp` to the bazel files under `cpp`. +Note that this one delegates rather than doing the work itself. The forwarder reaches a +recipe above its argument with `--justfile`, which runs it from the directory of the +justfile defining it unless the recipe is `[no-cd]`. A `_root_` that grows a body +therefore reads its default `.` as the whole repository rather than the directory the +caller is in, so one that does its own work needs `[no-cd]` itself. + Being a recipe like any other, a `_root_` is inherited by a justfile importing the one defining it, which is how the internal repository gets this one for free. It runs once either way, as the two spellings are the same recipe. A root that defines its own From 2dcc7f619a0e5a98201c69e92fc586e116c951f9 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 18:20:26 +0200 Subject: [PATCH 30/92] C++: opt the moved consistency queries into implicit this warnings CI requires every pack in this repository to set it, and the ten other consistency-queries packs already do. The internal repository does not check this, so the pack arrived without it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cpp/ql/consistency-queries/qlpack.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/cpp/ql/consistency-queries/qlpack.yml b/cpp/ql/consistency-queries/qlpack.yml index fed0e22e17ba..303f2271be12 100644 --- a/cpp/ql/consistency-queries/qlpack.yml +++ b/cpp/ql/consistency-queries/qlpack.yml @@ -3,3 +3,4 @@ groups: [cpp, test, consistency-queries] dependencies: codeql/cpp-all: ${workspace} extractor: cpp +warnOnImplicitThis: true From 33c6c4f544ec35019d2929be4b814e113c60b2f6 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 18:34:49 +0200 Subject: [PATCH 31/92] Just: keep this formatter's exclusions in step with the canonical target The comment claimed the recipe skipped what `//misc/bazel/buildifier` skips, but that target also excludes `.git`, and this did not. A branch name is a file, so `just format .` in an ordinary clone could hand a ref called `WORKSPACE` or anything `.bzl` to buildifier in fix mode. It does not bite in a worktree, where `.git` is a file rather than a directory, which is why it went unnoticed. Run the binary through the alias that exists for it too, so both entry points name a target in the same file. The two exclusion lists cannot be collapsed: the canonical target formats the whole workspace and so cannot take a path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index f1df04d00039..ba6885b590d8 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -15,11 +15,16 @@ _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-f # `_bazel` in build.just going the other way. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" +_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail //misc/bazel/buildifier:binary --" # bazel files are named rather than suffixed, and buildifier has no exclude option of its -# own, so the generated files skipped by the target above are skipped here too. bazel -# knows `BUILD` and `WORKSPACE` by those names and everything else by the `.bazel` +# own, so the files skipped by the target above are skipped here too. That target formats +# the whole workspace at once and so cannot take the path this recipe is given, which is +# why the two run the same binary through different entry points. Their exclusions are +# therefore stated twice, in two places, in two syntaxes: keep them in step, or `just +# format` rewrites what pre-commit and CI deliberately leave alone. +# +# bazel knows `BUILD` and `WORKSPACE` by those names and everything else by the `.bazel` # extension, so a file named `BUILD.` else is a template or a generator's input # rather than a bazel file, and is none of the formatter's business to parse. # @@ -34,7 +39,7 @@ _bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fai # not fix there, which are left for linting to report rather than raised on every format. _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky" -_bazel_generated := "*misc/bazel/3rdparty/*_deps/*" +_bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" _bazel_accounting := ': applied fixes, [0-9]+ warnings left$' @@ -75,4 +80,4 @@ _format_cpp *ARGS=".": [no-exit-message] [positional-arguments] _format_bazel *ARGS=".": - {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_generated }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} + {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} From 97ee0888524dee55be8171ff7787afa83e30e026 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 18:42:29 +0200 Subject: [PATCH 32/92] Just: say that these variables are an interface, not an implementation detail `set allow-duplicate-variables` lets a consuming root replace any of them, and `just` cannot warn about an assignment that no longer overrides anything: renaming one leaves the root parsing, listing and passing CI while silently falling back to the value here, losing only the variable that moved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/misc/just/README.md b/misc/just/README.md index b513d5067e99..405bc07621e9 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -63,6 +63,19 @@ instead replaces it, and then both run, each over the files of the repository th defines it: bazel formatting asks bazel from the root of the checkout the files belong to, so that a repository formats its own files with its own pin. +That last part is arranged by variables rather than by recipes. `set +allow-duplicate-variables` in `defs.just` lets an importing justfile assign a variable +defined here and have its value win, which is how a consuming root points the bazel +formatter at its own workspace, its own buildifier and its own exclusions. The leading +underscore says these are not meant to be run, not that they are private: any of them a +root might reasonably want to redirect is an interface between the two repositories. + +Renaming one is therefore a breaking change that nothing reports. `just` has no notion +of an assignment that fails to override, so a root assigning the old name keeps parsing, +keeps listing, keeps passing CI, and silently reverts to the value here. Worse, a root +overriding several loses only the renamed one, leaving a half-applied configuration. +Rename freely, but say so when handing the change over. + A directory that only makes sense when named explicitly can opt out of being found from above: From 17156b6ba176f4ebb4ecce64d5dd3acf07c3b0ea Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 18:46:46 +0200 Subject: [PATCH 33/92] Just: say why a dead override is invisible, and how to ask directly The underscore that keeps these variables out of `just --list` keeps them out of `--variables` and `--evaluate` too, so the only introspection that could reveal an override that no longer overrides anything does not show them. Asked by name they answer, which is the check worth reaching for, with the caveat that it sees a rename rather than a change of meaning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 405bc07621e9..16c91f90220a 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -73,8 +73,23 @@ root might reasonably want to redirect is an interface between the two repositor Renaming one is therefore a breaking change that nothing reports. `just` has no notion of an assignment that fails to override, so a root assigning the old name keeps parsing, keeps listing, keeps passing CI, and silently reverts to the value here. Worse, a root -overriding several loses only the renamed one, leaving a half-applied configuration. -Rename freely, but say so when handing the change over. +overriding several loses only the renamed one, leaving a half-applied configuration: +total failure would land in a state someone designed, while partial failure lands in one +nobody has ever seen. + +Nothing can see it either, because the underscore that keeps these out of `just --list` +keeps them out of `--variables` and a bare `--evaluate` as well. Asked by name they do +answer, which is how a root checks that an override of its own still overrides anything: + +```sh +just --evaluate _bazel_excluded # what mine is now +just --justfile /justfile --evaluate _bazel_excluded # what it would be +``` + +A name that has gone says so rather than reporting an empty value. That is a diagnostic +to reach for once something looks wrong, though: it answers whether a name still exists, +not whether its meaning has changed, so it passes happily when the value here gains or +loses a pattern. Rename freely, but say so when handing the change over. A directory that only makes sense when named explicitly can opt out of being found from above: From 681549b40317f711201e47ab236e059a104bf559 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:24:30 +0200 Subject: [PATCH 34/92] Just: let the command separator span the terminal The separator was a fixed 56 dashes, which on a wide terminal reads as a short dash in the middle of a line rather than as a break between commands. just cannot ask for the width itself, so `stty` does it. It is preferred to `tput` because it needs no terminfo and so survives `TERM=dumb`, and because it reads the terminal from stdin and so still answers when stdout is piped. Measured once and handed down in `JUST_CMD_RULE`. `shell()` runs on every parse, so a forwarded verb would otherwise re-measure in each justfile it reaches: `just format .` spawns nine processes here and would pay for nine measurements to arrive at one answer. `if` is lazy in its branches, so an inherited value skips the pipeline entirely, and a single measurement is also one that nothing downstream can disagree with. Nothing changes where there is no terminal. The guard falls through to the same 56 dashes, so piped output, CI logs and Windows are byte-identical to before -- and that fallback is one arm of a branch rather than a platform test, since just runs `sh` everywhere. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 14 ++++++++++++++ misc/just/defs.just | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/misc/just/README.md b/misc/just/README.md index 16c91f90220a..e9a5e7a7e309 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -149,3 +149,17 @@ to be understood by all of them. That is fine when they speak the same language, a broad `just test .` reaches bazel and pytest suites alike, and a flag meant for one of them will fail on the other. It fails rather than being quietly ignored, so the answer is to aim the verb at something narrower. + +# Command separators + +Commands are echoed between rules that span the terminal. The width is measured once, by +the outermost `just`, and handed down to everything it spawns in `JUST_CMD_RULE`. +Measuring is why: `shell()` runs on every parse, so a verb reaching a dozen justfiles +would otherwise ask a dozen times, and a single measurement is also one nothing can +disagree with. Setting `JUST_CMD_RULE` pins the rule rather than measuring it, which is +how to fix the width in CI or in a recording. + +With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a +fixed 56 characters, so anything not attached to a terminal looks as it always did. That +is one branch rather than a platform test: `just` runs `sh` everywhere, so Windows takes +whichever arm fits rather than a path of its own. diff --git a/misc/just/defs.just b/misc/just/defs.just index 47cedd124b44..b8241655b535 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -13,7 +13,28 @@ export PATH_SEP := if os() == "windows" { ";" } else { ":" } export JUST_EXECUTABLE := just_executable() error := f'{{ style("error") }}error{{ NORMAL }}: ' -cmd_sep := "\n#--------------------------------------------------------\n" + +# The rule `cmd_sep` draws, sized to the terminal. +# +# `stty` rather than `tput`: it needs no terminfo, so it survives `TERM=dumb`, and reads +# the terminal from stdin, so it answers when stdout is piped. The arithmetic is in the +# shell because just has no integers. +# +# With no terminal the value is non-numeric and this falls back to the 56 dashes used +# before. That is a branch, not a platform test: just runs `sh` everywhere. +# +# Measured once and inherited: `shell()` runs on every parse, so a forwarded verb would +# otherwise re-measure in every child it spawns, while `if` is lazy in its branches. +# Setting `JUST_CMD_RULE` pins the rule and skips measuring. +_rule := if env('JUST_CMD_RULE', '') != '' { env('JUST_CMD_RULE', '') } else { shell(''' + w=$(stty size 2>/dev/null | cut -d" " -f2) + case "$w" in '' | *[!0-9]*) w=57 ;; esac + printf "%*s" $((w - 1)) "" | tr " " - +''') } + +export JUST_CMD_RULE := _rule + +cmd_sep := "\n#" + _rule + "\n" export CMD_BEGIN := style("command") + cmd_sep export CMD_END := cmd_sep + NORMAL export JUST_ERROR := error From ca68b9b6f80f8cd58ae1de3ed88311a81a4f26c6 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:28:25 +0200 Subject: [PATCH 35/92] Just: say that the separator is inherited, not measured once The previous wording claimed one measurement per verb. That holds only for the process fan-out forwarding creates, where a child inherits the exported value. A `mod` spawns no process and nothing has been exported by the time sibling modules are parsed, so each module measures for itself. This repository has no `mod` statements -- sixty imports and none -- so it cannot observe the difference, which is why the claim read as general. Name the mechanism instead: inheritance needs a process boundary to cross. That is true either way and lets a reader work out which case they are in. Presetting `JUST_CMD_RULE` still skips measuring in both. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 16 ++++++++++------ misc/just/defs.just | 7 ++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index e9a5e7a7e309..d4ebd13da9a1 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -152,12 +152,16 @@ is to aim the verb at something narrower. # Command separators -Commands are echoed between rules that span the terminal. The width is measured once, by -the outermost `just`, and handed down to everything it spawns in `JUST_CMD_RULE`. -Measuring is why: `shell()` runs on every parse, so a verb reaching a dozen justfiles -would otherwise ask a dozen times, and a single measurement is also one nothing can -disagree with. Setting `JUST_CMD_RULE` pins the rule rather than measuring it, which is -how to fix the width in CI or in a recording. +Commands are echoed between rules that span the terminal. Measuring the width means a +`shell()` call, and that runs on every parse, so the result is exported as +`JUST_CMD_RULE` and an inherited value is preferred to measuring again. + +Inheritance crosses processes, which is what forwarding creates: a child per justfile +reached, each of them measuring nothing. A `mod` spawns no process, so a module measures +for itself, and the count follows `mod` statements rather than justfiles. That is cheap, +and modules agree anyway since they share a terminal, but it is worth knowing before +counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also how to +fix the width in CI or in a recording. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a fixed 56 characters, so anything not attached to a terminal looks as it always did. That diff --git a/misc/just/defs.just b/misc/just/defs.just index b8241655b535..9be4383bbe41 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -23,9 +23,10 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # With no terminal the value is non-numeric and this falls back to the 56 dashes used # before. That is a branch, not a platform test: just runs `sh` everywhere. # -# Measured once and inherited: `shell()` runs on every parse, so a forwarded verb would -# otherwise re-measure in every child it spawns, while `if` is lazy in its branches. -# Setting `JUST_CMD_RULE` pins the rule and skips measuring. +# Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded +# verb would otherwise re-measure in every child it spawns, and `if` is lazy in its +# branches. Inheritance needs a process, so a `mod` measures for itself; presetting +# `JUST_CMD_RULE` skips measuring entirely. _rule := if env('JUST_CMD_RULE', '') != '' { env('JUST_CMD_RULE', '') } else { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac From 4d9b19b8f920fa92a2226e7553dee45fff8ab154 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:32:01 +0200 Subject: [PATCH 36/92] Just: draw the separator out of the `#` that starts it A `#` followed by dashes reads as two things, a comment marker and then a rule. Repeating the `#` makes it one, and the line is a shell comment for the same reason it is a rule. Width is unchanged at 57 columns with no terminal, so logs keep their shape; only the glyph differs. The `#` stays in `cmd_sep` rather than moving into the rule, so a preset `JUST_CMD_RULE` cannot produce a line that the shell would try to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 4 ++-- misc/just/defs.just | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index d4ebd13da9a1..aed83d08da2b 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -164,6 +164,6 @@ counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also fix the width in CI or in a recording. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a -fixed 56 characters, so anything not attached to a terminal looks as it always did. That -is one branch rather than a platform test: `just` runs `sh` everywhere, so Windows takes +fixed 57 columns, so logs and CI output are the same width every time. That is one +branch rather than a platform test: `just` runs `sh` everywhere, so Windows takes whichever arm fits rather than a path of its own. diff --git a/misc/just/defs.just b/misc/just/defs.just index 9be4383bbe41..aa45c0065027 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -20,8 +20,11 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # the terminal from stdin, so it answers when stdout is piped. The arithmetic is in the # shell because just has no integers. # -# With no terminal the value is non-numeric and this falls back to the 56 dashes used -# before. That is a branch, not a platform test: just runs `sh` everywhere. +# With no terminal the value is non-numeric and this falls back to a fixed 57 columns. +# That is a branch, not a platform test: just runs `sh` everywhere. +# +# The leading `#` is in `cmd_sep`, not in the rule, so the line stays a comment whatever +# `JUST_CMD_RULE` is set to. # # Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its @@ -30,7 +33,7 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' _rule := if env('JUST_CMD_RULE', '') != '' { env('JUST_CMD_RULE', '') } else { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac - printf "%*s" $((w - 1)) "" | tr " " - + printf "%*s" $((w - 1)) "" | tr " " '#' ''') } export JUST_CMD_RULE := _rule From 420f24939fb92511c11a074ad93ae5d2700c95c4 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:34:44 +0200 Subject: [PATCH 37/92] Just: stop writing the separator's width in two places The hard-coded `#` and the `- 1` that made room for it were one constant spelled twice, once as a just string and once as shell arithmetic. Widening the prefix would have left the two disagreeing, and nothing would have said so -- the same duplicated-list shape this infrastructure already warns about for formatter exclusions. They were there to keep the line a comment whatever `JUST_CMD_RULE` held. That is worth less than it looks: a value that is not a comment fails loudly on first use, and it is set by hand or not at all. Say so instead, now that the variable is the whole line. Output is unchanged, byte for byte, measured and fallback alike. Also make the module count exact: it is one per `mod` reached, transitively, plus the file itself, which is a number a reader may work out for themselves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 9 +++++---- misc/just/defs.just | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index aed83d08da2b..8708217c4e12 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -158,10 +158,11 @@ Commands are echoed between rules that span the terminal. Measuring the width me Inheritance crosses processes, which is what forwarding creates: a child per justfile reached, each of them measuring nothing. A `mod` spawns no process, so a module measures -for itself, and the count follows `mod` statements rather than justfiles. That is cheap, -and modules agree anyway since they share a terminal, but it is worth knowing before -counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also how to -fix the width in CI or in a recording. +for itself, and the count is one per `mod` reached, however deeply nested, plus one for +the file itself. That is cheap, and modules agree anyway since they share a terminal, +but it is worth knowing before counting measurements. Presetting `JUST_CMD_RULE` skips +all of them, and is also how to fix the width in CI or in a recording — it is the whole +line, so set it to something the shell will treat as a comment. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a fixed 57 columns, so logs and CI output are the same width every time. That is one diff --git a/misc/just/defs.just b/misc/just/defs.just index aa45c0065027..4b016a1641d2 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -14,7 +14,7 @@ export JUST_EXECUTABLE := just_executable() error := f'{{ style("error") }}error{{ NORMAL }}: ' -# The rule `cmd_sep` draws, sized to the terminal. +# The separator line, sized to the terminal. # # `stty` rather than `tput`: it needs no terminfo, so it survives `TERM=dumb`, and reads # the terminal from stdin, so it answers when stdout is piped. The arithmetic is in the @@ -23,8 +23,8 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # With no terminal the value is non-numeric and this falls back to a fixed 57 columns. # That is a branch, not a platform test: just runs `sh` everywhere. # -# The leading `#` is in `cmd_sep`, not in the rule, so the line stays a comment whatever -# `JUST_CMD_RULE` is set to. +# `JUST_CMD_RULE` is the whole line and lands in a shell script, so a value set by hand +# has to be a comment itself. # # Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its @@ -33,12 +33,12 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' _rule := if env('JUST_CMD_RULE', '') != '' { env('JUST_CMD_RULE', '') } else { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac - printf "%*s" $((w - 1)) "" | tr " " '#' + printf "%*s" $w "" | tr " " '#' ''') } export JUST_CMD_RULE := _rule -cmd_sep := "\n#" + _rule + "\n" +cmd_sep := "\n" + _rule + "\n" export CMD_BEGIN := style("command") + cmd_sep export CMD_END := cmd_sep + NORMAL export JUST_ERROR := error From 4da5758bcb99c06c5f6d8167b46e8d3dcfa252b1 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:37:01 +0200 Subject: [PATCH 38/92] Just: spell out that a multi-line separator is multiple shell lines "Has to be a comment itself" reads as "has to start with `#`", and a value whose first line does while its later lines do not is not a comment -- every line after the first is its own shell line and runs. No mechanism for it. Flattening newlines would leave a single-line value that is not a comment still running while a multi-line one is defused: two spellings of the same mistake with opposite outcomes and no principle telling them apart. The obligation is on the value, so say what it is. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 2 +- misc/just/defs.just | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 8708217c4e12..9afe5b248f46 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -162,7 +162,7 @@ for itself, and the count is one per `mod` reached, however deeply nested, plus the file itself. That is cheap, and modules agree anyway since they share a terminal, but it is worth knowing before counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also how to fix the width in CI or in a recording — it is the whole -line, so set it to something the shell will treat as a comment. +separator and ends up in a shell script, so every line of it has to be a comment. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a fixed 57 columns, so logs and CI output are the same width every time. That is one diff --git a/misc/just/defs.just b/misc/just/defs.just index 4b016a1641d2..53bcaeb50095 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -23,8 +23,9 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # With no terminal the value is non-numeric and this falls back to a fixed 57 columns. # That is a branch, not a platform test: just runs `sh` everywhere. # -# `JUST_CMD_RULE` is the whole line and lands in a shell script, so a value set by hand -# has to be a comment itself. +# `JUST_CMD_RULE` is the whole separator and lands in a shell script, so a hand-set +# value has to be a comment -- every line of it, if it has more than one. Nothing checks +# that; a value that is not one gets run. # # Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its From 29c7a53fc720da627855cdd2699ca8f95c2e1caf Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:41:43 +0200 Subject: [PATCH 39/92] Just: opt out of MSYS2 argument conversion once, not per call MSYS2 rewrites arguments that look like paths on their way to a native program, which mangles bazel's `//target` labels. Three recipes said so individually and a fourth bazel call would have had to remember. Forgetting gives a recipe that works everywhere except Windows, which is not where anyone is looking while writing one. It describes the environment the tooling runs in rather than any one command, so it sits with `PATH_SEP` and is guarded the same way. On Windows this widens the opt-out from three commands to every recipe, so a tool wanting its arguments converted would stop getting that. The paths reaching native tools here come from `source_dir()` or from python and are already in native form, and user arguments are usually relative, which MSYS2 leaves alone -- but that is reasoning rather than a test, since no CI in this repository runs `just` on Windows at all. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/build.just | 4 ++-- misc/just/defs.just | 5 +++++ misc/just/format.just | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/misc/just/build.just b/misc/just/build.just index f9739f40f397..efb85aaa7e81 100644 --- a/misc/just/build.just +++ b/misc/just/build.just @@ -14,12 +14,12 @@ _build_dist LANGUAGE: _require_semmle_code (_maybe_build_dist LANGUAGE) # something `--ui_event_filters` can let back through, and a build this long is one to # see the progress of. [no-exit-message] -_maybe_build_dist LANGUAGE: (_if_in_semmle_code (f'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel test //language-packs:intree-{{ LANGUAGE }}-as-test --test_output=errors') '# using codeql from PATH, if any') +_maybe_build_dist LANGUAGE: (_if_in_semmle_code (f'cd "$SEMMLE_CODE"; tools/bazel test //language-packs:intree-{{ LANGUAGE }}-as-test --test_output=errors') '# using codeql from PATH, if any') # Call bazel. Uses our official bazel wrapper if we are in an internal repository checkout [no-cd] [no-exit-message] -_bazel *ARGS: (_if_in_semmle_code 'cd "$SEMMLE_CODE"; MSYS2_ARG_CONV_EXCL="*" tools/bazel' 'bazel' ARGS) +_bazel *ARGS: (_if_in_semmle_code 'cd "$SEMMLE_CODE"; tools/bazel' 'bazel' ARGS) # Call sembuild (requires an internal repository checkout) [no-cd] diff --git a/misc/just/defs.just b/misc/just/defs.just index 53bcaeb50095..3ac14ad32ec4 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -12,6 +12,11 @@ set allow-duplicate-variables export PATH_SEP := if os() == "windows" { ";" } else { ":" } export JUST_EXECUTABLE := just_executable() +# MSYS2 rewrites arguments that look like paths as it hands them to a native program, +# which mangles bazel's `//target` labels. Set once here rather than at each bazel call: +# a call that forgets it works everywhere except Windows, which is where nobody looks. +export MSYS2_ARG_CONV_EXCL := if os() == "windows" { "*" } else { "" } + error := f'{{ style("error") }}error{{ NORMAL }}: ' # The separator line, sized to the terminal. diff --git a/misc/just/format.just b/misc/just/format.just index ba6885b590d8..2cf2f70aa0ea 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -80,4 +80,4 @@ _format_cpp *ARGS=".": [no-exit-message] [positional-arguments] _format_bazel *ARGS=".": - {{ cmd_sep }}export MSYS2_ARG_CONV_EXCL="*"; {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} + {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} From e5ae5eacf4005249b21997cc1ab43d60b48ddbcc Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:44:55 +0200 Subject: [PATCH 40/92] Just: reject a separator value that would run, instead of warning about it `JUST_CMD_RULE` is interpolated whole into a shell script, so a value that is not a shell comment gets executed -- with a zero exit status, no diagnostic, and once for every place the separator appears. The comment above it said so, and saying so was all that happened, which leaves the obligation with whoever presets the variable to have first read the file explaining why they mustn't. A rule is a single line by construction, so demanding one line starting with `#` rejects exactly the values that would run, without having to describe what running looks like. Reading the value once instead of twice is what makes that expressible at all. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 4 +++- misc/just/defs.just | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 9afe5b248f46..e5f9d16077c6 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -162,7 +162,9 @@ for itself, and the count is one per `mod` reached, however deeply nested, plus the file itself. That is cheap, and modules agree anyway since they share a terminal, but it is worth knowing before counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also how to fix the width in CI or in a recording — it is the whole -separator and ends up in a shell script, so every line of it has to be a comment. +separator and ends up in a shell script, so it has to be a single line starting with +`#`. Anything else is rejected rather than documented against, because otherwise it +runs, silently and once per place the separator appears. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a fixed 57 columns, so logs and CI output are the same width every time. That is one diff --git a/misc/just/defs.just b/misc/just/defs.just index 3ac14ad32ec4..ce48296df3c7 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -28,19 +28,23 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # With no terminal the value is non-numeric and this falls back to a fixed 57 columns. # That is a branch, not a platform test: just runs `sh` everywhere. # -# `JUST_CMD_RULE` is the whole separator and lands in a shell script, so a hand-set -# value has to be a comment -- every line of it, if it has more than one. Nothing checks -# that; a value that is not one gets run. +# `JUST_CMD_RULE` is the whole separator and lands in a shell script, so a hand-set value +# that is not a shell comment gets run -- silently, with a zero exit, once per place the +# separator appears. Hence the check rather than a warning in prose: a rule is one line by +# construction, so demanding one line starting with `#` rejects every value that would +# run, without having to describe what running looks like. # # Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its # branches. Inheritance needs a process, so a `mod` measures for itself; presetting # `JUST_CMD_RULE` skips measuring entirely. -_rule := if env('JUST_CMD_RULE', '') != '' { env('JUST_CMD_RULE', '') } else { shell(''' +_given_rule := env('JUST_CMD_RULE', '') + +_rule := if _given_rule == '' { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac printf "%*s" $w "" | tr " " '#' -''') } +''') } else if _given_rule =~ '^#[^\n]*$' { _given_rule } else { error('JUST_CMD_RULE must be a single line starting with `#`') } export JUST_CMD_RULE := _rule From dd441b8d38d024d3320f5cb5268790af2291c230 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 09:52:35 +0200 Subject: [PATCH 41/92] Just: drop the separator check, keep the single read of the variable The check rejected a `JUST_CMD_RULE` that was not a shell comment. That is a real failure mode, but not one worth code: setting the variable at all means being able to run commands already, so the check only defended whoever set it from themselves, and nothing sets it. The requirement is documented where the value is read, which is where someone about to set it is looking. `_given_rule` stays. Naming the value was about not spelling `env('JUST_CMD_RULE', '')` twice, which stands whether or not it is checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 5 ++--- misc/just/defs.just | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index e5f9d16077c6..d81a415caedd 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -162,9 +162,8 @@ for itself, and the count is one per `mod` reached, however deeply nested, plus the file itself. That is cheap, and modules agree anyway since they share a terminal, but it is worth knowing before counting measurements. Presetting `JUST_CMD_RULE` skips all of them, and is also how to fix the width in CI or in a recording — it is the whole -separator and ends up in a shell script, so it has to be a single line starting with -`#`. Anything else is rejected rather than documented against, because otherwise it -runs, silently and once per place the separator appears. +separator and ends up in a shell script, so every line of it has to be a comment, or it +runs. With no terminal to ask — a pipe, a log, a shell without `stty` — it falls back to a fixed 57 columns, so logs and CI output are the same width every time. That is one diff --git a/misc/just/defs.just b/misc/just/defs.just index ce48296df3c7..e80253e5489a 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -29,10 +29,8 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # That is a branch, not a platform test: just runs `sh` everywhere. # # `JUST_CMD_RULE` is the whole separator and lands in a shell script, so a hand-set value -# that is not a shell comment gets run -- silently, with a zero exit, once per place the -# separator appears. Hence the check rather than a warning in prose: a rule is one line by -# construction, so demanding one line starting with `#` rejects every value that would -# run, without having to describe what running looks like. +# has to be a shell comment, every line of it, or it runs. Left unchecked: setting it at +# all means being able to run commands already, so there is nothing to defend. # # Exported and preferred over measuring: `shell()` runs on every parse, so a forwarded # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its @@ -44,7 +42,7 @@ _rule := if _given_rule == '' { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac printf "%*s" $w "" | tr " " '#' -''') } else if _given_rule =~ '^#[^\n]*$' { _given_rule } else { error('JUST_CMD_RULE must be a single line starting with `#`') } +''') } else { _given_rule } export JUST_CMD_RULE := _rule From c8bee28bdf3a4f76b1cfc29397a5b6e9bd4a598c Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:08:52 +0200 Subject: [PATCH 42/92] Just: name buildifier's binary by its external label The alias existed only to give that binary a label in this workspace, which is precisely what stopped it working from anywhere else: a repository with this one checked out inside it cannot resolve `//misc/bazel/buildifier`, so it had to override `_bazel_formatter` just to name the same binary again. `@buildifier_prebuilt//:buildifier` resolves from any workspace and picks up whichever version that workspace pins, which is the behaviour the override was reproducing by hand. Also removes the second copy of the whole-workspace-versus-paths explanation, which the alias carried alongside the one in format.just. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/bazel/buildifier/BUILD.bazel | 8 -------- misc/just/format.just | 17 +++++++++-------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/misc/bazel/buildifier/BUILD.bazel b/misc/bazel/buildifier/BUILD.bazel index ec7a152a144d..b71712515595 100644 --- a/misc/bazel/buildifier/BUILD.bazel +++ b/misc/bazel/buildifier/BUILD.bazel @@ -8,11 +8,3 @@ buildifier( ], lint_mode = "fix", ) - -# The binary behind the target above, which formats the paths it is given rather than -# always the whole workspace. `just format` goes through this so that formatting a -# directory formats that directory. -alias( - name = "binary", - actual = "@buildifier_prebuilt//:buildifier", -) diff --git a/misc/just/format.just b/misc/just/format.just index 2cf2f70aa0ea..c5855c065d62 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -6,16 +6,17 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } -# The `buildifier` bazel target always covers the whole workspace, so the binary behind -# it is used instead and given paths. bazel is asked from the root of this repository, -# even when it sits inside another, and the files are bounded to that root as well: each -# repository then formats its own bazel files with the buildifier version it pins, and a -# verb aimed at a tree spanning both is answered once by each. This is the opposite of -# building, where a target needs the enclosing workspace to resolve at all, hence -# `_bazel` in build.just going the other way. +# The `buildifier` bazel target always covers the whole workspace, so the binary it wraps +# is used instead and given paths. Naming that binary by its external label rather than +# through a local alias keeps it valid from any workspace. bazel is asked from the root of +# this repository, even when it sits inside another, and the files are bounded to that root +# as well: each repository then formats its own bazel files with the buildifier version it +# pins, and a verb aimed at a tree spanning both is answered once by each. This is the +# opposite of building, where a target needs the enclosing workspace to resolve at all, +# hence `_bazel` in build.just going the other way. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail //misc/bazel/buildifier:binary --" +_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" # bazel files are named rather than suffixed, and buildifier has no exclude option of its # own, so the files skipped by the target above are skipped here too. That target formats From c01e76d113debe3a0a759094252fea8aeee619f2 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:12:06 +0200 Subject: [PATCH 43/92] Just: say what setting the MSYS2 opt-out once costs The comment gave the reason for centralising it and not the consequence: on Windows nothing a recipe runs gets path conversion any more, not just bazel. Someone adding a recipe needs that, and it is not visible from the assignment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/defs.just | 2 ++ 1 file changed, 2 insertions(+) diff --git a/misc/just/defs.just b/misc/just/defs.just index e80253e5489a..dc5baf044f0c 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -15,6 +15,8 @@ export JUST_EXECUTABLE := just_executable() # MSYS2 rewrites arguments that look like paths as it hands them to a native program, # which mangles bazel's `//target` labels. Set once here rather than at each bazel call: # a call that forgets it works everywhere except Windows, which is where nobody looks. +# The price of setting it once is that it covers every command a recipe runs, so a tool +# that wants Windows path conversion has to ask for it back. export MSYS2_ARG_CONV_EXCL := if os() == "windows" { "*" } else { "" } error := f'{{ style("error") }}error{{ NORMAL }}: ' From 2ec54c89cd15caa57bd834ede2bb56e22026714c Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:18:28 +0200 Subject: [PATCH 44/92] Just: let the bazel formatter ask where it is, like the other three Of the four formatters here, only the bazel one did not check for an internal repository checkout, so a consumer wanting its own launcher had to restate the whole command line to change one word. An override that large is indistinguishable from a stale copy of the default, and it was one. The path is absolute rather than relative to `_bazel_workspace`, so the launcher and the workspace stop having to be overridden together to stay consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/misc/just/format.just b/misc/just/format.just index c5855c065d62..8fb0e6a98bfa 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -16,7 +16,10 @@ _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-f # hence `_bazel` in build.just going the other way. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -_bazel_formatter := "bazel run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" +# As with the formatters above, an internal repository checkout has its own launcher and +# that is the one to use. Unlike `_bazel` in build.just it is not `cd`ed to: the workspace +# is picked by `--chdir` below, so the launcher only has to be found, not entered. +_bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" # bazel files are named rather than suffixed, and buildifier has no exclude option of its # own, so the files skipped by the target above are skipped here too. That target formats From cdbe33a6b5d3b767dcdf320f0098b43fb69d196a Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:23:58 +0200 Subject: [PATCH 45/92] Just: cut format.just's comments back to what the code cannot say The file had grown 45 lines of comment around 29 of code, most of it restating what is visible or belonging in the README. What is left is the part a reader cannot recover: why the buildifier target is bypassed, why the two exclusion lists must stay in step, and what silently breaks if `[no-cd]` is dropped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 58 +++++++++++-------------------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 8fb0e6a98bfa..1b65b03a7477 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -6,61 +6,31 @@ _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } -# The `buildifier` bazel target always covers the whole workspace, so the binary it wraps -# is used instead and given paths. Naming that binary by its external label rather than -# through a local alias keeps it valid from any workspace. bazel is asked from the root of -# this repository, even when it sits inside another, and the files are bounded to that root -# as well: each repository then formats its own bazel files with the buildifier version it -# pins, and a verb aimed at a tree spanning both is answered once by each. This is the -# opposite of building, where a target needs the enclosing workspace to resolve at all, -# hence `_bazel` in build.just going the other way. +# The `buildifier` target formats a whole workspace and so cannot take a path, so its +# binary is driven directly. bazel is asked from this repository's root even when it sits +# inside another, and the files are bounded to it, so each repository formats its own with +# the version it pins. Building needs the enclosing workspace instead, hence `_bazel`. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -# As with the formatters above, an internal repository checkout has its own launcher and -# that is the one to use. Unlike `_bazel` in build.just it is not `cd`ed to: the workspace -# is picked by `--chdir` below, so the launcher only has to be found, not entered. _bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" -# bazel files are named rather than suffixed, and buildifier has no exclude option of its -# own, so the files skipped by the target above are skipped here too. That target formats -# the whole workspace at once and so cannot take the path this recipe is given, which is -# why the two run the same binary through different entry points. Their exclusions are -# therefore stated twice, in two places, in two syntaxes: keep them in step, or `just -# format` rewrites what pre-commit and CI deliberately leave alone. -# -# bazel knows `BUILD` and `WORKSPACE` by those names and everything else by the `.bazel` -# extension, so a file named `BUILD.` else is a template or a generator's input -# rather than a bazel file, and is none of the formatter's business to parse. -# -# Both lists are comma-separated, so a root defining its own `_root_format` can name -# several patterns in one variable. It has no need to repeat the ones here: the files -# they cover belong to this repository, which formats them itself. Exclusions match the -# path as walked rather than as spelled on the command line, so one naming a directory -# has to cover both the path it is reached by and the path it is walked from. -# -# As with the QL formatter, buildifier only names what it rewrote if it also accounts for -# every file it did not, so that accounting is dropped. It counts the warnings it could -# not fix there, which are left for linting to report rather than raised on every format. +# Keep in step with the `buildifier` target's own exclusions, or `just format` rewrites +# what pre-commit and CI deliberately leave alone. Patterns match the path as walked, so +# one naming a directory has to cover both how it is reached and how it is walked. _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" +# buildifier only names what it rewrote if it also accounts for every file it did not. _bazel_accounting := ': applied fixes, [0-9]+ warnings left$' -# `codeql query format` and `clang-format` take files rather than directories, so the -# files are collected by `run_on_files.py`. Arguments are passed positionally so that -# paths containing spaces survive, of which this repository has many. -# -# The files that were rewritten are worth reporting, but `codeql query format` only -# names those once it also names every file it leaves alone, which buries them under -# thousands of lines. So it is asked for all of it and the lines about files it did not -# touch are dropped. Only those are dropped, so errors still come through, as does -# anything unforeseen. +# These formatters take files rather than directories, so `run_on_files.py` collects them +# and passes them positionally, which is what lets paths contain spaces. `codeql query +# format` likewise only reports what it rewrote if it also names everything it did not, so +# those lines are dropped and nothing else is. # -# `[no-cd]` is what keeps a relative argument meaning the directory the caller is in. -# The forwarder reaches a recipe above its argument with `--justfile`, which otherwise -# runs it from that justfile's own directory: dropping the attribute would silently turn -# the default `.` into the whole repository, and the only symptom would be slowness. +# `[no-cd]` keeps a relative argument meaning the caller's directory: the forwarder passes +# `--justfile`, so without it the default `.` would silently become the whole repository. [no-cd] [no-exit-message] From eb980af5f8978789645e4d06a49171f64ecc0145 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:36:17 +0200 Subject: [PATCH 46/92] Just: banner what the formatters do, not how they are driven The banner was `just` echoing the recipe line, so it was the command in full: 520 characters for the bazel one, of which about 35 were worth reading. The rest was `run_on_files.py`, the workspace twice over, the patterns, the exclusions, the output filters and bazel's quietening. Shortening the line could not fix that. Folding `--chdir`/`--within`/`--absolute` into one flag, hiding the script path and moving the quietening out still leaves about 330 characters, so the banner had to stop being a verbatim echo. Nothing is given up by that, because pasting one never worked: the echoed line carried an unexpanded `"$@"`, which matches no file in another shell, so it formatted nothing and exited 0. A leading `-> ` now marks the line as a report, and `just -n` prints what actually runs, `@` or no `@`. Shown and real are composed from the same variables, so no meaningful token is written twice. `_bazel_quiet` is held apart because only output is at stake; it moves after the target, which bazel takes as readily as before it. Banners also move to stderr, the stream `just` uses and the one the formatters report on, so they no longer reorder against that output under a pipe. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 15 ++++++++++++--- misc/just/format.just | 38 +++++++++++++++++++++++++++++--------- misc/just/lib.just | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index d81a415caedd..5738c0a45d9a 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -152,9 +152,18 @@ is to aim the verb at something narrower. # Command separators -Commands are echoed between rules that span the terminal. Measuring the width means a -`shell()` call, and that runs on every parse, so the result is exported as -`JUST_CMD_RULE` and an inherited value is preferred to measuring again. +Commands are echoed between rules that span the terminal. The formatters echo a summary +rather than the line that runs: they name the formatter and what it is told to do, and +leave out the wrapper that collects the files, the patterns it walks and the flags that +only shape output. A leading `-> ` marks a line as that summary. `just -n` prints what +actually runs, which it does whether or not the recipe is `@`-quiet. + +Nothing is lost by this, as a banner has never been something to paste: the echoed line +carried an unexpanded `"$@"`, which matches no file in another shell, so pasting one +formatted nothing and exited 0. + +Measuring the width means a `shell()` call, and that runs on every parse, so the result +is exported as `JUST_CMD_RULE` and an inherited value is preferred to measuring again. Inheritance crosses processes, which is what forwarding creates: a child per justfile reached, each of them measuring nothing. A `mod` spawns no process, so a module measures diff --git a/misc/just/format.just b/misc/just/format.just index 1b65b03a7477..7915dddbd78e 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -2,17 +2,27 @@ import "build.just" _ql_formatter := if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" } +_ql_args := "query format --in-place" + _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } _cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } +_cpp_args := "-i" + # The `buildifier` target formats a whole workspace and so cannot take a path, so its # binary is driven directly. bazel is asked from this repository's root even when it sits # inside another, and the files are bounded to it, so each repository formats its own with # the version it pins. Building needs the enclosing workspace instead, hence `_bazel`. _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) -_bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run --noshow_progress --ui_event_filters=,+error,+fail @buildifier_prebuilt//:buildifier --" +_bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run @buildifier_prebuilt//:buildifier" + +_bazel_args := "-mode=fix -lint=fix" + +# Held apart from the invocation because only output is at stake, which is what the banner +# leaves out. bazel takes these after the target as readily as before it. +_bazel_quiet := "--noshow_progress --ui_event_filters=,+error,+fail" # Keep in step with the `buildifier` target's own exclusions, or `just format` rewrites # what pre-commit and CI deliberately leave alone. Patterns match the path as walked, so @@ -29,29 +39,39 @@ _bazel_accounting := ': applied fixes, [0-9]+ warnings left$' # format` likewise only reports what it rewrote if it also names everything it did not, so # those lines are dropped and nothing else is. # +# The banner is a report rather than a command to paste: it names the formatter and what +# it is told to do, and leaves out the collecting, the patterns walked and the flags that +# only shape output. `just -n` prints what actually runs, `@` or no `@`. Pasting never +# worked anyway: the banner used to carry an unexpanded `"$@"`, which matches no file in +# another shell and so formatted nothing while exiting 0. +# # `[no-cd]` keeps a relative argument meaning the caller's directory: the forwarder passes # `--justfile`, so without it the default `.` would silently become the whole repository. [no-cd] [no-exit-message] [positional-arguments] -_format_ql +ARGS: (_maybe_build_dist "nolang") - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} query format --in-place -v -- "$@"{{ cmd_sep }} +@_format_ql +ARGS: (_maybe_build_dist "nolang") + echo "$CMD_BEGIN-> {{ _ql_formatter }} {{ _ql_args }} -- $*$CMD_END" >&2 + {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} {{ _ql_args }} -v -- "$@" [no-cd] [no-exit-message] [positional-arguments] -_format_py *ARGS=".": - {{ cmd_sep }}{{ _py_formatter }} "$@"{{ cmd_sep }} +@_format_py *ARGS=".": + echo "$CMD_BEGIN-> {{ _py_formatter }} $*$CMD_END" >&2 + {{ _py_formatter }} "$@" [no-cd] [no-exit-message] [positional-arguments] -_format_cpp *ARGS=".": - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -i -- "$@"{{ cmd_sep }} +@_format_cpp *ARGS=".": + echo "$CMD_BEGIN-> {{ _cpp_formatter }} {{ _cpp_args }} -- $*$CMD_END" >&2 + {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} {{ _cpp_args }} -- "$@" [no-cd] [no-exit-message] [positional-arguments] -_format_bazel *ARGS=".": - {{ cmd_sep }}{{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} -mode=fix -lint=fix -v -- "$@"{{ cmd_sep }} +@_format_bazel *ARGS=".": + echo "$CMD_BEGIN-> {{ _bazel_formatter }} -- {{ _bazel_args }} -- $*$CMD_END" >&2 + {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} {{ _bazel_quiet }} -- {{ _bazel_args }} -v -- "$@" diff --git a/misc/just/lib.just b/misc/just/lib.just index 8ce335677287..6a254b4e99b8 100644 --- a/misc/just/lib.just +++ b/misc/just/lib.just @@ -27,5 +27,5 @@ import "format.just" [no-exit-message] [positional-arguments] @_integration_test *ARGS: _require_semmle_code - echo "$CMD_BEGIN$SEMMLE_CODE/tools/pytest --codeql=build-as-test $*$CMD_END" + echo "$CMD_BEGIN-> $SEMMLE_CODE/tools/pytest --codeql=build-as-test $*$CMD_END" >&2 "$SEMMLE_CODE/tools/pytest" --codeql=build-as-test "$@" From f19fd7d42cf610510589bfe6ed2f9ee77c3d05e8 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:45:38 +0200 Subject: [PATCH 47/92] Just: fold each formatter's own arguments in, and quote the banner `_ql_args` and `_cpp_args` were never used apart from the formatter they belong to, in either the banner or the command, so they are now part of it. `_bazel_args` cannot follow: `_bazel_quiet` has to sit between the target and `--`, and bazel rejects those flags in startup position outright. The banner interpolated the formatter inside a double-quoted `echo`, so a value carrying its own quotes closed the string and handed the rest to the shell as syntax: with an internal checkout the path was expanded rather than shown. It is a report, so it is quoted and printed as written. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 7915dddbd78e..2e240247a252 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -1,14 +1,10 @@ import "build.just" -_ql_formatter := if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" } - -_ql_args := "query format --in-place" +_ql_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" }) + " query format --in-place" _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } -_cpp_formatter := if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" } - -_cpp_args := "-i" +_cpp_formatter := (if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" }) + " -i" # The `buildifier` target formats a whole workspace and so cannot take a path, so its # binary is driven directly. bazel is asked from this repository's root even when it sits @@ -20,8 +16,9 @@ _bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else _bazel_args := "-mode=fix -lint=fix" -# Held apart from the invocation because only output is at stake, which is what the banner -# leaves out. bazel takes these after the target as readily as before it. +# These have to sit between the target and `--`, which is what keeps `_bazel_args` from +# folding into the invocation the way the other formatters' arguments do. Only output is +# at stake here, which is what the banner leaves out. _bazel_quiet := "--noshow_progress --ui_event_filters=,+error,+fail" # Keep in step with the `buildifier` target's own exclusions, or `just format` rewrites @@ -52,26 +49,26 @@ _bazel_accounting := ': applied fixes, [0-9]+ warnings left$' [no-exit-message] [positional-arguments] @_format_ql +ARGS: (_maybe_build_dist "nolang") - echo "$CMD_BEGIN-> {{ _ql_formatter }} {{ _ql_args }} -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} {{ _ql_args }} -v -- "$@" + echo "$CMD_BEGIN-> "{{ quote(_ql_formatter) }}" -- $*$CMD_END" >&2 + {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" [no-cd] [no-exit-message] [positional-arguments] @_format_py *ARGS=".": - echo "$CMD_BEGIN-> {{ _py_formatter }} $*$CMD_END" >&2 + echo "$CMD_BEGIN-> "{{ quote(_py_formatter) }}" $*$CMD_END" >&2 {{ _py_formatter }} "$@" [no-cd] [no-exit-message] [positional-arguments] @_format_cpp *ARGS=".": - echo "$CMD_BEGIN-> {{ _cpp_formatter }} {{ _cpp_args }} -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} {{ _cpp_args }} -- "$@" + echo "$CMD_BEGIN-> "{{ quote(_cpp_formatter) }}" -- $*$CMD_END" >&2 + {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" [no-cd] [no-exit-message] [positional-arguments] @_format_bazel *ARGS=".": - echo "$CMD_BEGIN-> {{ _bazel_formatter }} -- {{ _bazel_args }} -- $*$CMD_END" >&2 + echo "$CMD_BEGIN-> "{{ quote(_bazel_formatter + " -- " + _bazel_args) }}" -- $*$CMD_END" >&2 {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} {{ _bazel_quiet }} -- {{ _bazel_args }} -v -- "$@" From bd84802a4398e02d003aff543c840dd4a42e5d79 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:52:25 +0200 Subject: [PATCH 48/92] Just: stop hiding the warnings buildifier could not fix The drop pattern matched any tally, so a file left with warnings was as silent as one left clean. Nothing else reports them: buildifier prints no detail in fix mode and exits 0 either way, so that line is the only notice. Only the empty tally is noise, and it is printed for every file whether or not it was touched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 2e240247a252..b43b58b7c5cd 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -28,8 +28,9 @@ _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" -# buildifier only names what it rewrote if it also accounts for every file it did not. -_bazel_accounting := ': applied fixes, [0-9]+ warnings left$' +# `-v` accounts for every file, so the empty tally is dropped. A non-zero one is kept: it is +# the only notice of warnings buildifier could not fix, as the exit code stays 0 regardless. +_bazel_accounting := ': applied fixes, 0 warnings left$' # These formatters take files rather than directories, so `run_on_files.py` collects them # and passes them positionally, which is what lets paths contain spaces. `codeql query From ff341383596dc152a66d44ca0a2b719ae33d9ed0 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 10:57:27 +0200 Subject: [PATCH 49/92] Just: collect the one Starlark name buildifier knows and we did not The list stands in for buildifier's own idea of what a Starlark file is, since that check is skipped for paths handed to it rather than walked to: whatever is listed here gets rewritten, and whatever is not is left for CI to rewrite instead. `*.star` was the one name it recognises that was missing, so a file added under it would have been formatted in CI and not by `just format`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index b43b58b7c5cd..275df813bf46 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -21,10 +21,11 @@ _bazel_args := "-mode=fix -lint=fix" # at stake here, which is what the banner leaves out. _bazel_quiet := "--noshow_progress --ui_event_filters=,+error,+fail" -# Keep in step with the `buildifier` target's own exclusions, or `just format` rewrites -# what pre-commit and CI deliberately leave alone. Patterns match the path as walked, so -# one naming a directory has to cover both how it is reached and how it is walked. -_bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky" +# Keep both in step with the `buildifier` target. The names are what it recognises walking a +# workspace, a check it skips for paths handed to it, so anything extra here is rewritten +# regardless. Exclusions match the path as walked, so one naming a directory has to cover +# both how it is reached and how it is walked. +_bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" From 5729b4329bc7d82cba6dd889483ba4bc4767aace Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:00:01 +0200 Subject: [PATCH 50/92] Just: flush the account of what runs before a child writes over it `error` says why already: the streams are buffered differently when they are not both a terminal. The same holds where a child inherits this process' stdout, and there it was missed, so off a terminal the buffer flushed at exit and every account of what was about to run landed after the output it described. Visible only in redirected runs, which is to say in logs and never interactively. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index f1acbfb185ca..1814a0617dac 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -317,6 +317,9 @@ def report_opted_out(command, justfiles, *, ran): def invoke_just(cwd, args): """Run just with the given arguments.""" + # This process' stdout is block-buffered off a terminal, while the child writes to the + # same descriptor at once: without this the account lands after what it describes. + sys.stdout.flush() try: subprocess.run([JUST, *args], check=True, cwd=cwd) except subprocess.CalledProcessError as e: From fd05c30245d83d05cbcc2963055bdaa217f2137b Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:03:06 +0200 Subject: [PATCH 51/92] Just: say that buildifier's tally is kept on purpose The count grows with the tree and is mostly docstring lint, so the first reading of it at any size invites putting the filter back. Name the lever that makes it quieter without taking the warnings worth having with it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/misc/just/README.md b/misc/just/README.md index 5738c0a45d9a..8d62b276bcce 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -162,6 +162,14 @@ Nothing is lost by this, as a banner has never been something to paste: the echo carried an unexpanded `"$@"`, which matches no file in another shell, so pasting one formatted nothing and exited 0. +What a formatter says for itself is filtered down to what happened, as several name every +file they considered and most of them were left alone. One such line is kept on purpose: +buildifier's count of the warnings it could not fix, which is the only notice of them, +since it reports no detail in fix mode and exits 0 whether or not any remain. That tally +grows with the tree and is mostly lint about docstrings. Finding it tiresome is a reason +to configure what buildifier lints, never to widen the filter back over it, which would +take the warnings worth having along with the rest. + Measuring the width means a `shell()` call, and that runs on every parse, so the result is exported as `JUST_CMD_RULE` and an inherited value is preferred to measuring again. From 47c5faeb46fb1be8d1bdb8d84e94cfadf7e3ad0d Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:07:37 +0200 Subject: [PATCH 52/92] Just: inline the single-use values no other repository redirects A name here is an interface a consuming root may assign, so the ones worth keeping are the ones someone would reasonably point elsewhere, not the ones used more than once. Neither of these is: quieting bazel shapes output only, and the dropped tally is deliberately not the lever for a noisy one. Their reasons move to what is left, next to the argument split that is genuinely not obvious. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 275df813bf46..25b15724570d 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -14,13 +14,10 @@ _bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) _bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run @buildifier_prebuilt//:buildifier" +# Kept out of the formatter, unlike the other three: bazel's own flags have to sit between +# the target and `--`, so no single string spans both sides of it. _bazel_args := "-mode=fix -lint=fix" -# These have to sit between the target and `--`, which is what keeps `_bazel_args` from -# folding into the invocation the way the other formatters' arguments do. Only output is -# at stake here, which is what the banner leaves out. -_bazel_quiet := "--noshow_progress --ui_event_filters=,+error,+fail" - # Keep both in step with the `buildifier` target. The names are what it recognises walking a # workspace, a check it skips for paths handed to it, so anything extra here is rewritten # regardless. Exclusions match the path as walked, so one naming a directory has to cover @@ -29,14 +26,11 @@ _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" -# `-v` accounts for every file, so the empty tally is dropped. A non-zero one is kept: it is -# the only notice of warnings buildifier could not fix, as the exit code stays 0 regardless. -_bazel_accounting := ': applied fixes, 0 warnings left$' - # These formatters take files rather than directories, so `run_on_files.py` collects them -# and passes them positionally, which is what lets paths contain spaces. `codeql query -# format` likewise only reports what it rewrote if it also names everything it did not, so -# those lines are dropped and nothing else is. +# and passes them positionally, which is what lets paths contain spaces. Both name every +# file they were given, so the lines saying nothing happened are dropped: for buildifier +# that is an empty warning tally, and a non-zero one is left alone, being the only notice +# of what it could not fix now that the exit code is 0 either way. # # The banner is a report rather than a command to paste: it names the formatter and what # it is told to do, and leaves out the collecting, the patterns walked and the flags that @@ -73,4 +67,4 @@ _bazel_accounting := ': applied fixes, 0 warnings left$' [positional-arguments] @_format_bazel *ARGS=".": echo "$CMD_BEGIN-> "{{ quote(_bazel_formatter + " -- " + _bazel_args) }}" -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop '{{ _bazel_accounting }}' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} {{ _bazel_quiet }} -- {{ _bazel_args }} -v -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop ': applied fixes, 0 warnings left$' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} --noshow_progress --ui_event_filters=,+error,+fail -- {{ _bazel_args }} -v -- "$@" From 9b67e8fedb312aa109572411144dd793479484eb Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:10:33 +0200 Subject: [PATCH 53/92] Just: say why the names collected are wider than any one buildifier The set buildifier recognises walking a workspace is not fixed: 6.4.0 formats `MODULE..bazel` and 8.5.1 does not. Repositories sharing this pin their own, so a list exact for either is wrong for the other, and the safe direction is the wide one. Records the check that answers it, the conclusion having just expired. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 25b15724570d..7f57fb95cf22 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -20,8 +20,11 @@ _bazel_args := "-mode=fix -lint=fix" # Keep both in step with the `buildifier` target. The names are what it recognises walking a # workspace, a check it skips for paths handed to it, so anything extra here is rewritten -# regardless. Exclusions match the path as walked, so one naming a directory has to cover -# both how it is reached and how it is walked. +# regardless. Being wider is deliberate: what it recognises differs between versions, and +# each repository pins its own, so only a list covering every one of them can be shared. A +# directory of one file per candidate name, formatted with `-r`, says what a version takes. +# Exclusions match the path as walked, so one naming a directory has to cover both how it is +# reached and how it is walked. _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" From 991831032c7465e66741b5fafd3c539c6db09047 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:15:22 +0200 Subject: [PATCH 54/92] Just: refuse a path that is not there rather than formatting nothing Naming a file is an assertion that it exists, and a typo in one was answered with the silence that means everything already matched, exit 0 included, under a banner claiming the formatter had run on it. Only the named path is checked: a directory holding nothing to format is a fair answer, and so is a glob whose expansion this formatter does not claim. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/run_on_files.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index bed9c66b8b93..1f9a359098db 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -7,7 +7,9 @@ The command is run once per batch of file names rather than once per file, and the batches are sized so that no single command line runs into a length limit. Nothing is -run at all when no file matches. +run at all when no file matches, so silence means that nothing here matched rather than +that nothing was there: a path that does not exist is refused instead, naming one being +an assertion that it does. """ import argparse @@ -160,6 +162,9 @@ def parse_args(): args.command, args.paths = args.rest[:separator], args.rest[separator + 1 :] if not args.command: parser.error("no command given") + missing = [path for path in args.paths if not os.path.exists(path)] + if missing: + parser.error("no such path: " + ", ".join(missing)) return args From bfc52aae8e94bf84c6d939b23f7bf77214b30324 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:32:31 +0200 Subject: [PATCH 55/92] Just: put each formatter's values next to the recipe reading them The four groups formed a header that had to be read whole before any one recipe made sense, and bazel's five values and their comments sat furthest from the only recipe that uses them. Interleaving is what `--fmt` already produces, so nothing is fighting the formatter. All four recipe expansions are byte-identical across the move. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 58 +++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 7f57fb95cf22..981482e72ade 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -1,34 +1,5 @@ import "build.just" -_ql_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" }) + " query format --in-place" - -_py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } - -_cpp_formatter := (if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" }) + " -i" - -# The `buildifier` target formats a whole workspace and so cannot take a path, so its -# binary is driven directly. bazel is asked from this repository's root even when it sits -# inside another, and the files are bounded to it, so each repository formats its own with -# the version it pins. Building needs the enclosing workspace instead, hence `_bazel`. -_bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) - -_bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run @buildifier_prebuilt//:buildifier" - -# Kept out of the formatter, unlike the other three: bazel's own flags have to sit between -# the target and `--`, so no single string spans both sides of it. -_bazel_args := "-mode=fix -lint=fix" - -# Keep both in step with the `buildifier` target. The names are what it recognises walking a -# workspace, a check it skips for paths handed to it, so anything extra here is rewritten -# regardless. Being wider is deliberate: what it recognises differs between versions, and -# each repository pins its own, so only a list covering every one of them can be shared. A -# directory of one file per candidate name, formatted with `-r`, says what a version takes. -# Exclusions match the path as walked, so one naming a directory has to cover both how it is -# reached and how it is walked. -_bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" - -_bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" - # These formatters take files rather than directories, so `run_on_files.py` collects them # and passes them positionally, which is what lets paths contain spaces. Both name every # file they were given, so the lines saying nothing happened are dropped: for buildifier @@ -44,6 +15,8 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" # `[no-cd]` keeps a relative argument meaning the caller's directory: the forwarder passes # `--justfile`, so without it the default `.` would silently become the whole repository. +_ql_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nolang/codeql"' } else { "codeql" }) + " query format --in-place" + [no-cd] [no-exit-message] [positional-arguments] @@ -51,6 +24,8 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" echo "$CMD_BEGIN-> "{{ quote(_ql_formatter) }}" -- $*$CMD_END" >&2 {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" +_py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } + [no-cd] [no-exit-message] [positional-arguments] @@ -58,6 +33,8 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" echo "$CMD_BEGIN-> "{{ quote(_py_formatter) }}" $*$CMD_END" >&2 {{ _py_formatter }} "$@" +_cpp_formatter := (if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang-format" }) + " -i" + [no-cd] [no-exit-message] [positional-arguments] @@ -65,6 +42,29 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" echo "$CMD_BEGIN-> "{{ quote(_cpp_formatter) }}" -- $*$CMD_END" >&2 {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" +# The `buildifier` target formats a whole workspace and so cannot take a path, so its +# binary is driven directly. bazel is asked from this repository's root even when it sits +# inside another, and the files are bounded to it, so each repository formats its own with +# the version it pins. Building needs the enclosing workspace instead, hence `_bazel`. +_bazel_workspace := quote(parent_directory(parent_directory(source_dir()))) + +_bazel_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/tools/bazel"' } else { "bazel" }) + " run @buildifier_prebuilt//:buildifier" + +# Kept out of the formatter, unlike the other three: bazel's own flags have to sit between +# the target and `--`, so no single string spans both sides of it. +_bazel_args := "-mode=fix -lint=fix" + +# Keep both in step with the `buildifier` target. The names are what it recognises walking a +# workspace, a check it skips for paths handed to it, so anything extra here is rewritten +# regardless. Being wider is deliberate: what it recognises differs between versions, and +# each repository pins its own, so only a list covering every one of them can be shared. A +# directory of one file per candidate name, formatted with `-r`, says what a version takes. +# Exclusions match the path as walked, so one naming a directory has to cover both how it is +# reached and how it is walked. +_bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" + +_bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" + [no-cd] [no-exit-message] [positional-arguments] From 1881b9a0d6943acfb03a5bcf10374b73e43db12f Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:34:46 +0200 Subject: [PATCH 56/92] Just: match an exclusion against the absolute path as well The walk only ever extends the path it was given, so formatting `cpp` from inside a nested repository builds no path naming that repository, and an exclusion naming it never matched. A repository that excludes a nested one was therefore formatting it anyway whenever the path was given from inside it: the same files, twice, by two independently pinned buildifiers. A relative pattern is anchored at the start and so cannot match an absolute path, which is why trying both spellings only ever grants the reach the pattern was written with. Exclusion counts here are unchanged: 504 before and after. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 4 ++-- misc/just/run_on_files.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 981482e72ade..3c1414ada37a 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -59,8 +59,8 @@ _bazel_args := "-mode=fix -lint=fix" # regardless. Being wider is deliberate: what it recognises differs between versions, and # each repository pins its own, so only a list covering every one of them can be shared. A # directory of one file per candidate name, formatted with `-r`, says what a version takes. -# Exclusions match the path as walked, so one naming a directory has to cover both how it is -# reached and how it is walked. +# Exclusions match the path as walked and its absolute form, so one that has to hold +# however the path was reached is anchored absolutely. _bazel_names := "BUILD,WORKSPACE,*.bazel,*.bzl,*.sky,*.star" _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 1f9a359098db..eb7a7db7822a 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -50,9 +50,11 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): Patterns are matched against the file name, as bazel files are identified by name rather than by extension. Exclusions are matched against the whole path instead, - which is how a directory of generated files is left alone. That path is the one the - walk built, so an exclusion has to allow for how the paths it is given are spelled: - `*//*` does not match what is walked from `` itself. + which is how a directory of generated files is left alone. Both the path the walk + built and its absolute form are tried, as the walk only ever extends the path it + was given: walking `cpp` from inside a directory builds nothing naming that + directory, so an exclusion naming it could never match. An exclusion that has to + hold however the path was reached is therefore anchored absolutely. A `within` directory bounds the result to the files below it, for a command that answers for one project and may be handed a path reaching outside it. @@ -65,8 +67,11 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): def wanted(path): if boundary is not None and not path.resolve().is_relative_to(boundary): return False + # A relative pattern is anchored at the start, so it cannot match the absolute + # spelling: trying both only ever gives a pattern the reach it was written with. + spellings = (str(path), os.path.abspath(path)) return any(fnmatch(path.name, p) for p in patterns) and not any( - fnmatch(str(path), e) for e in excludes + fnmatch(spelling, e) for e in excludes for spelling in spellings ) found = set() From 6d87cbe25cd133c54f9942b519d24422709c95c9 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:52:53 +0200 Subject: [PATCH 57/92] Just: let the collector print the banner, so it reports rather than intends A recipe reaches its `echo` before knowing whether any file matched, so the banner could only ever announce an intention while claiming to describe an action. The two are the same string, and they came apart visibly once a repository stopped formatting a nested one: the banner was then the whole of what a run produced. Handing the text to `run_on_files.py` moves the decision to the only place that has it. `_format_py` keeps its own echo, having no collector to defer to. The banner is byte-identical whenever a file does match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 14 ++++++++------ misc/just/run_on_files.py | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 3c1414ada37a..614bded50e57 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -12,6 +12,11 @@ import "build.just" # worked anyway: the banner used to carry an unexpanded `"$@"`, which matches no file in # another shell and so formatted nothing while exiting 0. # +# It is handed to the collector rather than echoed here, as a recipe reaches this line +# before knowing whether any file matched, and so can only announce an intention. The +# collector announces the thing it is about to do. `_format_py` echoes its own, having no +# collector to defer to and nothing to decide. +# # `[no-cd]` keeps a relative argument meaning the caller's directory: the forwarder passes # `--justfile`, so without it the default `.` would silently become the whole repository. @@ -21,8 +26,7 @@ _ql_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nol [no-exit-message] [positional-arguments] @_format_ql +ARGS: (_maybe_build_dist "nolang") - echo "$CMD_BEGIN-> "{{ quote(_ql_formatter) }}" -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_ql_formatter) }}" -- $*$CMD_END" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } @@ -39,8 +43,7 @@ _cpp_formatter := (if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang- [no-exit-message] [positional-arguments] @_format_cpp *ARGS=".": - echo "$CMD_BEGIN-> "{{ quote(_cpp_formatter) }}" -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_cpp_formatter) }}" -- $*$CMD_END" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" # The `buildifier` target formats a whole workspace and so cannot take a path, so its # binary is driven directly. bazel is asked from this repository's root even when it sits @@ -69,5 +72,4 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" [no-exit-message] [positional-arguments] @_format_bazel *ARGS=".": - echo "$CMD_BEGIN-> "{{ quote(_bazel_formatter + " -- " + _bazel_args) }}" -- $*$CMD_END" >&2 - {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop ': applied fixes, 0 warnings left$' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} --noshow_progress --ui_event_filters=,+error,+fail -- {{ _bazel_args }} -v -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_bazel_formatter + " -- " + _bazel_args) }}" -- $*$CMD_END" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop ': applied fixes, 0 warnings left$' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} --noshow_progress --ui_event_filters=,+error,+fail -- {{ _bazel_args }} -v -- "$@" diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index eb7a7db7822a..395d5c969cd6 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -7,9 +7,9 @@ The command is run once per batch of file names rather than once per file, and the batches are sized so that no single command line runs into a length limit. Nothing is -run at all when no file matches, so silence means that nothing here matched rather than -that nothing was there: a path that does not exist is refused instead, naming one being -an assertion that it does. +run at all when no file matches, not even the `--banner` announcing what would have, +so silence means that nothing here matched rather than that nothing was there: a path +that does not exist is refused instead, naming one being an assertion that it does. """ import argparse @@ -147,6 +147,11 @@ def parse_args(): metavar="", help="hide matching lines of the command's output, repeatable", ) + parser.add_argument( + "--banner", + metavar="", + help="announce this on standard error, but only once a file has matched", + ) parser.add_argument( "patterns", metavar="[,...]", @@ -202,6 +207,10 @@ def main(): files = files_under( args.paths, args.patterns, args.exclude, args.absolute, args.within ) + if args.banner and files: + # The caller cannot say this itself: at the point it would, whether anything is + # going to run is precisely what is not yet known. + print(args.banner, file=sys.stderr, flush=True) limit = batch_limit() - sum(len(argument) + 1 for argument in args.command) status = 0 for batch in batched(files, limit): From 186ccd98d66e3b946c4e66ff8b768c583553dfc4 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 11:59:01 +0200 Subject: [PATCH 58/92] Just: resolve before matching an exclusion, and only for a candidate An absolutely anchored exclusion was still defeated by a symbolic link: the absolute form of a path given as `/link/ql/cpp` keeps the link's spelling, so a repository excluding a nested one formatted it anyway. That is the bug just fixed, reached by another route, so resolving is what the anchor was for. Asking the name first is what pays for it. Resolving was owed on every walked file, most of which no pattern can match; owing it only on a candidate takes a whole-repository walk here from 7.9s to 1.5s, over the same 174 files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/run_on_files.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 395d5c969cd6..23219ce6c046 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -51,10 +51,11 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): Patterns are matched against the file name, as bazel files are identified by name rather than by extension. Exclusions are matched against the whole path instead, which is how a directory of generated files is left alone. Both the path the walk - built and its absolute form are tried, as the walk only ever extends the path it + built and its resolved form are tried, as the walk only ever extends the path it was given: walking `cpp` from inside a directory builds nothing naming that directory, so an exclusion naming it could never match. An exclusion that has to - hold however the path was reached is therefore anchored absolutely. + hold however the path was reached is therefore anchored absolutely, and resolving + is what makes that spelling hold for a path reached through a symbolic link too. A `within` directory bounds the result to the files below it, for a command that answers for one project and may be handed a path reaching outside it. @@ -65,12 +66,19 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): boundary = Path(within).resolve() if within else None def wanted(path): - if boundary is not None and not path.resolve().is_relative_to(boundary): + # The name decides most files and costs nothing, so it is asked first: resolving + # is a system call, and is only owed for a file that could still be collected. + if not any(fnmatch(path.name, p) for p in patterns): return False - # A relative pattern is anchored at the start, so it cannot match the absolute + if boundary is None and not excludes: + return True + resolved = path.resolve() + if boundary is not None and not resolved.is_relative_to(boundary): + return False + # A relative pattern is anchored at the start, so it cannot match the resolved # spelling: trying both only ever gives a pattern the reach it was written with. - spellings = (str(path), os.path.abspath(path)) - return any(fnmatch(path.name, p) for p in patterns) and not any( + spellings = (str(path), str(resolved)) + return not any( fnmatch(spelling, e) for e in excludes for spelling in spellings ) From 47e09afd3ff3bb9ea735ea89e2ef66ccbfdf8e75 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:02:01 +0200 Subject: [PATCH 59/92] Just: say what keeps two root recipes apart, since nothing in the code does Two repositories that each define a root recipe produce identical dumps in every field a reader would call meaningful, and are kept apart only by the doc comment one of them carries. Removing `doc` from the comparison is the obvious cleanup, documentation having no business deciding behaviour, and on its own it silently discards an invocation: measured over the paired trees, six of eight paths collapse. Comment only. The discriminator this wants is a separate question, and the note is here so that whoever reaches for the cleanup meets the coupling first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 1814a0617dac..e0dba8c59195 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -232,6 +232,13 @@ def find_justfiles_above(command, arg): recipe = implements(dump, command, argc) # These justfiles are nested, so a recipe that was seen already is one this # one merely imported, and the nearest spelling of it has been taken. + # + # Two repositories that each define a root recipe are not that case: the text + # can match while the workspace, the tool it runs and the paths it excludes all + # differ, so they have to stay apart. Nothing here says so. They are told apart + # only by the doc comment one of them happens to carry, which means dropping + # `doc` from this comparison silently discards an invocation unless a real + # discriminator arrives in the same change. if recipe is not None and recipe not in seen: seen.append(recipe) found.append((justfile, recipe)) From 1a5de7a360fbc7838cfa77050715459c715027ff Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:36:31 +0200 Subject: [PATCH 60/92] Just: hand the whole banner to the collector, which alone knows what runs The recipe supplied the text and the collector decided whether to print it, which left the line assembled in two places and the paths in it the ones asked about rather than the ones holding a file. A repository formatting its own bazel files is handed a nested repository's path too, so naming it claimed work that never happened. Both halves are the collector's now: it tracks which of the given paths yielded a file, and builds the line from the command it is about to run. The option goes with them, every call site having wanted it and nothing outside this repository calling the script. The cost is that the command appears as really spelled, output-shaping flags and all, which is the right trade for a line that reports rather than one to paste. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/format.just | 25 ++++++------- misc/just/run_on_files.py | 78 ++++++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/misc/just/format.just b/misc/just/format.just index 614bded50e57..8175a84bbb6d 100644 --- a/misc/just/format.just +++ b/misc/just/format.just @@ -6,16 +6,15 @@ import "build.just" # that is an empty warning tally, and a non-zero one is left alone, being the only notice # of what it could not fix now that the exit code is 0 either way. # -# The banner is a report rather than a command to paste: it names the formatter and what -# it is told to do, and leaves out the collecting, the patterns walked and the flags that -# only shape output. `just -n` prints what actually runs, `@` or no `@`. Pasting never -# worked anyway: the banner used to carry an unexpanded `"$@"`, which matches no file in -# another shell and so formatted nothing while exiting 0. -# -# It is handed to the collector rather than echoed here, as a recipe reaches this line -# before knowing whether any file matched, and so can only announce an intention. The -# collector announces the thing it is about to do. `_format_py` echoes its own, having no -# collector to defer to and nothing to decide. +# The collector announces the run, rather than each recipe saying what it is about to do. +# A recipe reaches that line knowing neither whether any file matched nor which of the +# paths it was handed hold one, and both are worth waiting for: a path whose files are all +# excluded is the ordinary case here, as a repository formatting its own bazel files is +# handed a nested repository's path too. The price of letting the collector speak is that +# it names the command as it is really spelled, flags that only shape output and all. It +# is a report rather than something to paste in any case, the collecting being the point; +# `just -n` prints what actually runs, `@` or no `@`. `_format_py` echoes its own, having +# no collector to defer to and nothing to decide. # # `[no-cd]` keeps a relative argument meaning the caller's directory: the forwarder passes # `--justfile`, so without it the default `.` would silently become the whole repository. @@ -26,7 +25,7 @@ _ql_formatter := (if SEMMLE_CODE != "" { '"$SEMMLE_CODE/target/intree/codeql-nol [no-exit-message] [positional-arguments] @_format_ql +ARGS: (_maybe_build_dist "nolang") - {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_ql_formatter) }}" -- $*$CMD_END" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --drop '^Formatting ' --drop '^No change for ' "*.ql,*.qll" {{ _ql_formatter }} -v -- "$@" _py_formatter := if SEMMLE_CODE != "" { "uv run black" } else { "black" } @@ -43,7 +42,7 @@ _cpp_formatter := (if SEMMLE_CODE != "" { "uv run clang-format" } else { "clang- [no-exit-message] [positional-arguments] @_format_cpp *ARGS=".": - {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_cpp_formatter) }}" -- $*$CMD_END" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" "*.h,*.cpp" {{ _cpp_formatter }} -- "$@" # The `buildifier` target formats a whole workspace and so cannot take a path, so its # binary is driven directly. bazel is asked from this repository's root even when it sits @@ -72,4 +71,4 @@ _bazel_excluded := ".git/*,*/.git/*,*misc/bazel/3rdparty/*_deps/*" [no-exit-message] [positional-arguments] @_format_bazel *ARGS=".": - {{ py }} "{{ source_dir() }}/run_on_files.py" --banner "$CMD_BEGIN-> "{{ quote(_bazel_formatter + " -- " + _bazel_args) }}" -- $*$CMD_END" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop ': applied fixes, 0 warnings left$' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} --noshow_progress --ui_event_filters=,+error,+fail -- {{ _bazel_args }} -v -- "$@" + {{ py }} "{{ source_dir() }}/run_on_files.py" --absolute --chdir {{ _bazel_workspace }} --within {{ _bazel_workspace }} --drop ': applied fixes, 0 warnings left$' --exclude '{{ _bazel_excluded }}' "{{ _bazel_names }}" {{ _bazel_formatter }} --noshow_progress --ui_event_filters=,+error,+fail -- {{ _bazel_args }} -v -- "$@" diff --git a/misc/just/run_on_files.py b/misc/just/run_on_files.py index 23219ce6c046..dd77fcb7db2d 100644 --- a/misc/just/run_on_files.py +++ b/misc/just/run_on_files.py @@ -7,9 +7,9 @@ The command is run once per batch of file names rather than once per file, and the batches are sized so that no single command line runs into a length limit. Nothing is -run at all when no file matches, not even the `--banner` announcing what would have, -so silence means that nothing here matched rather than that nothing was there: a path -that does not exist is refused instead, naming one being an assertion that it does. +run at all when no file matches, and nothing is announced either, so silence means that +nothing here matched rather than that nothing was there: a path that does not exist is +refused instead, naming one being an assertion that it does. """ import argparse @@ -62,6 +62,11 @@ def files_under(paths, patterns, excludes=(), absolute=False, within=None): Symbolic links are not followed, which is what keeps the `bazel-*` convenience links out of the walk. + + Returns the file names, and the given paths that yielded one. The two differ + whenever a path is excluded or simply holds nothing matching, and telling them + apart is what lets the banner name the paths being acted on rather than the paths + that were asked about. """ boundary = Path(within).resolve() if within else None @@ -82,15 +87,31 @@ def wanted(path): fnmatch(spelling, e) for e in excludes for spelling in spellings ) - found = set() - for path in map(Path, paths): + def collect(path): if path.is_file(): - if wanted(path): - found.add(path) - continue - for directory, _, names in os.walk(path): - found.update(p for p in map(Path(directory).joinpath, names) if wanted(p)) - return sorted(os.path.abspath(p) if absolute else str(p) for p in found) + return {path} if wanted(path) else set() + return { + file + for directory, _, names in os.walk(path) + for file in map(Path(directory).joinpath, names) + if wanted(file) + } + + # Kept per path rather than in one set, as which path a file came from is not a + # question the collected names can be asked afterwards without resolving each of + # them again. A file reached by two paths still only appears once below. + contributed = {} + for given in paths: + collected = collect(Path(given)) + if collected: + # Keyed by the spelling that was given rather than a normalised one, as + # this goes back to whoever wrote it and is theirs to recognise. + contributed[str(given)] = collected + files = sorted( + os.path.abspath(file) if absolute else str(file) + for file in set().union(*contributed.values()) + ) + return files, list(contributed) def batched(files, limit): @@ -155,11 +176,6 @@ def parse_args(): metavar="", help="hide matching lines of the command's output, repeatable", ) - parser.add_argument( - "--banner", - metavar="", - help="announce this on standard error, but only once a file has matched", - ) parser.add_argument( "patterns", metavar="[,...]", @@ -210,15 +226,35 @@ def run(command, drops, chdir=None): return process.wait() +def banner(command, paths): + """Announce a command over the paths it turned out to have something to do in. + + Only the paths that yielded a file are named: one whose files were all excluded is + not being acted on, and naming it claims work that is not about to happen. The file + names are left out, there being thousands of them and the paths being what was + asked for. + + So this is a report rather than something to paste, the collecting being the whole + point. `just -n` prints what really runs. + + `CMD_BEGIN` and `CMD_END` are the rules the justfiles put around a command; with + neither set this is a plain line. + """ + begin = os.environ.get("CMD_BEGIN", "") + end = os.environ.get("CMD_END", "") + return f"{begin}-> {' '.join(command)} -- {' '.join(paths)}{end}" + + def main(): args = parse_args() - files = files_under( + files, contributing = files_under( args.paths, args.patterns, args.exclude, args.absolute, args.within ) - if args.banner and files: - # The caller cannot say this itself: at the point it would, whether anything is - # going to run is precisely what is not yet known. - print(args.banner, file=sys.stderr, flush=True) + if files: + # Neither half of this is known where the caller would have to say it: whether + # anything is going to run at all, and which of the paths it named hold any of + # it. + print(banner(args.command, contributing), file=sys.stderr, flush=True) limit = batch_limit() - sum(len(argument) + 1 for argument in args.command) status = 0 for batch in batched(files, limit): From 26463f34eaa0924f657158737b4fcac3be10da5b Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:36:39 +0200 Subject: [PATCH 61/92] Just: test the tooling here, whose failures are all silent ones Nothing under this directory had tests, and the ways it can break mostly exit zero: an exclusion that stops excluding, a verb that stops being found, an argument that arrives split in two. None of those is visible until someone notices work not being done, which is why a paired repository was carrying a redundant path pattern purely as a hedge. Each such test comes with a positive control, asserting the same setup can produce the loud outcome, so that a zero means the rule held rather than that nothing ran. Writing them turned up one crash: an argument list of nothing but blanks was counted before the blanks were dropped, leaving no root to take and an IndexError instead of the usage. Plain unittest and no new dependency, this being the layer that runs other tooling and so a poor place to need a package manager. A `test` recipe reaches them by the usual verb, and CI runs them on any change here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/python-tooling.yml | 5 + misc/just/BUILD.bazel | 27 ++ misc/just/README.md | 14 + misc/just/justfile | 11 + misc/just/language_tests.py | 9 +- misc/just/test_codeql_test_run.py | 119 ++++++++ misc/just/test_forward_command.py | 200 +++++++++++++ misc/just/test_language_tests.py | 133 +++++++++ misc/just/test_run_on_files.py | 415 +++++++++++++++++++++++++++ 9 files changed, 929 insertions(+), 4 deletions(-) create mode 100644 misc/just/BUILD.bazel create mode 100644 misc/just/test_codeql_test_run.py create mode 100644 misc/just/test_forward_command.py create mode 100644 misc/just/test_language_tests.py create mode 100644 misc/just/test_run_on_files.py diff --git a/.github/workflows/python-tooling.yml b/.github/workflows/python-tooling.yml index a3ad9900ea47..41fea23450f9 100644 --- a/.github/workflows/python-tooling.yml +++ b/.github/workflows/python-tooling.yml @@ -5,6 +5,7 @@ on: paths: - "misc/bazel/**" - "misc/codegen/**" + - "misc/just/**" - "misc/scripts/models-as-data/*.py" - "*.bazel*" - .github/workflows/codegen.yml @@ -33,3 +34,7 @@ jobs: shell: bash run: | bazel test //misc/codegen/... + - name: Run just tooling tests + shell: bash + run: | + bazel test //misc/just/... diff --git a/misc/just/BUILD.bazel b/misc/just/BUILD.bazel new file mode 100644 index 000000000000..f94804c9fcd0 --- /dev/null +++ b/misc/just/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "tooling", + srcs = [ + "codeql_test_run.py", + "forward_command.py", + "language_tests.py", + "run_on_files.py", + ], + imports = ["."], + visibility = ["//visibility:public"], +) + +[ + py_test( + name = src[:-len(".py")], + size = "small", + srcs = [src], + deps = [":tooling"], + ) + for src in glob(["test_*.py"]) +] + +test_suite( + name = "test", +) diff --git a/misc/just/README.md b/misc/just/README.md index 8d62b276bcce..45db0e4ee85c 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -186,3 +186,17 @@ With no terminal to ask — a pipe, a log, a shell without `stty` — it falls b fixed 57 columns, so logs and CI output are the same width every time. That is one branch rather than a platform test: `just` runs `sh` everywhere, so Windows takes whichever arm fits rather than a path of its own. + +# Tests + +The scripts here have tests, run by `just test misc/just`, by `bazel test +//misc/just/...`, or by CI on any change under this directory. They are plain +`unittest`: this is the layer that runs other tooling, so it should not need a package +manager to check itself. + +They exist for the class of bug that leaves no trace. An exclusion that stops excluding, +a verb that stops being found, an argument that arrives split in two — each of those +still exits zero, and the only symptom is work quietly not done. So the tests come in +pairs: one asserting the quiet outcome, and one positive control asserting the same +setup can produce the loud one. Without the second, a passing first test is also what a +test that runs nothing at all looks like. diff --git a/misc/just/justfile b/misc/just/justfile index 679295f47801..29f9659fe863 100644 --- a/misc/just/justfile +++ b/misc/just/justfile @@ -1,4 +1,15 @@ +import "defs.just" + [no-cd] [positional-arguments] format *ARGS=".": npx prettier --write "$@" + +# Test the tooling in this directory. +# Arguments are `unittest` ones, so a test file or a dotted test name rather than a +# directory. A path given stays valid as this does not change directory: `PYTHONPATH` is +# what lets the tests import the modules they are about, wherever they are run from. +[no-cd] +[positional-arguments] +@test *ARGS=['discover', '-s', source_dir()]: + PYTHONPATH="{{ source_dir() }}" {{ py }} -m unittest "$@" diff --git a/misc/just/language_tests.py b/misc/just/language_tests.py index 988a77d73cf3..efd5e3f244f0 100755 --- a/misc/just/language_tests.py +++ b/misc/just/language_tests.py @@ -15,7 +15,10 @@ def main(): - argv = sys.argv[1:] + # Blank arguments are dropped before the count is taken: one comes of a caller + # interpolating a variable that was never set, and a list of nothing but those is no + # arguments at all rather than a root to find a justfile above. + argv = [arg for arg in sys.argv[1:] if arg] if not argv: print("Usage: language_tests.py ROOT [ARG...]", file=sys.stderr) return 1 @@ -25,9 +28,7 @@ def main(): # the internal checkout, so relativize them there to keep command lines readable. # Anything else (flags, environment assignments, relative paths) is passed verbatim. args = [ - os.path.relpath(arg, semmle_code) if os.path.isabs(arg) else arg - for arg in argv - if arg + os.path.relpath(arg, semmle_code) if os.path.isabs(arg) else arg for arg in argv ] just = os.environ.get("JUST_EXECUTABLE", "just") diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py new file mode 100644 index 000000000000..28e8cfffd8bc --- /dev/null +++ b/misc/just/test_codeql_test_run.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Tests for `codeql_test_run.py`. + +Sorting arguments is the whole of what this file decides, and it decides it by looking +at each one: a word is a test, a `-` is a flag, `NAME=value` is an environment +assignment. Several of these pin down that an argument arrives whole, spaces and all, +which is what taking them as a list rather than re-splitting a string bought. +""" + +import os +import unittest +from unittest import mock + +import codeql_test_run + + +def empty_args(): + """What `main` builds before sorting, limited to what `parse_args` fills in.""" + return { + "tests": [], + "flags": [], + "env": [], + "all_checks": [], + "codeql": "host", + "all": False, + } + + +def sorted_args(*argv): + args = empty_args() + codeql_test_run.parse_args(args, list(argv)) + return args + + +class TestParseArgs(unittest.TestCase): + def test_a_plain_word_is_a_test(self): + self.assertEqual(sorted_args("ql/test/Foo")["tests"], ["ql/test/Foo"]) + + def test_a_dash_is_a_flag(self): + self.assertEqual(sorted_args("--fail-on-trap-errors")["flags"], ["--fail-on-trap-errors"]) + + def test_an_uppercase_assignment_is_an_environment_variable(self): + self.assertEqual(sorted_args("CPUS=4")["env"], ["CPUS=4"]) + + def test_a_lowercase_assignment_is_a_test(self): + # Only shouting counts, so a path that happens to contain `=` stays a path. + self.assertEqual(sorted_args("dir/a=b")["tests"], ["dir/a=b"]) + + def test_codeql_selects_the_executable(self): + self.assertEqual(sorted_args("--codeql=built")["codeql"], "built") + + def test_the_last_codeql_wins(self): + self.assertEqual(sorted_args("--codeql=host", "--codeql=built")["codeql"], "built") + + def test_all_checks_is_asked_for_by_either_spelling(self): + self.assertTrue(sorted_args("--all-checks")["all"]) + self.assertTrue(sorted_args("+")["all"]) + + def test_an_extra_check_is_held_back_until_it_is_asked_for(self): + held = sorted_args("--all-checks=--check-databases") + self.assertEqual(held["all_checks"], ["--check-databases"]) + # Held back means held back: it is not a flag until `--all-checks` arrives. + self.assertEqual(held["flags"], []) + self.assertFalse(held["all"]) + + def test_an_empty_argument_is_ignored(self): + # One of these comes of a caller interpolating a variable that was never set. + self.assertEqual(sorted_args("", "test")["tests"], ["test"]) + + def test_a_test_path_containing_a_space_stays_one_argument(self): + self.assertEqual(sorted_args("some dir/test")["tests"], ["some dir/test"]) + + def test_an_assignment_whose_value_contains_a_space_stays_whole(self): + """The value is the point, and a split one used to arrive as three characters. + + An argument list carries this; a whitespace-separated string cannot, as there + is nothing left in it to tell a separator from part of a value. + """ + self.assertEqual(sorted_args("EXTRA=a b")["env"], ["EXTRA=a b"]) + + def test_sorts_a_whole_command_line_at_once(self): + args = sorted_args("-j2", "CPUS=4", "ql/test", "+", "--all-checks=--check-diff") + self.assertEqual(args["flags"], ["-j2"]) + self.assertEqual(args["env"], ["CPUS=4"]) + self.assertEqual(args["tests"], ["ql/test"]) + self.assertEqual(args["all_checks"], ["--check-diff"]) + self.assertTrue(args["all"]) + + +class TestEnvValue(unittest.TestCase): + def test_prefers_a_test_argument(self): + args = sorted_args("CPUS=4") + with mock.patch.dict(os.environ, {"CPUS": "8"}): + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "4") + + def test_falls_back_to_the_environment(self): + with mock.patch.dict(os.environ, {"CPUS": "8"}): + self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "8") + + def test_falls_back_to_the_default(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "1") + + def test_the_last_assignment_wins(self): + args = sorted_args("CPUS=4", "CPUS=2") + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "2") + + def test_an_empty_value_does_not_count_as_a_setting(self): + args = sorted_args("CPUS=") + with mock.patch.dict(os.environ, {"CPUS": "8"}): + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "8") + + def test_a_value_containing_a_space_survives(self): + args = sorted_args("EXTRA=a b") + self.assertEqual(codeql_test_run.env_value(args, "EXTRA", "none"), "a b") + + +if __name__ == "__main__": + unittest.main() diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py new file mode 100644 index 000000000000..c39da38891c9 --- /dev/null +++ b/misc/just/test_forward_command.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Tests for `forward_command.py`. + +These cover the deciding rather than the running: which justfile answers a verb, with +how many arguments, and which ones ask to be passed over. All of that is read out of +`just --dump`, so the shapes below were taken from what `just` really emits rather than +imagined -- a test built on an invented shape would agree with itself forever. +""" + +import unittest +from pathlib import Path + +import forward_command + + +def parameter(name, kind="singular", default=None): + return {"name": name, "kind": kind, "default": default} + + +def recipe(name, parameters=(), dependencies=(), private=False): + return { + "name": name, + "private": private, + "parameters": list(parameters), + "dependencies": [{"recipe": dependency} for dependency in dependencies], + } + + +def dump(*recipes, aliases=None, assignments=None): + return { + "recipes": {recipe["name"]: recipe for recipe in recipes}, + "aliases": aliases or {}, + "assignments": assignments or {}, + } + + +def forwarding(command): + """The pair a justfile has when it both forwards a verb and answers it itself.""" + return dump( + recipe( + command, + [parameter("ARGS", kind="star")], + dependencies=[forward_command.FORWARD_RECIPE], + ), + recipe(f"{forward_command.ROOT_PREFIX}{command}", [parameter("ARGS", "star")]), + ) + + +class TestAccepts(unittest.TestCase): + def accepts(self, parameters, argc): + return forward_command.accepts(recipe("r", parameters), argc) + + def test_a_recipe_without_parameters_takes_none(self): + self.assertTrue(self.accepts([], 0)) + self.assertFalse(self.accepts([], 1)) + + def test_a_parameter_without_a_default_must_be_given(self): + self.assertFalse(self.accepts([parameter("X")], 0)) + self.assertTrue(self.accepts([parameter("X")], 1)) + self.assertFalse(self.accepts([parameter("X")], 2)) + + def test_a_defaulted_parameter_may_be_left_out(self): + defaulted = [parameter("X", default=".")] + self.assertTrue(self.accepts(defaulted, 0)) + self.assertTrue(self.accepts(defaulted, 1)) + self.assertFalse(self.accepts(defaulted, 2)) + + def test_a_star_parameter_takes_any_number(self): + star = [parameter("ARGS", kind="star")] + for argc in (0, 1, 7): + self.assertTrue(self.accepts(star, argc), argc) + + def test_a_plus_parameter_takes_at_least_one(self): + plus = [parameter("ARGS", kind="plus")] + self.assertFalse(self.accepts(plus, 0)) + self.assertTrue(self.accepts(plus, 1)) + self.assertTrue(self.accepts(plus, 7)) + + +class TestImplements(unittest.TestCase): + def test_finds_the_recipe_named_after_the_command(self): + found = forward_command.implements(dump(recipe("test")), "test", 0) + self.assertEqual(found["name"], "test") + + def test_follows_an_alias(self): + found = forward_command.implements( + dump(recipe("test"), aliases={"t": "test"}), "t", 0 + ) + self.assertEqual(found["name"], "test") + + def test_passes_over_a_private_recipe(self): + self.assertIsNone( + forward_command.implements(dump(recipe("test", private=True)), "test", 0) + ) + + def test_passes_over_a_recipe_that_cannot_take_the_arguments(self): + self.assertIsNone(forward_command.implements(dump(recipe("test")), "test", 1)) + + def test_takes_the_root_recipe_when_the_plain_name_forwards(self): + """A forwarder's own name says nothing about what its directory does. + + Settling on it would make the search find itself, so the one name the two can + share is `_root_`. + """ + found = forward_command.implements(forwarding("format"), "format", 1) + self.assertEqual(found["name"], "_root_format") + + def test_finds_nothing_when_a_forwarder_has_no_recipe_of_its_own(self): + forwarder = dump( + recipe( + "format", + [parameter("ARGS", kind="star")], + dependencies=[forward_command.FORWARD_RECIPE], + ) + ) + self.assertIsNone(forward_command.implements(forwarder, "format", 1)) + + +class TestListValue(unittest.TestCase): + def read(self, value): + return forward_command.list_value({"explicit_verbs": {"value": value}}, "explicit_verbs") + + def test_reads_a_list_literal(self): + self.assertEqual(self.read(["list", "test", "build"]), ["test", "build"]) + + def test_reads_an_empty_list_literal(self): + self.assertEqual(self.read(["list"]), []) + + def test_counts_an_expression_as_absent(self): + # What `['a'] ++ ['b']` dumps as. Evaluating it would mean running just. + concatenation = ["list-concatenate", ["list", "a"], ["list", "b"]] + self.assertEqual(self.read(concatenation), []) + + def test_counts_a_plain_string_as_absent(self): + self.assertEqual(self.read("test"), []) + + def test_counts_an_unset_name_as_absent(self): + self.assertEqual(forward_command.list_value({}, "explicit_verbs"), []) + + +class TestOptsOut(unittest.TestCase): + def test_a_listed_verb_asks_to_be_named(self): + listed = dump(assignments={"explicit_verbs": {"value": ["list", "test"]}}) + self.assertTrue(forward_command.opts_out(listed, "test")) + self.assertFalse(forward_command.opts_out(listed, "build")) + + def test_listing_nothing_opts_out_of_nothing(self): + self.assertFalse(forward_command.opts_out(dump(), "test")) + + +class TestGetJustContext(unittest.TestCase): + def test_a_justfile_sitting_on_the_argument_is_run_from_there(self): + # `just build ql/rust` becomes `just build` inside `ql/rust`, as repeating the + # directory it is already in says nothing. + cwd, args = forward_command.get_just_context( + Path("ql/rust/justfile"), "build", [], ["ql/rust"] + ) + self.assertEqual(cwd, "ql/rust") + self.assertEqual(args, ["build"]) + + def test_flags_survive_being_run_from_there(self): + cwd, args = forward_command.get_just_context( + Path("ql/rust/justfile"), "test", ["--all-checks"], ["ql/rust"] + ) + self.assertEqual(cwd, "ql/rust") + self.assertEqual(args, ["test", "--all-checks"]) + + def test_anything_else_names_the_justfile_and_keeps_the_arguments(self): + cwd, args = forward_command.get_just_context( + Path("ql/justfile"), "build", ["-x"], ["ql/rust"] + ) + self.assertIsNone(cwd) + self.assertEqual( + args, ["--justfile", str(Path("ql/justfile")), "build", "-x", "ql/rust"] + ) + + def test_two_arguments_are_both_kept(self): + cwd, args = forward_command.get_just_context( + Path("ql/justfile"), "format", [], ["ql/rust", "ql/cpp"] + ) + self.assertIsNone(cwd) + self.assertEqual(args[-2:], ["ql/rust", "ql/cpp"]) + + +class TestInvocationPath(unittest.TestCase): + def test_spells_a_path_relatively_when_the_argument_was(self): + found = Path.cwd() / "sub" / "justfile" + self.assertEqual( + forward_command.invocation_path(found, like="sub"), Path("sub/justfile") + ) + + def test_leaves_a_path_absolute_when_the_argument_was(self): + found = Path.cwd() / "sub" / "justfile" + self.assertEqual( + forward_command.invocation_path(found, like=str(Path.cwd())), found + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/misc/just/test_language_tests.py b/misc/just/test_language_tests.py new file mode 100644 index 000000000000..d1865599f9f4 --- /dev/null +++ b/misc/just/test_language_tests.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Tests for `language_tests.py`. + +`just` is stubbed out here: what this file decides is the invocation, and running it +needs a built CLI and a whole test suite. The invocation is also where the interesting +property lives, as an argument has to reach the suite exactly as it was written. +""" + +import contextlib +import io +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import language_tests + + +class TestMain(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + # Resolved once: macOS puts temporary directories behind a symbolic link, and + # an unresolved root would not be a prefix of the paths built from it. + self.semmle_code = Path(temporary.name).resolve() + self.suite = self.semmle_code / "ql" / "rust" / "ql" / "test" + self.suite.mkdir(parents=True) + (self.semmle_code / "ql" / "rust" / "justfile").touch() + + def run_main(self, *argv, environment=None, side_effect=None): + """Run `main` with `just` stubbed out, returning its status and that stub.""" + env = {"SEMMLE_CODE": str(self.semmle_code), "JUST_EXECUTABLE": "just"} + env.update(environment or {}) + printed = io.StringIO() + with ( + mock.patch.object( + language_tests.sys, "argv", ["language_tests.py", *argv] + ), + mock.patch.dict(os.environ, env), + mock.patch.object( + language_tests.subprocess, "run", side_effect=side_effect + ) as run, + contextlib.redirect_stdout(printed), + # The messages it writes here are expected by the tests below, and reading + # them among the results would suggest something had gone wrong. + contextlib.redirect_stderr(io.StringIO()), + ): + status = language_tests.main() + return status, run, printed.getvalue() + + def invocation(self, *argv, **kwargs): + status, run, _ = self.run_main(*argv, **kwargs) + self.assertEqual(status, 0) + return list(run.call_args.args[0]) + + def test_relativizes_an_absolute_root_against_the_checkout(self): + # Roots are absolute because a justfile builds them from `source_dir()`, and + # the command line is read by people. + self.assertEqual( + self.invocation(str(self.suite))[-1], + os.path.join("ql", "rust", "ql", "test"), + ) + + def test_finds_the_nearest_justfile_above_the_root(self): + invocation = self.invocation(str(self.suite)) + self.assertEqual( + invocation[invocation.index("--justfile") + 1], + str(Path("ql/rust/justfile")), + ) + + def test_asks_for_the_checks_ci_wants(self): + invocation = self.invocation(str(self.suite)) + self.assertIn("--all-checks", invocation) + self.assertIn("--codeql=built", invocation) + + def test_runs_from_the_checkout(self): + _, run, _ = self.run_main(str(self.suite)) + self.assertEqual(run.call_args.kwargs["cwd"], self.semmle_code) + + def test_an_argument_containing_a_space_stays_one_argument(self): + """The reason these arrive as a list rather than one string to re-split. + + Splitting on whitespace made this reach the suite as two arguments, and a value + that was only whitespace reached it as its own separators. + """ + self.assertIn("EXTRA=a b", self.invocation(str(self.suite), "EXTRA=a b")) + + def test_a_relative_argument_is_passed_verbatim(self): + self.assertIn("--fail-fast", self.invocation(str(self.suite), "--fail-fast")) + + def test_keeps_the_arguments_in_the_order_they_were_given(self): + invocation = self.invocation(str(self.suite), "CPUS=2", "--verbose") + self.assertEqual(invocation[-3:], [os.path.join("ql", "rust", "ql", "test"), "CPUS=2", "--verbose"]) + + def test_uses_the_just_it_was_given(self): + invocation = self.invocation( + str(self.suite), environment={"JUST_EXECUTABLE": "/opt/just"} + ) + self.assertEqual(invocation[0], "/opt/just") + + def test_says_what_it_is_about_to_run(self): + _, _, printed = self.run_main(str(self.suite)) + self.assertIn("-> just", printed) + + def test_needs_a_root(self): + status, run, _ = self.run_main() + self.assertEqual(status, 1) + run.assert_not_called() + + def test_nothing_but_blank_arguments_is_no_arguments(self): + # An unset variable interpolated by a caller arrives as one of these. Counting + # it as an argument and then dropping it left nothing to take a root from. + status, run, _ = self.run_main("", "") + self.assertEqual(status, 1) + run.assert_not_called() + + def test_reports_a_root_with_no_justfile_above_it(self): + orphan = self.semmle_code / "elsewhere" + orphan.mkdir() + status, run, _ = self.run_main(str(orphan)) + self.assertEqual(status, 1) + run.assert_not_called() + + def test_passes_on_the_status_of_a_failing_suite(self): + failure = subprocess.CalledProcessError(3, "just") + status, _, _ = self.run_main(str(self.suite), side_effect=failure) + self.assertEqual(status, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/misc/just/test_run_on_files.py b/misc/just/test_run_on_files.py new file mode 100644 index 000000000000..ce2517547c06 --- /dev/null +++ b/misc/just/test_run_on_files.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Tests for `run_on_files.py`. + +Two of these guard properties that cost nothing to break and say nothing when broken. +An exclusion that stops excluding does not fail: it formats files it was told to leave +alone, with a tool the other repository did not choose, and the only way to notice is +to go looking. These are that noticing, done once and kept. + +Each of those two carries a positive control, as the assertion they make is that a +collection is empty, and an empty collection is also what a mistyped pattern, a wrong +directory or a walk that never ran produce. +""" + +import contextlib +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import run_on_files + +HERE = Path(__file__).resolve().parent + +# Print each argument on its own line, so that a test can tell one argument containing a +# space from two arguments. +ECHO_ARGUMENTS = "import sys\nfor a in sys.argv[1:]: print(a)" +ECHO_TO_STDERR = "import sys\nfor a in sys.argv[1:]: print(a, file=sys.stderr)" + + +def can_symlink(): + """Whether this process may create symbolic links, which Windows restricts.""" + with tempfile.TemporaryDirectory() as directory: + try: + os.symlink(directory, Path(directory) / "link") + return True + except (OSError, NotImplementedError): + return False + + +CAN_SYMLINK = can_symlink() + + +@contextlib.contextmanager +def working_directory(directory): + previous = os.getcwd() + os.chdir(directory) + try: + yield + finally: + os.chdir(previous) + + +class TemporaryTree(unittest.TestCase): + """A scratch tree of empty files, named by `files`.""" + + files = () + + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + # Resolved once here: macOS puts temporary directories behind a symbolic link, + # and these tests compare against absolute paths. + self.root = Path(temporary.name).resolve() + for name in self.files: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + +class TestFilesUnder(TemporaryTree): + files = ( + "outer/BUILD", + "outer/BUILD.bazel", + "outer/notes.md", + "outer/nested/BUILD.bazel", + "outer/nested/deep/BUILD.bazel", + ) + + def collect(self, paths, patterns, *args, **kwargs): + files, _ = run_on_files.files_under(paths, patterns, *args, **kwargs) + return files + + def test_matches_the_whole_name_rather_than_an_extension(self): + # A bazel file may be called `BUILD`, with no extension to match on. + with working_directory(self.root / "outer"): + self.assertEqual(self.collect(["."], ["BUILD"]), ["BUILD"]) + + def test_leaves_out_a_file_no_pattern_names(self): + with working_directory(self.root): + found = self.collect(["outer"], ["*.bazel"]) + self.assertNotIn(os.path.join("outer", "notes.md"), found) + + def test_walks_the_whole_tree_below_a_directory(self): + with working_directory(self.root): + found = self.collect(["outer"], ["*.bazel"]) + self.assertEqual( + found, + [ + os.path.join("outer", "BUILD.bazel"), + os.path.join("outer", "nested", "BUILD.bazel"), + os.path.join("outer", "nested", "deep", "BUILD.bazel"), + ], + ) + + def test_takes_a_file_as_well_as_a_directory(self): + with working_directory(self.root): + path = os.path.join("outer", "BUILD.bazel") + self.assertEqual(self.collect([path], ["*.bazel"]), [path]) + + def test_leaves_out_a_file_named_directly_that_no_pattern_matches(self): + with working_directory(self.root): + self.assertEqual(self.collect([os.path.join("outer", "notes.md")], ["*.bazel"]), []) + + def test_excludes_are_matched_against_the_path_not_the_name(self): + with working_directory(self.root): + found = self.collect(["outer"], ["*.bazel"], ["*/nested/*"]) + self.assertEqual(found, [os.path.join("outer", "BUILD.bazel")]) + + def test_absolute_exclusion_holds_for_a_path_walked_from_inside(self): + """The walk only ever extends the path it was given. + + Walking `nested` from inside `outer` builds no path naming `outer`, so an + exclusion spelled relative to the enclosing directory cannot match it. One + anchored absolutely has to, which is what a repository relies on to stay out of + a nested one that is being formatted from within. + """ + with working_directory(self.root / "outer"): + # Positive control: there is something here to exclude, so an empty result + # below means the exclusion worked rather than that the walk found nothing. + self.assertEqual(len(self.collect(["nested"], ["*.bazel"])), 2) + self.assertEqual( + self.collect(["nested"], ["*.bazel"], [f"{self.root / 'outer'}/*"]), + [], + ) + + @unittest.skipUnless(CAN_SYMLINK, "symbolic links need a privilege on Windows") + def test_absolute_exclusion_holds_for_a_path_reached_through_a_link(self): + """An absolute path may still be spelled through a symbolic link. + + Joining it with the working directory leaves that spelling alone, so only + resolving it makes an absolutely anchored exclusion hold here too. + """ + link = self.root / "link" + link.symlink_to(self.root / "outer", target_is_directory=True) + reached_through_link = str(link / "nested") + # Positive control, as above. + self.assertEqual(len(self.collect([reached_through_link], ["*.bazel"])), 2) + self.assertEqual( + self.collect([reached_through_link], ["*.bazel"], [f"{self.root / 'outer'}/*"]), + [], + ) + + def test_a_relative_exclusion_still_holds_for_a_relative_walk(self): + # Trying both spellings must not cost a pattern the reach it was written with. + with working_directory(self.root): + self.assertEqual(self.collect(["outer"], ["*.bazel"], ["outer/*"]), []) + + def test_within_leaves_out_what_lies_beyond_it(self): + with working_directory(self.root): + self.assertEqual( + self.collect(["outer"], ["*.bazel"], within=str(self.root / "outer" / "nested")), + [ + os.path.join("outer", "nested", "BUILD.bazel"), + os.path.join("outer", "nested", "deep", "BUILD.bazel"), + ], + ) + + @unittest.skipUnless(CAN_SYMLINK, "symbolic links need a privilege on Windows") + def test_does_not_walk_through_a_symbolic_link(self): + # This is what keeps the `bazel-*` convenience links out of a walk, which would + # otherwise reach the whole output tree. + (self.root / "outer" / "bazel-out").symlink_to( + self.root / "outer" / "nested", target_is_directory=True + ) + with working_directory(self.root): + found = self.collect(["outer"], ["*.bazel"]) + self.assertFalse([path for path in found if "bazel-out" in path]) + + def test_absolute_asks_for_absolute_names(self): + with working_directory(self.root): + found = self.collect(["outer"], ["BUILD"], absolute=True) + self.assertEqual(found, [str(self.root / "outer" / "BUILD")]) + + def test_a_file_is_collected_once_however_many_paths_reach_it(self): + with working_directory(self.root): + found = self.collect(["outer", os.path.join("outer", "BUILD.bazel")], ["*.bazel"]) + self.assertEqual(len(found), len(set(found))) + + +class TestContributingPaths(TemporaryTree): + """The second half of what a walk learns, and the reason the banner is honest. + + A path that was asked about is not the same as a path being acted on. Naming the + first is what made a run over a repository and a nested one report the nested path + twice, once from an invocation that had excluded every file in it. + """ + + files = ( + "outer/BUILD.bazel", + "outer/nested/BUILD.bazel", + "outer/barren/notes.md", + ) + + def contributing(self, paths, patterns, *args, **kwargs): + _, contributing = run_on_files.files_under(paths, patterns, *args, **kwargs) + return contributing + + def test_names_only_a_path_that_yielded_a_file(self): + with working_directory(self.root): + self.assertEqual( + self.contributing( + [os.path.join("outer", "nested"), os.path.join("outer", "barren")], + ["*.bazel"], + ), + [os.path.join("outer", "nested")], + ) + + def test_leaves_out_a_path_whose_files_were_all_excluded(self): + with working_directory(self.root): + self.assertEqual( + self.contributing( + ["outer"], + ["*.bazel"], + [f"{self.root / 'outer'}/*"], + ), + [], + ) + + def test_keeps_the_spelling_the_path_was_given_in(self): + # It goes back to whoever wrote it, so it has to be recognisable as theirs. + with working_directory(self.root): + self.assertEqual(self.contributing(["./outer"], ["*.bazel"]), ["./outer"]) + + def test_names_both_paths_that_reach_the_same_file(self): + # The file is collected once, but each path did have something in it. + with working_directory(self.root): + paths = ["outer", os.path.join("outer", "nested")] + files, contributing = run_on_files.files_under(paths, ["*.bazel"]) + self.assertEqual(contributing, paths) + self.assertEqual(len(files), 2) + + +class TestBanner(unittest.TestCase): + def test_names_the_command_and_the_paths_it_has_something_to_do_in(self): + # The bare form has to be asked for rather than assumed: `just` exports these + # two, so run through the `test` recipe the ambient environment is not empty, + # and a test that reads it would pass or fail by how it was started. + with mock.patch.dict(os.environ): + os.environ.pop("CMD_BEGIN", None) + os.environ.pop("CMD_END", None) + self.assertEqual( + run_on_files.banner(["clang-format", "-i"], ["cpp", "swift"]), + "-> clang-format -i -- cpp swift", + ) + + def test_is_wrapped_in_the_rules_the_justfiles_set(self): + with mock.patch.dict( + os.environ, {"CMD_BEGIN": "", "CMD_END": ""} + ): + self.assertEqual( + run_on_files.banner(["black"], ["."]), "-> black -- ." + ) + + +class TestBatched(unittest.TestCase): + def test_keeps_everything_in_one_batch_when_it_fits(self): + self.assertEqual(list(run_on_files.batched(["a", "b"], 100)), [["a", "b"]]) + + def test_splits_once_the_limit_is_reached(self): + batches = list(run_on_files.batched(["aaa", "bbb", "ccc"], 8)) + self.assertEqual(batches, [["aaa", "bbb"], ["ccc"]]) + + def test_loses_no_file_and_keeps_their_order(self): + files = [f"file{n}" for n in range(50)] + batched = [file for batch in run_on_files.batched(files, 20) for file in batch] + self.assertEqual(batched, files) + + def test_yields_nothing_for_no_files(self): + self.assertEqual(list(run_on_files.batched([], 100)), []) + + def test_still_yields_a_file_longer_than_the_limit(self): + # Dropping it would be silent, and the command is a better place for the + # complaint than a batch that never happens. + long = "x" * 200 + self.assertEqual(list(run_on_files.batched([long], 10)), [[long]]) + + +class TestBatchLimit(unittest.TestCase): + def test_leaves_room_for_the_environment_and_a_single_argument(self): + limit = run_on_files.batch_limit() + self.assertGreaterEqual(limit, 4096) + # A single argument is capped far lower than the whole command line, and a + # command handing its arguments on through a shell arrives as one of them. + self.assertLessEqual(limit, 100000) + + +class TestCommaSeparated(unittest.TestCase): + def test_splits_a_group_given_as_one_argument(self): + self.assertEqual(run_on_files.comma_separated("a,b,c"), ["a", "b", "c"]) + + def test_leaves_a_single_pattern_alone(self): + self.assertEqual(run_on_files.comma_separated("a"), ["a"]) + + +class TestCommandLine(TemporaryTree): + files = ( + "project/BUILD.bazel", + "project/with space/BUILD.bazel", + "project/notes.md", + "elsewhere/BUILD.bazel", + ) + + def run_script(self, *arguments, cwd=None): + return subprocess.run( + [sys.executable, str(HERE / "run_on_files.py"), *arguments], + cwd=cwd or self.root, + capture_output=True, + text=True, + ) + + def echo(self, script=ECHO_ARGUMENTS): + return [sys.executable, "-c", script] + + def test_passes_a_name_containing_a_space_as_one_argument(self): + """The reason this program exists rather than a shell command substitution.""" + result = self.run_script("*.bazel", *self.echo(), "--", "project") + self.assertEqual(result.returncode, 0, result.stderr) + printed = result.stdout.splitlines() + self.assertIn(os.path.join("project", "with space", "BUILD.bazel"), printed) + + def test_announces_the_command_once_a_file_has_matched(self): + result = self.run_script("*.bazel", *self.echo(), "--", "project") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("-> ", result.stderr) + self.assertIn(" -- project", result.stderr) + + def test_the_banner_leaves_out_a_path_that_contributed_nothing(self): + """What a repository formatting its own files over a nested one used to say. + + Both paths were named while one of them had every file excluded, so the run + read as covering ground it had already declined to touch. + """ + result = self.run_script( + "--exclude", + f"{self.root / 'project'}/*", + "*.bazel", + *self.echo(), + "--", + "elsewhere", + "project", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(" -- elsewhere", result.stderr) + self.assertNotIn("project", result.stderr) + + def test_says_nothing_at_all_when_no_file_matches(self): + """Silence has to mean that nothing matched, not that nothing was looked at. + + The banner is the whole point: announced before the collection, it would claim + a formatter ran over a directory it never opened. + """ + result = self.run_script("*.nomatch", *self.echo(), "--", "project") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, "") + + def test_refuses_a_path_that_does_not_exist(self): + # Naming a path is an assertion that it is there, so this is not the silent case + # above: an unexpanded glob would otherwise look exactly like nothing to do. + result = self.run_script("*.bazel", *self.echo(), "--", "absent") + self.assertNotEqual(result.returncode, 0) + self.assertIn("no such path: absent", result.stderr) + + def test_needs_the_paths_separated_from_the_command(self): + result = self.run_script("*.bazel", *self.echo(), "project") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--", result.stderr) + + def test_needs_a_command(self): + # Two separators: argparse takes the first for its own end-of-options marker + # when nothing precedes it, so one alone leaves no separator to find and is + # reported as the missing one above. + result = self.run_script("*.bazel", "--", "--", "project") + self.assertNotEqual(result.returncode, 0) + self.assertIn("no command given", result.stderr) + + def test_separates_on_the_last_dash_dash_so_a_command_may_contain_one(self): + result = self.run_script("*.bazel", *self.echo(), "--", "--", "project") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--", result.stdout.splitlines()) + + def test_drops_the_lines_it_was_told_to_drop(self): + result = self.run_script( + "--drop", + "notes", + "*.bazel,*.md", + *self.echo(ECHO_TO_STDERR), + "--", + "project", + ) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertNotIn("notes.md", result.stderr) + self.assertIn("BUILD.bazel", result.stderr) + + def test_reports_the_command_failing(self): + failing = [sys.executable, "-c", "import sys; sys.exit(3)"] + result = self.run_script("*.bazel", *failing, "--", "project") + self.assertEqual(result.returncode, 3) + + +if __name__ == "__main__": + unittest.main() From ce901295370b766ff8b314a4f90e80864b01d4d4 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:41:40 +0200 Subject: [PATCH 62/92] Just: read an alias the way just dumps one now `aliases` maps a name to an object rather than to its target, so resolving one handed a dict to a dict lookup and raised. Unreachable from the forwarder, which only ever passes whole verb names, until a justfile aliases something to a verb's own name -- and then it raises rather than quietly forwarding the wrong thing, which is the good half of it. The test covering aliases was written against the shape that went away and went on passing, which is the part worth fixing properly. A fixture is only as good as the day it was written and cannot notice that `just` has moved on, so they are now held against a real dump of a justfile using every construct they model. Only the fields the code reads are compared, leaving `just` free to dump more. That check needs `just`, so it skips in CI and fires for whoever bumps it, who has one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 6 +- misc/just/test_forward_command.py | 103 +++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index e0dba8c59195..139f4cf44751 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -111,7 +111,11 @@ def accepts(recipe, argc): def implements(dump, command, argc): """Return the recipe a justfile runs for a command, if it has a usable one.""" recipes = dump["recipes"] - recipe = recipes.get(dump["aliases"].get(command, command)) + # An alias dumps as an object rather than as its target, so the name has to be read + # out of it. Unreachable from the forwarder, which only ever passes whole verbs, + # until a justfile aliases something to a verb's own name. + alias = dump["aliases"].get(command) + recipe = recipes.get(alias["target"] if alias else command) if recipe is None or recipe["private"]: return None if any( diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index c39da38891c9..58ff4fa653a2 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -4,9 +4,15 @@ These cover the deciding rather than the running: which justfile answers a verb, with how many arguments, and which ones ask to be passed over. All of that is read out of `just --dump`, so the shapes below were taken from what `just` really emits rather than -imagined -- a test built on an invented shape would agree with itself forever. +imagined. That is checked rather than claimed: the last class here dumps a real justfile +and holds the fixtures against it, because a hand-written shape is otherwise only as +good as the day it was written, and agrees with itself long after `just` has moved on. """ +import json +import shutil +import subprocess +import tempfile import unittest from pathlib import Path @@ -26,6 +32,10 @@ def recipe(name, parameters=(), dependencies=(), private=False): } +def alias(name, target): + return {"attributes": [], "name": name, "target": target} + + def dump(*recipes, aliases=None, assignments=None): return { "recipes": {recipe["name"]: recipe for recipe in recipes}, @@ -84,7 +94,7 @@ def test_finds_the_recipe_named_after_the_command(self): def test_follows_an_alias(self): found = forward_command.implements( - dump(recipe("test"), aliases={"t": "test"}), "t", 0 + dump(recipe("test"), aliases={"t": alias("t", "test")}), "t", 0 ) self.assertEqual(found["name"], "test") @@ -198,3 +208,92 @@ def test_leaves_a_path_absolute_when_the_argument_was(self): if __name__ == "__main__": unittest.main() + + +JUST = shutil.which("just") + +# The justfile below uses every construct the fixtures above model, so that a dump of it +# can be checked against them. +CONSTRUCTS = """ +set unstable +set lists + +alias t := test + +explicit_verbs := ['test'] + +test *ARGS='.': _helper + echo {{ ARGS }} + +build X Y='y': + echo {{ X }} {{ Y }} + +lint +ARGS: + echo {{ ARGS }} + +[private] +_helper: + echo helper +""" + + +@unittest.skipUnless(JUST, "needs `just` on PATH") +class TestFixturesStillMatchJust(unittest.TestCase): + """Check the fixtures above against what `just` really dumps. + + Everything else here reads a shape written by hand, which is only as good as the + day it was written: `just` changed how it dumps an alias once already, and the test + covering aliases went on passing against the shape that had gone away. A fixture + cannot notice that on its own, so this asks the real thing. + + Only the fields the code reads are compared. `just` is free to dump more, and a + test that failed whenever it did would be noise rather than a warning. + """ + + @classmethod + def setUpClass(cls): + with tempfile.TemporaryDirectory() as directory: + justfile = Path(directory) / "justfile" + justfile.write_text(CONSTRUCTS) + dumped = subprocess.run( + [JUST, "--justfile", str(justfile), "--dump", "--dump-format", "json"], + capture_output=True, + text=True, + check=True, + ) + cls.dump = json.loads(dumped.stdout) + + def test_a_dump_is_read_by_the_keys_the_fixtures_use(self): + self.assertLessEqual(set(dump()), set(self.dump)) + + def test_an_alias_names_its_target(self): + real = self.dump["aliases"]["t"] + self.assertEqual(set(alias("t", "test")), set(real)) + self.assertEqual(real["target"], "test") + + def test_a_recipe_is_read_by_the_keys_the_fixtures_use(self): + self.assertLessEqual(set(recipe("test")), set(self.dump["recipes"]["test"])) + + def test_a_private_recipe_says_so(self): + self.assertIs(self.dump["recipes"]["_helper"]["private"], True) + self.assertIs(self.dump["recipes"]["test"]["private"], False) + + def test_a_dependency_names_its_recipe(self): + dependencies = self.dump["recipes"]["test"]["dependencies"] + self.assertEqual([d["recipe"] for d in dependencies], ["_helper"]) + + def test_parameters_keep_the_kinds_and_defaults_accepts_reads(self): + kinds = { + name: [(p["kind"], p["default"]) for p in recipe["parameters"]] + for name, recipe in self.dump["recipes"].items() + } + self.assertEqual(kinds["test"], [("star", ".")]) + self.assertEqual(kinds["build"], [("singular", None), ("singular", "y")]) + self.assertEqual(kinds["lint"], [("plus", None)]) + self.assertEqual(kinds["_helper"], []) + + def test_a_list_assignment_is_the_shape_list_value_unwraps(self): + self.assertEqual( + forward_command.list_value(self.dump["assignments"], "explicit_verbs"), + ["test"], + ) From c91a89465e83eb8b61703a222e885a81cf163707 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:47:31 +0200 Subject: [PATCH 63/92] Just: resolve an alias once, not once per use The previous commit read the alias target where the plain recipe is looked up and left the root one reached by the unresolved name, so an alias landing on a forwarder went looking for `_root_`. That is worse than the crash it replaced. `_root_format` exists in most repository roots, so the lookup finds a real recipe rather than nothing: another directory's answer, run in earnest. Where no such recipe exists it returns None instead, which reads as a justfile that does not implement the verb at all. A verb reached by an alias is the verb, so the name is resolved once and used for both. The test that existed aliased onto a recipe that does not forward, returning before the second lookup, which is how a branch stayed covered and unexercised. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 9 +++++---- misc/just/test_forward_command.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 139f4cf44751..8870705a83b0 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -112,10 +112,11 @@ def implements(dump, command, argc): """Return the recipe a justfile runs for a command, if it has a usable one.""" recipes = dump["recipes"] # An alias dumps as an object rather than as its target, so the name has to be read - # out of it. Unreachable from the forwarder, which only ever passes whole verbs, - # until a justfile aliases something to a verb's own name. + # out of it. Resolved once and used throughout: a verb reached by an alias is the + # verb, so the justfile's own answer to it is named after the target too. alias = dump["aliases"].get(command) - recipe = recipes.get(alias["target"] if alias else command) + name = alias["target"] if alias else command + recipe = recipes.get(name) if recipe is None or recipe["private"]: return None if any( @@ -124,7 +125,7 @@ def implements(dump, command, argc): # Here the plain name is the forwarder's own, so it says nothing about what this # directory does. A justfile that both forwards and answers the command itself # spells its own answer `_root_`, the one name the two can share. - recipe = recipes.get(f"{ROOT_PREFIX}{command}") + recipe = recipes.get(f"{ROOT_PREFIX}{name}") if recipe is None: return None return recipe if accepts(recipe, argc) else None diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index 58ff4fa653a2..ff44e47c8764 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -98,6 +98,39 @@ def test_follows_an_alias(self): ) self.assertEqual(found["name"], "test") + def test_follows_an_alias_into_a_forwarding_justfile(self): + # The recipe named after the alias forwards, so the answer is the root one -- + # and that is named after the verb, which is the target rather than the alias. + # Reaching here needs the alias to be a verb's own name, the only spelling the + # forwarder ever passes. + found = forward_command.implements( + dump( + recipe("build", dependencies=[forward_command.FORWARD_RECIPE]), + recipe(f"{forward_command.ROOT_PREFIX}build"), + aliases={"format": alias("format", "build")}, + ), + "format", + 0, + ) + self.assertEqual(found["name"], f"{forward_command.ROOT_PREFIX}build") + + def test_does_not_settle_on_a_root_recipe_named_after_the_alias(self): + # `_root_format` exists in most repository roots, so looking the alias up + # unresolved finds a real recipe rather than nothing: the wrong directory's + # answer, run in earnest. The version of this without one returns None, which + # is indistinguishable from a justfile that does not implement the verb at all. + found = forward_command.implements( + dump( + recipe("build", dependencies=[forward_command.FORWARD_RECIPE]), + recipe(f"{forward_command.ROOT_PREFIX}build"), + recipe(f"{forward_command.ROOT_PREFIX}format"), + aliases={"format": alias("format", "build")}, + ), + "format", + 0, + ) + self.assertEqual(found["name"], f"{forward_command.ROOT_PREFIX}build") + def test_passes_over_a_private_recipe(self): self.assertIsNone( forward_command.implements(dump(recipe("test", private=True)), "test", 0) From d514a1b44b374b66ab6871d5766d2e597b3ea9ca Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:47:31 +0200 Subject: [PATCH 64/92] Point the Python tooling workflow's path filter at itself It listed `codegen.yml`, which does not exist here and has no other referent, so the workflow could not be triggered by editing it. Adding a step to it in an earlier commit only ran because the same change touched `misc/just`, which does match, and a filter that matches nothing looks exactly like one with nothing to match. Naming a path asserts it exists, which is what the collector in this directory enforces for the paths it is handed. Self-listing is what the other workflows here do, and what this one was evidently reaching for under a former name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/python-tooling.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tooling.yml b/.github/workflows/python-tooling.yml index 41fea23450f9..51ca49533cc3 100644 --- a/.github/workflows/python-tooling.yml +++ b/.github/workflows/python-tooling.yml @@ -8,7 +8,7 @@ on: - "misc/just/**" - "misc/scripts/models-as-data/*.py" - "*.bazel*" - - .github/workflows/codegen.yml + - .github/workflows/python-tooling.yml - .pre-commit-config.yaml branches: - main From 844da50a80aa9b8bcd69728cd46dbf372a4a5d48 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 12:51:45 +0200 Subject: [PATCH 65/92] Just: stop the shared README claiming which shape another repository uses It said an importing root is how the paired checkout gets the bazel recipe for free. That one defines its own now, so the sentence named the single repository that is no longer an example of it, and it was the only one, leaving the reader no referent. The other mentions of a paired checkout here are conditions this code tests at runtime, which keep themselves honest. A claim about how another repository is arranged is not one of those: nothing here can check it, nothing fails when it stops being true, and the side that would notice is the side that changed. So the mechanism stays documented -- both shapes are supported and the difference between them matters -- and the guess about who uses which goes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 45db0e4ee85c..4c2e69d3b74b 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -57,11 +57,10 @@ therefore reads its default `.` as the whole repository rather than the director caller is in, so one that does its own work needs `[no-cd]` itself. Being a recipe like any other, a `_root_` is inherited by a justfile importing the -one defining it, which is how the internal repository gets this one for free. It runs -once either way, as the two spellings are the same recipe. A root that defines its own -instead replaces it, and then both run, each over the files of the repository that -defines it: bazel formatting asks bazel from the root of the checkout the files belong -to, so that a repository formats its own files with its own pin. +one defining it. It runs once either way, as the two spellings are the same recipe. A +root that defines its own instead replaces it, and then both run, each over the files of +the repository that defines it: bazel formatting asks bazel from the root of the checkout +the files belong to, so that a repository formats its own files with its own pin. That last part is arranged by variables rather than by recipes. `set allow-duplicate-variables` in `defs.just` lets an importing justfile assign a variable From 241a4219e617f0e226c1788edbe0a59260e6b948 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:09:37 +0200 Subject: [PATCH 66/92] Just: pin the two shapes a consuming root can have The README promises that a root inheriting a `_root_` runs it once and a root redefining it runs both, and the dedup implementing that had no test: neither shape had ever been run, so the promise rested on the one configuration that happens to exist. Both are here now, with the case that separates them. Two roots are told apart by comparing their recipes, so a copy identical down to the comment above it is taken for the inherited one and silently loses an invocation. Say so where someone about to copy one will read it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 5 ++ misc/just/forward_command.py | 7 +-- misc/just/test_forward_command.py | 80 ++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index 4c2e69d3b74b..6a738ac79349 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -62,6 +62,11 @@ root that defines its own instead replaces it, and then both run, each over the the repository that defines it: bazel formatting asks bazel from the root of the checkout the files belong to, so that a repository formats its own files with its own pin. +Nothing in a justfile says which of the two happened, so they are told apart by comparing +the recipes. A root whose own copy is identical to the one it would otherwise inherit, +down to the comment above it, is therefore taken for the inherited one and runs once. +Copy such a recipe to start from if it helps, but leave its comment behind. + That last part is arranged by variables rather than by recipes. `set allow-duplicate-variables` in `defs.just` lets an importing justfile assign a variable defined here and have its value win, which is how a consuming root points the bazel diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 8870705a83b0..6624306f6607 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -241,9 +241,10 @@ def find_justfiles_above(command, arg): # Two repositories that each define a root recipe are not that case: the text # can match while the workspace, the tool it runs and the paths it excludes all # differ, so they have to stay apart. Nothing here says so. They are told apart - # only by the doc comment one of them happens to carry, which means dropping - # `doc` from this comparison silently discards an invocation unless a real - # discriminator arrives in the same change. + # only by whatever the two happened not to write identically, which today is a + # doc comment on one of them, so dropping a field from this comparison silently + # discards an invocation unless a real discriminator arrives in the same change. + # `TestFindJustfilesAbove` holds both shapes. if recipe is not None and recipe not in seen: seen.append(recipe) found.append((justfile, recipe)) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index ff44e47c8764..3381a12c462e 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -15,6 +15,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock import forward_command @@ -23,10 +24,11 @@ def parameter(name, kind="singular", default=None): return {"name": name, "kind": kind, "default": default} -def recipe(name, parameters=(), dependencies=(), private=False): +def recipe(name, parameters=(), dependencies=(), private=False, doc=None): return { "name": name, "private": private, + "doc": doc, "parameters": list(parameters), "dependencies": [{"recipe": dependency} for dependency in dependencies], } @@ -243,6 +245,73 @@ def test_leaves_a_path_absolute_when_the_argument_was(self): unittest.main() +class TestFindJustfilesAbove(unittest.TestCase): + """The two shapes a consuming root can have, both of which the README promises. + + A root that imports the justfile defining a `_root_` inherits it, and the verb + is then reached twice under two spellings of one recipe, which has to run once. A + root that defines its own replaces it, and both have to run, each over the files of + the repository defining it. No dump says which of the two happened, so they are told + apart by comparing the recipes themselves, and the fields that carry the difference + are whatever the two repositories happened not to write identically. + + That makes a verbatim copy of one root recipe into another indistinguishable from + inheritance, comment included, and a comment is the part of a recipe most likely to + survive the paste that produces this. + """ + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name).resolve() + self.outer = root / "justfile" + self.inner = root / "inner" / "justfile" + self.argument = root / "inner" / "below" + self.argument.mkdir(parents=True) + for justfile in (self.outer, self.inner): + justfile.write_text("") + + def implementing(self, doc=None, dependencies=()): + """A justfile that forwards `format` and answers it itself.""" + return dump( + recipe( + "format", + [parameter("ARGS", "star")], + dependencies=[forward_command.FORWARD_RECIPE], + ), + recipe( + f"{forward_command.ROOT_PREFIX}format", + [parameter("ARGS", "star")], + dependencies=dependencies, + doc=doc, + ), + ) + + def found(self, outer, inner): + dumps = {self.outer: outer, self.inner: inner} + with mock.patch.object( + forward_command, + "dump_justfile", + side_effect=lambda justfile: (dumps[Path(justfile)], None), + ): + return forward_command.find_justfiles_above("format", str(self.argument)) + + def test_a_recipe_reached_under_two_spellings_runs_once(self): + found = self.found(self.implementing(), self.implementing()) + self.assertEqual([justfile for justfile, _ in found], [self.inner]) + + def test_two_roots_differing_only_in_their_comment_both_run(self): + found = self.found(self.implementing(), self.implementing(doc="From here.")) + self.assertEqual([justfile for justfile, _ in found], [self.inner, self.outer]) + + def test_two_roots_sharing_a_comment_still_both_run_if_they_do_different_work(self): + found = self.found( + self.implementing(doc="From here.", dependencies=["_format_other"]), + self.implementing(doc="From here."), + ) + self.assertEqual([justfile for justfile, _ in found], [self.inner, self.outer]) + + JUST = shutil.which("just") # The justfile below uses every construct the fixtures above model, so that a dump of it @@ -258,6 +327,7 @@ def test_leaves_a_path_absolute_when_the_argument_was(self): test *ARGS='.': _helper echo {{ ARGS }} +# A comment above a recipe becomes its doc. build X Y='y': echo {{ X }} {{ Y }} @@ -307,6 +377,14 @@ def test_an_alias_names_its_target(self): def test_a_recipe_is_read_by_the_keys_the_fixtures_use(self): self.assertLessEqual(set(recipe("test")), set(self.dump["recipes"]["test"])) + def test_a_comment_above_a_recipe_is_the_doc_the_comparison_reads(self): + # Two root recipes doing different jobs are often told apart by this alone. + recipes = self.dump["recipes"] + self.assertEqual( + recipes["build"]["doc"], "A comment above a recipe becomes its doc." + ) + self.assertIsNone(recipes["test"]["doc"]) + def test_a_private_recipe_says_so(self): self.assertIs(self.dump["recipes"]["_helper"]["private"], True) self.assertIs(self.dump["recipes"]["test"]["private"], False) From 09b6722bb6f8f9efd945f201549f10e68186acec Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:11:59 +0200 Subject: [PATCH 67/92] Just: say that overriding a shared variable freezes it A root assigns the whole value, so an exclusion added here later never reaches one, and `just` rejects an append as self-referential. This is the reverse of the rename already documented: the override keeps applying exactly as written, and the roots that miss the addition are the ones that cared enough to redirect the setting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/misc/just/README.md b/misc/just/README.md index 6a738ac79349..c30c119420cb 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -81,7 +81,16 @@ overriding several loses only the renamed one, leaving a half-applied configurat total failure would land in a state someone designed, while partial failure lands in one nobody has ever seen. -Nothing can see it either, because the underscore that keeps these out of `just --list` +An override also freezes what it replaces. A root assigns the whole value, so an +exclusion or a name added here later never reaches one, and `just` offers no way to +append: a root writing `_bazel_excluded := _bazel_excluded + ",mine"` is told the +variable is defined in terms of itself. This runs the opposite way from a rename, where +the override stops applying and the value here wins. Here the override keeps applying +exactly as written, and the roots that never see the addition are the ones that cared +enough about the setting to redirect it. Adding to one of these values is therefore a +change to make on both sides at once. + +Neither shows up anywhere, because the underscore that keeps these out of `just --list` keeps them out of `--variables` and a bare `--evaluate` as well. Asked by name they do answer, which is how a root checks that an override of its own still overrides anything: From 8a9c28bf9a69b63b60518b04d2ad1f8c939338a3 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:14:43 +0200 Subject: [PATCH 68/92] Just: ask just for the premise the deduplication rests on Two spellings of one imported recipe are told apart from two repositories each defining their own by comparing the recipes whole, so that comparison reads every field just emits rather than the few modelled here. A release adding one that varies between justfiles would run an inherited recipe twice, and would arrive looking like the discriminator this code otherwise wants. The fixtures cannot see that: they are compared as subsets, which is right for reading a field by name and blind to a field nobody thought to model. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/test_forward_command.py | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index 3381a12c462e..babe1801d4f7 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -408,3 +408,63 @@ def test_a_list_assignment_is_the_shape_list_value_unwraps(self): forward_command.list_value(self.dump["assignments"], "explicit_verbs"), ["test"], ) + + +@unittest.skipUnless(JUST, "needs `just` on PATH") +class TestImportingDoesNotChangeARecipe(unittest.TestCase): + """Ask `just` for the premise the deduplication rests on rather than assuming it. + + A root importing the justfile that defines a `_root_` reaches one recipe under + two spellings, and it is told apart from two repositories each defining their own by + comparing the recipes whole. That comparison reads every field `just` emits, not the + few the rest of this file models, so a release adding a per-recipe field that varies + between justfiles -- a source path, a line number, anything saying where a recipe was + written -- would stop the two spellings comparing equal and run an inherited recipe + twice. + + Such a field is precisely the discriminator this code would otherwise want, so it + would arrive looking like a feature. The fixtures above cannot see any of this: they + are compared as subsets, which is right for reading a field by name and blind to a + field nobody thought to model. + """ + + def recipes(self, justfile): + dumped = subprocess.run( + [JUST, "--justfile", str(justfile), "--dump", "--dump-format", "json"], + capture_output=True, + text=True, + check=True, + ) + return json.loads(dumped.stdout)["recipes"] + + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + (root / "inner").mkdir() + self.inner = root / "inner" / "justfile" + self.inner.write_text("# Shared.\n_root_format:\n echo shared\n") + self.outer = root / "justfile" + + def test_an_imported_recipe_is_the_one_it_came_from(self): + self.outer.write_text("import 'inner/justfile'\n") + self.assertEqual( + self.recipes(self.outer)["_root_format"], + self.recipes(self.inner)["_root_format"], + ) + + def test_a_root_defining_its_own_is_not(self): + # Same body, so only the comment separates them: the narrowest the difference + # between the two shapes ever gets. + self.outer.write_text( + "set allow-duplicate-recipes\n" + "import 'inner/justfile'\n" + "\n" + "# Mine.\n" + "_root_format:\n" + " echo shared\n" + ) + self.assertNotEqual( + self.recipes(self.outer)["_root_format"], + self.recipes(self.inner)["_root_format"], + ) From a0e5edf79df75c7f3e1a92ea88ca80363d4a0a05 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:28:39 +0200 Subject: [PATCH 69/92] Just: make the sorted command line an object, not a dict passed around Filling a caller's dict in place is a translation artefact. The kinds an argument can have are fixed and known, so they are fields, and sorting into them is a method on the thing being sorted. `argparse` handles the parsing in `run_on_files.py` but cannot express this grammar: every flag not named here belongs to `codeql test run` untouched, and `--all-checks` is both a flag and an assignment, which `argparse` can only spell in a way that makes the bare form swallow the test path after it. Say so where the next reader will ask. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 121 +++++++++++++++++------------- misc/just/test_codeql_test_run.py | 65 ++++++++-------- 2 files changed, 97 insertions(+), 89 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index 5bda2d5b86eb..a54d435f61ca 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -9,6 +9,7 @@ its `+` abbreviation) turns on. """ +import dataclasses import os import re import subprocess @@ -39,33 +40,52 @@ def error(message): print(f"{ERROR}{message}", file=sys.stderr) -def parse_args(args, argv): - """Sort arguments into tests, flags and environment assignments.""" - for arg in argv: - if not arg: - # an empty argument can come from a caller interpolating an unset variable - continue - if arg.startswith(ALL_CHECKS_PREFIX): - args["all_checks"].append(arg[len(ALL_CHECKS_PREFIX) :]) - elif arg.startswith("--codeql="): - args["codeql"] = arg.split("=", 1)[1] - elif arg in ("+", "--all-checks"): - args["all"] = True - elif arg.startswith("-"): - args["flags"].append(arg) - elif ENV_RE.match(arg): - args["env"].append(arg) - else: - args["tests"].append(arg) - - -def env_value(args, name, default): - """Resolve a setting from test arguments, then the environment, then a default.""" - for assignment in reversed(args["env"]): - key, _, value = assignment.partition("=") - if key == name and value: - return value - return os.environ.get(name) or default +@dataclasses.dataclass +class Arguments: + """A command line sorted into the kinds that are handled differently. + + Sorted by hand rather than by `argparse`, which cannot express this grammar: every + flag not named here belongs to `codeql test run` and has to survive untouched, and + `--all-checks` is both a flag and an assignment. Asking `argparse` for the latter + makes a bare `--all-checks` swallow the test path after it. + """ + + codeql: str = dataclasses.field( + default_factory=lambda: "build" if SEMMLE_CODE else "host" + ) + all: bool = False + tests: list = dataclasses.field(default_factory=list) + flags: list = dataclasses.field(default_factory=list) + env: list = dataclasses.field(default_factory=list) + all_checks: list = dataclasses.field(default_factory=list) + + def parse(self, argv): + """Sort arguments into tests, flags and environment assignments.""" + for arg in argv: + if not arg: + # an empty argument can come from a caller interpolating an unset + # variable + continue + if arg.startswith(ALL_CHECKS_PREFIX): + self.all_checks.append(arg[len(ALL_CHECKS_PREFIX) :]) + elif arg.startswith("--codeql="): + self.codeql = arg.split("=", 1)[1] + elif arg in ("+", "--all-checks"): + self.all = True + elif arg.startswith("-"): + self.flags.append(arg) + elif ENV_RE.match(arg): + self.env.append(arg) + else: + self.tests.append(arg) + + def env_value(self, name, default): + """Resolve a setting from test arguments, then the environment, then a default.""" + for assignment in reversed(self.env): + key, _, value = assignment.partition("=") + if key == name and value: + return value + return os.environ.get(name) or default def main(): @@ -76,45 +96,38 @@ def main(): language, *rest = argv - args = { - "tests": [], - "flags": [], - "env": [], - "all_checks": [], - "codeql": "build" if SEMMLE_CODE else "host", - "all": False, - } - parse_args(args, rest) - if args["all"]: - parse_args(args, args["all_checks"]) - - if not SEMMLE_CODE and args["codeql"] in ("build", "built"): + args = Arguments() + args.parse(rest) + if args.all: + args.parse(args.all_checks) + + if not SEMMLE_CODE and args.codeql in ("build", "built"): error( "Using `--codeql=build` or `--codeql=built` requires working " "with the internal repository" ) return 1 - if not args["tests"]: - args["tests"].append(".") + if not args.tests: + args.tests.append(".") # Resolve these only once all arguments are known, so that a `RAM_PER_THREAD=` test # argument can lower the default on memory-heavy suites. default_ram = 3000 if sys.platform == "linux" else 2048 - ram_per_thread = int(env_value(args, "RAM_PER_THREAD", default_ram)) - cpus = int(env_value(args, "CPUS", os.cpu_count() or 1)) - args["flags"][:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] + ram_per_thread = int(args.env_value("RAM_PER_THREAD", default_ram)) + cpus = int(args.env_value("CPUS", os.cpu_count() or 1)) + args.flags[:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] - if args["codeql"] == "build": + if args.codeql == "build": if invoke([JUST, language, "build"], cwd=SEMMLE_CODE) != 0: return 1 - if args["codeql"] != "host": + if args.codeql != "host": # Disable the default implicit config file, but keep an explicit one. # Same behavior wrt --codeql as the integration test runner. os.environ.setdefault("CODEQL_CONFIG_FILE", ".") - for env_var in args["env"]: + for env_var in args.env: key, _, value = env_var.partition("=") if not key: error(f"Invalid environment variable assignment: {env_var}") @@ -122,12 +135,12 @@ def main(): os.environ[key] = value # Resolve codeql executable - if args["codeql"] in ("built", "build"): + if args.codeql in ("built", "build"): codeql = Path(SEMMLE_CODE, "target", "intree", f"codeql-{language}", "codeql") - elif args["codeql"] == "host": + elif args.codeql == "host": codeql = Path("codeql") else: - codeql = Path(args["codeql"]) + codeql = Path(args.codeql) if codeql.is_dir(): codeql = codeql / "codeql" @@ -138,13 +151,13 @@ def main(): if exe.exists(): codeql = exe - if args["codeql"] != "host" and not codeql.exists(): + if args.codeql != "host" and not codeql.exists(): error(f"CodeQL executable not found: {codeql}") return 1 return invoke( - [str(codeql), "test", "run", *args["flags"], "--", *args["tests"]], - log_prefix=" ".join(args["env"]), + [str(codeql), "test", "run", *args.flags, "--", *args.tests], + log_prefix=" ".join(args.env), ) diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index 28e8cfffd8bc..ca6acc678cfd 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -15,60 +15,55 @@ def empty_args(): - """What `main` builds before sorting, limited to what `parse_args` fills in.""" - return { - "tests": [], - "flags": [], - "env": [], - "all_checks": [], - "codeql": "host", - "all": False, - } + """What `main` builds before sorting anything into it.""" + return codeql_test_run.Arguments(codeql="host") def sorted_args(*argv): args = empty_args() - codeql_test_run.parse_args(args, list(argv)) + args.parse(list(argv)) return args class TestParseArgs(unittest.TestCase): def test_a_plain_word_is_a_test(self): - self.assertEqual(sorted_args("ql/test/Foo")["tests"], ["ql/test/Foo"]) + self.assertEqual(sorted_args("ql/test/Foo").tests, ["ql/test/Foo"]) def test_a_dash_is_a_flag(self): - self.assertEqual(sorted_args("--fail-on-trap-errors")["flags"], ["--fail-on-trap-errors"]) + self.assertEqual( + sorted_args("--fail-on-trap-errors").flags, ["--fail-on-trap-errors"] + ) def test_an_uppercase_assignment_is_an_environment_variable(self): - self.assertEqual(sorted_args("CPUS=4")["env"], ["CPUS=4"]) + self.assertEqual(sorted_args("CPUS=4").env, ["CPUS=4"]) def test_a_lowercase_assignment_is_a_test(self): # Only shouting counts, so a path that happens to contain `=` stays a path. - self.assertEqual(sorted_args("dir/a=b")["tests"], ["dir/a=b"]) + self.assertEqual(sorted_args("dir/a=b").tests, ["dir/a=b"]) def test_codeql_selects_the_executable(self): - self.assertEqual(sorted_args("--codeql=built")["codeql"], "built") + self.assertEqual(sorted_args("--codeql=built").codeql, "built") def test_the_last_codeql_wins(self): - self.assertEqual(sorted_args("--codeql=host", "--codeql=built")["codeql"], "built") + self.assertEqual(sorted_args("--codeql=host", "--codeql=built").codeql, "built") def test_all_checks_is_asked_for_by_either_spelling(self): - self.assertTrue(sorted_args("--all-checks")["all"]) - self.assertTrue(sorted_args("+")["all"]) + self.assertTrue(sorted_args("--all-checks").all) + self.assertTrue(sorted_args("+").all) def test_an_extra_check_is_held_back_until_it_is_asked_for(self): held = sorted_args("--all-checks=--check-databases") - self.assertEqual(held["all_checks"], ["--check-databases"]) + self.assertEqual(held.all_checks, ["--check-databases"]) # Held back means held back: it is not a flag until `--all-checks` arrives. - self.assertEqual(held["flags"], []) - self.assertFalse(held["all"]) + self.assertEqual(held.flags, []) + self.assertFalse(held.all) def test_an_empty_argument_is_ignored(self): # One of these comes of a caller interpolating a variable that was never set. - self.assertEqual(sorted_args("", "test")["tests"], ["test"]) + self.assertEqual(sorted_args("", "test").tests, ["test"]) def test_a_test_path_containing_a_space_stays_one_argument(self): - self.assertEqual(sorted_args("some dir/test")["tests"], ["some dir/test"]) + self.assertEqual(sorted_args("some dir/test").tests, ["some dir/test"]) def test_an_assignment_whose_value_contains_a_space_stays_whole(self): """The value is the point, and a split one used to arrive as three characters. @@ -76,43 +71,43 @@ def test_an_assignment_whose_value_contains_a_space_stays_whole(self): An argument list carries this; a whitespace-separated string cannot, as there is nothing left in it to tell a separator from part of a value. """ - self.assertEqual(sorted_args("EXTRA=a b")["env"], ["EXTRA=a b"]) + self.assertEqual(sorted_args("EXTRA=a b").env, ["EXTRA=a b"]) def test_sorts_a_whole_command_line_at_once(self): args = sorted_args("-j2", "CPUS=4", "ql/test", "+", "--all-checks=--check-diff") - self.assertEqual(args["flags"], ["-j2"]) - self.assertEqual(args["env"], ["CPUS=4"]) - self.assertEqual(args["tests"], ["ql/test"]) - self.assertEqual(args["all_checks"], ["--check-diff"]) - self.assertTrue(args["all"]) + self.assertEqual(args.flags, ["-j2"]) + self.assertEqual(args.env, ["CPUS=4"]) + self.assertEqual(args.tests, ["ql/test"]) + self.assertEqual(args.all_checks, ["--check-diff"]) + self.assertTrue(args.all) class TestEnvValue(unittest.TestCase): def test_prefers_a_test_argument(self): args = sorted_args("CPUS=4") with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "4") + self.assertEqual(args.env_value("CPUS", "1"), "4") def test_falls_back_to_the_environment(self): with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "8") + self.assertEqual(empty_args().env_value("CPUS", "1"), "8") def test_falls_back_to_the_default(self): with mock.patch.dict(os.environ, {}, clear=True): - self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "1") + self.assertEqual(empty_args().env_value("CPUS", "1"), "1") def test_the_last_assignment_wins(self): args = sorted_args("CPUS=4", "CPUS=2") - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "2") + self.assertEqual(args.env_value("CPUS", "1"), "2") def test_an_empty_value_does_not_count_as_a_setting(self): args = sorted_args("CPUS=") with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "8") + self.assertEqual(args.env_value("CPUS", "1"), "8") def test_a_value_containing_a_space_survives(self): args = sorted_args("EXTRA=a b") - self.assertEqual(codeql_test_run.env_value(args, "EXTRA", "none"), "a b") + self.assertEqual(args.env_value("EXTRA", "none"), "a b") if __name__ == "__main__": From 4b2718fc9697c96c69a61bd2629a78af3fe2af49 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:35:34 +0200 Subject: [PATCH 70/92] Just: give the offered checks their own option name `--all-checks=FLAG` and `--all-checks` were one name doing two jobs. A per-language justfile uses the first to offer a check; a caller uses the second to ask for the offered ones. Spelling the offer `--extra-check=` makes each name mean one thing, and makes the order of the two tests in the parser stop mattering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/ql/test/justfile | 2 +- cpp/ql/test/justfile | 2 +- csharp/ql/test/justfile | 2 +- go/ql/test/justfile | 2 +- java/ql/test-kotlin1/justfile | 2 +- java/ql/test-kotlin2/justfile | 2 +- java/ql/test/justfile | 2 +- javascript/ql/test/justfile | 2 +- misc/just/codeql_test_run.py | 24 +++++++++++++----------- misc/just/lib.just | 6 +++--- misc/just/test_codeql_test_run.py | 8 ++++---- python/ql/test/justfile | 2 +- ruby/ql/test/justfile | 2 +- rust/ql/test/justfile | 2 +- swift/ql/test/justfile | 2 +- unified/ql/test/justfile | 2 +- 16 files changed, 33 insertions(+), 31 deletions(-) diff --git a/actions/ql/test/justfile b/actions/ql/test/justfile index a824f3029972..0c6841ebb405 100644 --- a/actions/ql/test/justfile +++ b/actions/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks [no-cd] -test *ARGS=".": (_codeql_test "actions" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "actions" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/cpp/ql/test/justfile b/cpp/ql/test/justfile index 7ccd81541018..bc32c4f8d970 100644 --- a/cpp/ql/test/justfile +++ b/cpp/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := ['--include-location-in-star'] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "cpp" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "cpp" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/csharp/ql/test/justfile b/csharp/ql/test/justfile index ba3e238580c5..5c25af2e049e 100644 --- a/csharp/ql/test/justfile +++ b/csharp/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--additional-packs=ql', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "csharp" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "csharp" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/go/ql/test/justfile b/go/ql/test/justfile index e4f9665c1773..c79e9a543f55 100644 --- a/go/ql/test/justfile +++ b/go/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "go" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "go" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/java/ql/test-kotlin1/justfile b/java/ql/test-kotlin1/justfile index a9815627d15e..4edd755f19fe 100644 --- a/java/ql/test-kotlin1/justfile +++ b/java/ql/test-kotlin1/justfile @@ -10,4 +10,4 @@ base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT='] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/java/ql/test-kotlin2/justfile b/java/ql/test-kotlin2/justfile index bda00ff0ca75..d3609681ad27 100644 --- a/java/ql/test-kotlin2/justfile +++ b/java/ql/test-kotlin2/justfile @@ -10,4 +10,4 @@ base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT=', 'CODEQL_KOTLIN_LEGAC all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/java/ql/test/justfile b/java/ql/test/justfile index aedf78381a05..f2b6774fa41c 100644 --- a/java/ql/test/justfile +++ b/java/ql/test/justfile @@ -9,4 +9,4 @@ base_flags := ['CODEQL_EXTRACTOR_KOTLIN_DIAGNOSTIC_LIMIT='] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "java" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/javascript/ql/test/justfile b/javascript/ql/test/justfile index 366b4e1e43dd..37ea1df3c257 100644 --- a/javascript/ql/test/justfile +++ b/javascript/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks [no-cd] -test *ARGS=".": (_codeql_test "javascript" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "javascript" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index a54d435f61ca..63ea94ec49c1 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -5,8 +5,9 @@ python3 codeql_test_run.py LANGUAGE [ARG...] Arguments are already split by `just` (see `set lists`), so each one is taken verbatim. -`--all-checks=FLAG` contributes FLAG to the set of extra checks that `--all-checks` (or -its `+` abbreviation) turns on. +`--extra-check=FLAG` offers FLAG as a check to run, and `--all-checks` (or its `+` +abbreviation) turns the offered ones on. Per-language justfiles supply the offers and +the caller supplies the switch, so the two are separate options rather than one. """ import dataclasses @@ -22,7 +23,7 @@ CMD_END = os.environ.get("CMD_END", "") SEMMLE_CODE = os.environ.get("SEMMLE_CODE") -ALL_CHECKS_PREFIX = "--all-checks=" +EXTRA_CHECK_PREFIX = "--extra-check=" ENV_RE = re.compile(r"^[A-Z_][A-Z_0-9]*=.*$") @@ -44,10 +45,11 @@ def error(message): class Arguments: """A command line sorted into the kinds that are handled differently. - Sorted by hand rather than by `argparse`, which cannot express this grammar: every - flag not named here belongs to `codeql test run` and has to survive untouched, and - `--all-checks` is both a flag and an assignment. Asking `argparse` for the latter - makes a bare `--all-checks` swallow the test path after it. + Sorted by hand rather than by `argparse`, which could own the three options named + below but none of the rest: every flag not named here belongs to `codeql test run` + and has to survive untouched, `+` is not a spelling `argparse` has, and `CPUS=4` and + `ql/test` are both positionals told apart only by shape. Handing it the half it can + take would leave this loop in place for the other half. """ codeql: str = dataclasses.field( @@ -57,7 +59,7 @@ class Arguments: tests: list = dataclasses.field(default_factory=list) flags: list = dataclasses.field(default_factory=list) env: list = dataclasses.field(default_factory=list) - all_checks: list = dataclasses.field(default_factory=list) + extra_checks: list = dataclasses.field(default_factory=list) def parse(self, argv): """Sort arguments into tests, flags and environment assignments.""" @@ -66,8 +68,8 @@ def parse(self, argv): # an empty argument can come from a caller interpolating an unset # variable continue - if arg.startswith(ALL_CHECKS_PREFIX): - self.all_checks.append(arg[len(ALL_CHECKS_PREFIX) :]) + if arg.startswith(EXTRA_CHECK_PREFIX): + self.extra_checks.append(arg[len(EXTRA_CHECK_PREFIX) :]) elif arg.startswith("--codeql="): self.codeql = arg.split("=", 1)[1] elif arg in ("+", "--all-checks"): @@ -99,7 +101,7 @@ def main(): args = Arguments() args.parse(rest) if args.all: - args.parse(args.all_checks) + args.parse(args.extra_checks) if not SEMMLE_CODE and args.codeql in ("build", "built"): error( diff --git a/misc/just/lib.just b/misc/just/lib.just index 6a254b4e99b8..84389e84b1d3 100644 --- a/misc/just/lib.just +++ b/misc/just/lib.just @@ -5,9 +5,9 @@ import "format.just" # Run language tests for LANGUAGE. # -# Arguments tagged with `--all-checks=` are held back and only applied when `--all-checks` -# or `+` is passed along, which is how per-language justfiles express the extra checks CI -# runs on top of the default ones. +# `--extra-check=` offers a check without running it, and `--all-checks` or `+` runs the +# offered ones. Per-language justfiles supply the offers, which is how the extra checks +# CI runs stay next to the language they belong to. [no-cd] [no-exit-message] [positional-arguments] diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index ca6acc678cfd..b4d51907ae5b 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -52,8 +52,8 @@ def test_all_checks_is_asked_for_by_either_spelling(self): self.assertTrue(sorted_args("+").all) def test_an_extra_check_is_held_back_until_it_is_asked_for(self): - held = sorted_args("--all-checks=--check-databases") - self.assertEqual(held.all_checks, ["--check-databases"]) + held = sorted_args("--extra-check=--check-databases") + self.assertEqual(held.extra_checks, ["--check-databases"]) # Held back means held back: it is not a flag until `--all-checks` arrives. self.assertEqual(held.flags, []) self.assertFalse(held.all) @@ -74,11 +74,11 @@ def test_an_assignment_whose_value_contains_a_space_stays_whole(self): self.assertEqual(sorted_args("EXTRA=a b").env, ["EXTRA=a b"]) def test_sorts_a_whole_command_line_at_once(self): - args = sorted_args("-j2", "CPUS=4", "ql/test", "+", "--all-checks=--check-diff") + args = sorted_args("-j2", "CPUS=4", "ql/test", "+", "--extra-check=--check-diff") self.assertEqual(args.flags, ["-j2"]) self.assertEqual(args.env, ["CPUS=4"]) self.assertEqual(args.tests, ["ql/test"]) - self.assertEqual(args.all_checks, ["--check-diff"]) + self.assertEqual(args.extra_checks, ["--check-diff"]) self.assertTrue(args.all) diff --git a/python/ql/test/justfile b/python/ql/test/justfile index 0f44a489e82f..c02c78ba8241 100644 --- a/python/ql/test/justfile +++ b/python/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := _python_env all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "python" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "python" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/ruby/ql/test/justfile b/ruby/ql/test/justfile index 9673cebe0370..ab79be8d890e 100644 --- a/ruby/ql/test/justfile +++ b/ruby/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--check-undefined-labels', '--check-unused-labels', '--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "ruby" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "ruby" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/rust/ql/test/justfile b/rust/ql/test/justfile index 8d5d6c05da2d..8b6133008e8a 100644 --- a/rust/ql/test/justfile +++ b/rust/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "rust" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "rust" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/swift/ql/test/justfile b/swift/ql/test/justfile index 6f15ac6d0723..f396305ed282 100644 --- a/swift/ql/test/justfile +++ b/swift/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "swift" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "swift" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) diff --git a/unified/ql/test/justfile b/unified/ql/test/justfile index 1ca509465bd8..4a097e0b1d65 100644 --- a/unified/ql/test/justfile +++ b/unified/ql/test/justfile @@ -8,4 +8,4 @@ base_flags := [] all_checks := default_db_checks ++ ['--check-repeated-labels', '--check-redefined-labels', '--check-use-before-definition', '--consistency-queries=' + consistency_queries] [no-cd] -test *ARGS=".": (_codeql_test "unified" (base_flags ++ prepend('--all-checks=', all_checks) ++ ARGS)) +test *ARGS=".": (_codeql_test "unified" (base_flags ++ prepend('--extra-check=', all_checks) ++ ARGS)) From 88ab62be17ff535333851a67eae0e16f831806c2 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:41:15 +0200 Subject: [PATCH 71/92] Just: let argparse own the options this script acts on Hand-sorting every argument was justified while `--all-checks` was both a flag and an assignment, which argparse cannot express. Naming the offer separately removed that, and the loop was then keeping three silent mistakes alive: `--codeql built` made `built` a test path, and a bare `--codeql` or `--extra-check` was forwarded to `codeql test run` to fail there instead of here. argparse takes those three options; the shape test stays for the rest, which belongs to `codeql test run` and is forwarded untouched. Errors are routed back through `error` so they still arrive in a just banner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 60 +++++++++++++++++++++---------- misc/just/test_codeql_test_run.py | 10 +++++- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index 63ea94ec49c1..dd16f3f3a111 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -10,6 +10,7 @@ the caller supplies the switch, so the two are separate options rather than one. """ +import argparse import dataclasses import os import re @@ -23,7 +24,6 @@ CMD_END = os.environ.get("CMD_END", "") SEMMLE_CODE = os.environ.get("SEMMLE_CODE") -EXTRA_CHECK_PREFIX = "--extra-check=" ENV_RE = re.compile(r"^[A-Z_][A-Z_0-9]*=.*$") @@ -41,15 +41,37 @@ def error(message): print(f"{ERROR}{message}", file=sys.stderr) +class _Parser(argparse.ArgumentParser): + """An `argparse` parser that fails the way the rest of this script does. + + The default reports to `stderr` in its own format and exits 2, which would arrive + in a `just` banner unprefixed and alongside a usage line naming this script rather + than the recipe the caller actually typed. + """ + + def error(self, message): + error(message) + raise SystemExit(1) + + +def build_parser(): + # `+` can be an option string only because it is also a prefix character. `-h` and + # `--help` are left unclaimed so that they reach `codeql test run`. + parser = _Parser(add_help=False, allow_abbrev=False, prefix_chars="-+") + parser.add_argument("--codeql") + parser.add_argument("--extra-check", action="append", dest="extra_checks") + parser.add_argument("--all-checks", "+", action="store_true", dest="all") + return parser + + @dataclasses.dataclass class Arguments: """A command line sorted into the kinds that are handled differently. - Sorted by hand rather than by `argparse`, which could own the three options named - below but none of the rest: every flag not named here belongs to `codeql test run` - and has to survive untouched, `+` is not a spelling `argparse` has, and `CPUS=4` and - `ql/test` are both positionals told apart only by shape. Handing it the half it can - take would leave this loop in place for the other half. + `argparse` owns the three options this script acts on itself. Everything else + belongs to `codeql test run` and has to survive untouched, which is what + `parse_known_args` hands back, and what is sorted by shape below: a test path and + a `CPUS=4` are both positionals, told apart only by how they look. """ codeql: str = dataclasses.field( @@ -62,19 +84,19 @@ class Arguments: extra_checks: list = dataclasses.field(default_factory=list) def parse(self, argv): - """Sort arguments into tests, flags and environment assignments.""" - for arg in argv: - if not arg: - # an empty argument can come from a caller interpolating an unset - # variable - continue - if arg.startswith(EXTRA_CHECK_PREFIX): - self.extra_checks.append(arg[len(EXTRA_CHECK_PREFIX) :]) - elif arg.startswith("--codeql="): - self.codeql = arg.split("=", 1)[1] - elif arg in ("+", "--all-checks"): - self.all = True - elif arg.startswith("-"): + """Sort arguments into tests, flags and environment assignments. + + Additive, because `main` parses a second time to apply the checks held back + until `--all-checks` asked for them. + """ + # An empty argument can come from a caller interpolating an unset variable. + known, rest = build_parser().parse_known_args([arg for arg in argv if arg]) + if known.codeql: + self.codeql = known.codeql + self.all = self.all or known.all + self.extra_checks += known.extra_checks or [] + for arg in rest: + if arg.startswith("-"): self.flags.append(arg) elif ENV_RE.match(arg): self.env.append(arg) diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index b4d51907ae5b..2f0c10c788e1 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -58,6 +58,12 @@ def test_an_extra_check_is_held_back_until_it_is_asked_for(self): self.assertEqual(held.flags, []) self.assertFalse(held.all) + def test_a_double_dash_hands_everything_after_it_to_codeql(self): + # Standard `--`: past it, an option is the caller's business and not ours. + args = sorted_args("--", "--codeql=built") + self.assertEqual(args.codeql, "host") + self.assertIn("--codeql=built", args.flags) + def test_an_empty_argument_is_ignored(self): # One of these comes of a caller interpolating a variable that was never set. self.assertEqual(sorted_args("", "test").tests, ["test"]) @@ -74,7 +80,9 @@ def test_an_assignment_whose_value_contains_a_space_stays_whole(self): self.assertEqual(sorted_args("EXTRA=a b").env, ["EXTRA=a b"]) def test_sorts_a_whole_command_line_at_once(self): - args = sorted_args("-j2", "CPUS=4", "ql/test", "+", "--extra-check=--check-diff") + args = sorted_args( + "-j2", "CPUS=4", "ql/test", "+", "--extra-check=--check-diff" + ) self.assertEqual(args.flags, ["-j2"]) self.assertEqual(args.env, ["CPUS=4"]) self.assertEqual(args.tests, ["ql/test"]) From 1f1bd46839f23666039d1eaf205d6570fdb79fc1 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:43:40 +0200 Subject: [PATCH 72/92] Just: correct what the two evaluate lines can be read for The paragraph above tells a root that adding to a shared value is a change to make on both sides, and then the diagnostic beside it claimed it could not see such a change. It can: read as two values rather than as two names, the pair shows the pattern that never arrived. The reason to say so is that the two lines differ in both directions, since overriding usually means adding, and only one of the two differences is the bug. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index c30c119420cb..a90213544cf1 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -99,10 +99,12 @@ just --evaluate _bazel_excluded # what mine is now just --justfile /justfile --evaluate _bazel_excluded # what it would be ``` -A name that has gone says so rather than reporting an empty value. That is a diagnostic -to reach for once something looks wrong, though: it answers whether a name still exists, -not whether its meaning has changed, so it passes happily when the value here gains or -loses a pattern. Rename freely, but say so when handing the change over. +A name that has gone says so rather than reporting an empty value. Read as two values +rather than as two names, the same pair also shows a freeze: a pattern appearing only +under what it would be is one this repository added and the override never received. +Expect differences both ways, since a root that overrode a value usually added something +of its own, and only the missing half is a bug. Rename freely, but say so when handing +the change over. A directory that only makes sense when named explicitly can opt out of being found from above: From 5f59009a5795975dcd3916d62f803245075ae84e Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:47:13 +0200 Subject: [PATCH 73/92] Just: keep the parsed command line in argparse's own namespace The dataclass existed to merge two parses by hand, which was only needed because the offered checks were applied in a second pass. Parsing the original line again with them appended reaches the same place, so the merging goes, and with it the reason to have a class at all. `LANGUAGE` joins the parser as the positional it always was, which also retires the hand-written usage check: a missing one is now reported the same way as every other bad argument. `--` is consumed rather than forwarded as a result, so `codeql test run` stops receiving a stray separator ahead of ours. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 97 ++++++++++++------------------- misc/just/test_codeql_test_run.py | 26 +++++---- 2 files changed, 52 insertions(+), 71 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index dd16f3f3a111..67b54576e56a 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -11,7 +11,6 @@ """ import argparse -import dataclasses import os import re import subprocess @@ -58,72 +57,50 @@ def build_parser(): # `+` can be an option string only because it is also a prefix character. `-h` and # `--help` are left unclaimed so that they reach `codeql test run`. parser = _Parser(add_help=False, allow_abbrev=False, prefix_chars="-+") - parser.add_argument("--codeql") + parser.add_argument("language") + parser.add_argument("--codeql", default="build" if SEMMLE_CODE else "host") parser.add_argument("--extra-check", action="append", dest="extra_checks") parser.add_argument("--all-checks", "+", action="store_true", dest="all") + parser.set_defaults(extra_checks=[], tests=[], flags=[], env=[]) return parser -@dataclasses.dataclass -class Arguments: - """A command line sorted into the kinds that are handled differently. +def parse_arguments(argv): + """Sort a command line into the kinds that are handled differently. - `argparse` owns the three options this script acts on itself. Everything else - belongs to `codeql test run` and has to survive untouched, which is what - `parse_known_args` hands back, and what is sorted by shape below: a test path and - a `CPUS=4` are both positionals, told apart only by how they look. + `argparse` owns the options this script acts on itself. Everything else belongs to + `codeql test run` and has to survive untouched, which is what `parse_known_args` + hands back, and what is sorted by shape here: a test path and a `CPUS=4` are both + positionals, told apart only by how they look. """ - - codeql: str = dataclasses.field( - default_factory=lambda: "build" if SEMMLE_CODE else "host" - ) - all: bool = False - tests: list = dataclasses.field(default_factory=list) - flags: list = dataclasses.field(default_factory=list) - env: list = dataclasses.field(default_factory=list) - extra_checks: list = dataclasses.field(default_factory=list) - - def parse(self, argv): - """Sort arguments into tests, flags and environment assignments. - - Additive, because `main` parses a second time to apply the checks held back - until `--all-checks` asked for them. - """ - # An empty argument can come from a caller interpolating an unset variable. - known, rest = build_parser().parse_known_args([arg for arg in argv if arg]) - if known.codeql: - self.codeql = known.codeql - self.all = self.all or known.all - self.extra_checks += known.extra_checks or [] - for arg in rest: - if arg.startswith("-"): - self.flags.append(arg) - elif ENV_RE.match(arg): - self.env.append(arg) - else: - self.tests.append(arg) - - def env_value(self, name, default): - """Resolve a setting from test arguments, then the environment, then a default.""" - for assignment in reversed(self.env): - key, _, value = assignment.partition("=") - if key == name and value: - return value - return os.environ.get(name) or default + # An empty argument can come from a caller interpolating an unset variable. + args, rest = build_parser().parse_known_args([arg for arg in argv if arg]) + for arg in rest: + if arg.startswith("-"): + args.flags.append(arg) + elif ENV_RE.match(arg): + args.env.append(arg) + else: + args.tests.append(arg) + return args + + +def env_value(args, name, default): + """Resolve a setting from test arguments, then the environment, then a default.""" + for assignment in reversed(args.env): + key, _, value = assignment.partition("=") + if key == name and value: + return value + return os.environ.get(name) or default def main(): argv = sys.argv[1:] - if not argv: - error("Usage: codeql_test_run.py LANGUAGE [ARG...]") - return 1 - - language, *rest = argv - - args = Arguments() - args.parse(rest) + args = parse_arguments(argv) if args.all: - args.parse(args.extra_checks) + # Apply what the language offered by parsing it alongside everything else, so an + # offered check lands exactly where the same flag typed by hand would. + args = parse_arguments(argv + args.extra_checks) if not SEMMLE_CODE and args.codeql in ("build", "built"): error( @@ -138,12 +115,12 @@ def main(): # Resolve these only once all arguments are known, so that a `RAM_PER_THREAD=` test # argument can lower the default on memory-heavy suites. default_ram = 3000 if sys.platform == "linux" else 2048 - ram_per_thread = int(args.env_value("RAM_PER_THREAD", default_ram)) - cpus = int(args.env_value("CPUS", os.cpu_count() or 1)) + ram_per_thread = int(env_value(args, "RAM_PER_THREAD", default_ram)) + cpus = int(env_value(args, "CPUS", os.cpu_count() or 1)) args.flags[:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] if args.codeql == "build": - if invoke([JUST, language, "build"], cwd=SEMMLE_CODE) != 0: + if invoke([JUST, args.language, "build"], cwd=SEMMLE_CODE) != 0: return 1 if args.codeql != "host": @@ -160,7 +137,9 @@ def main(): # Resolve codeql executable if args.codeql in ("built", "build"): - codeql = Path(SEMMLE_CODE, "target", "intree", f"codeql-{language}", "codeql") + codeql = Path( + SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" + ) elif args.codeql == "host": codeql = Path("codeql") else: diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index 2f0c10c788e1..f1ee5ae527fa 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -15,14 +15,14 @@ def empty_args(): - """What `main` builds before sorting anything into it.""" - return codeql_test_run.Arguments(codeql="host") + """A command line carrying nothing but the language `main` reads off the front.""" + return sorted_args() def sorted_args(*argv): - args = empty_args() - args.parse(list(argv)) - return args + """Sort a command line, supplying the language that always precedes it.""" + with mock.patch.object(codeql_test_run, "SEMMLE_CODE", None): + return codeql_test_run.parse_arguments(["alanguage", *argv]) class TestParseArgs(unittest.TestCase): @@ -59,10 +59,12 @@ def test_an_extra_check_is_held_back_until_it_is_asked_for(self): self.assertFalse(held.all) def test_a_double_dash_hands_everything_after_it_to_codeql(self): - # Standard `--`: past it, an option is the caller's business and not ours. + # Standard `--`: past it, an option is the caller's business and not ours. The + # separator itself is ours, though, so it is not passed on as well. args = sorted_args("--", "--codeql=built") self.assertEqual(args.codeql, "host") self.assertIn("--codeql=built", args.flags) + self.assertNotIn("--", args.flags) def test_an_empty_argument_is_ignored(self): # One of these comes of a caller interpolating a variable that was never set. @@ -94,28 +96,28 @@ class TestEnvValue(unittest.TestCase): def test_prefers_a_test_argument(self): args = sorted_args("CPUS=4") with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(args.env_value("CPUS", "1"), "4") + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "4") def test_falls_back_to_the_environment(self): with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(empty_args().env_value("CPUS", "1"), "8") + self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "8") def test_falls_back_to_the_default(self): with mock.patch.dict(os.environ, {}, clear=True): - self.assertEqual(empty_args().env_value("CPUS", "1"), "1") + self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "1") def test_the_last_assignment_wins(self): args = sorted_args("CPUS=4", "CPUS=2") - self.assertEqual(args.env_value("CPUS", "1"), "2") + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "2") def test_an_empty_value_does_not_count_as_a_setting(self): args = sorted_args("CPUS=") with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(args.env_value("CPUS", "1"), "8") + self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "8") def test_a_value_containing_a_space_survives(self): args = sorted_args("EXTRA=a b") - self.assertEqual(args.env_value("EXTRA", "none"), "a b") + self.assertEqual(codeql_test_run.env_value(args, "EXTRA", "none"), "a b") if __name__ == "__main__": From ee6fcd4966d7d420bf81e873087c27520b8b8db8 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:47:47 +0200 Subject: [PATCH 74/92] Just: name the separator variables after what they hold `_rule` reads as a policy in a file that has several, and the value is a line of `#`. `JUST_CMD_RULE` keeps its name, being the documented way to preset one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/defs.just | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/misc/just/defs.just b/misc/just/defs.just index dc5baf044f0c..f9741d58b2e9 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -38,17 +38,17 @@ error := f'{{ style("error") }}error{{ NORMAL }}: ' # verb would otherwise re-measure in every child it spawns, and `if` is lazy in its # branches. Inheritance needs a process, so a `mod` measures for itself; presetting # `JUST_CMD_RULE` skips measuring entirely. -_given_rule := env('JUST_CMD_RULE', '') +_given_horizontal_rule := env('JUST_CMD_RULE', '') -_rule := if _given_rule == '' { shell(''' +_horizontal_rule := if _given_horizontal_rule == '' { shell(''' w=$(stty size 2>/dev/null | cut -d" " -f2) case "$w" in '' | *[!0-9]*) w=57 ;; esac printf "%*s" $w "" | tr " " '#' -''') } else { _given_rule } +''') } else { _given_horizontal_rule } -export JUST_CMD_RULE := _rule +export JUST_CMD_RULE := _horizontal_rule -cmd_sep := "\n" + _rule + "\n" +cmd_sep := "\n" + _horizontal_rule + "\n" export CMD_BEGIN := style("command") + cmd_sep export CMD_END := cmd_sep + NORMAL export JUST_ERROR := error From c02acdcac3d17077c86d505ed61f2a4ca4d93689 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:50:34 +0200 Subject: [PATCH 75/92] Just: name the field that looks like the missing discriminator `namepath` is what anyone hunting one would try first, and it is a module path, so two top-level recipes read alike and it says nothing about where either was written. Worth naming so the next reader stops there rather than checking. Also records that no repository would notice this failure: defining a recipe of one's own only moves it further from the imported one, so the shape that would start running twice is the one this test builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/test_forward_command.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index babe1801d4f7..e74b063ec237 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -423,9 +423,13 @@ class TestImportingDoesNotChangeARecipe(unittest.TestCase): twice. Such a field is precisely the discriminator this code would otherwise want, so it - would arrive looking like a feature. The fixtures above cannot see any of this: they - are compared as subsets, which is right for reading a field by name and blind to a - field nobody thought to model. + would arrive looking like a feature. `namepath`, the closest thing to one today, is + a module path rather than a file one, and so reads identically for two top-level + recipes. The fixtures above cannot see any of this: they are compared as subsets, + which is right for reading a field by name and blind to a field nobody thought to + model. Nor would a repository notice, since defining a recipe of one's own only + moves it further from the imported one: the shape that would start running twice is + the one built here and kept nowhere else. """ def recipes(self, justfile): From 7f26a0fdbf4c1fa23a77009bcadfeb0967a34be5 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:52:48 +0200 Subject: [PATCH 76/92] Just: say why these two imports are in this order The first definition of a variable wins, so the optional internal file has to come before the stub that stands in for it. Reversed, the stub wins even where the real file exists and that checkout quietly behaves as if it were external. Worth a comment rather than trusting the reader's intuition, which says last wins, and because this repository cannot catch the mistake: the file is never present here, so both orders evaluate identically. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/defs.just | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/misc/just/defs.just b/misc/just/defs.just index f9741d58b2e9..bb360d6a687a 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -1,3 +1,8 @@ +# The first definition of a variable wins, not the last, so this order is what lets the +# internal file set `SEMMLE_CODE` while the stub is the fallback when it is absent. +# Swapped, the stub wins even where the real file exists and that checkout then behaves +# as if it were external. Nothing here can catch that: with the file absent, as it +# always is in this repository, both orders evaluate the same. import? '../../../semmle-code.just' # internal repo just file, if present import 'semmle-code-stub.just' From 92af6838f4dbd64be88a7ef24994e9e1ae919a9e Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 13:58:40 +0200 Subject: [PATCH 77/92] Just: drop a check the pattern above already makes An assignment only reaches this loop by matching `ENV_RE`, which requires a name before the `=`, so the key can never come back empty and the branch could not run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index 67b54576e56a..f7b8b274c421 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -130,9 +130,6 @@ def main(): for env_var in args.env: key, _, value = env_var.partition("=") - if not key: - error(f"Invalid environment variable assignment: {env_var}") - return 1 os.environ[key] = value # Resolve codeql executable From ca7cece421d8aa9d907757981cf8815b90dc3ac3 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:06:11 +0200 Subject: [PATCH 78/92] Just: state the version these recipes need `set lists` is what makes argument forwarding work and it did not exist before 1.58, but nothing here said so. The error an older `just` gives is clear and points at the line, yet names no version to move to, which is the gap worth closing in prose rather than with a check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/misc/just/README.md b/misc/just/README.md index a90213544cf1..454cc3ae3ba9 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -4,6 +4,11 @@ have common verbs (`build`, `test`, `format`, `lint`, `generate`) that individua of the project can implement, and some common functionality that can be used to that effect. +`just` 1.58 or newer is required: recipes forward argument lists using `set lists`, which +is still unstable and did not exist before then. An older one stops with an +`Unknown setting` error pointing at that line, which is clear enough but does not say +which version to move to. + # Forwarding The core of the functionality is given by forwarding. The idea is that: From 56ad762fc581bcbb96d3d2fd3f7ebefa2ffe07b5 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:24:25 +0200 Subject: [PATCH 79/92] refactor: clean up `codeql_test_run.py` --- misc/just/codeql_test_run.py | 89 +++++++++++++++++------------------- 1 file changed, 41 insertions(+), 48 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index f7b8b274c421..c90107549e20 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -15,6 +15,7 @@ import re import subprocess import sys +import shutil from pathlib import Path JUST = os.environ.get("JUST_EXECUTABLE", "just") @@ -23,12 +24,12 @@ CMD_END = os.environ.get("CMD_END", "") SEMMLE_CODE = os.environ.get("SEMMLE_CODE") -ENV_RE = re.compile(r"^[A-Z_][A-Z_0-9]*=.*$") +ENV_RE = re.compile(r"(^[A-Z_][A-Z_0-9]*)=(.*)$") def invoke(invocation, *, cwd=None, log_prefix=""): prefix = f"{log_prefix} " if log_prefix else "" - print(f"{CMD_BEGIN}{prefix}{' '.join(invocation)}{CMD_END}") + print(f"{CMD_BEGIN}{prefix}{' '.join(map(str, invocation))}{CMD_END}") try: subprocess.run(invocation, check=True, cwd=cwd) except subprocess.CalledProcessError as e: @@ -61,11 +62,11 @@ def build_parser(): parser.add_argument("--codeql", default="build" if SEMMLE_CODE else "host") parser.add_argument("--extra-check", action="append", dest="extra_checks") parser.add_argument("--all-checks", "+", action="store_true", dest="all") - parser.set_defaults(extra_checks=[], tests=[], flags=[], env=[]) + parser.set_defaults(extra_checks=[], tests=[], flags=[], env={}) return parser -def parse_arguments(argv): +def parse_arguments(): """Sort a command line into the kinds that are handled differently. `argparse` owns the options this script acts on itself. Everything else belongs to @@ -74,33 +75,44 @@ def parse_arguments(argv): positionals, told apart only by how they look. """ # An empty argument can come from a caller interpolating an unset variable. - args, rest = build_parser().parse_known_args([arg for arg in argv if arg]) + args, rest = build_parser().parse_known_args() for arg in rest: - if arg.startswith("-"): + if not arg: + pass + elif arg.startswith("-"): args.flags.append(arg) - elif ENV_RE.match(arg): - args.env.append(arg) + elif m := ENV_RE.match(arg): + k, v = m.groups() + args.env[k] = v else: args.tests.append(arg) return args -def env_value(args, name, default): - """Resolve a setting from test arguments, then the environment, then a default.""" - for assignment in reversed(args.env): - key, _, value = assignment.partition("=") - if key == name and value: - return value - return os.environ.get(name) or default - +def resolve_codeql(args: argparse.Namespace) -> Path: + suffix = ".exe" if sys.platform == "win32" else "" + match args.codeql: + case "built" | "build": + return Path( + SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" + ).with_suffix(suffix) + case "host": + return Path(shutil.which("codeql" + suffix)) + case _: + codeql = Path(args.codeql) + if codeql.is_dir(): + codeql /= "codeql" + return codeql.with_suffix(suffix) + def main(): - argv = sys.argv[1:] - args = parse_arguments(argv) + args = parse_arguments() + if args.all: # Apply what the language offered by parsing it alongside everything else, so an # offered check lands exactly where the same flag typed by hand would. - args = parse_arguments(argv + args.extra_checks) + sys.argv[1:1] = args.extra_checks + args = parse_arguments() if not SEMMLE_CODE and args.codeql in ("build", "built"): error( @@ -112,52 +124,33 @@ def main(): if not args.tests: args.tests.append(".") + os.environ.update(args.env) + # Resolve these only once all arguments are known, so that a `RAM_PER_THREAD=` test # argument can lower the default on memory-heavy suites. default_ram = 3000 if sys.platform == "linux" else 2048 - ram_per_thread = int(env_value(args, "RAM_PER_THREAD", default_ram)) - cpus = int(env_value(args, "CPUS", os.cpu_count() or 1)) + ram_per_thread = int(os.environ.get("RAM_PER_THREAD", default_ram)) + cpus = int(os.environ.get("CPUS", os.cpu_count() or 1)) args.flags[:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] if args.codeql == "build": - if invoke([JUST, args.language, "build"], cwd=SEMMLE_CODE) != 0: - return 1 + if ret := invoke([JUST, args.language, "build"], cwd=SEMMLE_CODE): + return ret if args.codeql != "host": # Disable the default implicit config file, but keep an explicit one. # Same behavior wrt --codeql as the integration test runner. os.environ.setdefault("CODEQL_CONFIG_FILE", ".") - for env_var in args.env: - key, _, value = env_var.partition("=") - os.environ[key] = value - - # Resolve codeql executable - if args.codeql in ("built", "build"): - codeql = Path( - SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" - ) - elif args.codeql == "host": - codeql = Path("codeql") - else: - codeql = Path(args.codeql) - - if codeql.is_dir(): - codeql = codeql / "codeql" - - # On Windows, prefer codeql.exe over the Unix shell wrapper - if sys.platform == "win32" and codeql.suffix != ".exe": - exe = codeql.with_suffix(".exe") - if exe.exists(): - codeql = exe + codeql = resolve_codeql(args) - if args.codeql != "host" and not codeql.exists(): + if not codeql.exists(): error(f"CodeQL executable not found: {codeql}") return 1 return invoke( - [str(codeql), "test", "run", *args.flags, "--", *args.tests], - log_prefix=" ".join(args.env), + [codeql, "test", "run", *args.flags, "--", *args.tests], + log_prefix=" ".join(f"{k}={v}" for k, v in args.env.items()), ) From ad63c2d4997d2ad879d16b29792d0b40df8b22e7 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:34:13 +0200 Subject: [PATCH 80/92] refactor: move an error into arg parsing --- misc/just/codeql_test_run.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index c90107549e20..7229110c72ac 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -74,9 +74,16 @@ def parse_arguments(): hands back, and what is sorted by shape here: a test path and a `CPUS=4` are both positionals, told apart only by how they look. """ - # An empty argument can come from a caller interpolating an unset variable. - args, rest = build_parser().parse_known_args() + p = build_parser() + args, rest = p.parse_known_args() + if args.codeql in ("build", "built") and not SEMMLE_CODE: + p.error( + "Using `--codeql=build` or `--codeql=built` requires working " + "with the internal repository" + ) + for arg in rest: + # An empty argument can come from a caller interpolating an unset variable. if not arg: pass elif arg.startswith("-"): @@ -114,13 +121,6 @@ def main(): sys.argv[1:1] = args.extra_checks args = parse_arguments() - if not SEMMLE_CODE and args.codeql in ("build", "built"): - error( - "Using `--codeql=build` or `--codeql=built` requires working " - "with the internal repository" - ) - return 1 - if not args.tests: args.tests.append(".") From 850f3c99c71414bc59f23420e917dcf1dfc0aef3 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:52:29 +0200 Subject: [PATCH 81/92] fix: fix various problems in the codeql test runner --- misc/just/codeql_test_run.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index 7229110c72ac..62f6b04f44b4 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -39,7 +39,7 @@ def invoke(invocation, *, cwd=None, log_prefix=""): def error(message): print(f"{ERROR}{message}", file=sys.stderr) - + raise SystemExit(1) class _Parser(argparse.ArgumentParser): """An `argparse` parser that fails the way the rest of this script does. @@ -51,7 +51,6 @@ class _Parser(argparse.ArgumentParser): def error(self, message): error(message) - raise SystemExit(1) def build_parser(): @@ -83,10 +82,7 @@ def parse_arguments(): ) for arg in rest: - # An empty argument can come from a caller interpolating an unset variable. - if not arg: - pass - elif arg.startswith("-"): + if arg.startswith("-"): args.flags.append(arg) elif m := ENV_RE.match(arg): k, v = m.groups() @@ -101,18 +97,23 @@ def resolve_codeql(args: argparse.Namespace) -> Path: match args.codeql: case "built" | "build": return Path( - SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" - ).with_suffix(suffix) + SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" + suffix + ) case "host": - return Path(shutil.which("codeql" + suffix)) + codeql = shutil.which("codeql" + suffix) + if not codeql: + error("CodeQL executable not found in PATH") + return Path(codeql) case _: codeql = Path(args.codeql) if codeql.is_dir(): - codeql /= "codeql" - return codeql.with_suffix(suffix) + codeql /= "codeql" + suffix + return codeql def main(): + # An empty argument can come from a caller interpolating an unset variable. + sys.argv = [a for a in sys.argv if a] args = parse_arguments() if args.all: @@ -129,8 +130,8 @@ def main(): # Resolve these only once all arguments are known, so that a `RAM_PER_THREAD=` test # argument can lower the default on memory-heavy suites. default_ram = 3000 if sys.platform == "linux" else 2048 - ram_per_thread = int(os.environ.get("RAM_PER_THREAD", default_ram)) - cpus = int(os.environ.get("CPUS", os.cpu_count() or 1)) + ram_per_thread = int(os.environ.get("RAM_PER_THREAD") or default_ram) + cpus = int(os.environ.get("CPUS") or os.cpu_count() or 1) args.flags[:0] = [f"--ram={ram_per_thread * cpus}", f"-j{cpus}"] if args.codeql == "build": @@ -146,7 +147,6 @@ def main(): if not codeql.exists(): error(f"CodeQL executable not found: {codeql}") - return 1 return invoke( [codeql, "test", "run", *args.flags, "--", *args.tests], From fa5b78209973a703a793de98c735e00cb3f0069c Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:57:34 +0200 Subject: [PATCH 82/92] Just: run black over the test runner Two trailing-whitespace lines and a long call. `misc/just` is outside the black scope in `.pre-commit-config.yaml`, so nothing in CI would have said so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index 62f6b04f44b4..baa358dc0482 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -41,6 +41,7 @@ def error(message): print(f"{ERROR}{message}", file=sys.stderr) raise SystemExit(1) + class _Parser(argparse.ArgumentParser): """An `argparse` parser that fails the way the rest of this script does. @@ -80,7 +81,7 @@ def parse_arguments(): "Using `--codeql=build` or `--codeql=built` requires working " "with the internal repository" ) - + for arg in rest: if arg.startswith("-"): args.flags.append(arg) @@ -97,7 +98,11 @@ def resolve_codeql(args: argparse.Namespace) -> Path: match args.codeql: case "built" | "build": return Path( - SEMMLE_CODE, "target", "intree", f"codeql-{args.language}", "codeql" + suffix + SEMMLE_CODE, + "target", + "intree", + f"codeql-{args.language}", + "codeql" + suffix, ) case "host": codeql = shutil.which("codeql" + suffix) @@ -109,7 +114,7 @@ def resolve_codeql(args: argparse.Namespace) -> Path: if codeql.is_dir(): codeql /= "codeql" + suffix return codeql - + def main(): # An empty argument can come from a caller interpolating an unset variable. From a50585e60726b2c9f4b783d428ac77225b27f92e Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 14:57:34 +0200 Subject: [PATCH 83/92] Just: test settings where they are now resolved `env_value` is gone and `parse_arguments` reads `sys.argv` itself, so every test that called either of them stopped running. Precedence is now decided in `main`, where an assignment and an inherited variable first meet, so the tests about precedence go through `main` rather than through a function that no longer exists. A later empty value now erases an earlier setting instead of being skipped. That is only about what this script reads back: every assignment is still exported, so an empty one reaches the child set and empty rather than absent. Both halves are pinned, since the first is easy to mistake for the second. Empty arguments are dropped before parsing rather than while sorting. Only the `--` case tells the two placements apart, so that is the test worth having. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/test_codeql_test_run.py | 151 +++++++++++++++++++++++------- 1 file changed, 118 insertions(+), 33 deletions(-) diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index f1ee5ae527fa..ff386cd2ae3c 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -5,24 +5,61 @@ at each one: a word is a test, a `-` is a flag, `NAME=value` is an environment assignment. Several of these pin down that an argument arrives whole, spaces and all, which is what taking them as a list rather than re-splitting a string bought. + +Sorting and resolving are tested at different levels because they happen at different +levels. `parse_arguments` only sorts; a setting is not resolved until `main` has merged +the assignments into the environment, so anything about precedence is observed there. """ import os +import sys import unittest from unittest import mock import codeql_test_run -def empty_args(): - """A command line carrying nothing but the language `main` reads off the front.""" - return sorted_args() +def sorted_args(*argv, semmle_code=None): + """Sort a command line, supplying the language that always precedes it.""" + with ( + mock.patch.object(codeql_test_run, "SEMMLE_CODE", semmle_code), + mock.patch.object(sys, "argv", ["codeql_test_run.py", "alanguage", *argv]), + ): + return codeql_test_run.parse_arguments() -def sorted_args(*argv): - """Sort a command line, supplying the language that always precedes it.""" - with mock.patch.object(codeql_test_run, "SEMMLE_CODE", None): - return codeql_test_run.parse_arguments(["alanguage", *argv]) +def run_main(*argv, environ=None): + """Run `main` with the executable and the child process stubbed out. + + Returns the flags and tests handed to `codeql test run`, and the environment the + child would have been given. + """ + codeql = mock.MagicMock() + codeql.exists.return_value = True + with ( + mock.patch.object(codeql_test_run, "SEMMLE_CODE", None), + mock.patch.object(sys, "argv", ["codeql_test_run.py", "alanguage", *argv]), + mock.patch.dict(os.environ, environ or {}, clear=True), + mock.patch.object(codeql_test_run, "resolve_codeql", return_value=codeql), + mock.patch.object(codeql_test_run, "invoke", return_value=0) as invoke, + ): + codeql_test_run.main() + (invocation,) = invoke.call_args.args + separator = invocation.index("--") + # Past the executable and `test run`, up to the separator `main` adds itself. + return invocation[3:separator], invocation[separator + 1 :], dict(os.environ) + + +def flags(*argv, environ=None): + return run_main(*argv, environ=environ)[0] + + +def paths(*argv, environ=None): + return run_main(*argv, environ=environ)[1] + + +def child_environ(*argv, environ=None): + return run_main(*argv, environ=environ)[2] class TestParseArgs(unittest.TestCase): @@ -35,17 +72,21 @@ def test_a_dash_is_a_flag(self): ) def test_an_uppercase_assignment_is_an_environment_variable(self): - self.assertEqual(sorted_args("CPUS=4").env, ["CPUS=4"]) + self.assertEqual(sorted_args("CPUS=4").env, {"CPUS": "4"}) def test_a_lowercase_assignment_is_a_test(self): # Only shouting counts, so a path that happens to contain `=` stays a path. self.assertEqual(sorted_args("dir/a=b").tests, ["dir/a=b"]) def test_codeql_selects_the_executable(self): - self.assertEqual(sorted_args("--codeql=built").codeql, "built") + # `built` is rejected outright without an internal checkout to build in, so + # this says what it means to sort the option, not to act on it. + args = sorted_args("--codeql=built", semmle_code="/somewhere") + self.assertEqual(args.codeql, "built") def test_the_last_codeql_wins(self): - self.assertEqual(sorted_args("--codeql=host", "--codeql=built").codeql, "built") + args = sorted_args("--codeql=host", "--codeql=built", semmle_code="/somewhere") + self.assertEqual(args.codeql, "built") def test_all_checks_is_asked_for_by_either_spelling(self): self.assertTrue(sorted_args("--all-checks").all) @@ -66,10 +107,6 @@ def test_a_double_dash_hands_everything_after_it_to_codeql(self): self.assertIn("--codeql=built", args.flags) self.assertNotIn("--", args.flags) - def test_an_empty_argument_is_ignored(self): - # One of these comes of a caller interpolating a variable that was never set. - self.assertEqual(sorted_args("", "test").tests, ["test"]) - def test_a_test_path_containing_a_space_stays_one_argument(self): self.assertEqual(sorted_args("some dir/test").tests, ["some dir/test"]) @@ -79,45 +116,93 @@ def test_an_assignment_whose_value_contains_a_space_stays_whole(self): An argument list carries this; a whitespace-separated string cannot, as there is nothing left in it to tell a separator from part of a value. """ - self.assertEqual(sorted_args("EXTRA=a b").env, ["EXTRA=a b"]) + self.assertEqual(sorted_args("EXTRA=a b").env, {"EXTRA": "a b"}) def test_sorts_a_whole_command_line_at_once(self): args = sorted_args( "-j2", "CPUS=4", "ql/test", "+", "--extra-check=--check-diff" ) self.assertEqual(args.flags, ["-j2"]) - self.assertEqual(args.env, ["CPUS=4"]) + self.assertEqual(args.env, {"CPUS": "4"}) self.assertEqual(args.tests, ["ql/test"]) self.assertEqual(args.extra_checks, ["--check-diff"]) self.assertTrue(args.all) -class TestEnvValue(unittest.TestCase): +class TestEmptyArguments(unittest.TestCase): + """Dropped by `main` before parsing, which is earlier than it looks. + + Filtering these inside the sorting loop instead would leave them in front of + `argparse`, and an empty argument ahead of a `--` stops the separator being + recognised, so the `--` would reach `codeql test run` as an argument of its own. + """ + + def test_an_empty_argument_is_ignored(self): + # One of these comes of a caller interpolating a variable that was never set. + self.assertEqual(paths("", "some/test"), ["some/test"]) + + def test_an_empty_argument_does_not_disturb_a_separator(self): + self.assertEqual(flags("", "--", "--check-databases")[-1], "--check-databases") + self.assertNotIn("--", flags("", "--", "--check-databases")) + + +class TestSettings(unittest.TestCase): + """`RAM_PER_THREAD` and `CPUS` are read back after assignments are applied. + + Resolution is what these pin down, so they go through `main`: an assignment and an + inherited variable only meet once `main` has merged them. + """ + def test_prefers_a_test_argument(self): - args = sorted_args("CPUS=4") - with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "4") + self.assertIn("-j4", flags("CPUS=4", environ={"CPUS": "8"})) def test_falls_back_to_the_environment(self): - with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "8") + self.assertIn("-j8", flags(environ={"CPUS": "8"})) def test_falls_back_to_the_default(self): - with mock.patch.dict(os.environ, {}, clear=True): - self.assertEqual(codeql_test_run.env_value(empty_args(), "CPUS", "1"), "1") + self.assertIn(f"-j{os.cpu_count()}", flags()) def test_the_last_assignment_wins(self): - args = sorted_args("CPUS=4", "CPUS=2") - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "2") + self.assertIn("-j2", flags("CPUS=4", "CPUS=2")) + + def test_an_empty_value_falls_back_to_the_default(self): + """An empty value does not override, so a later one erases an earlier setting. + + Compared against a run that never mentions the setting, so this pins the + behaviour without restating what the default happens to be. It is about + resolution only: see below for what the child is given. + """ + self.assertEqual(flags("CPUS=4", "CPUS="), flags()) - def test_an_empty_value_does_not_count_as_a_setting(self): - args = sorted_args("CPUS=") - with mock.patch.dict(os.environ, {"CPUS": "8"}): - self.assertEqual(codeql_test_run.env_value(args, "CPUS", "1"), "8") + def test_an_empty_assignment_still_reaches_the_child(self): + """Falling back to the default is not the same as the assignment being dropped. - def test_a_value_containing_a_space_survives(self): - args = sorted_args("EXTRA=a b") - self.assertEqual(codeql_test_run.env_value(args, "EXTRA", "none"), "a b") + `RAM_PER_THREAD` and `CPUS` are read back out of the environment, so an empty + one reads as unset and the default stands. Every assignment is exported either + way, so the child sees the variable set and empty rather than absent, and a + variable this script does not read has no other behaviour to fall back to. + """ + self.assertEqual(child_environ("CPUS=")["CPUS"], "") + self.assertNotIn("CPUS", child_environ()) + + def test_an_assignment_reaches_the_child(self): + self.assertEqual(child_environ("EXTRA=a b")["EXTRA"], "a b") + + def test_ram_is_per_thread(self): + self.assertIn("--ram=200", flags("CPUS=2", "RAM_PER_THREAD=100")) + + +class TestDefaults(unittest.TestCase): + def test_the_current_directory_is_the_default_test(self): + self.assertEqual(paths(), ["."]) + + def test_a_named_test_replaces_the_default(self): + self.assertEqual(paths("ql/test/Foo"), ["ql/test/Foo"]) + + def test_an_offered_check_becomes_a_flag_once_asked_for(self): + self.assertIn( + "--check-databases", flags("--extra-check=--check-databases", "+") + ) if __name__ == "__main__": From fff48a41f4a216b604bcb55a44f69206b859798a Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 15:07:57 +0200 Subject: [PATCH 84/92] Just: run the whole of the forwarding tests The entry point sat two thirds of the way up the file, so running it as a script stopped there: the thirteen tests defined below it never ran, and `bazel test` reported a clean twenty-six with nothing to say it was a subset. Among the missing were the checks that the fixtures still match what `just` really dumps, and the one pinning that a recipe reached under two spellings runs once. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/test_forward_command.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index e74b063ec237..ec19994e59ab 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -241,10 +241,6 @@ def test_leaves_a_path_absolute_when_the_argument_was(self): ) -if __name__ == "__main__": - unittest.main() - - class TestFindJustfilesAbove(unittest.TestCase): """The two shapes a consuming root can have, both of which the README promises. @@ -472,3 +468,7 @@ def test_a_root_defining_its_own_is_not(self): self.recipes(self.outer)["_root_format"], self.recipes(self.inner)["_root_format"], ) + + +if __name__ == "__main__": + unittest.main() From b3035cdf22e37796d611cf0b6d0dffa7c92c77ce Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 15:08:09 +0200 Subject: [PATCH 85/92] Just: check the fixtures against the `just` the code will run `forward_command` honours JUST_EXECUTABLE while these tests went to PATH, so pinning a `just` would have had the fixtures agree with one binary and the code run another. Resolving the module's own setting also makes the tests reachable under `bazel test`, whose sandbox offers a fixed PATH that no `just` is on, so they skipped there on every machine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/test_forward_command.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index ec19994e59ab..831b917647e1 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -308,7 +308,11 @@ def test_two_roots_sharing_a_comment_still_both_run_if_they_do_different_work(se self.assertEqual([justfile for justfile, _ in found], [self.inner, self.outer]) -JUST = shutil.which("just") +# Resolve the same binary `forward_command` will run: it honours JUST_EXECUTABLE, so a +# pinned `just` would otherwise have these fixtures checked against a different binary +# than the code uses. `which` covers both spellings, returning an explicit path as given +# and looking a bare name up on PATH. +JUST = shutil.which(forward_command.JUST) # The justfile below uses every construct the fixtures above model, so that a dump of it # can be checked against them. From 5149728e1fa7b26528582655fc2c2d89b62b1c95 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 15:13:41 +0200 Subject: [PATCH 86/92] Just: pin what a root offers apart from what it runs `--all-checks` enables the checks a justfile offers; a root can separately pass checks unconditionally, which are not offers. Nothing distinguished the two, and they are easy to conflate when reading a justfile. The flag is injected on every language test run rather than typed, so it means "enable whatever this root offers" and a root offering nothing enables nothing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 3 ++- misc/just/test_codeql_test_run.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/misc/just/README.md b/misc/just/README.md index 454cc3ae3ba9..ecd8b4de19ef 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -158,7 +158,8 @@ Another point is how launching QL tests can be tweaked: - you can add the additional checks that CI does with `--all-checks` or the `+` abbreviation. These additional checks are configured in justfiles per language, and correspond to all the additional checks that CI adds (but that a dev might not want to - run by default). + run by default). Checks a root passes unconditionally are not offers: what it offers + is what `--all-checks` can enable Test arguments are passed around as `just` lists (`set lists`), so they reach the underlying runner already split and arguments containing spaces survive intact. diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index ff386cd2ae3c..ff57a4c9aae7 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -146,6 +146,37 @@ def test_an_empty_argument_does_not_disturb_a_separator(self): self.assertNotIn("--", flags("", "--", "--check-databases")) +class TestOfferedChecks(unittest.TestCase): + """What a root offers and what `--all-checks` enables are separate things. + + These go through `main` because that is where the two meet. `--all-checks` is + injected on every language test run rather than typed, so it means "enable whatever + this root offers" and not "I want more coverage": a root offering nothing has to + stay runnable through it. + """ + + def test_an_offered_check_is_applied_when_asked_for(self): + applied = flags("--extra-check=--check-databases", "--all-checks") + self.assertIn("--check-databases", applied) + + def test_an_offered_check_stays_held_back_until_it_is(self): + self.assertNotIn("--check-databases", flags("--extra-check=--check-databases")) + + def test_asking_for_checks_a_root_offers_none_of_enables_nothing(self): + self.assertEqual(paths("--all-checks", "some/test"), ["some/test"]) + self.assertEqual(flags("--all-checks", "some/test"), flags("some/test")) + + def test_a_check_passed_unconditionally_is_not_an_offer(self): + # A root can mean to run a check always rather than put it behind the flag. That + # is a flag like any other here, so it neither becomes an offer nor is withheld + # until the offers are asked for. + always = flags("--check-databases", "some/test") + self.assertIn("--check-databases", always) + self.assertEqual( + flags("--check-databases", "--all-checks", "some/test"), always + ) + + class TestSettings(unittest.TestCase): """`RAM_PER_THREAD` and `CPUS` are read back after assignments are applied. From c06208cf4787a3696b1ed50e9f3ef0ae2f656d38 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 15:13:41 +0200 Subject: [PATCH 87/92] Just: say which arguments a language test run visits Only the first argument finds a justfile; the rest are handed to that one `test` recipe. The docstring said the first "must be a test root", which reads as though the others are roots that get visited in turn, and it has now been read that way by someone porting a suite onto it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/language_tests.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/misc/just/language_tests.py b/misc/just/language_tests.py index efd5e3f244f0..ea736abd1bff 100755 --- a/misc/just/language_tests.py +++ b/misc/just/language_tests.py @@ -4,8 +4,10 @@ Called from just recipes as: python3 language_tests.py ROOT [ARG...] -Arguments are already split by `just` (see `set lists`). The first one must be a test -root, which is used to locate the justfile implementing `test` for that suite. +Arguments are already split by `just` (see `set lists`). Only the first locates a +justfile: its `test` recipe is run once, and every argument after it is handed to that +one recipe rather than visited in turn. Roots wanting different `test` recipes therefore +cannot be run together. """ import os From 34792a9283c3c3b324c6af99bb14338e87d7bb79 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 16:10:41 +0200 Subject: [PATCH 88/92] Just: let an empty argument be an argument Both entry points dropped empty arguments before looking at them, to absorb a caller interpolating a variable that was never set. No justfile here can produce one: a root that contributes nothing contributes no list element, so the case the filtering existed for cannot arise, and dropping arguments silently is a bad way to find out otherwise. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/codeql_test_run.py | 2 -- misc/just/language_tests.py | 5 +---- misc/just/test_codeql_test_run.py | 17 ----------------- misc/just/test_language_tests.py | 7 ------- 4 files changed, 1 insertion(+), 30 deletions(-) diff --git a/misc/just/codeql_test_run.py b/misc/just/codeql_test_run.py index baa358dc0482..a4d70b5a260f 100755 --- a/misc/just/codeql_test_run.py +++ b/misc/just/codeql_test_run.py @@ -117,8 +117,6 @@ def resolve_codeql(args: argparse.Namespace) -> Path: def main(): - # An empty argument can come from a caller interpolating an unset variable. - sys.argv = [a for a in sys.argv if a] args = parse_arguments() if args.all: diff --git a/misc/just/language_tests.py b/misc/just/language_tests.py index ea736abd1bff..ab6d4bd5a148 100755 --- a/misc/just/language_tests.py +++ b/misc/just/language_tests.py @@ -17,10 +17,7 @@ def main(): - # Blank arguments are dropped before the count is taken: one comes of a caller - # interpolating a variable that was never set, and a list of nothing but those is no - # arguments at all rather than a root to find a justfile above. - argv = [arg for arg in sys.argv[1:] if arg] + argv = sys.argv[1:] if not argv: print("Usage: language_tests.py ROOT [ARG...]", file=sys.stderr) return 1 diff --git a/misc/just/test_codeql_test_run.py b/misc/just/test_codeql_test_run.py index ff57a4c9aae7..acb88ac11db6 100644 --- a/misc/just/test_codeql_test_run.py +++ b/misc/just/test_codeql_test_run.py @@ -129,23 +129,6 @@ def test_sorts_a_whole_command_line_at_once(self): self.assertTrue(args.all) -class TestEmptyArguments(unittest.TestCase): - """Dropped by `main` before parsing, which is earlier than it looks. - - Filtering these inside the sorting loop instead would leave them in front of - `argparse`, and an empty argument ahead of a `--` stops the separator being - recognised, so the `--` would reach `codeql test run` as an argument of its own. - """ - - def test_an_empty_argument_is_ignored(self): - # One of these comes of a caller interpolating a variable that was never set. - self.assertEqual(paths("", "some/test"), ["some/test"]) - - def test_an_empty_argument_does_not_disturb_a_separator(self): - self.assertEqual(flags("", "--", "--check-databases")[-1], "--check-databases") - self.assertNotIn("--", flags("", "--", "--check-databases")) - - class TestOfferedChecks(unittest.TestCase): """What a root offers and what `--all-checks` enables are separate things. diff --git a/misc/just/test_language_tests.py b/misc/just/test_language_tests.py index d1865599f9f4..b2e93a28306c 100644 --- a/misc/just/test_language_tests.py +++ b/misc/just/test_language_tests.py @@ -109,13 +109,6 @@ def test_needs_a_root(self): self.assertEqual(status, 1) run.assert_not_called() - def test_nothing_but_blank_arguments_is_no_arguments(self): - # An unset variable interpolated by a caller arrives as one of these. Counting - # it as an argument and then dropping it left nothing to take a root from. - status, run, _ = self.run_main("", "") - self.assertEqual(status, 1) - run.assert_not_called() - def test_reports_a_root_with_no_justfile_above_it(self): orphan = self.semmle_code / "elsewhere" orphan.mkdir() From 453ef9f5f8aaf002c5707f147a2000d925f49d1b Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 16:10:41 +0200 Subject: [PATCH 89/92] Just: print the error prefix instead of the word for it The prefix was written `{error}`, which is Python's spelling. just interpolates with `{{ }}`, so every internal-checkout failure said `{error}` where it meant a red `error:`. It is the only single-brace interpolation in any justfile here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/defs.just | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/just/defs.just b/misc/just/defs.just index bb360d6a687a..16a9c18c0aa3 100644 --- a/misc/just/defs.just +++ b/misc/just/defs.just @@ -65,7 +65,7 @@ default_db_checks := ['--check-databases', '--check-diff-informed', '--fail-on-t [no-exit-message] @_require_semmle_code: {{ if SEMMLE_CODE == "" { f''' - echo "{error} running this recipe requires doing so from an internal repository checkout" >&2 + echo "{{ error }}running this recipe requires doing so from an internal repository checkout" >&2 exit 1 ''' } else { "" } }} From e4433b1a89b31c98074e58122a54e6bbbc1b0152 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 16:10:53 +0200 Subject: [PATCH 90/92] Just: make three recipes do what they said `misc/codegen`'s `test` declared arguments and passed none on, so anything given to it was accepted and discarded. `_ensure_long_filename` ran before the dependency that checks for an internal checkout, so outside one it built a path from an unset variable and created directories at the filesystem root, or failed on permissions while saying nothing about the real reason. The Kotlin shards pointed at a recipe name that exists nowhere. They are reached by naming their directory, which is what opting out of a downward search leaves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- java/ql/test-kotlin1/justfile | 5 +++-- java/ql/test-kotlin2/justfile | 5 +++-- misc/codegen/justfile | 2 +- python/justfile | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/java/ql/test-kotlin1/justfile b/java/ql/test-kotlin1/justfile index 4edd755f19fe..fc29900a05c7 100644 --- a/java/ql/test-kotlin1/justfile +++ b/java/ql/test-kotlin1/justfile @@ -1,7 +1,8 @@ import "../justfile" -# These are CI shards of the Kotlin language tests, run as `just java -# kotlin-language-tests-1`, so they only run when asked for by name. +# These are CI shards of the Kotlin language tests, too long to run by accident, +# so a verb coming from above passes over them and they run only when this +# directory is named. explicit_verbs := ['test'] # Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. diff --git a/java/ql/test-kotlin2/justfile b/java/ql/test-kotlin2/justfile index d3609681ad27..4ef773c27ecd 100644 --- a/java/ql/test-kotlin2/justfile +++ b/java/ql/test-kotlin2/justfile @@ -1,7 +1,8 @@ import "../justfile" -# These are CI shards of the Kotlin language tests, run as `just java -# kotlin-language-tests-2`, so they only run when asked for by name. +# These are CI shards of the Kotlin language tests, too long to run by accident, +# so a verb coming from above passes over them and they run only when this +# directory is named. explicit_verbs := ['test'] # Kotlin tests may fail the diags.ql consistency test if the diagnostic limit is set. diff --git a/misc/codegen/justfile b/misc/codegen/justfile index a65fa16e5679..bd632b74817b 100644 --- a/misc/codegen/justfile +++ b/misc/codegen/justfile @@ -1,5 +1,5 @@ import "../just/lib.just" -test *ARGS="": (_bazel ['test', '@codeql//misc/codegen/...']) +test *ARGS: (_bazel (['test', '@codeql//misc/codegen/...'] ++ ARGS)) format *ARGS=".": (_format_py ARGS) diff --git a/python/justfile b/python/justfile index 33580aa1e05c..dec897cdf672 100644 --- a/python/justfile +++ b/python/justfile @@ -6,7 +6,7 @@ build: (_build_dist "python") # Long filename needed for extractor tests (too long for Git on Windows) [no-cd] -@_ensure_long_filename: +@_ensure_long_filename: _require_semmle_code #!/usr/bin/env bash longfile="$SEMMLE_CODE/ql/python/ql/test/extractor-tests/long_path/really_rather_too_long_for_windows_path_length/with_unecessarily_longwinded_and_verbose_sub_folder/extremely_long_module_name_with_lots_of_digits_at_the_end_000000000000000000000000000000000000000000000000000000000000000000/test0000000000000000000000000000000000000000000000000000000.py" mkdir -p "$(dirname "$longfile")" From a7fb6e0b2a9112e5c899f86e66374c3cba1649c8 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 16:10:53 +0200 Subject: [PATCH 91/92] Just: let a verb fail when it could not read a justfile A candidate that failed to parse was reported and then dropped, so a broad verb ran everything else and still exited zero: a malformed justfile removed its own work from CI while the diagnostic scrolled past. Failure now travels back through resolution and no partial run is started on top of it. Carrying the recipe rather than just its name also lets arguments be grouped by what the recipe can actually be called with. Nothing reachable today needs that, as every verb resolves to a variadic or zero-argument recipe, but the arguments are paths and a fixed-arity recipe would otherwise fail on arity alone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/forward_command.py | 120 +++++++++++++++++++----------- misc/just/test_forward_command.py | 77 ++++++++++++++++++- 2 files changed, 152 insertions(+), 45 deletions(-) diff --git a/misc/just/forward_command.py b/misc/just/forward_command.py index 6624306f6607..300e1a0a8263 100644 --- a/misc/just/forward_command.py +++ b/misc/just/forward_command.py @@ -96,16 +96,20 @@ def list_value(assignments, name): return [] +def is_variadic(recipe): + parameters = recipe["parameters"] + return bool(parameters and parameters[-1]["kind"] in ("star", "plus")) + + def accepts(recipe, argc): """Check whether a recipe can be called with a given number of arguments.""" parameters = recipe["parameters"] - variadic = parameters and parameters[-1]["kind"] in ("star", "plus") required = sum( 1 for parameter in parameters if parameter["default"] is None and parameter["kind"] != "star" ) - return required <= argc and (variadic or argc <= len(parameters)) + return required <= argc and (is_variadic(recipe) or argc <= len(parameters)) def implements(dump, command, argc): @@ -141,12 +145,14 @@ def dump_all(justfiles): with ThreadPoolExecutor(PROBE_WORKERS) as executor: dumps = list(executor.map(dump_justfile, justfiles)) parsed = [] + failed = False for justfile, (dump, failure) in zip(justfiles, dumps): if dump is None: + failed = True error(f"could not read {justfile}:\n{failure}") else: parsed.append((justfile, dump)) - return parsed + return parsed, failed def git(directory, *args): @@ -159,28 +165,26 @@ def git(directory, *args): ) if result.returncode != 0: error(f"`git {' '.join(args)}` failed in {directory}:\n{result.stderr.strip()}") - return [] - return result.stdout.splitlines() + return [], True + return result.stdout.splitlines(), False def submodules(directory): """List the initialised submodules under a directory.""" - toplevel = git(directory, "rev-parse", "--show-toplevel") + toplevel, failed = git(directory, "rev-parse", "--show-toplevel") if not toplevel or not (Path(toplevel[0]) / ".gitmodules").exists(): - return [] - paths = [ - Path(toplevel[0]) / line.split(" ", 1)[1] - for line in git( - toplevel[0], "config", "--file", ".gitmodules", "--get-regexp", r"\.path$" - ) - ] + return [], failed + lines, config_failed = git( + toplevel[0], "config", "--file", ".gitmodules", "--get-regexp", r"\.path$" + ) + paths = [Path(toplevel[0]) / line.split(" ", 1)[1] for line in lines] within = Path(directory).resolve() return [ Path(directory) / os.path.relpath(path, within) for path in paths # An uninitialised submodule is an empty directory, with nothing to run. if path.is_relative_to(within) and (path / ".git").exists() - ] + ], failed or config_failed def find_justfiles(directory): @@ -191,21 +195,21 @@ def find_justfiles(directory): worth finding. """ justfiles = set() - for repository in [directory, *submodules(directory)]: - justfiles.update( - Path(repository) / line - for line in git( - repository, - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "--", - "justfile", - "*/justfile", - ) + repositories, failed = submodules(directory) + for repository in [directory, *repositories]: + lines, git_failed = git( + repository, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "--", + "justfile", + "*/justfile", ) - return justfiles + failed = failed or git_failed + justfiles.update(Path(repository) / line for line in lines) + return justfiles, failed def invocation_path(path, *, like): @@ -230,7 +234,8 @@ def find_justfiles_above(command, arg): ] found = [] seen = [] - for justfile, dump in dump_all(candidates): + parsed, failed = dump_all(candidates) + for justfile, dump in parsed: # A justfile sitting exactly on the argument is called without it, as the # argument would only repeat where it already is. argc = 0 if justfile.parent.resolve() == directory else 1 @@ -248,7 +253,7 @@ def find_justfiles_above(command, arg): if recipe is not None and recipe not in seen: seen.append(recipe) found.append((justfile, recipe)) - return found + return found, failed def find_justfiles_below(command, directory, covered=()): @@ -263,10 +268,13 @@ def find_justfiles_below(command, directory, covered=()): but ask to be named rather than found. """ # The justfile at `directory` is covered by the search above it. - candidates = sorted(find_justfiles(directory) - {Path(directory) / "justfile"}) + justfiles, failed = find_justfiles(directory) + candidates = sorted(justfiles - {Path(directory) / "justfile"}) matches = [] opted_out = [] - for justfile, dump in dump_all(candidates): + parsed, dump_failed = dump_all(candidates) + failed = failed or dump_failed + for justfile, dump in parsed: recipe = implements(dump, command, 0) if recipe is None: continue @@ -283,7 +291,7 @@ def find_justfiles_below(command, directory, covered=()): continue contributed.setdefault(justfile.parent, []).append(recipe) found.append((justfile, recipe)) - return sorted(found, key=lambda match: match[0]), sorted(opted_out) + return sorted(found, key=lambda match: match[0]), sorted(opted_out), failed def resolve(command, arg): @@ -294,18 +302,18 @@ def resolve(command, arg): on. One found below gets its own directory instead, as there the argument only said where to look. Justfiles below that asked to be named are returned separately. """ - above = find_justfiles_above(command, arg) - resolved = [(justfile, arg, recipe["name"]) for justfile, recipe in above] + above, failed = find_justfiles_above(command, arg) + resolved = [(justfile, arg, recipe) for justfile, recipe in above] opted_out = [] if os.path.isdir(arg): - below, opted_out = find_justfiles_below( + below, opted_out, below_failed = find_justfiles_below( command, arg, [recipe for _, recipe in above] ) + failed = failed or below_failed resolved += [ - (justfile, str(justfile.parent), recipe["name"]) - for justfile, recipe in below + (justfile, str(justfile.parent), recipe) for justfile, recipe in below ] - return resolved, opted_out + return resolved, opted_out, failed def report_opted_out(command, justfiles, *, ran): @@ -340,6 +348,19 @@ def invoke_just(cwd, args): return 0 +def invocation_argument_groups(recipe, pos_args): + """Split arguments into what the recipe can be called with at once. + + Every recipe reachable from a verb today is variadic or takes none, so this only + ever yields one group. It is here because the arguments are paths and running the + verb once per path is what a fixed-arity recipe would mean, where passing them + together would fail on arity alone and say nothing useful about why. + """ + if is_variadic(recipe) or len(pos_args) <= 1: + return [pos_args] + return [[arg] for arg in pos_args] + + def forward(cmd, args): """Forward a command to language-specific justfiles.""" is_non_positional = re.compile(r"^(-.*|\+|[A-Z_][A-Z_0-9]*=.*)$") @@ -348,16 +369,26 @@ def forward(cmd, args): justfiles = {} opted_out = [] + resolution_failed = False for arg in positional_args or ["."]: - resolved, skipped = resolve(cmd, arg) + resolved, skipped, failed = resolve(cmd, arg) opted_out += skipped + resolution_failed = resolution_failed or failed if not resolved: + # A candidate that could not be read is reported below rather than here: + # saying nothing matched would blame the argument for a broken justfile. + if failed: + continue error(f"No justfile found for {cmd} on {arg}") report_opted_out(cmd, skipped, ran=False) return 1 for justfile, justfile_arg, recipe in resolved: justfiles.setdefault(justfile, (recipe, []))[1].append(justfile_arg) + if resolution_failed: + report_opted_out(cmd, opted_out, ran=False) + return 1 + invocations = [] for justfile, (recipe, pos_args) in justfiles.items(): # An argument standing for the whole directory subsumes any more specific one @@ -365,10 +396,11 @@ def forward(cmd, args): whole_directory = str(justfile.parent) if whole_directory in pos_args: pos_args = [whole_directory] - cwd, just_args = get_just_context(justfile, recipe, flags, pos_args) - prefix = f"cd {cwd}; " if cwd else "" - print(f"-> {prefix}just {' '.join(just_args)}") - invocations.append((cwd, just_args)) + for group in invocation_argument_groups(recipe, pos_args): + cwd, just_args = get_just_context(justfile, recipe["name"], flags, group) + prefix = f"cd {cwd}; " if cwd else "" + print(f"-> {prefix}just {' '.join(just_args)}") + invocations.append((cwd, just_args)) report_opted_out(cmd, opted_out, ran=True) diff --git a/misc/just/test_forward_command.py b/misc/just/test_forward_command.py index 831b917647e1..512423389773 100644 --- a/misc/just/test_forward_command.py +++ b/misc/just/test_forward_command.py @@ -290,7 +290,11 @@ def found(self, outer, inner): "dump_justfile", side_effect=lambda justfile: (dumps[Path(justfile)], None), ): - return forward_command.find_justfiles_above("format", str(self.argument)) + found, failed = forward_command.find_justfiles_above( + "format", str(self.argument) + ) + self.assertFalse(failed) + return found def test_a_recipe_reached_under_two_spellings_runs_once(self): found = self.found(self.implementing(), self.implementing()) @@ -308,6 +312,77 @@ def test_two_roots_sharing_a_comment_still_both_run_if_they_do_different_work(se self.assertEqual([justfile for justfile, _ in found], [self.inner, self.outer]) +class TestDiscoveryFailures(unittest.TestCase): + def test_dump_all_reports_every_failure(self): + justfiles = [Path("one/justfile"), Path("two/justfile"), Path("three/justfile")] + dumps = [(None, "bad one"), (dump(), None), (None, "bad three")] + with mock.patch.object( + forward_command, "dump_justfile", side_effect=dumps + ), mock.patch.object(forward_command, "error") as report: + parsed, failed = forward_command.dump_all(justfiles) + self.assertTrue(failed) + self.assertEqual(parsed, [(Path("two/justfile"), dump())]) + self.assertEqual(report.call_count, 2) + + def test_git_failure_is_reported_to_callers(self): + result = subprocess.CompletedProcess( + ["git"], 128, stdout="", stderr="fatal: not a repository" + ) + with mock.patch.object( + subprocess, "run", return_value=result + ), mock.patch.object(forward_command, "error"): + lines, failed = forward_command.git(".", "ls-files") + self.assertEqual(lines, []) + self.assertTrue(failed) + + def test_forward_fails_without_running_after_resolution_failure(self): + justfile = Path("pkg/justfile") + resolved = [(justfile, "pkg", recipe("test", [parameter("ARGS", "star")]))] + with mock.patch.object( + forward_command, "resolve", return_value=(resolved, [], True) + ), mock.patch.object(forward_command, "invoke_just") as invoke: + self.assertEqual(forward_command.forward("test", ["pkg"]), 1) + invoke.assert_not_called() + + +class TestForwardInvocations(unittest.TestCase): + def test_accumulates_variadic_recipe_arguments(self): + justfile = Path("pkg/justfile") + test_recipe = recipe("test", [parameter("ARGS", "star")]) + + def resolve(command, arg): + return [(justfile, arg, test_recipe)], [], False + + with mock.patch.object( + forward_command, "resolve", side_effect=resolve + ), mock.patch.object(forward_command, "invoke_just", return_value=0) as invoke: + self.assertEqual(forward_command.forward("test", ["pkg/a", "pkg/b"]), 0) + + invoke.assert_called_once_with( + None, ["--justfile", "pkg/justfile", "test", "pkg/a", "pkg/b"] + ) + + def test_splits_non_variadic_recipe_arguments(self): + justfile = Path("pkg/justfile") + test_recipe = recipe("test", [parameter("ARG")]) + + def resolve(command, arg): + return [(justfile, arg, test_recipe)], [], False + + with mock.patch.object( + forward_command, "resolve", side_effect=resolve + ), mock.patch.object(forward_command, "invoke_just", return_value=0) as invoke: + self.assertEqual(forward_command.forward("test", ["pkg/a", "pkg/b"]), 0) + + self.assertEqual( + invoke.call_args_list, + [ + mock.call(None, ["--justfile", "pkg/justfile", "test", "pkg/a"]), + mock.call(None, ["--justfile", "pkg/justfile", "test", "pkg/b"]), + ], + ) + + # Resolve the same binary `forward_command` will run: it honours JUST_EXECUTABLE, so a # pinned `just` would otherwise have these fixtures checked against a different binary # than the code uses. `which` covers both spellings, returning an explicit path as given From 327bbf6739ef947d6b068b419c036393043316bc Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Wed, 16 Sep 2026 16:39:57 +0200 Subject: [PATCH 92/92] Just: state the version these recipes actually need The floor was given as 1.58 on the grounds that `set lists` did not exist before then. It did: `set lists`, list literals, `++` and variadic parameters that pass their elements on one at a time all arrived together in 1.53, which is the whole of what the forwarding depends on. Checked by running the tree under both: every justfile here parses and evaluates under 1.53 and none does under 1.52. Lists being unstable is a separate fact that has not changed, and is what `set unstable` is for, so that part of the reason moved rather than went away. The error an older `just` gives was also wrong. `Unknown setting` is what `defs.just` produces when read on its own; anything importing it dies earlier, on the first list literal, complaining about an unexpected `[` and saying nothing about versions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- misc/just/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/misc/just/README.md b/misc/just/README.md index ecd8b4de19ef..9dc2693e36b1 100644 --- a/misc/just/README.md +++ b/misc/just/README.md @@ -4,10 +4,14 @@ have common verbs (`build`, `test`, `format`, `lint`, `generate`) that individua of the project can implement, and some common functionality that can be used to that effect. -`just` 1.58 or newer is required: recipes forward argument lists using `set lists`, which -is still unstable and did not exist before then. An older one stops with an -`Unknown setting` error pointing at that line, which is clear enough but does not say -which version to move to. +`just` 1.53 or newer is required. Recipes forward argument lists rather than encoding +them as whitespace separated strings, and every piece of that arrived in that one +release: `set lists`, list literals, the `++` operator, and variadic parameters that pass +their elements on one at a time rather than space-joined. Lists are still unstable, at +1.58 as much as at 1.53, which is what `set unstable` in `defs.just` is for. An older +`just` stops at the first list literal it parses, complaining about an unexpected `[` and +saying nothing about versions; only `defs.just` read on its own gives the clearer +`unknown setting` naming `lists`. # Forwarding