From 58855cbae41ff2ee59fb3ab5e8c3333c80f1edf5 Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:21:38 +0800 Subject: [PATCH 1/2] Split a scan command into invocations before reading it A scan is built by walking a command's words, and two of the four walks that do it decided what a word meant from its position in the whole list rather than in the invocation it belongs to. Both matchers searched for the compile flag across every word; both builders folded source words in from wherever they appeared. The criterion the next commit applies has to be asked once, of each invocation, which needs the invocations to exist as a thing the code can name. Split them in dep_words.cpp, beside the separator notion the scanners already share, and re-express all four sites over it. Invocation boundaries are the generic part and live in the primitive; which invocations count as compiles is each scanner's own question and stays there. Two separators, not one: a redirection stops a scan reading further flags but hands its target to the same program, so it divides no invocation. An operator with nothing after it likewise begins none. While the two notions are being separated, `is_command_separator` is renamed to `is_flag_barrier`. It never meant "separates commands": it means "the scan reads no flags past this word", which is why redirections belong in it and why it is the wrong set to divide invocations on. The old name is what let a design premise be written from a correct reading of the code, and a name that has to be corrected by its own comment is the encoding paying for the mistake twice. No behaviour change. --- include/pup/graph/scanners/dep_words.hpp | 20 ++++- src/graph/scanners/clang_cl.cpp | 95 ++++++++++++++--------- src/graph/scanners/dep_words.cpp | 27 ++++++- src/graph/scanners/gcc.cpp | 96 +++++++++++++++--------- 4 files changed, 163 insertions(+), 75 deletions(-) diff --git a/include/pup/graph/scanners/dep_words.hpp b/include/pup/graph/scanners/dep_words.hpp index ff54cf06..aa7c09fe 100644 --- a/include/pup/graph/scanners/dep_words.hpp +++ b/include/pup/graph/scanners/dep_words.hpp @@ -4,6 +4,7 @@ #pragma once #include "pup/core/buf.hpp" +#include "pup/core/vec.hpp" #include #include @@ -50,10 +51,23 @@ auto append_separate_arg_into(Buf& out, std::string_view word, SeparateArg kind) [[nodiscard]] auto is_blank_word(std::string_view word) -> bool; -/// True for the shell words that end one command and begin another. A scan carries the flags of -/// the invocation it was built for, so it takes none from what follows one of these. +/// True for the shell words after which a scan may read no further flags -- a control operator, or +/// a redirection whose target belongs to the program rather than to the scan. [[nodiscard]] -auto is_command_separator(std::string_view word) -> bool; +auto is_flag_barrier(std::string_view word) -> bool; + +/// True for the shell words that end one program's invocation and begin another's. Narrower than +/// `is_flag_barrier`: a redirection stops a scan from reading further flags but hands its +/// target to the same program, so it divides no invocation. +[[nodiscard]] +auto is_invocation_separator(std::string_view word) -> bool; + +/// The invocations a command's control operators divide its words into, in order. An invocation is +/// empty where two operators meet or where the command begins with one; a word list with no +/// operator is one invocation, and a trailing operator adds none. Which of them a scan may draw +/// from is each scanner's question, but where they begin and end is not, so it is answered here. +[[nodiscard]] +auto split_invocations(std::span words) -> Vec>; /// A flag the scan command carries, and what its argument is. struct ArgFlag { diff --git a/src/graph/scanners/clang_cl.cpp b/src/graph/scanners/clang_cl.cpp index 6468eaa7..4ffa2a81 100644 --- a/src/graph/scanners/clang_cl.cpp +++ b/src/graph/scanners/clang_cl.cpp @@ -117,27 +117,49 @@ auto normalize_flag_path_into(Buf& out, std::string_view flag) -> void out += flag; } +/// Where the driver stands in one invocation -- first, or behind one recognized wrapper. +auto driver_index(std::span invocation) -> std::optional +{ + if (invocation.empty()) { + return std::nullopt; + } + auto idx = std::size_t { 0 }; + if (is_compiler_wrapper(program_basename(invocation[0])) && invocation.size() > 1) { + idx = 1; + } + if (!is_clang_cl_name(program_basename(invocation[idx]))) { + return std::nullopt; + } + return idx; +} + +auto command_words(std::string_view command) -> Vec +{ + auto& pool = global_pool(); + auto words = Vec {}; + for (auto id : core::tokenize_shell_command(command)) { + words.push_back(pool.get(id)); + } + return words; +} + } // namespace auto matches_clang_cl_compile(std::string_view command) -> bool { - auto word_ids = core::tokenize_shell_command(command); - if (word_ids.empty()) { + auto words = command_words(command); + if (words.empty()) { return false; } - auto& pool = global_pool(); - auto driver_idx = std::size_t { 0 }; - if (is_compiler_wrapper(program_basename(pool.get(word_ids[0]))) && word_ids.size() > 1) { - driver_idx = 1; - } - - if (!is_clang_cl_name(program_basename(pool.get(word_ids[driver_idx])))) { + auto invocations = split_invocations(std::span { words.data(), words.size() }); + auto driver_idx = driver_index(invocations[0]); + if (!driver_idx) { return false; } - for (auto i = driver_idx + 1; i < word_ids.size(); ++i) { - if (is_compile_flag(pool.get(word_ids[i]))) { + for (auto i = *driver_idx + 1; i < words.size(); ++i) { + if (is_compile_flag(words[i])) { return true; } } @@ -166,49 +188,44 @@ auto ClangClScanner::has_dep_flags(std::string_view cmd) const -> bool auto ClangClScanner::build_dep_command(CommandInfo const& cmd) const -> std::optional { auto& pool = global_pool(); - auto word_ids = core::tokenize_shell_command(pool.get(cmd.command)); - if (word_ids.empty()) { + auto words = command_words(pool.get(cmd.command)); + if (words.empty()) { return std::nullopt; } - auto words = Vec {}; - words.reserve(word_ids.size()); - for (auto id : word_ids) { - words.push_back(pool.get(id)); - } - - auto driver_idx = std::size_t { 0 }; - if (is_compiler_wrapper(program_basename(words[0])) && words.size() > 1) { - driver_idx = 1; - } - - if (!is_clang_cl_name(program_basename(words[driver_idx]))) { + auto invocations = split_invocations(std::span { words.data(), words.size() }); + auto const first = invocations[0]; + auto driver_idx = driver_index(first); + if (!driver_idx) { return std::nullopt; } auto dep_cmd = Buf {}; - for (auto i = std::size_t { 0 }; i <= driver_idx; ++i) { + for (auto i = std::size_t { 0 }; i <= *driver_idx; ++i) { if (i > 0) { dep_cmd += ' '; } - dep_cmd += words[i]; + dep_cmd += first[i]; } dep_cmd += " /clang:-M"; auto pending = std::optional {}; - auto later_invocation = false; + auto linker_tail = false; + auto redirected = false; auto source_files = Vec {}; - for (auto i = driver_idx + 1; i < words.size(); ++i) { - auto w = words[i]; + for (auto i = *driver_idx + 1; i < first.size(); ++i) { + auto w = first[i]; - if (is_command_separator(w)) { - later_invocation = true; + // A redirection hands its target to this same invocation, so the words after it are not + // flags the scan may carry. + if (is_flag_barrier(w)) { + redirected = true; pending.reset(); continue; } - if (later_invocation) { + if (redirected) { if (is_source_file(w)) { source_files.push_back(w); } @@ -230,8 +247,10 @@ auto ClangClScanner::build_dep_command(CommandInfo const& cmd) const -> std::opt continue; } - // /link hands everything after it to the linker, source words included. + // /link hands everything after it to the linker, source words included -- and the rest of + // the command with it, so no later invocation contributes one either. if (w == "/link") { + linker_tail = true; break; } @@ -251,6 +270,14 @@ auto ClangClScanner::build_dep_command(CommandInfo const& cmd) const -> std::opt pending = separate_arg(w); } + for (auto later = std::size_t { 1 }; !linker_tail && later < invocations.size(); ++later) { + for (auto w : invocations[later]) { + if (is_source_file(w)) { + source_files.push_back(w); + } + } + } + if (source_files.empty()) { return std::nullopt; } diff --git a/src/graph/scanners/dep_words.cpp b/src/graph/scanners/dep_words.cpp index 6969157e..cc0391ad 100644 --- a/src/graph/scanners/dep_words.cpp +++ b/src/graph/scanners/dep_words.cpp @@ -117,9 +117,9 @@ auto is_blank_word(std::string_view word) -> bool return word.find_first_not_of(" \t\n\r") == std::string_view::npos; } -auto is_command_separator(std::string_view word) -> bool +auto is_flag_barrier(std::string_view word) -> bool { - if (word == "&&" || word == "||" || word == ";" || word == "|" || word == "&") { + if (is_invocation_separator(word)) { return true; } // A redirection may name its file descriptor first: `>log`, `1>log`, `2>&1`, `3 bool return rest.starts_with(">") || rest.starts_with("<"); } +auto is_invocation_separator(std::string_view word) -> bool +{ + return word == "&&" || word == "||" || word == ";" || word == "|" || word == "&"; +} + +auto split_invocations(std::span words) -> Vec> +{ + auto result = Vec> {}; + auto start = std::size_t { 0 }; + for (auto i = std::size_t { 0 }; i < words.size(); ++i) { + if (is_invocation_separator(words[i])) { + result.push_back(words.subspan(start, i - start)); + start = i + 1; + } + } + // An operator with nothing after it ends the last invocation rather than beginning another; one + // with nothing before it does begin an empty one, which is a command no scan can reproduce. + if (start < words.size() || result.empty()) { + result.push_back(words.subspan(start)); + } + return result; +} + auto find_joined_flag(std::span table, std::string_view word) -> ArgFlag const* { for (auto const& flag : table) { diff --git a/src/graph/scanners/gcc.cpp b/src/graph/scanners/gcc.cpp index 155073fe..aec3c6b0 100644 --- a/src/graph/scanners/gcc.cpp +++ b/src/graph/scanners/gcc.cpp @@ -105,27 +105,49 @@ auto is_scan_hazard(std::string_view flag) -> bool return is_blank_word(flag) || has_shell_special(flag) || leads_any(hazard_flags, flag); } +/// Where the compiler stands in one invocation -- first, or behind one recognized wrapper. +auto compiler_index(std::span invocation) -> std::optional +{ + if (invocation.empty()) { + return std::nullopt; + } + auto idx = std::size_t { 0 }; + if (is_compiler_wrapper(program_basename(invocation[0])) && invocation.size() > 1) { + idx = 1; + } + if (!is_compiler_name(program_basename(invocation[idx]))) { + return std::nullopt; + } + return idx; +} + +auto command_words(std::string_view command) -> Vec +{ + auto& pool = global_pool(); + auto words = Vec {}; + for (auto id : core::tokenize_shell_command(command)) { + words.push_back(pool.get(id)); + } + return words; +} + } // namespace auto matches_gcc_compile(std::string_view command) -> bool { - auto word_ids = core::tokenize_shell_command(command); - if (word_ids.empty()) { + auto words = command_words(command); + if (words.empty()) { return false; } - auto& pool = global_pool(); - auto compiler_idx = std::size_t { 0 }; - if (is_compiler_wrapper(program_basename(pool.get(word_ids[0]))) && word_ids.size() > 1) { - compiler_idx = 1; - } - - if (!is_compiler_name(program_basename(pool.get(word_ids[compiler_idx])))) { + auto invocations = split_invocations(std::span { words.data(), words.size() }); + auto compiler_idx = compiler_index(invocations[0]); + if (!compiler_idx) { return false; } - for (auto i = compiler_idx + 1; i < word_ids.size(); ++i) { - if (pool.get(word_ids[i]) == "-c") { + for (auto i = *compiler_idx + 1; i < words.size(); ++i) { + if (words[i] == "-c") { return true; } } @@ -162,61 +184,55 @@ auto GccScanner::has_dep_flags(std::string_view cmd) const -> bool auto GccScanner::build_dep_command(CommandInfo const& cmd) const -> std::optional { auto& pool = global_pool(); - auto word_ids = core::tokenize_shell_command(pool.get(cmd.command)); - if (word_ids.empty()) { + auto words = command_words(pool.get(cmd.command)); + if (words.empty()) { return std::nullopt; } - auto words = Vec {}; - words.reserve(word_ids.size()); - for (auto id : word_ids) { - words.push_back(pool.get(id)); - } - - auto compiler_idx = std::size_t { 0 }; - if (is_compiler_wrapper(program_basename(words[0])) && words.size() > 1) { - compiler_idx = 1; - } - - if (!is_compiler_name(program_basename(words[compiler_idx]))) { + auto invocations = split_invocations(std::span { words.data(), words.size() }); + auto const first = invocations[0]; + auto compiler_idx = compiler_index(first); + if (!compiler_idx) { return std::nullopt; } auto dep_cmd = Buf {}; - for (auto i = std::size_t { 0 }; i <= compiler_idx; ++i) { + for (auto i = std::size_t { 0 }; i <= *compiler_idx; ++i) { if (i > 0) { dep_cmd += ' '; } - dep_cmd += words[i]; + dep_cmd += first[i]; } dep_cmd += " -M"; auto pending = std::optional {}; - auto later_invocation = false; + auto redirected = false; auto source_files = Vec {}; - for (auto i = compiler_idx + 1; i < words.size(); ++i) { - if (is_command_separator(words[i])) { - later_invocation = true; + for (auto i = *compiler_idx + 1; i < first.size(); ++i) { + // A redirection hands its target to this same invocation, so the words after it are not + // flags the scan may carry. + if (is_flag_barrier(first[i])) { + redirected = true; pending.reset(); continue; } - if (later_invocation) { - if (is_source_file(words[i])) { - source_files.push_back(words[i]); + if (redirected) { + if (is_source_file(first[i])) { + source_files.push_back(first[i]); } continue; } if (pending) { - append_separate_arg_into(dep_cmd, words[i], *pending); + append_separate_arg_into(dep_cmd, first[i], *pending); pending.reset(); continue; } - auto w = words[i]; + auto w = first[i]; if (w == "-c") { continue; @@ -243,6 +259,14 @@ auto GccScanner::build_dep_command(CommandInfo const& cmd) const -> std::optiona pending = separate_arg(w); } + for (auto later = std::size_t { 1 }; later < invocations.size(); ++later) { + for (auto w : invocations[later]) { + if (is_source_file(w)) { + source_files.push_back(w); + } + } + } + if (source_files.empty()) { return std::nullopt; } From 12bfb89627b4f1e5ffce64fd93fcb2ceaad7fc0b Mon Sep 17 00:00:00 2001 From: Mura Li <2606021+typeless@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:21:42 +0800 Subject: [PATCH 2/2] Scan only a command whose invocations are all compiles A scan runs from the rule's directory with the rest of the command stripped, so a word it takes from a later invocation is a word it reads in a state that invocation never had. `gcc -c a.c -o a.o && cd sub && gcc -c b.c -o b.o` emitted `gcc -M a.c b.c`: with no b.c here the build failed outright, and with a same-named b.c here it succeeded and recorded that file's headers instead -- stale against the header the rule reads, and rebuilding on one it does not. The fold was not even limited to compiles: `&& rm junk.c` contributed junk.c, and the matcher would call a link a compile on a later invocation's -c. So: a scan may carry a word only from an invocation putup can reproduce, and it can reproduce an invocation only when it and every invocation before it is a compile it recognizes. Invocation 0 is the old front gate -- the same rule with the word "first" deleted -- and the matcher and the builder now ask it through one call, which is what keeps the unscanned-rule report and the scan agreeing about a rule. Refusal needs no new channel: with no scan node, the report #352 installed names the object under `parse` and counts the rule under `build`. That is the standing constraint here -- reporting is binary per rule, so no scan decision may create a state it cannot express. Measured cost in tree: none. No example rule is compiler-first with a separator. Closes #356. --- DESIGN.md | 11 +++++ spec/requirements/dep-scan.ears.md | 34 +++++++------ src/graph/scanners/clang_cl.cpp | 29 +++++++----- src/graph/scanners/gcc.cpp | 29 +++++++----- test/unit/test_dep_scanner.cpp | 76 ++++++++++++++++++++++++++++++ test/unit/test_e2e.cpp | 45 ++++++++++++++++++ 6 files changed, 188 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 1aa110c4..9aece720 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -890,6 +890,17 @@ class DepScannerRegistry { The `GccScanner` implementation handles GCC, Clang, and compatible compilers. +**What a scan may cover.** A scan runs from the rule's directory with the rest of the command +stripped, so it may carry a word only from an invocation it can reproduce, and it can reproduce an +invocation only when it and every invocation before it is a compile the scanner recognizes. +Refusal is decidable from the token stream; anything finer requires modeling the shell. A +redirection ends what the scan may read from an invocation but divides no invocation — only a +control operator starts a new one. Two +consequences bind every future change here. Per rule, reporting is binary — scanned, or reported +unscanned — so no encoding may create a scan state the reporter cannot express; widen the reporter +first. And `matches` and `build_dep_command` answer through one criterion, because the unscanned +report consumes the production predicate rather than a re-derivation of it. + ### Generation Flow ``` diff --git a/spec/requirements/dep-scan.ears.md b/spec/requirements/dep-scan.ears.md index b50e735a..8c9e66ad 100644 --- a/spec/requirements/dep-scan.ears.md +++ b/spec/requirements/dep-scan.ears.md @@ -21,11 +21,10 @@ changes neither and the scan keeps it. Declining is therefore correct — but de silence leaves a rule whose headers are never recorded, which is why the last requirement here exists. -That narrowness is enforced at the *front* of a command only. Once a scan is generated, source -words are folded in from every later invocation without asking what ran between them, so a -command whose second invocation changes directory can still contribute a source to a scan that -will not find it — issue #356. `REQ-SCAN-FLAG-SOURCE` below records that folding as it is -today, not as it should be. +The unit that narrowness is measured in is the invocation: a command's control operators divide it +into invocations, and a scan may draw a word only from one it can reproduce. A redirection is not +such a divider — it hands its target to the same program — though the scan still carries no flag +from beyond it. Two classes escape the report. A compile-and-link command with no `-c` produces an executable rather than an object file, so the output-shaped trigger cannot see it (the `HOSTCC` generator @@ -39,29 +38,36 @@ unrelated word spelling one silences the report for that rule. Which commands a scan is generated for. -### REQ-SCAN-UNREPRODUCIBLE-PREFIX +### REQ-SCAN-UNREPRODUCIBLE-INVOCATION - conformance: putup-only - discharge: test "GccScanner rejects compound shell commands" - discharge: test "GccScanner compiler wrapper handling" +- discharge: test "GccScanner refuses a command whose later invocation changes directory" +- discharge: test "GccScanner refuses a command whose later invocation is not a compile" +- discharge: test "matches_gcc_compile refuses a link whose later invocation compiles" +- discharge: test "ClangClScanner refuses a later invocation that is not a compile" -Where any word other than a recognized compiler wrapper precedes a command's compiler, such as -a loop, a directory change or an environment assignment, putup shall generate no dependency -scan for that command, because the scan runs without that word and would preprocess in a state -the compile never had. +Where a command runs any invocation that is not a compile putup recognizes, whether a loop, a +directory change, an environment assignment, a link or any other program, putup shall generate no +dependency scan for that command, because the scan runs from the rule's directory with the rest of +the command stripped and would preprocess in a state the compile never had. ### REQ-SCAN-FLAG-SOURCE - conformance: putup-only - discharge: test "GccScanner takes flags from the invocation it scans, sources from all of them" -Where a command runs more than one invocation, putup shall build the scan from the flags of the -first invocation only, and from the source-file words of every invocation, whether or not the -later ones run a compiler. +Where a command runs more than one invocation and every one of them is a compile putup recognizes, +putup shall build one scan carrying the source-file words of all of them; that scan takes its flags +from the first invocation alone, which issue #355 records as a limitation rather than a behaviour a +later invocation may rely on. ## Group: reporting -What putup says about a rule it did not scan. +What putup says about a rule it did not scan. Per rule the reporting is binary — scanned, or +reported unscanned — so no scan decision may create a state this group cannot express, such as a +rule scanned in part; widening what a scan may cover means widening this group first. ### REQ-SCAN-REPORT-UNSCANNED diff --git a/src/graph/scanners/clang_cl.cpp b/src/graph/scanners/clang_cl.cpp index 4ffa2a81..e990e5ad 100644 --- a/src/graph/scanners/clang_cl.cpp +++ b/src/graph/scanners/clang_cl.cpp @@ -133,6 +133,19 @@ auto driver_index(std::span invocation) -> std::optional return idx; } +/// A scan reproduces one invocation from the rule's directory, so it may carry a word only from a +/// command whose invocations are all compiles it recognizes (#356). +auto every_invocation_is_a_compile(std::span const> invocations) -> bool +{ + return std::ranges::all_of(invocations, [](auto invocation) { + auto idx = driver_index(invocation); + if (!idx) { + return false; + } + return std::ranges::any_of(invocation.subspan(*idx + 1), is_compile_flag); + }); +} + auto command_words(std::string_view command) -> Vec { auto& pool = global_pool(); @@ -153,17 +166,7 @@ auto matches_clang_cl_compile(std::string_view command) -> bool } auto invocations = split_invocations(std::span { words.data(), words.size() }); - auto driver_idx = driver_index(invocations[0]); - if (!driver_idx) { - return false; - } - - for (auto i = *driver_idx + 1; i < words.size(); ++i) { - if (is_compile_flag(words[i])) { - return true; - } - } - return false; + return every_invocation_is_a_compile(std::span { invocations.data(), invocations.size() }); } auto ClangClScanner::matches(CommandInfo const& cmd) const -> bool @@ -194,6 +197,10 @@ auto ClangClScanner::build_dep_command(CommandInfo const& cmd) const -> std::opt } auto invocations = split_invocations(std::span { words.data(), words.size() }); + if (!every_invocation_is_a_compile(std::span { invocations.data(), invocations.size() })) { + return std::nullopt; + } + auto const first = invocations[0]; auto driver_idx = driver_index(first); if (!driver_idx) { diff --git a/src/graph/scanners/gcc.cpp b/src/graph/scanners/gcc.cpp index aec3c6b0..3b874b97 100644 --- a/src/graph/scanners/gcc.cpp +++ b/src/graph/scanners/gcc.cpp @@ -121,6 +121,19 @@ auto compiler_index(std::span invocation) -> std::option return idx; } +/// A scan reproduces one invocation from the rule's directory, so it may carry a word only from a +/// command whose invocations are all compiles it recognizes (#356). +auto every_invocation_is_a_compile(std::span const> invocations) -> bool +{ + return std::ranges::all_of(invocations, [](auto invocation) { + auto idx = compiler_index(invocation); + if (!idx) { + return false; + } + return std::ranges::any_of(invocation.subspan(*idx + 1), [](auto w) { return w == "-c"; }); + }); +} + auto command_words(std::string_view command) -> Vec { auto& pool = global_pool(); @@ -141,17 +154,7 @@ auto matches_gcc_compile(std::string_view command) -> bool } auto invocations = split_invocations(std::span { words.data(), words.size() }); - auto compiler_idx = compiler_index(invocations[0]); - if (!compiler_idx) { - return false; - } - - for (auto i = *compiler_idx + 1; i < words.size(); ++i) { - if (words[i] == "-c") { - return true; - } - } - return false; + return every_invocation_is_a_compile(std::span { invocations.data(), invocations.size() }); } auto GccScanner::matches(CommandInfo const& cmd) const -> bool @@ -190,6 +193,10 @@ auto GccScanner::build_dep_command(CommandInfo const& cmd) const -> std::optiona } auto invocations = split_invocations(std::span { words.data(), words.size() }); + if (!every_invocation_is_a_compile(std::span { invocations.data(), invocations.size() })) { + return std::nullopt; + } + auto const first = invocations[0]; auto compiler_idx = compiler_index(first); if (!compiler_idx) { diff --git a/test/unit/test_dep_scanner.cpp b/test/unit/test_dep_scanner.cpp index 233799bb..aa476162 100644 --- a/test/unit/test_dep_scanner.cpp +++ b/test/unit/test_dep_scanner.cpp @@ -1305,6 +1305,82 @@ TEST_CASE("GccScanner takes flags from the invocation it scans, sources from all REQUIRE(pup::global_pool().get(*dep_cmd) == "gcc -M -O2 a.c b.c"); } +TEST_CASE("GccScanner refuses a command whose later invocation changes directory", "[dep_scanner][gcc]") +{ + auto scanner = scanners::GccScanner {}; + auto dep_cmd = scanner.build_dep_command( + gcc_compile(70, "gcc -c a.c -o a.o && cd sub && gcc -c b.c -o b.o") + ); + + REQUIRE(!dep_cmd.has_value()); +} + +TEST_CASE("GccScanner refuses a command whose later invocation is not a compile", "[dep_scanner][gcc]") +{ + auto scanner = scanners::GccScanner {}; + + SECTION("a later invocation that deletes a file contributes no source") + { + auto dep_cmd = scanner.build_dep_command(gcc_compile(71, "gcc -c a.c -o a.o && rm junk.c")); + REQUIRE(!dep_cmd.has_value()); + } + + SECTION("a later invocation that copies files contributes neither operand") + { + auto dep_cmd = scanner.build_dep_command(gcc_compile(72, "gcc -c a.c -o a.o && cp b.c x.c")); + REQUIRE(!dep_cmd.has_value()); + } +} + +TEST_CASE("A separator with nothing after it begins no invocation", "[dep_scanner][gcc]") +{ + auto scanner = scanners::GccScanner {}; + + SECTION("a trailing terminator") + { + auto dep_cmd = scanner.build_dep_command(gcc_compile(74, "gcc -c a.c -o a.o ;")); + REQUIRE(dep_cmd.has_value()); + REQUIRE(pup::global_pool().get(*dep_cmd) == "gcc -M a.c"); + } + + SECTION("a trailing backgrounding operator") + { + REQUIRE(scanners::matches_gcc_compile("gcc -c a.c -o a.o &")); + } + + SECTION("a leading separator still forfeits the scan") + { + REQUIRE(!scanners::matches_gcc_compile("; gcc -c a.c -o a.o")); + } +} + +TEST_CASE("matches_gcc_compile refuses a command whose later invocation is not a compile", "[dep_scanner][gcc]") +{ + // The diagnostic consumes the matcher and the scan consumes the builder; a rule they answer + // differently about is a rule reported as covered and scanned wrongly. + REQUIRE(!scanners::matches_gcc_compile("gcc -c a.c -o a.o && rm junk.c")); +} + +TEST_CASE("matches_gcc_compile refuses a link whose later invocation compiles", "[dep_scanner][gcc]") +{ + REQUIRE(!scanners::matches_gcc_compile("gcc -o prog main.c && gcc -c helper.c")); +} + +TEST_CASE("matches_clang_cl_compile refuses a link whose later invocation compiles", "[dep_scanner][clang_cl]") +{ + REQUIRE(!scanners::matches_clang_cl_compile("clang-cl foo.obj -o foo.exe && clang-cl -c bar.cpp")); +} + +TEST_CASE("ClangClScanner refuses a later invocation that is not a compile", "[dep_scanner][clang_cl]") +{ + auto scanner = scanners::ClangClScanner {}; + auto dep_cmd = scanner.build_dep_command( + clang_cl_compile(73, "clang-cl -c a.cpp -o a.obj && cd sub && clang-cl -c b.cpp -o b.obj") + ); + + REQUIRE(!dep_cmd.has_value()); +} + TEST_CASE("a line continuation never reaches the scan command", "[dep_scanner]") { SECTION("a continuation the command text kept is not a word the scan carries") diff --git a/test/unit/test_e2e.cpp b/test/unit/test_e2e.cpp index e4614ffd..3adf38c5 100644 --- a/test/unit/test_e2e.cpp +++ b/test/unit/test_e2e.cpp @@ -11114,6 +11114,51 @@ SCENARIO("Check level controls convention enforcement", "[e2e][strict]") } } +SCENARIO("A rule whose second compile runs elsewhere is reported instead of scanned wrongly", "[e2e][strict][depscan]") +{ + // The scan runs from the Tupfile's directory, so a source word taken from an invocation that + // ran in sub/ resolves against a same-named file here -- deps recorded for a file the rule + // never compiled (#356). + GIVEN("a rule that compiles here and then again after a cd, with a same-named source in both") + { + auto f = E2EFixture { "glob_mixed_space" }; + f.mkdir("sub"); + f.write_file("a.c", "#include \"root.h\"\nint a(void){return 0;}\n"); + f.write_file("b.c", "#include \"decoy.h\"\nint decoy(void){return 0;}\n"); + f.write_file("sub/b.c", "#include \"subonly.h\"\nint b(void){return 1;}\n"); + f.write_file("root.h", "#define R 1\n"); + f.write_file("decoy.h", "#define D 1\n"); + f.write_file("sub/subonly.h", "#define S 1\n"); + f.write_file("Tupfile", ": a.c |> gcc -c a.c -o a.o && cd sub && gcc -c b.c -o b.o |> a.o sub/b.o\n"); + REQUIRE(f.build().success()); + + WHEN("parse reports on the rule") + { + auto result = f.pup({ "parse" }); + + THEN("it names the object rather than leaving the rule looking covered") + { + INFO("stderr: " << result.stderr_output); + REQUIRE(result.stderr_output.find("no dependency scan") != std::string::npos); + REQUIRE(result.stderr_output.find("a.o") != std::string::npos); + } + } + + WHEN("the source that only the Tupfile directory's copy includes is edited") + { + f.write_file("decoy.h", "#define D 2\n"); + auto const result = f.build(); + + THEN("the rule does not rebuild, because it never compiled that file") + { + INFO("stdout: " << result.stdout_output); + REQUIRE(result.success()); + REQUIRE(result.stdout_output.find("Nothing to do") != std::string::npos); + } + } + } +} + SCENARIO("A compile-shaped rule with no dependency scan is reported", "[e2e][strict][depscan]") { // The scan is declined correctly — putup cannot reproduce the prefix's shell state — but