From 278db1485271c8b23ee8f88b44c39195c93ca515 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 15 Sep 2026 11:34:38 +0200 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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: