From 0df8508b1bdb962e8a08d190e530cfaabb504f26 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 13 Oct 2026 08:14:30 +0200 Subject: [PATCH 001/152] test(const-param): a const suggestion must compile and keep the generated code A const suggestion must compile in every analyzed file and keep the generated code. --- run_test.py | 79 +++++++++ .../const-suggestion-abi-none.cpp | 44 +++++ .../const-suggestion-abi.cpp | 16 ++ .../const-suggestion-ir-input.ll | 43 +++++ .../const-suggestion-kept-blocks.cpp | 35 ++++ .../const-suggestion-kept-inlined.cpp | 24 +++ .../const-suggestion-kept.c | 22 +++ .../const-suggestion-kept.cpp | 23 +++ .../const-suggestion-macros.c | 91 ++++++++++ .../const-suggestion-must-compile.c | 69 ++++++++ .../const-suggestion-must-compile.cpp | 50 ++++++ .../const-suggestion-override-elsewhere.cpp | 24 +++ .../const-suggestion-printed-signature.c | 26 +++ .../const-suggestion-same-code.cpp | 167 ++++++++++++++++++ .../cross-tu-const-suggestion-api.h | 7 + .../cross-tu-const-suggestion-callback.c | 6 + .../cross-tu-const-suggestion-caller.c | 9 + .../cross-tu-const-suggestion-cast.c | 11 ++ .../cross-tu-const-suggestion-def.c | 7 + .../cross-tu-const-suggestion-ir-caller.ll | 43 +++++ .../cross-tu-const-suggestion-redecl.c | 9 + test/unit/analyzer_module_unit_tests.cpp | 62 +++++++ 22 files changed, 867 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-abi.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-ir-input.ll create mode 100644 test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-kept.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-kept.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-macros.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-must-compile.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-printed-signature.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-same-code.cpp create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-api.h create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-callback.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-caller.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-cast.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-def.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-ir-caller.ll create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-redecl.c diff --git a/run_test.py b/run_test.py index 8107762..8151123 100755 --- a/run_test.py +++ b/run_test.py @@ -2858,6 +2858,83 @@ def check_const_param_contract_message_across_files() -> bool: print() return True + +def check_const_suggestion_cross_tu() -> bool: + """ + #217: a suggested const type is reported only if every analyzed file still compiles with it, + once every declaration the defining file sees is rewritten. Another analyzed file that stores + the function where a non-const parameter is expected, declares it itself, or converts its + address with an explicit cast, removes the suggestion. A file that is not analyzed is not + checked. The callback and cast files sort before the definition, the redeclaration after it. + """ + return _check_cross_tu_rule( + "suggested const types checked in every analyzed file", + "pointer_reference-const_correctness", + "cross-tu-const-suggestion-", + "ConstParameterNotModified.Pointer", + [ + ("def alone", ["def.c"], ["read_value"]), + ("def and a caller", ["def.c", "caller.c"], ["read_value"]), + ("def and a callback of the old type", ["def.c", "callback.c"], []), + ("def and a redeclaration", ["def.c", "redecl.c"], []), + ("def and an explicit cast", ["def.c", "cast.c"], []), + ], + [], + ) + + +def check_const_suggestion_without_source() -> bool: + """ + #217: a suggestion made on LLVM IR, with no source to rewrite, is kept and says that the + suggested type was not checked with Clang. So is a suggestion for a function that an analyzed + IR input uses: that input cannot be recompiled. Neither claims the Clang check. + """ + print("=== Testing const suggestions without source ===") + fixtures = RUN_CONFIG.test_dir / "pointer_reference-const_correctness" + checked = "checked in the analyzed files only" + + def message(files: list[Path], function: str) -> Optional[str]: + result = run_analyzer([*map(str, files), "--format=json"]) + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return None + for d in diagnostics: + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified") and str( + d.get("location", {}).get("function", "") + ) == function: + return str(d.get("details", {}).get("message", "")) + return None + + ok = True + for label, files, function, expected in ( + ( + "IR input alone", + [fixtures / "const-suggestion-ir-input.ll"], + "read_value_ir", + "not checked with Clang: no source file, so whether the suggested type compiles " + "was not verified", + ), + ( + "source function used by an IR input", + [ + fixtures / "cross-tu-const-suggestion-def.c", + fixtures / "cross-tu-const-suggestion-ir-caller.ll", + ], + "read_value", + "cross-tu-const-suggestion-ir-caller.ll uses the function but has no source", + ), + ): + text = message(files, function) + if text is not None and expected in text and checked not in text: + print(f" ✅ {label}") + else: + print(f" ❌ {label}: {text!r}") + ok = False + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -5497,6 +5574,8 @@ def record_ok(ok: bool): check_uninitialized_cross_tu_no_effect, check_const_param_cross_tu, check_const_param_contract_message_across_files, + check_const_suggestion_cross_tu, + check_const_suggestion_without_source, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp new file mode 100644 index 0000000..2da89c0 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a function whose symbol does not change gets no ABI note: internal linkage, or a C name. + +// strict-expectation-details: true +// not contains: ↳ ABI: + +// at line 12, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'internal_read(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +static int internal_read(int* p) +{ + return *p; +} + +namespace +{ + // at line 24, column 0 + // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function '(anonymous namespace)::anonymous_read(int*)' is never used to modify the pointed object + // ↳ current type: int *p + // ↳ suggested type: const int *p + // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized + int anonymous_read(int* p) + { + return *p; + } +} // namespace + +// at line 35, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'c_read' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +extern "C" int c_read(int* p) +{ + return *p; +} + +int use_internal_reads() +{ + int value = 1; + return internal_read(&value) + anonymous_read(&value); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp new file mode 100644 index 0000000..b558ddd --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: in C++, const on the pointee changes the symbol of a function with external linkage. +// Code compiled separately against the current declaration no longer links: the message says so. + +// strict-expectation-details: true + +// at line 13, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'external_read(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z13external_readPi -> _Z13external_readPKi): code compiled separately must use the updated declaration and be recompiled +int external_read(int* p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-ir-input.ll b/test/pointer_reference-const_correctness/const-suggestion-ir-input.ll new file mode 100644 index 0000000..26f7f23 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-ir-input.ll @@ -0,0 +1,43 @@ +; ModuleID = 'const-suggestion-ir-input.c' +source_filename = "const-suggestion-ir-input.c" +target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32" +target triple = "arm64-apple-macosx15.0.0" + +; Function Attrs: noinline nounwind optnone ssp uwtable(sync) +define i32 @read_value_ir(ptr noundef %p) #0 !dbg !10 { +entry: + %p.addr = alloca ptr, align 8 + store ptr %p, ptr %p.addr, align 8 + #dbg_declare(ptr %p.addr, !16, !DIExpression(), !17) + %0 = load ptr, ptr %p.addr, align 8, !dbg !18 + %1 = load i32, ptr %0, align 4, !dbg !19 + ret i32 %1, !dbg !20 +} + +attributes #0 = { noinline nounwind optnone ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a,+zcm,+zcz" } + +!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6} +!llvm.dbg.cu = !{!7} +!llvm.ident = !{!9} + +!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]} +!1 = !{i32 7, !"Dwarf Version", i32 5} +!2 = !{i32 2, !"Debug Info Version", i32 3} +!3 = !{i32 1, !"wchar_size", i32 4} +!4 = !{i32 8, !"PIC Level", i32 2} +!5 = !{i32 7, !"uwtable", i32 1} +!6 = !{i32 7, !"frame-pointer", i32 1} +!7 = distinct !DICompileUnit(language: DW_LANG_C11, file: !8, producer: "Homebrew clang version 20.1.2", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug, splitDebugInlining: false, nameTableKind: Apple, sysroot: "/Library/Developer/CommandLineTools/SDKs/MacOSX15.sdk", sdk: "MacOSX15.sdk") +!8 = !DIFile(filename: "const-suggestion-ir-input.c", directory: ".", checksumkind: CSK_MD5, checksum: "929812bcf8a8ea56e7e27bdc384c05af") +!9 = !{!"Homebrew clang version 20.1.2"} +!10 = distinct !DISubprogram(name: "read_value_ir", scope: !8, file: !8, line: 1, type: !11, scopeLine: 2, flags: DIFlagPrototyped, spFlags: DISPFlagDefinition, unit: !7, retainedNodes: !15) +!11 = !DISubroutineType(types: !12) +!12 = !{!13, !14} +!13 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed) +!14 = !DIDerivedType(tag: DW_TAG_pointer_type, baseType: !13, size: 64) +!15 = !{} +!16 = !DILocalVariable(name: "p", arg: 1, scope: !10, file: !8, line: 1, type: !14) +!17 = !DILocation(line: 1, column: 24, scope: !10) +!18 = !DILocation(line: 3, column: 13, scope: !10) +!19 = !DILocation(line: 3, column: 12, scope: !10) +!20 = !DILocation(line: 3, column: 5, scope: !10) diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp new file mode 100644 index 0000000..17b550d --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggestion is kept when the generated code is the same. log_text's join block has +// three predecessors, which the printed IR lists in a comment, in an order that copying a module +// may change: comments are not code. + +// strict-expectation-details: true +#include + +void sink(const std::string& text); + +static void log_text(const std::string& text) +{ + if (text.empty()) + return; + if (text.back() == '\n') + { + sink(text); + } + else + { + sink(text + "\n"); + } +} + +// at line 31, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_and_log(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z12read_and_logPi -> _Z12read_and_logPKi): code compiled separately must use the updated declaration and be recompiled +int read_and_log(int* p) +{ + log_text("value"); + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp new file mode 100644 index 0000000..88c2f8b --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggestion is kept when the generated code is the same. Inlining read_once, which is +// instantiated with a lambda declared in the function, names a block after it: the name holds the +// function's symbol, which the rewrite changes. Names of values and blocks are not code. + +// strict-expectation-details: true +template __attribute__((always_inline)) inline int read_once(Read read, bool skip) +{ + if (skip) + return 0; + return read(); +} + +// at line 20, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_with_lambda(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z16read_with_lambdaPi -> _Z16read_with_lambdaPKi): code compiled separately must use the updated declaration and be recompiled +int read_with_lambda(int* p) +{ + auto get = [p] { return *p; }; + return read_once(get, false); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.c b/test/pointer_reference-const_correctness/const-suggestion-kept.c new file mode 100644 index 0000000..de3adb2 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.c @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggestion is kept when the generated code is the same. Inlining a callee with a +// restrict parameter adds metadata whose numbers move with the debug information of the new type. + +// strict-expectation-details: true +// not contains: ↳ ABI: + +static inline __attribute__((always_inline)) int load(const int* __restrict q) +{ + return *q; +} + +// at line 19, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_restricted' is only read, or passed where a pointer to const is expected +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ the callee's body is not checked: it may still modify the pointed object through a cast +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +int read_restricted(int* p) +{ + return load(p); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp new file mode 100644 index 0000000..c4dbd61 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggestion is kept when the generated code is the same once the symbols the rewrite +// renames are paired. The typeinfo name of a local type holds the function's mangled name, which +// the rewrite changes with it: typeid shows it at run time, so the message says so. + +// strict-expectation-details: true + +// at line 15, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_or_throw(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized, except for the differences below +// ↳ difference: the name typeid gives a type named after the function changes, from "Z13read_or_throwPiE7Missing" to "Z13read_or_throwPKiE7Missing" +// ↳ ABI: the suggested type changes the symbol (_Z13read_or_throwPi -> _Z13read_or_throwPKi): code compiled separately must use the updated declaration and be recompiled +int read_or_throw(int* p) +{ + struct Missing + { + }; + if (!p) + throw Missing{}; + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-macros.c b/test/pointer_reference-const_correctness/const-suggestion-macros.c new file mode 100644 index 0000000..bd60b02 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-macros.c @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: types written by macros. A macro argument is rewritten where it is written; a macro that +// writes only the parameter gets const before its invocation, since a macro is text. A macro +// definition that writes the declaration is not rewritten: the suggestion is kept, not checked. + +// strict-expectation-details: true +// not contains: parameter 'buffer' in function 'object_macro_callback' +// not contains: ↳ ABI: +#define BYTE unsigned char +#define BYTE_POINTER unsigned char* +#define BUFFER_PARAM(name) unsigned char* name +#define DECLARE_READER(name, type) int name(type buffer) +#define READER_DECLARATIONS int written_by_definition(unsigned char* buffer); + +typedef unsigned char* byte_pointer; +#define BYTE_TYPEDEF byte_pointer + +// at line 23, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'object_macro' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +int object_macro(BYTE* buffer) +{ + return buffer[0]; +} + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'pointer_macro' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +int pointer_macro(BYTE_POINTER buffer) +{ + return buffer[0]; +} + +// at line 43, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'declarator_macro' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +int declarator_macro(BUFFER_PARAM(buffer)) +{ + return buffer[0]; +} + +// at line 53, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'argument_macro' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +DECLARE_READER(argument_macro, unsigned char*) +{ + return buffer[0]; +} + +// at line 63, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'typedef_macro' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ not checked with Clang: the parameter's typedef is written by a macro +int typedef_macro(BYTE_TYPEDEF buffer) +{ + return buffer[0]; +} + +READER_DECLARATIONS + +// at line 75, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'written_by_definition' is never used to modify the pointed object +// ↳ current type: unsigned char *buffer +// ↳ suggested type: const unsigned char *buffer +// ↳ not checked with Clang: the declaration is written by a macro definition +int written_by_definition(unsigned char* buffer) +{ + return buffer[0]; +} + +// A macro type, and a callback of the old type: the const version does not compile. +int object_macro_callback(BYTE* buffer) +{ + return buffer[0]; +} + +int apply(int (*reader)(BYTE*), BYTE* buffer); + +int run_callback(BYTE* buffer) +{ + return apply(object_macro_callback, buffer); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.c b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c new file mode 100644 index 0000000..061e8ec --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a parameter is reported only if the analyzed files still compile with its suggested type +// and generate the same code. Here the const version does not compile, or selects other code. + +// strict-expectation-details: true +// not contains: parameter 'c' in function 'writer' +// not contains: parameter 'slot' in function 'read_slot' +// not contains: parameter 'p' in function 'address_to_int_pp' +// not contains: parameter 'p' in function 'generic_selection' +// not contains: ↳ ABI: + +struct ctx +{ + int* value; +}; + +void apply(void (*callback)(struct ctx*), struct ctx* c); + +// Passed where a callback with a non-const parameter is expected. +static void writer(struct ctx* c) +{ + (void)c->value; +} + +void run(struct ctx* c) +{ + apply(writer, c); +} + +// Its address initializes a pointer whose type has a non-const parameter. +static int read_slot(int* slot) +{ + return *slot; +} + +int (*const slot_reader)(int*) = read_slot; + +// &p is passed where int** is expected: const would differ in a nested qualifier. +static int read_pp(int** pp) +{ + return **pp; +} + +int address_to_int_pp(int* p) +{ + return read_pp(&p); +} + +// _Generic selects on the parameter's type: the const version compiles, but returns 0 instead +// of reading *p. +#define READ_ONLY(x) _Generic((x), const int*: 1, default: 0) + +int generic_selection(int* p) +{ + if (READ_ONLY(p)) + return 0; + return *p; +} + +// Control: nothing depends on the parameter's type. +// at line 66, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +int read_value(int* p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp new file mode 100644 index 0000000..da05486 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a parameter is reported only if the analyzed files still compile with its suggested type. + +// strict-expectation-details: true +// not contains: parameter 'p' in function 'address_to_int_pp(int*)' +// not contains: parameter 'value' in function 'unused_lambda(int*)' +// not contains: parameter 'r' in function 'keep(int&)' + +// &p is passed where int** is expected: no function matches the const version. +static int read_pp(int** pp) +{ + return **pp; +} + +int address_to_int_pp(int* p) +{ + return read_pp(&p); +} + +// A writing lambda that is never called: its body is not in the generated code. +int unused_lambda(int* value) +{ + auto bump = [&]() { return ++*value; }; + (void)bump; + return 0; +} + +// A reference bound to an int& member of an aggregate. +struct Root +{ + int& ref; +}; + +void keep(int& r) +{ + Root root{r}; + (void)root; +} + +// Control: nothing depends on the parameter's type. +// at line 47, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z10read_valuePi -> _Z10read_valuePKi): code compiled separately must use the updated declaration and be recompiled +int read_value(int* p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp b/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp new file mode 100644 index 0000000..ce22683 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: with const, Derived::read no longer overrides Base::read: it becomes another virtual +// function. The using-declaration keeps -Woverloaded-virtual silent, and the key functions are +// defined in another file, so this one emits no vtable and no call: its code changes only the +// method's symbol. The methods it overrides change. + +// not contains: parameter 'p' in function 'Derived::read(int*)' +struct Base +{ + virtual ~Base(); + virtual int read(int* p); +}; + +struct Derived : Base +{ + ~Derived() override; + using Base::read; + virtual int read(int* p); +}; + +int Derived::read(int* p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c new file mode 100644 index 0000000..dcaf2e5 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: __PRETTY_FUNCTION__ prints the function's signature, which the suggested type changes. The +// suggestion is kept, and the message says what the program prints differently. Any other use of +// that text, such as its size, is a change of the generated code. + +// strict-expectation-details: true +// not contains: parameter 'p' in function 'signature_size' +// not contains: ↳ ABI: +#include + +// at line 17, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'print_and_read' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized, except for the differences below +// ↳ difference: the signature printed by __PRETTY_FUNCTION__ changes, from "int print_and_read(int *)" to "int print_and_read(const int *)" +int print_and_read(int* p) +{ + puts(__PRETTY_FUNCTION__); + return *p; +} + +int signature_size(int* p) +{ + return *p ? (int)sizeof(__PRETTY_FUNCTION__) : 0; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp new file mode 100644 index 0000000..1b224e9 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the const version compiles, but the generated code changes: a call, an override or a +// constant now resolves elsewhere. The parameter is not reported. + +// strict-expectation-details: true +// not contains: parameter 'p' in function 'overload_target(int*)' +// not contains: parameter 'p' in function 'Shape::area(int*)' +// not contains: parameter 'p' in function 'ReadDerived::read(int*)' +// not contains: parameter 'p' in function 'ReadBase::read(int*)' +// not contains: parameter 'p' in function 'sized_target(int*)' +// not contains: parameter 'p' in function 'branch_target(int*)' +// not contains: parameter 'p' in function 'self_typed(int*)' +#include + +// A caller with a pointer to const now calls this overload instead of the bool one. +int overload_target(int* p) +{ + return *p; +} + +int overload_target(bool flag) +{ + return flag ? 1 : 0; +} + +int call_overload(const int* q) +{ + return overload_target(q); +} + +// With const, ReadDerived::read no longer overrides ReadBase::read. The using-declaration keeps +// -Woverloaded-virtual silent: nothing warns. +struct ReadBase +{ + virtual ~ReadBase() = default; + virtual int read(int* p) + { + return *p; + } +}; + +struct ReadDerived : ReadBase +{ + using ReadBase::read; + int read(int* p) + { + return *p; + } +}; + +int call_read(ReadBase& object, int* p) +{ + return object.read(p); +} + +ReadBase base_reader; +ReadDerived derived_reader; + +int make_read(int* p) +{ + return call_read(derived_reader, p); +} + +// A virtual call now selects another overload: only its vtable slot and argument type change. +// Shape::area(int*) is defined out of line: it is the key function, the vtable is emitted here. +struct Shape +{ + virtual ~Shape() = default; + virtual int area(bool flag) + { + return flag ? 1 : 0; + } + virtual int area(int* p); +}; + +int Shape::area(int* p) +{ + return *p; +} + +int call_area(Shape& shape, const int* q) +{ + return shape.area(q); +} + +// sizeof selects an overload without calling it: the returned constant changes. +int sized_target(int* p) +{ + return *p; +} + +char sized_target(bool flag); + +int call_sized(const int* q) +{ + return static_cast(sizeof(sized_target(q))); +} + +// if constexpr chooses its branch from an unevaluated call. +int branch_target(int* p) +{ + return *p; +} + +char branch_target(bool flag); + +template int pick_branch(T q) +{ + if constexpr (sizeof(branch_target(q)) == 1) + return 10; + else + return 20; +} + +int call_branch(const int* q) +{ + return pick_branch(q); +} + +// The function inspects its own parameter's type. +int self_typed(int* p) +{ + if constexpr (std::is_const_v>) + return 1; + else + return *p; +} + +// Controls: the code stays the same. +// at line 136, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'called_directly(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z15called_directlyPi -> _Z15called_directlyPKi): code compiled separately must use the updated declaration and be recompiled +int called_directly(int* p) +{ + return *p; +} + +int call_directly(int* p) +{ + return called_directly(p); +} + +// at line 152, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'twice_target(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ ABI: the suggested type changes the symbol (_Z12twice_targetPi -> _Z12twice_targetPKi): code compiled separately must use the updated declaration and be recompiled +int twice_target(int* p) +{ + return *p; +} + +int twice_target(bool flag) +{ + return flag ? 2 : 3; +} + +#define TWICE(x) (twice_target(x) == twice_target(true)) + +int call_twice(int* p) +{ + return TWICE(p); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-api.h b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-api.h new file mode 100644 index 0000000..666b4f5 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-api.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +#ifndef CROSS_TU_CONST_SUGGESTION_API_H +#define CROSS_TU_CONST_SUGGESTION_API_H + +int read_value(int* p); + +#endif diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-callback.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-callback.c new file mode 100644 index 0000000..0eafe43 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-callback.c @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +// Another file stores the function where a non-const parameter is expected: with the suggested +// type, this file no longer compiles. +#include "cross-tu-const-suggestion-api.h" + +int (*const value_reader)(int*) = read_value; diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-caller.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-caller.c new file mode 100644 index 0000000..37d63bb --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-caller.c @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Another file calls the function through the header: it compiles with the suggested type. +#include "cross-tu-const-suggestion-api.h" + +int call_value(void) +{ + int value = 1; + return read_value(&value); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-cast.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-cast.c new file mode 100644 index 0000000..0a2e084 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-cast.c @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Another file converts the function's address with an explicit cast: the cast would accept the +// new type silently. +#include "cross-tu-const-suggestion-api.h" + +typedef int (*erased_reader)(void*); + +erased_reader erase_reader(void) +{ + return (erased_reader)read_value; +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-def.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-def.c new file mode 100644 index 0000000..88848d2 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-def.c @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "cross-tu-const-suggestion-api.h" + +int read_value(int* p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-ir-caller.ll b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-ir-caller.ll new file mode 100644 index 0000000..cf0bb95 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-ir-caller.ll @@ -0,0 +1,43 @@ +; ModuleID = 'cross-tu-const-suggestion-ir-caller.c' +source_filename = "cross-tu-const-suggestion-ir-caller.c" +target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32" +target triple = "arm64-apple-macosx15.0.0" + +; Function Attrs: noinline nounwind optnone ssp uwtable(sync) +define i32 @call_value_from_ir() #0 !dbg !10 { +entry: + %value = alloca i32, align 4 + #dbg_declare(ptr %value, !15, !DIExpression(), !16) + store i32 1, ptr %value, align 4, !dbg !16 + %call = call i32 @read_value(ptr noundef %value), !dbg !17 + ret i32 %call, !dbg !18 +} + +declare i32 @read_value(ptr noundef) #1 + +attributes #0 = { noinline nounwind optnone ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a,+zcm,+zcz" } +attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a,+zcm,+zcz" } + +!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6} +!llvm.dbg.cu = !{!7} +!llvm.ident = !{!9} + +!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]} +!1 = !{i32 7, !"Dwarf Version", i32 5} +!2 = !{i32 2, !"Debug Info Version", i32 3} +!3 = !{i32 1, !"wchar_size", i32 4} +!4 = !{i32 8, !"PIC Level", i32 2} +!5 = !{i32 7, !"uwtable", i32 1} +!6 = !{i32 7, !"frame-pointer", i32 1} +!7 = distinct !DICompileUnit(language: DW_LANG_C11, file: !8, producer: "Homebrew clang version 20.1.2", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug, splitDebugInlining: false, nameTableKind: Apple, sysroot: "/Library/Developer/CommandLineTools/SDKs/MacOSX15.sdk", sdk: "MacOSX15.sdk") +!8 = !DIFile(filename: "cross-tu-const-suggestion-ir-caller.c", directory: ".", checksumkind: CSK_MD5, checksum: "c0dbcf087f44a6adb4189892f747b393") +!9 = !{!"Homebrew clang version 20.1.2"} +!10 = distinct !DISubprogram(name: "call_value_from_ir", scope: !8, file: !8, line: 3, type: !11, scopeLine: 4, flags: DIFlagPrototyped, spFlags: DISPFlagDefinition, unit: !7, retainedNodes: !14) +!11 = !DISubroutineType(types: !12) +!12 = !{!13} +!13 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed) +!14 = !{} +!15 = !DILocalVariable(name: "value", scope: !10, file: !8, line: 5, type: !13) +!16 = !DILocation(line: 5, column: 9, scope: !10) +!17 = !DILocation(line: 6, column: 12, scope: !10) +!18 = !DILocation(line: 6, column: 5, scope: !10) diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-redecl.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-redecl.c new file mode 100644 index 0000000..d123905 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-redecl.c @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Another file declares the function itself, without the header: rewriting the header would +// leave this declaration with the old type. +int read_value(int* p); + +int read_through_own_declaration(int* q) +{ + return read_value(q); +} diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index c084e0b..d443fb8 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include "StackUsageAnalyzer.hpp" #include "analysis/AnalyzerUtils.hpp" +#include "analysis/ConstSuggestionCheck.hpp" #include "app/CrossTUSummaryDriver.hpp" #include "app/AnalyzerApp.hpp" #include "cli/ArgParser.hpp" @@ -743,6 +744,66 @@ namespace return report.failures == 0; } + /// #217: the code generated before and after a const rewrite is compared once the symbols + /// the rewrite renames are paired. A symbol the unit only declares has no code to compare: it + /// is paired only if it is the rewritten function itself. + bool testConstSuggestionCodeComparison(TestReport& report) + { + using ctrace::stack::analysis::rewrittenCodeDifference; + llvm::LLVMContext context; + llvm::SMDiagnostic error; + const auto parse = + [&](std::string text, const std::string& function, const std::string& typeInfo) + { + for (const auto& [from, to] : {std::pair{std::string("@F"), "@" + function}, + std::pair{std::string("@TI"), "@" + typeInfo}}) + { + for (std::size_t at = text.find(from); at != std::string::npos; + at = text.find(from, at + to.size())) + { + text.replace(at, from.size(), to); + } + } + return llvm::parseAssemblyString(text, error, context); + }; + // The rewritten function and its caller. + const std::string called = "define i32 @F(ptr %p) {\n" + " %v = load i32, ptr %p\n" + " ret i32 %v\n" + "}\n" + "define i32 @_Z3usePi(ptr %p) {\n" + " %r = call i32 @F(ptr %p)\n" + " ret i32 %r\n" + "}\n"; + // The rewritten function throws its parameter: the thrown type changes with it, and a + // handler `catch (int*)` elsewhere no longer catches it. + const std::string thrown = "@TI = external constant ptr\n" + "declare ptr @__cxa_allocate_exception(i64)\n" + "declare void @__cxa_throw(ptr, ptr, ptr)\n" + "define void @F(ptr %p) {\n" + " %e = call ptr @__cxa_allocate_exception(i64 8)\n" + " store ptr %p, ptr %e\n" + " call void @__cxa_throw(ptr %e, ptr @TI, ptr null)\n" + " unreachable\n" + "}\n"; + const std::unique_ptr calledBefore = parse(called, "_Z1fPi", ""); + const std::unique_ptr calledAfter = parse(called, "_Z1fPKi", ""); + const std::unique_ptr thrownBefore = parse(thrown, "_Z1fPi", "_ZTIPi"); + const std::unique_ptr thrownAfter = parse(thrown, "_Z1fPKi", "_ZTIPKi"); + if (!calledBefore || !calledAfter || !thrownBefore || !thrownAfter) + { + report.expect(false, "ConstSuggestionCode setup: the IR does not parse"); + return false; + } + report.expect(!rewrittenCodeDifference(*calledBefore, *calledAfter, "_Z1fPi"), + "ConstSuggestionCode: the rewritten function and its caller are renamed " + "together"); + report.expect(rewrittenCodeDifference(*thrownBefore, *thrownAfter, "_Z1fPi").has_value(), + "ConstSuggestionCode: typeinfo for int* and for int const*, only declared, " + "are not paired"); + return report.failures == 0; + } + bool testUninitializedSummaryKeysAreLinkerSymbols(const std::filesystem::path& repoRoot, TestReport& report) { @@ -2358,6 +2419,7 @@ int main(int argc, char** argv) (void)testSarifRuleCweTags(report); (void)testSarifSecuritySeverity(report); (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); + (void)testConstSuggestionCodeComparison(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From 9a8fce89aedb5e3489e4242d3824fadc4f147468 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 13 Oct 2026 08:37:12 +0200 Subject: [PATCH 002/152] fix(const-param): check each const suggestion with Clang before reporting it --- CMakeLists.txt | 20 + README.md | 47 + include/StackUsageAnalyzer.hpp | 4 + include/analysis/ConstParamAnalysis.hpp | 5 + include/analysis/ConstSuggestionCheck.hpp | 80 ++ include/analysis/InputPipeline.hpp | 12 + src/StackUsageAnalyzer.cpp | 5 +- src/analysis/ConstParamAnalysis.cpp | 2 + src/analysis/ConstSuggestionCheck.cpp | 1057 +++++++++++++++++++++ src/analysis/ConstSuggestionCode.cpp | 541 +++++++++++ src/analysis/ConstSuggestionInternal.hpp | 14 + src/analysis/InputPipeline.cpp | 23 + src/analyzer/AnalysisPipeline.cpp | 4 +- src/analyzer/DiagnosticEmitter.cpp | 2 + src/app/AnalyzerApp.cpp | 22 +- 15 files changed, 1834 insertions(+), 4 deletions(-) create mode 100644 include/analysis/ConstSuggestionCheck.hpp create mode 100644 src/analysis/ConstSuggestionCheck.cpp create mode 100644 src/analysis/ConstSuggestionCode.cpp create mode 100644 src/analysis/ConstSuggestionInternal.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index de2e44d..6e64187 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,8 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(LLVM_LINK_LLVM_DYLIB ON) find_package(LLVM REQUIRED CONFIG) +# The check of const suggestions drives Clang itself (#217). +find_package(Clang REQUIRED CONFIG) message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}") message(STATUS "Using LLVMConfig.cmake in: ${LLVM_DIR}") @@ -74,6 +76,8 @@ set(STACK_ANALYZER_SOURCES src/analysis/AnalyzerUtils.cpp src/analysis/CompileCommands.cpp src/analysis/ConstParamAnalysis.cpp + src/analysis/ConstSuggestionCheck.cpp + src/analysis/ConstSuggestionCode.cpp src/analysis/CommandInjectionAnalysis.cpp src/analysis/DuplicateIfCondition.cpp src/analysis/DynamicAlloca.cpp @@ -191,6 +195,7 @@ target_include_directories(stack_usage_analyzer_lib target_include_directories(stack_usage_analyzer_lib SYSTEM PUBLIC ${LLVM_INCLUDE_DIRS} + ${CLANG_INCLUDE_DIRS} ) # Treat external dependency headers as system includes to reduce warning noise @@ -233,6 +238,21 @@ else() coretrace::logger ) endif() +# The same Clang libraries as compilerlib links. +if(CLANG_LINK_CLANG_DYLIB) + target_link_libraries(stack_usage_analyzer_lib PUBLIC clang-cpp) +else() + target_link_libraries(stack_usage_analyzer_lib + PUBLIC + clangAST + clangBasic + clangCodeGen + clangDriver + clangFrontend + clangLex + clangSema + ) +endif() # # ===== CLI BINARY ===== # add_executable(stack_usage_analyzer diff --git a/README.md b/README.md index caf8c04..bfa104c 100644 --- a/README.md +++ b/README.md @@ -628,6 +628,53 @@ Example run: For test files, `run_test.py` also supports per-file model selection with: `// resource-model: `. +### Const parameter suggestions checked with Clang + +`ConstParameterNotModified` finds, in the LLVM IR, the pointer and reference parameters through +which nothing is written. Before reporting one, the analyzer checks the suggested type with Clang: + +1. Every declaration of the function that the defining file sees, in that file and its headers, + is rewritten in memory with the suggested type: + - a type written in a macro argument is rewritten where it is written; + - a type written by a macro that writes only the parameter gets `const` before the invocation. + + A macro definition is never rewritten. +2. Every analyzed file that reads a rewritten file, or whose code references the function, is + compiled again with its own compile command, at `-O0`. +3. The suggestion is removed when one of these files: + - no longer compiles; + - gains one of these warnings: `incompatible-pointer-types-discards-qualifiers`, + `incompatible-pointer-types`, `incompatible-function-pointer-types`, `int-conversion`, + `overloaded-virtual`; + - declares the function, outside the rewritten files, with another parameter type; + - converts the function's address with an explicit cast; + - changes what the methods of that name override; + - generates different code. + +Generated code is compared function by function and global by global, after these +normalizations: +- debug information is removed; +- metadata are compared by the places that use them, not by their numbers or contents; +- attribute groups are compared by contents, not by numbers; +- comments of the printed IR are ignored; +- the symbols the rewrite renames are paired: the function, and what is named or instantiated + inside it. A symbol the file only declares is paired only if it is the function itself. + +Two differences that a program can observe are accepted, each only once verified, and the message +lists them: +- the name that `typeid` gives a type named after the function: it holds the function's mangled + name; +- a string constant whose only change is the signature that `__PRETTY_FUNCTION__` prints. + +The check covers only the analyzed files, with their compile commands, and the code these +configurations compile. The message says how the suggestion was checked: +- `checked in the analyzed files only, with their compile commands: ...`; +- `difference: ...`, for each accepted difference; +- `ABI: ...`, for a C++ function with external linkage: its symbol changes, so code compiled + separately must use the updated declaration and be recompiled; +- `not checked with Clang: ...`, when the check could not run, for example on an LLVM IR input + (`.ll`, `.bc`), which has no source. The suggestion then rests on the IR analysis alone. + ### Example Given this code: diff --git a/include/StackUsageAnalyzer.hpp b/include/StackUsageAnalyzer.hpp index 27d9988..2eaaf07 100644 --- a/include/StackUsageAnalyzer.hpp +++ b/include/StackUsageAnalyzer.hpp @@ -22,6 +22,7 @@ namespace llvm namespace ctrace::stack::analysis { + struct AnalyzedUnits; class CompilationDatabase; struct ConstPointeeParamIndex; struct GlobalReadBeforeWriteSummaryIndex; @@ -81,6 +82,9 @@ namespace ctrace::stack // Max stacks and recursion over one call graph of the modules analyzed together, which // they were prepared for: the pipeline then leaves their IR as it is. std::shared_ptr globalStackFacts; + // The inputs analyzed together, for the Clang check of const suggestions (#217). Null: + // the suggestions say they were not checked. + std::shared_ptr analyzedUnits; std::vector excludeDirs; std::vector extraCompileArgs; diff --git a/include/analysis/ConstParamAnalysis.hpp b/include/analysis/ConstParamAnalysis.hpp index 3790d48..da98e71 100644 --- a/include/analysis/ConstParamAnalysis.hpp +++ b/include/analysis/ConstParamAnalysis.hpp @@ -24,7 +24,12 @@ namespace ctrace::stack::analysis std::string currentType; std::string suggestedType; std::string suggestedTypeAlt; + // Lines that say how the suggestion was checked with Clang, or why it could not be (#217). + std::vector checkLines; double confidence = -1.0; + // The parameter's number in the debug information (1 is the first, `this` included). + std::uint32_t debugArgNo = 0; + std::uint32_t reservedArgPadding = 0; std::uint64_t pointerConstOnly : 1 = false; // ex: T * const param std::uint64_t isReference : 1 = false; std::uint64_t isRvalueRef : 1 = false; diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp new file mode 100644 index 0000000..5b1b11c --- /dev/null +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "StackUsageAnalyzer.hpp" +#include "analysis/ConstParamAnalysis.hpp" + +namespace llvm +{ + class Module; +} // namespace llvm + +namespace ctrace::stack::analysis +{ + // One input of the analysis, for the Clang check of const suggestions (#217). + struct AnalyzedUnit + { + std::string filename; // as given to the analyzer + const llvm::Module* module = nullptr; + LanguageType language = LanguageType::Unknown; + std::uint32_t reservedLanguagePadding = 0; + }; + + // The inputs analyzed together. + struct AnalyzedUnits + { + std::vector units; + // The files each unit reads, by unit filename, found when first needed. + mutable std::mutex filesReadMutex; + mutable std::map> filesRead; + }; + + // The one input of an analysis that loads it alone. + std::shared_ptr analyzedUnitOf(const std::string& filename, + const llvm::Module& module); + + // What __PRETTY_FUNCTION__ prints for the rewritten function, before and after the rewrite. + struct PrintedSignature + { + std::string before; + std::string after; + }; + + // Compares the code generated for one unit before and after the declarations of the function + // `rewritten` (its symbol before the rewrite) are rewritten. Returns the first difference, or + // nullopt when the code matches once normalized: + // - debug information is left out; + // - metadata are compared by the order in which each function or global uses them, not by + // their numbers or contents; + // - attribute groups are compared by contents, not by numbers; + // - the symbols the rewrite renames (the function, and what is named or instantiated inside + // it) are paired. A symbol the unit only declares is paired only if it is the function. + // Two differences that a run can observe are accepted, each only when verified, and described + // in `accepted`: the name of a type renamed with the function (_ZTS holds the mangled name + // of T), and a string constant whose only change is the function's printed signature, from + // `signature` (null: none is accepted). `renamedTo` receives the function's symbol after the + // rewrite. + std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, + llvm::StringRef rewritten, + const PrintedSignature* signature = nullptr, + std::vector* accepted = nullptr, + std::string* renamedTo = nullptr); + + // Checks the suggestions made for `mod` with Clang (#217): rewrites every declaration of the + // parameter with the suggested type, recompiles every analyzed unit that sees a rewritten file + // or references the function, and compares their generated code. A suggestion that does not + // compile, or that changes the generated code, is removed; the others get detail lines that + // say what was checked, or why it could not be. + void checkConstSuggestions(const llvm::Module& mod, std::vector& issues, + const AnalysisConfig& config); +} // namespace ctrace::stack::analysis diff --git a/include/analysis/InputPipeline.hpp b/include/analysis/InputPipeline.hpp index f641e6a..427354a 100644 --- a/include/analysis/InputPipeline.hpp +++ b/include/analysis/InputPipeline.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include #include @@ -33,4 +34,15 @@ namespace ctrace::stack::analysis ModuleLoadResult loadModuleForAnalysis(const std::string& filename, const AnalysisConfig& config, llvm::LLVMContext& ctx, llvm::SMDiagnostic& err); + + // The compile command the analysis uses for a C or C++ input: the arguments it gives the + // compiler, input included, and the directory to compile in (empty: the current one). + bool buildSourceCompileCommand(const std::string& filename, LanguageType language, + const AnalysisConfig& config, std::vector& args, + std::string& workingDir, std::string& error); + + // Runs `compile` while no other compilation runs, in `workingDir` when it is not empty: + // Clang and LLVM keep global state, and a compile command's directory is the process's. + bool runSerializedCompile(const std::string& workingDir, const std::function& compile, + std::string& error); } // namespace ctrace::stack::analysis diff --git a/src/StackUsageAnalyzer.cpp b/src/StackUsageAnalyzer.cpp index 07a956b..354aef0 100644 --- a/src/StackUsageAnalyzer.cpp +++ b/src/StackUsageAnalyzer.cpp @@ -3,6 +3,7 @@ #include "analyzer/AnalysisPipeline.hpp" #include "analyzer/HotspotProfiler.hpp" +#include "analysis/ConstSuggestionCheck.hpp" #include "analysis/InputPipeline.hpp" #include @@ -44,7 +45,9 @@ namespace ctrace::stack AnalysisResult result; { const analyzer::ScopedHotspot hotspot(config.timing, "analyze.pipeline"); - result = analyzeModule(*load.module, config); + AnalysisConfig unitConfig = config; + unitConfig.analyzedUnits = analysis::analyzedUnitOf(filename, *load.module); + result = analyzeModule(*load.module, unitConfig); } if (!load.frontendDiagnostics.empty()) { diff --git a/src/analysis/ConstParamAnalysis.cpp b/src/analysis/ConstParamAnalysis.cpp index 9ea9844..055ab66 100644 --- a/src/analysis/ConstParamAnalysis.cpp +++ b/src/analysis/ConstParamAnalysis.cpp @@ -719,6 +719,8 @@ namespace ctrace::stack::analysis ConstParamIssue issue; issue.funcName = F.getName().str(); issue.binding = binding; + if (const DILocalVariable* variable = spilledParameter(Arg)) + issue.debugArgNo = variable->getArg(); issue.confidence = computeIssueConfidence(binding, readOnlyByMetadata, anonymousType); issue.pointerConstOnly = diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp new file mode 100644 index 0000000..d0d6ff8 --- /dev/null +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -0,0 +1,1057 @@ +// SPDX-License-Identifier: Apache-2.0 +// Checks the const suggestions of ConstParameterNotModified with Clang (#217): the analysis of the +// IR decides whether an execution writes the pointee; Clang decides whether the suggested type +// compiles in every analyzed file and leaves the generated code as it is. +#include "analysis/ConstSuggestionCheck.hpp" + +#include "ConstSuggestionInternal.hpp" +#include "analysis/InputPipeline.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ctrace::stack::analysis +{ + namespace + { + using const_suggestion::variantOf; + using const_suggestion::withoutConst; + + constexpr const char* kChecked = + "checked in the analyzed files only, with their compile commands: they compile with " + "the suggested type, and their generated code matches once debug information, " + "metadata numbering and renamed symbols are normalized"; + constexpr const char* kNotChecked = "not checked with Clang: "; + + // Warnings that the C and C++ standards make constraint violations, or that a rewrite + // turns on silently: the rewritten code must not add any. + constexpr const char* kCheckedWarnings[] = { + "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", + "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual"}; + + // `length` bytes at `offset` replaced with `text`. + struct Edit + { + std::size_t offset = 0; + std::size_t length = 0; + std::string text; + }; + + // What one compilation found about the function a suggestion names. + struct FunctionFacts + { + // Before the rewrite, in the unit that defines it: the rewrite, and what it must give. + std::map> edits; // by real path + std::string notRewritten; + std::string expectedType; + // In every compilation. + std::string printedSignature; + std::string problem; + std::multiset overrides; + unsigned paramIndex = 0; + std::uint32_t reservedPadding = 0; + }; + + // What to look for in one compilation. + struct Target + { + std::string symbol; // the function's symbol before the rewrite + std::string renamed; // and after, once known + std::string name; // its identifier + std::string expectedType; + std::set rewrittenFiles; + const std::set* symbolsBefore = nullptr; // to find the renamed function + unsigned debugArgNo = 0; + unsigned paramIndex = 0; + bool plan = false; // before the rewrite, in the defining unit + std::uint8_t reservedPadding[7] = {}; + }; + + struct Compilation + { + std::string failure; // the compilation could not run + std::multiset diagnostics; + std::string firstDiagnostic; + std::unique_ptr context; + std::unique_ptr module; + std::vector facts; + }; + + std::string realPath(llvm::StringRef path) + { + llvm::SmallString<256> out; + if (llvm::sys::fs::real_path(path, out)) + return path.str(); + return out.str().str(); + } + + // Errors, and the checked warnings, by place and kind: their texts name types that the + // rewrite changes. + class DiagnosticCollector : public clang::DiagnosticConsumer + { + public: + explicit DiagnosticCollector(Compilation& out) : out_(out) {} + + void HandleDiagnostic(clang::DiagnosticsEngine::Level level, + const clang::Diagnostic& info) override + { + clang::DiagnosticConsumer::HandleDiagnostic(level, info); + bool kept = level >= clang::DiagnosticsEngine::Error; + if (level == clang::DiagnosticsEngine::Warning) + { + const llvm::StringRef group = + info.getDiags()->getDiagnosticIDs()->getWarningOptionForDiag(info.getID()); + kept = std::find(std::begin(kCheckedWarnings), std::end(kCheckedWarnings), + group) != std::end(kCheckedWarnings); + } + if (!kept) + return; + std::string place = "?"; + if (info.hasSourceManager() && info.getLocation().isValid()) + { + const clang::SourceManager& sm = info.getSourceManager(); + const clang::PresumedLoc presumed = + sm.getPresumedLoc(sm.getExpansionLoc(info.getLocation())); + if (presumed.isValid()) + { + place = std::string(presumed.getFilename()) + ":" + + std::to_string(presumed.getLine()); + } + } + out_.diagnostics.insert(place + " #" + std::to_string(info.getID())); + if (out_.firstDiagnostic.empty()) + { + llvm::SmallString<160> text; + info.FormatDiagnostic(text); + out_.firstDiagnostic = place + ": " + text.str().str(); + } + } + + private: + Compilation& out_; + }; + + // Whether `offset` of `buffer` lies on a #define line, continuations included. + bool inMacroDefinition(llvm::StringRef buffer, std::size_t offset) + { + std::size_t start = buffer.rfind('\n', offset); + start = start == llvm::StringRef::npos ? 0 : start + 1; + while (start >= 2 && buffer[start - 2] == '\\') + { + const std::size_t previous = buffer.rfind('\n', start - 2); + start = previous == llvm::StringRef::npos ? 0 : previous + 1; + } + const llvm::StringRef line = buffer.substr(start, offset - start).ltrim(); + return line.starts_with("#") && line.drop_front().ltrim().starts_with("define"); + } + + class Planner + { + public: + Planner(clang::ASTContext& context, FunctionFacts& facts) + : context_(context), sm_(context.getSourceManager()), facts_(facts) + { + } + + // Writes the suggested type where `param` writes its type. + bool plan(const clang::FunctionDecl& function, const clang::ParmVarDecl& param) + { + const clang::TypeSourceInfo* written = param.getTypeSourceInfo(); + if (!written) + return fail("the parameter's type is not written in the source"); + clang::TypeLoc loc = written->getTypeLoc(); + for (;;) + { + loc = loc.getUnqualifiedLoc(); + if (auto attributed = loc.getAs()) + loc = attributed.getModifiedLoc(); + else if (auto paren = loc.getAs()) + loc = paren.getInnerLoc(); + else if (auto macro = loc.getAs()) + loc = macro.getInnerLoc(); + else if (auto decayed = loc.getAs()) + loc = decayed.getOriginalLoc(); + else + break; + } + if (auto pointer = loc.getAs()) + return insertConst(function, pointer.getPointeeLoc().getBeginLoc()); + if (auto reference = loc.getAs()) + return insertConst(function, reference.getPointeeLoc().getBeginLoc()); + if (auto reference = loc.getAs()) + { + // The suggestion names the const lvalue reference. + return insertConst(function, reference.getPointeeLoc().getBeginLoc()) && + replace(function, reference.getAmpAmpLoc(), 2, "&"); + } + if (auto array = loc.getAs()) + return insertConst(function, array.getElementLoc().getBeginLoc()); + // A typedef of a pointer: its desugared type, qualified. + clang::TypeLoc named = loc; + if (auto elaborated = loc.getAs()) + named = elaborated.getNamedTypeLoc(); + if (named.getAs() || named.getAs()) + { + const auto* pointer = loc.getType()->getAs(); + if (!pointer || pointer->getPointeeType()->isFunctionType() || + pointer->getPointeeType()->isArrayType()) + { + return fail("the parameter's type is a typedef of no plain pointer"); + } + clang::PrintingPolicy policy(context_.getLangOpts()); + const std::string text = + pointer->getPointeeType().withConst().getAsString(policy) + " *"; + if (text.find("(unnamed") != std::string::npos || + text.find("(anonymous") != std::string::npos || + text.find("(lambda") != std::string::npos) + { + return fail("the pointed type has no name to write"); + } + const clang::SourceLocation begin = loc.getBeginLoc(); + const clang::SourceLocation end = loc.getEndLoc(); + if (begin.isMacroID() || end.isMacroID()) + return fail("the parameter's typedef is written by a macro"); + const unsigned length = sm_.getFileOffset(clang::Lexer::getLocForEndOfToken( + end, 0, sm_, context_.getLangOpts())) - + sm_.getFileOffset(begin); + return replace(function, begin, length, text); + } + return fail("the parameter's type has no form this check rewrites"); + } + + private: + bool fail(std::string why) + { + if (facts_.notRewritten.empty()) + facts_.notRewritten = std::move(why); + return false; + } + + bool insertConst(const clang::FunctionDecl& function, clang::SourceLocation at) + { + return replace(function, at, 0, "const "); + } + + bool replace(const clang::FunctionDecl& function, clang::SourceLocation at, + unsigned length, const std::string& text) + { + if (at.isInvalid()) + return fail("the parameter's type has no position"); + if (at.isMacroID()) + { + if (sm_.isMacroArgExpansion(at)) + { + // Written in a macro argument: rewritten where it is written. + at = sm_.getSpellingLoc(at); + } + else + { + // Written by a macro: a macro is text, `const` goes before its + // invocation, unless that invocation writes the declaration too. + const clang::CharSourceRange invocation = sm_.getExpansionRange(at); + const clang::SourceLocation name = + sm_.getExpansionLoc(function.getLocation()); + const bool writesDeclaration = + !sm_.isBeforeInTranslationUnit(name, invocation.getBegin()) && + !sm_.isBeforeInTranslationUnit(invocation.getEnd(), name); + if (length != 0 || writesDeclaration) + return fail("the declaration is written by a macro definition"); + at = invocation.getBegin(); + } + if (at.isMacroID()) + return fail("the declaration is written by a macro definition"); + } + const auto [file, offset] = sm_.getDecomposedLoc(at); + const clang::OptionalFileEntryRef entry = sm_.getFileEntryRefForID(file); + if (!entry) + return fail("the parameter's type is not written in a file"); + if (inMacroDefinition(sm_.getBufferData(file), offset)) + return fail("the declaration is written by a macro definition"); + std::vector& edits = facts_.edits[realPath(entry->getName())]; + const bool seen = std::any_of(edits.begin(), edits.end(), [&](const Edit& edit) + { return edit.offset == offset; }); + if (!seen) + edits.push_back({offset, length, text}); + return true; + } + + clang::ASTContext& context_; + const clang::SourceManager& sm_; + FunctionFacts& facts_; + }; + + // The type the parameter must have once rewritten, written canonically. + std::string expectedType(clang::ASTContext& context, clang::QualType type) + { + clang::QualType out; + if (const auto* pointer = type->getAs()) + { + out = context.getQualifiedType( + context.getPointerType(pointer->getPointeeType().withConst()), + type.getQualifiers()); + } + else if (type->isReferenceType()) + { + out = context.getLValueReferenceType(type.getNonReferenceType().withConst()); + } + else + { + return ""; + } + return out.getCanonicalType().getAsString(clang::PrintingPolicy(context.getLangOpts())); + } + + std::string writtenType(clang::ASTContext& context, clang::QualType type) + { + return type.getCanonicalType().getAsString( + clang::PrintingPolicy(context.getLangOpts())); + } + + // Declarations of the function, explicit casts of its address, and what each method of + // its name overrides. + class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor + { + public: + Inspector(clang::CodeGenAction& action, std::vector& targets, Compilation& out) + : action_(action), targets_(targets), out_(out) + { + } + + bool shouldVisitTemplateInstantiations() const + { + return true; + } + + void HandleTranslationUnit(clang::ASTContext& context) override + { + context_ = &context; + names_ = std::make_unique(context); + prefix_ = llvm::DataLayout(context.getTargetInfo().getDataLayoutString()) + .getGlobalPrefix(); + out_.facts.resize(targets_.size()); + located_.assign(targets_.size(), nullptr); + clang::CodeGenerator* generator = action_.getCodeGenerator(); + for (std::size_t i = 0; i < targets_.size(); ++i) + locate(i, generator); + TraverseDecl(context.getTranslationUnitDecl()); + } + + bool VisitFunctionDecl(clang::FunctionDecl* function) + { + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (!isTarget(i, *function)) + continue; + const Target& target = targets_[i]; + FunctionFacts& facts = out_.facts[i]; + if (!target.expectedType.empty() && facts.problem.empty() && + !function->isDependentContext() && + target.paramIndex < function->getNumParams()) + { + const std::string type = writtenType( + *context_, function->getParamDecl(target.paramIndex)->getType()); + if (type != target.expectedType) + { + const clang::SourceManager& sm = context_->getSourceManager(); + const clang::SourceLocation at = + sm.getExpansionLoc(function->getLocation()); + const clang::OptionalFileEntryRef entry = + sm.getFileEntryRefForID(sm.getFileID(at)); + const std::string file = entry ? realPath(entry->getName()) : ""; + facts.problem = + target.rewrittenFiles.count(file) + ? "rewritten:" + type + : "declared in " + llvm::sys::path::filename(file).str() + ":" + + std::to_string(sm.getExpansionLineNumber(at)) + + " with type " + type; + } + } + if (const auto* method = llvm::dyn_cast(function)) + recordOverrides(facts, *method); + } + if (const auto* method = llvm::dyn_cast(function)) + { + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (method->getIdentifier() && + method->getIdentifier()->getName() == targets_[i].name && + !isTarget(i, *function)) + { + recordOverrides(out_.facts[i], *method); + } + } + } + return true; + } + + bool VisitExplicitCastExpr(clang::ExplicitCastExpr* cast) + { + const clang::Expr* operand = cast->getSubExpr()->IgnoreParenImpCasts(); + if (const auto* address = llvm::dyn_cast(operand)) + { + if (address->getOpcode() == clang::UO_AddrOf) + operand = address->getSubExpr()->IgnoreParenImpCasts(); + } + const auto* reference = llvm::dyn_cast(operand); + const auto* function = + reference ? llvm::dyn_cast(reference->getDecl()) : nullptr; + if (!function) + return true; + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (out_.facts[i].problem.empty() && isTarget(i, *function)) + out_.facts[i].problem = "its address is converted by an explicit cast"; + } + return true; + } + + private: + void recordOverrides(FunctionFacts& facts, const clang::CXXMethodDecl& method) + { + std::vector overridden; + for (const clang::CXXMethodDecl* base : method.overridden_methods()) + overridden.push_back(base->getQualifiedNameAsString()); + std::sort(overridden.begin(), overridden.end()); + facts.overrides.insert(method.getQualifiedNameAsString() + " -> " + + llvm::join(overridden, ", ")); + } + + std::string symbolOf(const clang::FunctionDecl& function) + { + if (function.isDependentContext() || + llvm::isa(function) || + llvm::isa(function)) + { + return ""; + } + std::string name = names_->getName(&function); + if (prefix_ && !name.empty() && name.front() == prefix_) + name.erase(0, 1); + return name; + } + + // Whether `function` is a declaration of the function target i names, in this + // compilation: the one CodeGen emitted under its symbol, a redeclaration of it, or a + // declaration with its symbol before or after the rewrite. + bool isTarget(std::size_t i, const clang::FunctionDecl& function) + { + const clang::FunctionDecl* found = located_[i]; + if (found && function.getCanonicalDecl() == found->getCanonicalDecl()) + return true; + const Target& target = targets_[i]; + if (!function.getIdentifier() || function.getIdentifier()->getName() != target.name) + return false; + const std::string symbol = symbolOf(function); + return !symbol.empty() && (symbol == target.symbol || symbol == target.renamed); + } + + void locate(std::size_t i, clang::CodeGenerator* generator) + { + Target& target = targets_[i]; + FunctionFacts& facts = out_.facts[i]; + if (!generator) + return; + std::string symbol = target.symbol; + if (!target.plan && target.symbolsBefore && generator->GetModule()) + { + // After the rewrite, the function may carry a new symbol: the one this + // compilation defines that it did not before, with the same name once const + // is left out. + const std::string key = withoutConst(target.symbol); + const std::string variant = variantOf(target.symbol); + for (const llvm::Function& emitted : *generator->GetModule()) + { + const std::string name = emitted.getName().str(); + if (!emitted.isDeclaration() && !target.symbolsBefore->count(name) && + withoutConst(name) == key && variantOf(name) == variant) + { + symbol = name; + } + } + if (target.renamed.empty()) + target.renamed = symbol; + } + const auto* function = llvm::dyn_cast_or_null( + generator->GetDeclForMangledName(symbol)); + located_[i] = function; + if (!function) + return; + facts.printedSignature = clang::PredefinedExpr::ComputeName( + clang::PredefinedIdentKind::PrettyFunction, function); + if (target.name.empty() && function->getIdentifier()) + target.name = function->getIdentifier()->getName().str(); + if (target.plan) + plan(target, facts, *function); + } + + void plan(Target& target, FunctionFacts& facts, const clang::FunctionDecl& function) + { + const auto* method = llvm::dyn_cast(&function); + const unsigned implicitThis = method && method->isInstance() ? 1 : 0; + if (target.debugArgNo <= implicitThis) + { + facts.notRewritten = "the parameter is not found in the source"; + return; + } + facts.paramIndex = target.debugArgNo - 1 - implicitThis; + if (facts.paramIndex >= function.getNumParams()) + { + facts.notRewritten = "the parameter is not found in the source"; + return; + } + facts.expectedType = + expectedType(*context_, function.getParamDecl(facts.paramIndex)->getType()); + if (facts.expectedType.empty()) + { + facts.notRewritten = "the parameter is no pointer or reference"; + return; + } + const clang::FunctionDecl* pattern = function.getTemplateInstantiationPattern(); + if (!pattern) + pattern = &function; + Planner planner(*context_, facts); + for (const clang::FunctionDecl* declaration : pattern->redecls()) + { + if (declaration->isImplicit()) + continue; + if (facts.paramIndex >= declaration->getNumParams()) + { + facts.notRewritten = "the declarations disagree on the parameters"; + return; + } + if (!planner.plan(*declaration, *declaration->getParamDecl(facts.paramIndex))) + return; + } + } + + char prefix_ = 0; + std::uint8_t reservedPadding_[6] = {}; + clang::CodeGenAction& action_; + std::vector& targets_; + Compilation& out_; + clang::ASTContext* context_ = nullptr; + std::unique_ptr names_; + std::vector located_; + }; + + class InspectAction : public clang::EmitLLVMOnlyAction + { + public: + InspectAction(llvm::LLVMContext* context, std::vector& targets, + Compilation& out) + : clang::EmitLLVMOnlyAction(context), targets_(targets), out_(out) + { + } + + protected: + std::unique_ptr CreateASTConsumer(clang::CompilerInstance& ci, + llvm::StringRef file) override + { + std::unique_ptr generator = + clang::EmitLLVMOnlyAction::CreateASTConsumer(ci, file); + if (!generator) + return nullptr; + std::vector> consumers; + consumers.push_back(std::move(generator)); + consumers.push_back(std::make_unique(*this, targets_, out_)); + return std::make_unique(std::move(consumers)); + } + + private: + std::vector& targets_; + Compilation& out_; + }; + + struct UnitCommand + { + std::vector args; // the driver's, as compilerlib builds them + std::string workingDir; + }; + + bool commandFor(const AnalyzedUnit& unit, const AnalysisConfig& config, UnitCommand& out, + std::string& error) + { + std::vector args; + if (!buildSourceCompileCommand(unit.filename, unit.language, config, args, + out.workingDir, error)) + { + return false; + } + // Options only compilerlib understands. + std::erase_if(args, [](const std::string& arg) { return arg.starts_with("--ct-"); }); + compilerlib::DriverConfig driver; + if (!compilerlib::resolveDriverConfig(args, driver, error)) + return false; + out.args = {driver.clang_path}; + if (driver.force_cxx_driver) + out.args.push_back("--driver-mode=g++"); + if (driver.add_resource_dir) + { + out.args.push_back("-resource-dir"); + out.args.push_back(driver.resource_dir); + } + if (driver.add_sysroot) + { + out.args.push_back("-isysroot"); + out.args.push_back(driver.sysroot); + } + out.args.insert(out.args.end(), args.begin(), args.end()); + return true; + } + + Compilation compile(const UnitCommand& command, + const std::map& rewritten, + std::vector& targets) + { + static std::once_flag targetsReady; + std::call_once(targetsReady, + [] + { + llvm::InitializeAllTargetInfos(); + llvm::InitializeAllTargets(); + llvm::InitializeAllTargetMCs(); + }); + Compilation out; + std::string error; + const bool ran = runSerializedCompile( + command.workingDir, + [&] + { + llvm::IntrusiveRefCntPtr files = + llvm::vfs::getRealFileSystem(); + DiagnosticCollector collector(out); + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &collector, false); + options.VFS = files; + options.ProbePrecompiled = false; + std::unique_ptr invocation = + clang::createInvocation(argv, options); + if (!invocation) + { + out.failure = + "the compile command is not understood: " + out.firstDiagnostic; + return; + } + // Nothing is written: no dependency file, no output. + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + // The inspection reads the AST after CodeGen: keep it for the backend. + invocation->getCodeGenOpts().ClearASTBeforeBackend = false; + // Values and blocks numbered, not named: a block that inlining names after + // the inlined function carries the rewritten function's symbol. + invocation->getCodeGenOpts().DiscardValueNames = true; + clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); + // No "N warnings generated" on the analyzer's output. + diagnostics.ShowCarets = false; + diagnostics.IgnoreWarnings = false; + for (const char* warning : kCheckedWarnings) + diagnostics.Warnings.push_back(warning); + out.diagnostics.clear(); + out.firstDiagnostic.clear(); + + clang::CompilerInstance instance; + instance.setInvocation(std::move(invocation)); + instance.createDiagnostics(*files, &collector, false); + instance.createFileManager(files); + for (const auto& [path, text] : rewritten) + { + instance.getPreprocessorOpts().addRemappedFile( + path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); + } + instance.getPreprocessorOpts().RetainRemappedFileBuffers = false; + instance.getFrontendOpts().DisableFree = false; + instance.getCodeGenOpts().DisableFree = false; + out.context = std::make_unique(); + InspectAction action(out.context.get(), targets, out); + instance.ExecuteAction(action); + out.module = action.takeModule(); + }, + error); + if (!ran) + out.failure = error; + return out; + } + + // The files a unit reads, from its preprocessing. + std::set filesRead(const UnitCommand& command) + { + std::set out; + std::string error; + runSerializedCompile( + command.workingDir, + [&] + { + llvm::IntrusiveRefCntPtr files = + llvm::vfs::getRealFileSystem(); + clang::IgnoringDiagConsumer ignore; + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &ignore, false); + options.VFS = files; + options.ProbePrecompiled = false; + std::unique_ptr invocation = + clang::createInvocation(argv, options); + if (!invocation) + return; + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + invocation->getDiagnosticOpts().ShowCarets = false; + clang::CompilerInstance instance; + instance.setInvocation(std::move(invocation)); + instance.createDiagnostics(*files, &ignore, false); + instance.createFileManager(files); + clang::PreprocessOnlyAction action; + instance.ExecuteAction(action); + if (!instance.hasSourceManager()) + return; + const clang::SourceManager& sm = instance.getSourceManager(); + for (auto it = sm.fileinfo_begin(); it != sm.fileinfo_end(); ++it) + out.insert(realPath(it->first.getName())); + }, + error); + return out; + } + + bool references(const llvm::Module& module, llvm::StringRef symbol) + { + const llvm::GlobalValue* value = module.getNamedValue(symbol); + return value && !value->hasLocalLinkage(); + } + + std::map rewrite(const FunctionFacts& facts) + { + std::map out; + for (const auto& [path, edits] : facts.edits) + { + llvm::ErrorOr> buffer = + llvm::MemoryBuffer::getFile(path); + if (!buffer) + continue; + std::string text = (*buffer)->getBuffer().str(); + std::vector sorted = edits; + std::sort(sorted.begin(), sorted.end(), + [](const Edit& a, const Edit& b) { return a.offset > b.offset; }); + for (const Edit& edit : sorted) + text.replace(edit.offset, edit.length, edit.text); + out[path] = std::move(text); + } + return out; + } + + // Checks one suggestion in one unit: compiled before and after the rewrite. + bool checkUnit(const UnitCommand& command, Compilation* before, const Target& planned, + const std::map& rewritten, + const std::set& symbolsBefore, const FunctionFacts& plan, + PrintedSignature& signature, std::vector& accepted, + std::string& renamed, std::string& removed, std::string& notChecked) + { + Compilation own; + if (!before) + { + std::vector targets{planned}; + targets[0].plan = false; + targets[0].expectedType.clear(); + own = compile(command, {}, targets); + before = &own; + } + if (!before->failure.empty() || !before->module) + { + notChecked = "a file does not compile with Clang as it is"; + return false; + } + std::vector targets{planned}; + targets[0].plan = false; + targets[0].renamed = renamed; + targets[0].expectedType = plan.expectedType; + targets[0].paramIndex = plan.paramIndex; + targets[0].symbolsBefore = &symbolsBefore; + for (const auto& [path, edits] : plan.edits) + targets[0].rewrittenFiles.insert(path); + Compilation after = compile(command, rewritten, targets); + if (!after.failure.empty()) + { + notChecked = after.failure; + return false; + } + for (const std::string& diagnostic : after.diagnostics) + { + if (after.diagnostics.count(diagnostic) > before->diagnostics.count(diagnostic)) + { + removed = "does not compile: " + after.firstDiagnostic; + return true; + } + } + if (!after.module) + { + removed = "does not compile: " + after.firstDiagnostic; + return true; + } + if (renamed.empty()) + renamed = targets[0].renamed; + const FunctionFacts& facts = after.facts.empty() ? FunctionFacts() : after.facts[0]; + if (facts.problem.starts_with("rewritten:")) + { + notChecked = "the rewritten declaration does not get the suggested type"; + return false; + } + if (!facts.problem.empty()) + { + removed = facts.problem; + return true; + } + const FunctionFacts& old = before->facts.empty() ? FunctionFacts() : before->facts[0]; + if (old.overrides != facts.overrides) + { + removed = "the methods it overrides change"; + return true; + } + if (signature.after.empty() && !facts.printedSignature.empty()) + signature.after = facts.printedSignature; + std::string renamedTo; + std::unique_ptr was = llvm::CloneModule(*before->module); + const std::optional difference = rewrittenCodeDifference( + *was, *after.module, planned.symbol, &signature, &accepted, &renamedTo); + if (difference) + { + removed = "the generated code changes: " + *difference; + return true; + } + if (renamed.empty() || renamed == planned.symbol) + renamed = renamedTo; + return true; + } + } // namespace + + std::shared_ptr analyzedUnitOf(const std::string& filename, + const llvm::Module& module) + { + auto units = std::make_shared(); + units->units.push_back({filename, &module, detectFromExtension(filename)}); + return units; + } + + void checkConstSuggestions(const llvm::Module& mod, std::vector& issues, + const AnalysisConfig& config) + { + if (issues.empty() || config.warningsOnly) + return; + const AnalyzedUnits* units = config.analyzedUnits.get(); + const AnalyzedUnit* self = nullptr; + for (std::size_t i = 0; units && i < units->units.size(); ++i) + { + if (units->units[i].module == &mod) + self = &units->units[i]; + } + const auto trace = [](const ConstParamIssue& issue, const std::string& outcome) + { + coretrace::log(coretrace::Level::Debug, "[const-suggestion] {} parameter {}: {}\n", + issue.funcName, issue.binding.name, outcome); + }; + const auto notChecked = [&](const std::string& why) + { + for (ConstParamIssue& issue : issues) + { + issue.checkLines.push_back(kNotChecked + why); + trace(issue, "not checked, " + why); + } + }; + if (!self || (self->language != LanguageType::C && self->language != LanguageType::CXX)) + { + notChecked("no source file, so whether the suggested type compiles was not verified"); + return; + } + UnitCommand command; + std::string error; + if (!commandFor(*self, config, command, error)) + { + notChecked("no compile command (" + error + ")"); + return; + } + + // Before the rewrite, the defining unit: one compilation plans every suggestion. + std::vector targets(issues.size()); + for (std::size_t i = 0; i < issues.size(); ++i) + { + targets[i].symbol = issues[i].funcName; + targets[i].debugArgNo = issues[i].debugArgNo; + targets[i].plan = true; + } + Compilation before = compile(command, {}, targets); + if (!before.failure.empty() || !before.module || before.facts.size() != issues.size()) + { + notChecked("the file does not compile with Clang as it is"); + return; + } + std::set symbolsBefore; + for (const llvm::Function& function : *before.module) + symbolsBefore.insert(function.getName().str()); + + std::vector kept; + for (std::size_t i = 0; i < issues.size(); ++i) + { + ConstParamIssue& issue = issues[i]; + const FunctionFacts& plan = before.facts[i]; + if (plan.printedSignature.empty() && plan.notRewritten.empty()) + { + issue.checkLines.push_back(kNotChecked + + std::string("the function is not found in the source")); + trace(issue, "not checked, the function is not found in the source"); + kept.push_back(std::move(issue)); + continue; + } + if (!plan.notRewritten.empty()) + { + issue.checkLines.push_back(kNotChecked + plan.notRewritten); + trace(issue, "not checked, " + plan.notRewritten); + kept.push_back(std::move(issue)); + continue; + } + const std::map rewritten = rewrite(plan); + std::set rewrittenFiles; + for (const auto& [path, text] : rewritten) + rewrittenFiles.insert(path); + + // The defining unit first: it gives the function's symbol after the rewrite. + Target planned = targets[i]; + planned.plan = false; + PrintedSignature signature{plan.printedSignature, ""}; + std::vector accepted; + std::string renamed; + std::string removed; + std::string unchecked; + Compilation selfBefore; + selfBefore.module = llvm::CloneModule(*before.module); + selfBefore.diagnostics = before.diagnostics; + selfBefore.facts = {plan}; + std::size_t checkedUnits = 1; + bool done = checkUnit(command, &selfBefore, planned, rewritten, symbolsBefore, plan, + signature, accepted, renamed, removed, unchecked); + // Then every other analyzed unit that sees a rewritten file or references the + // function. + for (const AnalyzedUnit& unit : units->units) + { + if (!done || !removed.empty() || &unit == self) + continue; + const bool referencing = unit.module && references(*unit.module, issue.funcName); + if (unit.language != LanguageType::C && unit.language != LanguageType::CXX) + { + if (referencing) + { + done = false; + unchecked = unit.filename + " uses the function but has no source"; + } + continue; + } + UnitCommand other; + if (!commandFor(unit, config, other, error)) + { + done = false; + unchecked = "no compile command for " + unit.filename; + continue; + } + bool sees = referencing; + if (!sees) + { + std::set read; + { + std::lock_guard lock(units->filesReadMutex); + const auto cached = units->filesRead.find(unit.filename); + if (cached != units->filesRead.end()) + read = cached->second; + } + if (read.empty()) + { + read = filesRead(other); + std::lock_guard lock(units->filesReadMutex); + units->filesRead[unit.filename] = read; + } + sees = std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), + [&](const std::string& path) { return read.count(path); }); + } + if (!sees) + continue; + std::vector otherAccepted; + ++checkedUnits; + done = checkUnit(other, nullptr, planned, rewritten, symbolsBefore, plan, signature, + otherAccepted, renamed, removed, unchecked); + for (const std::string& line : otherAccepted) + { + if (std::find(accepted.begin(), accepted.end(), line) == accepted.end()) + accepted.push_back(line); + } + } + if (!done) + { + issue.checkLines.push_back(kNotChecked + unchecked); + trace(issue, "not checked, " + unchecked); + kept.push_back(std::move(issue)); + continue; + } + if (!removed.empty()) + { + trace(issue, "removed, " + removed); + continue; + } + trace(issue, "checked in " + std::to_string(checkedUnits) + " file(s)"); + issue.checkLines.push_back(accepted.empty() ? std::string(kChecked) + : std::string(kChecked) + + ", except for the differences below"); + issue.checkLines.insert(issue.checkLines.end(), accepted.begin(), accepted.end()); + const llvm::Function* function = mod.getFunction(issue.funcName); + if (function && function->hasExternalLinkage() && !renamed.empty() && + renamed != issue.funcName) + { + issue.checkLines.push_back("ABI: the suggested type changes the symbol (" + + issue.funcName + " -> " + renamed + + "): code compiled separately must use the updated " + "declaration and be recompiled"); + } + kept.push_back(std::move(issue)); + } + issues = std::move(kept); + } +} // namespace ctrace::stack::analysis diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp new file mode 100644 index 0000000..b775f50 --- /dev/null +++ b/src/analysis/ConstSuggestionCode.cpp @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: Apache-2.0 +// Comparison of the code generated before and after a const rewrite (#217). +#include "analysis/ConstSuggestionCheck.hpp" +#include "ConstSuggestionInternal.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ctrace::stack::analysis +{ + namespace + { + bool isSymbolChar(char c) + { + return std::isalnum(static_cast(c)) || c == '-' || c == '$' || + c == '.' || c == '_'; + } + + // The symbol written at text[at] ('@' or '$' excluded), quoted or not, and its length. + std::pair symbolAt(const std::string& text, std::size_t at) + { + if (at < text.size() && text[at] == '"') + { + const std::size_t end = text.find('"', at + 1); + if (end == std::string::npos) + return {"", 0}; + return {text.substr(at + 1, end - at - 1), end - at + 1}; + } + std::size_t end = at; + while (end < text.size() && isSymbolChar(text[end])) + ++end; + return {text.substr(at, end - at), end - at}; + } + + std::string spellSymbol(const std::string& name) + { + const bool plain = !name.empty() && + std::all_of(name.begin(), name.end(), isSymbolChar) && + !std::isdigit(static_cast(name.front())); + return plain ? name : "\"" + name + "\""; + } + + // A printed line without its comment: the predecessors a block label lists follow the + // order of uses, which copying a module changes, and say nothing an execution does. + llvm::StringRef withoutComment(llvm::StringRef line) + { + bool quoted = false; + for (std::size_t i = 0; i < line.size(); ++i) + { + if (line[i] == '"') + quoted = !quoted; + else if (line[i] == ';' && !quoted) + return line.take_front(i).rtrim(); + } + return line; + } + + // The text of one module, keyed by what each piece defines. + struct ModuleText + { + std::map globals; // functions, variables, aliases + std::map types; + std::map attributeGroups; // "#N" -> "{ ... }" + std::set defined; // has code or data here + }; + + ModuleText textOf(llvm::Module& module) + { + llvm::StripDebugInfo(module); + std::string printed; + llvm::raw_string_ostream os(printed); + module.print(os, nullptr); + os.flush(); + + ModuleText out; + std::string current; + std::string body; + for (llvm::StringRef printedLine : llvm::split(printed, '\n')) + { + const llvm::StringRef line = withoutComment(printedLine); + if (!current.empty()) + { + body += line.str() + "\n"; + if (line == "}") + { + out.globals[current] = body; + current.clear(); + } + continue; + } + // Comdat selections are left out: each definition names its comdat, renamed and + // compared with it. + if (line.empty() || line.starts_with(";") || line.starts_with("!") || + line.starts_with("$") || line.starts_with("source_filename") || + line.starts_with("target ")) + { + continue; + } + if (line.starts_with("attributes #")) + { + const std::size_t equal = line.find(" = "); + out.attributeGroups[line.substr(11, equal - 11).str()] = + line.substr(equal + 3).str(); + continue; + } + if (line.starts_with("%")) + { + const std::size_t equal = line.find(" = "); + out.types[line.substr(0, equal).str()] = line.str(); + continue; + } + const bool definition = line.starts_with("define "); + if (definition || line.starts_with("declare ")) + { + const std::size_t at = line.find('@'); + const std::string name = symbolAt(line.str(), at + 1).first; + if (definition) + { + current = name; + body = line.str() + "\n"; + out.defined.insert(name); + } + else + { + out.globals[name] = line.str(); + } + continue; + } + if (line.starts_with("@")) + { + const std::string name = symbolAt(line.str(), 1).first; + out.globals[name] = line.str(); + if (!line.contains("= external ") && !line.contains("= extern_weak ") && + !line.contains("= dllimport external ")) + { + out.defined.insert(name); + } + continue; + } + // Module-level assembly. + out.globals[line.str()] = line.str(); + } + return out; + } + + // Rewrites `text` symbol by symbol, attribute group by contents, and numbers its + // metadata by first use. Quoted strings are left alone. + std::string normalized(const std::string& text, + const std::map& rename, + const std::map& attributeGroups) + { + std::string out; + std::map metadata; + bool quoted = false; + for (std::size_t i = 0; i < text.size();) + { + const char c = text[i]; + if (c == '"' && !(i > 0 && text[i - 1] == '@')) + { + quoted = !quoted; + out += c; + ++i; + continue; + } + if (quoted) + { + out += c; + ++i; + continue; + } + if (c == '@' || c == '$') + { + const auto [name, length] = symbolAt(text, i + 1); + if (length != 0) + { + const auto renamed = rename.find(name); + out += c; + out += spellSymbol(renamed == rename.end() ? name : renamed->second); + i += 1 + length; + continue; + } + } + if (c == '!' && i + 1 < text.size() && + std::isdigit(static_cast(text[i + 1]))) + { + std::size_t end = i + 1; + while (end < text.size() && std::isdigit(static_cast(text[end]))) + ++end; + const auto [it, inserted] = + metadata.try_emplace(text.substr(i, end - i), metadata.size()); + out += "!m" + std::to_string(it->second); + i = end; + continue; + } + if (c == '#' && i + 1 < text.size() && + std::isdigit(static_cast(text[i + 1]))) + { + std::size_t end = i + 1; + while (end < text.size() && std::isdigit(static_cast(text[end]))) + ++end; + const auto group = attributeGroups.find(text.substr(i, end - i)); + out += group == attributeGroups.end() ? text.substr(i, end - i) + : "#" + group->second; + i = end; + continue; + } + out += c; + ++i; + } + return out; + } + + // The contents of a string constant defined on `line`, and where they start and end. + bool stringConstant(const std::string& line, std::size_t& begin, std::size_t& end) + { + const std::size_t marker = line.find(" c\""); + if (marker == std::string::npos || line.find(" constant [") == std::string::npos) + return false; + begin = marker + 3; + end = line.find("\\00\"", begin); + return end != std::string::npos; + } + + std::string escaped(llvm::StringRef text) + { + std::string out; + llvm::raw_string_ostream os(out); + llvm::printEscapedString(text, os); + os.flush(); + return out; + } + + // `line` with the contents and length of its string constant replaced. + std::string withString(const std::string& line, std::size_t begin, std::size_t end, + const std::string& contents, std::size_t length) + { + std::string out = line.substr(0, begin) + contents + line.substr(end); + const std::size_t array = out.find(" constant ["); + const std::size_t times = out.find(" x i8]", array); + if (array != std::string::npos && times != std::string::npos) + out.replace(array + 11, times - array - 11, std::to_string(length + 1)); + return out; + } + + std::size_t unescapedLength(llvm::StringRef escapedText) + { + std::size_t length = 0; + for (std::size_t i = 0; i < escapedText.size(); ++i, ++length) + { + if (escapedText[i] == '\\') + i += 2; + } + return length; + } + + std::string firstDifference(const std::string& before, const std::string& after) + { + const auto a = llvm::to_vector(llvm::split(before, '\n')); + const auto b = llvm::to_vector(llvm::split(after, '\n')); + std::size_t i = 0; + while (i < a.size() && i < b.size() && a[i] == b[i]) + ++i; + const std::string was = i < a.size() ? a[i].trim().str() : "(end)"; + const std::string now = i < b.size() ? b[i].trim().str() : "(end)"; + return "'" + was.substr(0, 160) + "' became '" + now.substr(0, 160) + "'"; + } + } // namespace + + std::string const_suggestion::withoutConst(const std::string& symbol) + { + const std::string text = llvm::demangle(symbol); + std::string out; + for (std::size_t i = 0; i < text.size();) + { + const bool wordStart = i == 0 || !std::isalnum(static_cast(text[i - 1])); + if (wordStart && text.compare(i, 5, "const") == 0 && + (i + 5 == text.size() || !std::isalnum(static_cast(text[i + 5])))) + { + i += 5; + continue; + } + if (text[i] != ' ') + out += text[i]; + ++i; + } + std::size_t pos = 0; + while ((pos = out.find("&&", pos)) != std::string::npos) + out.replace(pos, 2, "&"); + return out; + } + + std::string const_suggestion::variantOf(const std::string& symbol) + { + std::string variant; + for (std::size_t i = 1; i + 1 < symbol.size(); ++i) + { + const char kind = symbol[i]; + const char digit = symbol[i + 1]; + const bool afterName = + std::isalnum(static_cast(symbol[i - 1])) || symbol[i - 1] == '_'; + const bool next = + i + 2 == symbol.size() || std::isupper(static_cast(symbol[i + 2])); + if (afterName && next && + ((kind == 'C' && digit >= '1' && digit <= '5') || + (kind == 'D' && digit >= '0' && digit <= '5'))) + { + variant += symbol.substr(i, 2); + } + } + const std::string demangled = llvm::demangle(symbol); + if (llvm::StringRef(demangled).rtrim().ends_with(" const")) + variant += "|const"; + return variant; + } + + std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, + llvm::StringRef rewritten, + const PrintedSignature* signature, + std::vector* accepted, + std::string* renamedTo) + { + using const_suggestion::variantOf; + using const_suggestion::withoutConst; + const ModuleText was = textOf(before); + const ModuleText now = textOf(after); + const std::string function = rewritten.str(); + + std::vector removed; + std::vector added; + for (const auto& [name, text] : was.globals) + { + if (!now.globals.count(name)) + removed.push_back(name); + } + for (const auto& [name, text] : now.globals) + { + if (!was.globals.count(name)) + added.push_back(name); + } + + // The rewritten function and what is named inside it change their mangled names: pair + // them by their names with the function's encoding masked, then by their demangled names + // without const. + const std::string functionKey = withoutConst(function); + std::vector encodings; + if (rewritten.starts_with("_Z")) + encodings.push_back(function.substr(2)); + for (const std::string& name : added) + { + if (name.starts_with("_Z") && withoutConst(name) == functionKey) + encodings.push_back(name.substr(2)); + } + std::sort(encodings.begin(), encodings.end(), + [](const std::string& a, const std::string& b) { return a.size() > b.size(); }); + const auto masked = [&](std::string name) + { + for (const std::string& encoding : encodings) + { + for (std::size_t at = name.find(encoding); at != std::string::npos; + at = name.find(encoding, at + 3)) + { + name.replace(at, encoding.size(), "@F@"); + } + } + // Substitution indices (S_, S0_, ...) depend on what precedes them. + std::string out; + for (std::size_t i = 0; i < name.size(); ++i) + { + if (name[i] == 'S') + { + std::size_t end = i + 1; + while (end < name.size() && + (std::isdigit(static_cast(name[end])) || + std::isupper(static_cast(name[end])))) + ++end; + if (end < name.size() && name[end] == '_') + { + out += "S_"; + i = end; + continue; + } + } + out += name[i]; + } + return out; + }; + + std::map> byMasked; + std::map> byDemangled; + for (const std::string& name : added) + { + byMasked[masked(name)].insert(name); + byDemangled[withoutConst(name)].insert(name); + } + std::map rename; + std::set taken; + std::vector unpaired; + for (const std::string& name : removed) + { + bool paired = false; + for (const auto* table : {&byMasked, &byDemangled}) + { + const auto found = + table->find(table == &byMasked ? masked(name) : withoutConst(name)); + if (found == table->end()) + continue; + std::vector candidates; + for (const std::string& candidate : found->second) + { + if (!taken.count(candidate)) + candidates.push_back(candidate); + } + if (candidates.size() > 1) + { + std::erase_if(candidates, [&](const std::string& candidate) + { return variantOf(candidate) != variantOf(name); }); + } + if (candidates.size() != 1) + continue; + // A symbol the unit only declares has no code to compare: it may be paired only + // if it is the rewritten function itself. + const bool itself = name == function || withoutConst(name) == functionKey || + masked(name) == "_Z@F@"; + if (!itself && (!was.defined.count(name) || !now.defined.count(candidates[0]))) + break; + rename[name] = candidates[0]; + taken.insert(candidates[0]); + paired = true; + break; + } + if (!paired) + unpaired.push_back(name); + } + std::vector unexpected; + for (const std::string& name : added) + { + if (!taken.count(name)) + unexpected.push_back(name); + } + if (!unpaired.empty() || !unexpected.empty()) + { + const auto list = [](const std::vector& names) + { return names.empty() ? std::string("none") : llvm::join(names, ", "); }; + return "symbols no longer used: " + list(unpaired) + + "; symbols used only after the rewrite: " + list(unexpected); + } + if (renamedTo) + { + const auto found = rename.find(function); + *renamedTo = found == rename.end() ? function : found->second; + } + + if (was.types != now.types) + { + for (const auto& [name, definition] : was.types) + { + const auto other = now.types.find(name); + if (other == now.types.end() || other->second != definition) + return "type " + name + " changed"; + } + return "types added by the rewrite"; + } + + bool signatureAccepted = false; + for (const auto& [name, text] : was.globals) + { + const auto renamed = rename.find(name); + const std::string newName = renamed == rename.end() ? name : renamed->second; + const std::string expected = normalized(text, rename, was.attributeGroups); + std::string actual = normalized(now.globals.at(newName), {}, now.attributeGroups); + if (expected == actual) + continue; + + std::size_t begin = 0; + std::size_t end = 0; + std::size_t newBegin = 0; + std::size_t newEnd = 0; + if (stringConstant(expected, begin, end) && stringConstant(actual, newBegin, newEnd)) + { + const std::string oldText = expected.substr(begin, end - begin); + const std::string newText = actual.substr(newBegin, newEnd - newBegin); + // A type name holds the mangled name of its type, renamed with the function. + const std::string prefix = oldText.starts_with("*") ? "*" : ""; + const bool typeName = name.starts_with("_ZTS") && renamed != rename.end() && + oldText == prefix + name.substr(4) && + newText == prefix + newName.substr(4); + // A string whose only change is the function's printed signature. + bool printedSignature = false; + if (!typeName && signature && signature->before != signature->after) + { + const std::string from = escaped(signature->before); + const std::string to = escaped(signature->after); + std::string replaced = oldText; + for (std::size_t at = from.empty() ? std::string::npos : replaced.find(from); + at != std::string::npos; at = replaced.find(from, at + to.size())) + { + replaced.replace(at, from.size(), to); + printedSignature = true; + } + printedSignature = printedSignature && replaced == newText; + } + if (typeName || printedSignature) + { + actual = + withString(actual, newBegin, newEnd, oldText, unescapedLength(oldText)); + if (actual == expected) + { + if (accepted && typeName) + { + accepted->push_back("difference: the name typeid gives a type named " + "after the function changes, from \"" + + oldText + "\" to \"" + newText + "\""); + } + if (accepted && printedSignature && !signatureAccepted) + { + accepted->push_back("difference: the signature printed by " + "__PRETTY_FUNCTION__ changes, from \"" + + signature->before + "\" to \"" + signature->after + + "\""); + } + signatureAccepted = signatureAccepted || printedSignature; + continue; + } + } + } + return "code changed in " + name + ": " + firstDifference(expected, actual); + } + return std::nullopt; + } +} // namespace ctrace::stack::analysis diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp new file mode 100644 index 0000000..106a5a7 --- /dev/null +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace ctrace::stack::analysis::const_suggestion +{ + // The demangled form of a symbol without const and spaces, with && written &: the rewritten + // function, and what is named inside it, keep this form across the rewrite. + std::string withoutConst(const std::string& symbol); + + // The constructor or destructor variant a symbol names, and whether it names a const method. + std::string variantOf(const std::string& symbol); +} // namespace ctrace::stack::analysis::const_suggestion diff --git a/src/analysis/InputPipeline.cpp b/src/analysis/InputPipeline.cpp index b19df82..53bca06 100644 --- a/src/analysis/InputPipeline.cpp +++ b/src/analysis/InputPipeline.cpp @@ -807,6 +807,29 @@ namespace ctrace::stack::analysis return detectFromExtension(path); } + bool buildSourceCompileCommand(const std::string& filename, LanguageType language, + const AnalysisConfig& config, std::vector& args, + std::string& workingDir, std::string& error) + { + std::error_code cwdErr; + const std::filesystem::path baseDir = config.inputBaseDir.empty() + ? std::filesystem::current_path(cwdErr) + : std::filesystem::path(config.inputBaseDir); + const std::string inputPath = makeAbsolutePathFrom(filename, baseDir.string()); + return buildCompileArgs(filename, inputPath, language, config, args, workingDir, error); + } + + bool runSerializedCompile(const std::string& workingDir, const std::function& compile, + std::string& error) + { + std::lock_guard compileLock(gCompileInvokeMutex); + ScopedCurrentPath cwdGuard(workingDir, error); + if (!error.empty()) + return false; + compile(); + return true; + } + ModuleLoadResult loadModuleForAnalysis(const std::string& filename, const AnalysisConfig& config, llvm::LLVMContext& ctx, llvm::SMDiagnostic& err) diff --git a/src/analyzer/AnalysisPipeline.cpp b/src/analyzer/AnalysisPipeline.cpp index 515e368..bd6827f 100644 --- a/src/analyzer/AnalysisPipeline.cpp +++ b/src/analyzer/AnalysisPipeline.cpp @@ -12,6 +12,7 @@ #include "analysis/AllocaUsage.hpp" #include "analysis/ConstParamAnalysis.hpp" +#include "analysis/ConstSuggestionCheck.hpp" #include "analysis/CommandInjectionAnalysis.hpp" #include "analysis/DuplicateIfCondition.hpp" #include "analysis/DynamicAlloca.hpp" @@ -647,9 +648,10 @@ namespace ctrace::stack::analyzer auto shouldAnalyze = [&](const llvm::Function& F) -> bool { return state.prepared->ctx.shouldAnalyze(F); }; const auto* otherModules = state.config.constPointeeParamIndex.get(); - const std::vector issues = + std::vector issues = analysis::analyzeConstParams(state.mod, shouldAnalyze, otherModules); + analysis::checkConstSuggestions(state.mod, issues, state.config); appendConstParamDiagnostics(state.result, issues); }, kPrepared, kNone, true, ExecutionModel::SubscriberCompatible}); diff --git a/src/analyzer/DiagnosticEmitter.cpp b/src/analyzer/DiagnosticEmitter.cpp index 8e37f32..250b5ef 100644 --- a/src/analyzer/DiagnosticEmitter.cpp +++ b/src/analyzer/DiagnosticEmitter.cpp @@ -1071,6 +1071,8 @@ namespace ctrace::stack::analyzer body << kDiagIndentArrow << "the callee's body is not checked: it may still modify " << "the " << object << " object through a cast\n"; } + for (const std::string& line : issue.checkLines) + body << kDiagIndentArrow << line << "\n"; DiagnosticBuilder builder; builder.function(issue.funcName) diff --git a/src/app/AnalyzerApp.cpp b/src/app/AnalyzerApp.cpp index ed68226..450020a 100644 --- a/src/app/AnalyzerApp.cpp +++ b/src/app/AnalyzerApp.cpp @@ -47,6 +47,7 @@ #include "analysis/AnalyzerUtils.hpp" #include "analysis/CompileCommands.hpp" #include "analysis/ConstParamAnalysis.hpp" +#include "analysis/ConstSuggestionCheck.hpp" #include "analysis/DuplicateIfCondition.hpp" #include "analysis/FunctionFilter.hpp" #include "analysis/GlobalReadBeforeWriteAnalysis.hpp" @@ -988,6 +989,17 @@ static AppStatus analyzeWithSharedModuleLoading(const std::vector& } loadedModules.swap(orderedLoadedModules); + { + // Every input analyzed together, for the Clang check of const suggestions (#217). + auto units = std::make_shared(); + for (const auto& loaded : loadedModules) + { + units->units.push_back({loaded.filename, loaded.module.get(), + analysis::detectFromExtension(loaded.filename)}); + } + cfg.analyzedUnits = std::move(units); + } + { // A function found never to return ends the paths that call it in the other modules; // repeat until no round finds a new one, for chains across several files. A caller may @@ -1159,6 +1171,8 @@ static AppStatus analyzeWithSharedModuleLoading(const std::vector& logText(coretrace::Level::Info, emptyMsg); results.emplace_back(loaded.filename, std::move(result)); } + // The modules are freed on return. + cfg.analyzedUnits.reset(); return AppStatus::success(); } @@ -1201,7 +1215,9 @@ static AppStatus analyzeWithoutSharedModuleLoading(const std::vector Date: Tue, 13 Oct 2026 23:47:09 +0200 Subject: [PATCH 003/152] test(const-param): check suggestions in the compilation database, metadata included A const suggestion must hold in every file of the compilation database, metadata included. --- run_test.py | 136 ++++++++++++++++ .../const-suggestion-abi-none.cpp | 6 +- .../const-suggestion-abi.cpp | 2 +- .../const-suggestion-kept-blocks.cpp | 2 +- .../const-suggestion-kept-inlined.cpp | 2 +- .../const-suggestion-kept.c | 2 +- .../const-suggestion-kept.cpp | 2 +- .../const-suggestion-macros.c | 8 +- .../const-suggestion-must-compile.c | 2 +- .../const-suggestion-must-compile.cpp | 2 +- .../const-suggestion-printed-signature.c | 2 +- .../const-suggestion-same-code.cpp | 4 +- test/unit/analyzer_module_unit_tests.cpp | 154 ++++++++++++++++++ 13 files changed, 307 insertions(+), 17 deletions(-) diff --git a/run_test.py b/run_test.py index 8151123..20df933 100755 --- a/run_test.py +++ b/run_test.py @@ -2935,6 +2935,141 @@ def message(files: list[Path], function: str) -> Optional[str]: return ok +def check_const_suggestion_compilation_database() -> bool: + """ + #217: with a compilation database, a suggested const type is checked in every file the + database lists that sees the change, analyzed or not, with its own compile command. A file + that is not analyzed and stores the function where a non-const parameter is expected, declares + it itself without the header, or converts its address with an explicit cast, removes the + suggestion, even listed after a file that cannot be checked. So does a file that takes the + address of a method with the old type: it sees the change through the header alone. A file + that sees the change but does not compile as it is, or cannot be preprocessed, keeps the + suggestion as not checked, without the claim of the check. A file that does not see the change + is not compiled. + """ + print("=== Testing const suggestions checked in the compilation database ===") + # Absolute: a compilation database names absolute directories. + fixtures = (RUN_CONFIG.test_dir / "pointer_reference-const_correctness").resolve() + prefix = "cross-tu-const-suggestion-" + checked = "checked in every file of the compilation database that sees the change" + method = "_ZNK6Reader4readEPi" + generated = { + "broken.c": f'#include "{prefix}api.h"\nint broken(void) {{ return read_value(0) }}\n', + "missing.c": '#include "no-such-header.h"\n', + "unrelated.c": "int unrelated(void) { return 0 }\n", + # The database lists its files sorted: this callback comes after broken.c. + "stores-reader.c": f'#include "{prefix}api.h"\n' + "int (*const stored_reader)(int*) = read_value;\n", + "method.hpp": "struct Reader\n{\n int read(int* p) const;\n};\n", + "method.cpp": '#include "method.hpp"\n' + "int Reader::read(int* p) const\n{\n return *p;\n}\n", + "member-pointer.cpp": '#include "method.hpp"\n' + "int (Reader::*const stored)(int*) const = &Reader::read;\n", + } + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_database_") as tmp: + # Resolved: the analyzer names files by their real path. + root = Path(tmp).resolve() + for name, text in generated.items(): + (root / name).write_text(text) + + def message(label: str, listed: list[str], function: str) -> tuple[bool, Optional[str]]: + """Whether the analysis ran, and the message of the suggestion, if it is reported.""" + files = [root / f if f in generated else fixtures / f"{prefix}{f}" for f in listed] + database = root / label.replace(" ", "-").replace("'", "") / "compile_commands.json" + database.parent.mkdir() + database.write_text( + json.dumps( + [ + { + "directory": str(fixtures), + "file": str(f), + "arguments": ( + ["clang++", "-std=c++17"] if f.suffix == ".cpp" else ["clang"] + ) + + ["-I", str(fixtures), "-c", str(f)], + } + for f in files + ] + ) + ) + # The database is an input the cache key does not cover. + result = run_analyzer_uncached( + [str(files[0]), f"--compile-commands={database}", "--format=json"] + ) + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return False, None + ran = result.returncode == 0 + for d in diagnostics: + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified") and str( + d.get("location", {}).get("function", "") + ) == function: + return ran, str(d.get("details", {}).get("message", "")) + return ran, None + + for label, listed, function, expected in ( + ("a caller", ["def.c", "caller.c"], "read_value", checked), + ( + "a callback of the old type, not analyzed", + ["def.c", "callback.c"], + "read_value", + None, + ), + # Without the header: only the name of the function shows that it sees the change. + ("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None), + ("an explicit cast, not analyzed", ["def.c", "cast.c"], "read_value", None), + ("a method alone", ["method.cpp"], method, checked), + ( + "a method's address of the old type, not analyzed", + ["method.cpp", "member-pointer.cpp"], + method, + None, + ), + ( + "a file that does not compile as it is", + ["def.c", "broken.c"], + "read_value", + "not checked with Clang: " + str(root / "broken.c") + + " does not compile with Clang as it is", + ), + ( + "a file that cannot be preprocessed", + ["def.c", "missing.c"], + "read_value", + "not checked with Clang: " + str(root / "missing.c") + + " cannot be preprocessed with Clang", + ), + ( + "a file that cannot be checked, then a callback", + ["def.c", "broken.c", "stores-reader.c"], + "read_value", + None, + ), + ( + "a file that does not see the change", + ["def.c", "unrelated.c"], + "read_value", + checked, + ), + ): + ran, text = message(label, listed, function) + if not ran: + good = False + elif expected is None: + good = text is None + else: + good = text is not None and expected in text + if expected != checked: + good = good and checked not in text + detail = f": {text!r}" if ran else ": the analysis did not run" + print(f" {'✅' if good else '❌'} {label}" + ("" if good else detail)) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -5576,6 +5711,7 @@ def record_ok(ok: bool): check_const_param_contract_message_across_files, check_const_suggestion_cross_tu, check_const_suggestion_without_source, + check_const_suggestion_compilation_database, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp index 2da89c0..9ad3575 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp @@ -8,7 +8,7 @@ // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'internal_read(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside static int internal_read(int* p) { return *p; @@ -20,7 +20,7 @@ namespace // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function '(anonymous namespace)::anonymous_read(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p - // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized + // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int anonymous_read(int* p) { return *p; @@ -31,7 +31,7 @@ namespace // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'c_read' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside extern "C" int c_read(int* p) { return *p; diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp index b558ddd..b53ce31 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-abi.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp @@ -8,7 +8,7 @@ // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'external_read(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z13external_readPi -> _Z13external_readPKi): code compiled separately must use the updated declaration and be recompiled int external_read(int* p) { diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp index 17b550d..a0f9b54 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp @@ -26,7 +26,7 @@ static void log_text(const std::string& text) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_and_log(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z12read_and_logPi -> _Z12read_and_logPKi): code compiled separately must use the updated declaration and be recompiled int read_and_log(int* p) { diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp index 88c2f8b..a1e8afe 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp @@ -15,7 +15,7 @@ template __attribute__((always_inline)) inline int read_once(Read r // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_with_lambda(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z16read_with_lambdaPi -> _Z16read_with_lambdaPKi): code compiled separately must use the updated declaration and be recompiled int read_with_lambda(int* p) { diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.c b/test/pointer_reference-const_correctness/const-suggestion-kept.c index de3adb2..5f56f24 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept.c +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.c @@ -15,7 +15,7 @@ static inline __attribute__((always_inline)) int load(const int* __restrict q) // ↳ current type: int *p // ↳ suggested type: const int *p // ↳ the callee's body is not checked: it may still modify the pointed object through a cast -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int read_restricted(int* p) { return load(p); diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp index c4dbd61..b7bee5e 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp @@ -9,7 +9,7 @@ // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_or_throw(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized, except for the differences below +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside, except for the differences below // ↳ difference: the name typeid gives a type named after the function changes, from "Z13read_or_throwPiE7Missing" to "Z13read_or_throwPKiE7Missing" // ↳ ABI: the suggested type changes the symbol (_Z13read_or_throwPi -> _Z13read_or_throwPKi): code compiled separately must use the updated declaration and be recompiled int read_or_throw(int* p) diff --git a/test/pointer_reference-const_correctness/const-suggestion-macros.c b/test/pointer_reference-const_correctness/const-suggestion-macros.c index bd60b02..592f1b4 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-macros.c +++ b/test/pointer_reference-const_correctness/const-suggestion-macros.c @@ -19,7 +19,7 @@ typedef unsigned char* byte_pointer; // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'object_macro' is never used to modify the pointed object // ↳ current type: unsigned char *buffer // ↳ suggested type: const unsigned char *buffer -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int object_macro(BYTE* buffer) { return buffer[0]; @@ -29,7 +29,7 @@ int object_macro(BYTE* buffer) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'pointer_macro' is never used to modify the pointed object // ↳ current type: unsigned char *buffer // ↳ suggested type: const unsigned char *buffer -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int pointer_macro(BYTE_POINTER buffer) { return buffer[0]; @@ -39,7 +39,7 @@ int pointer_macro(BYTE_POINTER buffer) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'declarator_macro' is never used to modify the pointed object // ↳ current type: unsigned char *buffer // ↳ suggested type: const unsigned char *buffer -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int declarator_macro(BUFFER_PARAM(buffer)) { return buffer[0]; @@ -49,7 +49,7 @@ int declarator_macro(BUFFER_PARAM(buffer)) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'buffer' in function 'argument_macro' is never used to modify the pointed object // ↳ current type: unsigned char *buffer // ↳ suggested type: const unsigned char *buffer -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside DECLARE_READER(argument_macro, unsigned char*) { return buffer[0]; diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.c b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c index 061e8ec..ac3a808 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-must-compile.c +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c @@ -62,7 +62,7 @@ int generic_selection(int* p) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside int read_value(int* p) { return *p; diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp index da05486..68f7b95 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp @@ -42,7 +42,7 @@ void keep(int& r) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z10read_valuePi -> _Z10read_valuePKi): code compiled separately must use the updated declaration and be recompiled int read_value(int* p) { diff --git a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c index dcaf2e5..e442161 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c +++ b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c @@ -12,7 +12,7 @@ // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'print_and_read' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized, except for the differences below +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside, except for the differences below // ↳ difference: the signature printed by __PRETTY_FUNCTION__ changes, from "int print_and_read(int *)" to "int print_and_read(const int *)" int print_and_read(int* p) { diff --git a/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp index 1b224e9..15009fc 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp @@ -131,7 +131,7 @@ int self_typed(int* p) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'called_directly(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z15called_directlyPi -> _Z15called_directlyPKi): code compiled separately must use the updated declaration and be recompiled int called_directly(int* p) { @@ -147,7 +147,7 @@ int call_directly(int* p) // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'twice_target(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches once debug information, metadata numbering and renamed symbols are normalized +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z12twice_targetPi -> _Z12twice_targetPKi): code compiled separately must use the updated declaration and be recompiled int twice_target(int* p) { diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index d443fb8..2e19c5b 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -804,6 +804,159 @@ namespace return report.failures == 0; } + /// #217: the code comparison sets aside what carries no meaning for an execution: debug + /// information, diagnostic metadata, the descriptive names of alias scopes, the numbers of + /// metadata and the comments of the printed IR. Every other metadata is compared by contents. + bool testConstSuggestionNormalizations(TestReport& report) + { + using ctrace::stack::analysis::rewrittenCodeDifference; + llvm::LLVMContext context; + llvm::SMDiagnostic error; + const auto differ = [&](const std::string& before, const std::string& after) + { + const std::unique_ptr was = + llvm::parseAssemblyString(before, error, context); + const std::unique_ptr now = + llvm::parseAssemblyString(after, error, context); + if (!was || !now) + return std::optional(); + return std::optional(rewrittenCodeDifference(*was, *now, "f").has_value()); + }; + const auto replaced = [](std::string text, const std::string& from, const std::string& to) + { + text.replace(text.find(from), from.size(), to); + return text; + }; + const std::string range = "define i32 @f(ptr %p) {\n" + " %v = load i32, ptr %p, !range !0\n" + " ret i32 %v\n" + "}\n" + "!0 = !{i32 0, i32 10}\n"; + const std::string debug = + "define i32 @f(ptr %p) !dbg !3 {\n" + " %v = load i32, ptr %p, !dbg !6\n" + " ret i32 %v, !dbg !6\n" + "}\n" + "!llvm.dbg.cu = !{!0}\n" + "!llvm.module.flags = !{!2}\n" + "!0 = distinct !DICompileUnit(language: DW_LANG_C11, file: !1, emissionKind: " + "FullDebug)\n" + "!1 = !DIFile(filename: \"a.c\", directory: \"/\")\n" + "!2 = !{i32 2, !\"Debug Info Version\", i32 3}\n" + "!3 = distinct !DISubprogram(name: \"f\", scope: !1, file: !1, line: 1, type: !4, " + "spFlags: DISPFlagDefinition, unit: !0)\n" + "!4 = !DISubroutineType(types: !5)\n" + "!5 = !{}\n" + "!6 = !DILocation(line: 2, column: 3, scope: !3)\n"; + const std::string srcloc = "define void @f() {\n" + " call void asm sideeffect \"nop\", \"\"(), !srcloc !0\n" + " ret void\n" + "}\n" + "!0 = !{i64 100}\n"; + const std::string annotation = "define void @f() {\n" + " %a = alloca i32, align 4, !annotation !0\n" + " ret void\n" + "}\n" + "!0 = !{!\"auto-init\"}\n"; + const std::string scopes = + "declare void @llvm.experimental.noalias.scope.decl(metadata)\n" + "define void @f(ptr %p, ptr %q) {\n" + " call void @llvm.experimental.noalias.scope.decl(metadata !2)\n" + " %v = load i32, ptr %p, align 4, !alias.scope !2, !noalias !3\n" + " store i32 %v, ptr %q, align 4, !alias.scope !3, !noalias !2\n" + " ret void\n" + "}\n" + "!0 = distinct !{!0, !\"domain of f(int*)\"}\n" + "!1 = distinct !{!1, !0, !\"f(int*): %p\"}\n" + "!4 = distinct !{!4, !0, !\"f(int*): %q\"}\n" + "!2 = !{!1}\n" + "!3 = !{!4}\n"; + const std::string flags = "define void @f() {\n" + " ret void\n" + "}\n" + "!llvm.module.flags = !{!0}\n" + "!0 = !{i32 1, !\"wchar_size\", i32 4}\n"; + // The same two functions, defined in the other order: the printer numbers their loop + // metadata in the other order. + const std::string loopF = "define void @f() {\n" + "entry:\n" + " br label %a\n" + "a:\n" + " br label %a, !llvm.loop !0\n" + "}\n"; + const std::string loopG = "define void @g() {\n" + "entry:\n" + " br label %b\n" + "b:\n" + " br label %b, !llvm.loop !1\n" + "}\n"; + const std::string loops = "!0 = distinct !{!0, !2}\n" + "!1 = distinct !{!1, !3}\n" + "!2 = !{!\"llvm.loop.mustprogress\"}\n" + "!3 = !{!\"llvm.loop.unroll.disable\"}\n"; + const std::optional orderChanged = + differ(loopF + loopG + loops, loopG + loopF + loops); + // The same code, its join block's predecessors listed in the other order: only the + // "; preds" comment of the printed IR changes. + const std::string join = "define i32 @f(i1 %c) {\n" + "entry:\n" + " br i1 %c, label %a, label %b\n" + "a:\n" + " br label %join\n" + "b:\n" + " br label %join\n" + "join:\n" + " %v = phi i32 [ 1, %a ], [ 2, %b ]\n" + " ret i32 %v\n" + "}\n"; + const std::optional predecessorsChanged = + differ(join, replaced(join, " ret i32 %v\n", + " ret i32 %v\n uselistorder label %join, { 1, 0 }\n")); + const std::optional rangeChanged = + differ(range, replaced(range, "i32 0, i32 10", "i32 0, i32 5")); + const std::optional debugChanged = + differ(debug, replaced(debug, "column: 3", "column: 9")); + const std::optional srclocChanged = + differ(srcloc, replaced(srcloc, "i64 100", "i64 200")); + const std::optional annotationChanged = + differ(annotation, replaced(annotation, "auto-init", "other")); + const std::optional namesChanged = differ( + scopes, + replaced(replaced(replaced(scopes, "domain of f(int*)", "domain of f(const int*)"), + "f(int*): %p", "f(const int*): %p"), + "f(int*): %q", "f(const int*): %q")); + const std::optional scopesChanged = + differ(scopes, replaced(scopes, "!alias.scope !3, !noalias !2", + "!alias.scope !2, !noalias !2")); + const std::optional flagsChanged = differ(flags, replaced(flags, "i32 4}", "i32 2}")); + if (!orderChanged || !predecessorsChanged || !rangeChanged || !debugChanged || + !srclocChanged || !annotationChanged || !namesChanged || !scopesChanged || + !flagsChanged) + { + report.expect(false, "ConstSuggestionMetadata setup: the IR does not parse: " + + error.getMessage().str()); + return false; + } + report.expect(!*orderChanged, + "ConstSuggestionMetadata: metadata numbered in another order are the same"); + report.expect(!*predecessorsChanged, + "ConstSuggestionCode: predecessors listed in another order are the same"); + report.expect(*rangeChanged, "ConstSuggestionMetadata: another !range is a difference"); + report.expect(!*debugChanged, "ConstSuggestionMetadata: debug information is not compared"); + report.expect(!*srclocChanged, + "ConstSuggestionMetadata: the source position of inline assembly is not " + "compared"); + report.expect(!*annotationChanged, "ConstSuggestionMetadata: annotations are not compared"); + report.expect(!*namesChanged, + "ConstSuggestionMetadata: the descriptive names of alias scopes are not " + "compared"); + report.expect(*scopesChanged, + "ConstSuggestionMetadata: another alias scope on an access is a difference"); + report.expect(*flagsChanged, + "ConstSuggestionMetadata: another module flag is a difference"); + return report.failures == 0; + } + bool testUninitializedSummaryKeysAreLinkerSymbols(const std::filesystem::path& repoRoot, TestReport& report) { @@ -2420,6 +2573,7 @@ int main(int argc, char** argv) (void)testSarifSecuritySeverity(report); (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); (void)testConstSuggestionCodeComparison(report); + (void)testConstSuggestionNormalizations(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From ff4223571bb677bcb462c67256c1f0d6b95f5ab5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 13 Oct 2026 23:47:59 +0200 Subject: [PATCH 004/152] fix(const-param): recheck the compilation database and compare semantic metadata Recheck every file of the compilation database and compare semantic metadata. --- README.md | 31 ++- include/analysis/ConstSuggestionCheck.hpp | 15 +- src/analysis/ConstSuggestionCheck.cpp | 308 ++++++++++++++++------ src/analysis/ConstSuggestionCode.cpp | 266 ++++++++++++++----- src/analysis/ConstSuggestionInternal.hpp | 38 +++ 5 files changed, 501 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index bfa104c..e3708bf 100644 --- a/README.md +++ b/README.md @@ -639,8 +639,11 @@ which nothing is written. Before reporting one, the analyzer checks the suggeste - a type written by a macro that writes only the parameter gets `const` before the invocation. A macro definition is never rewritten. -2. Every analyzed file that reads a rewritten file, or whose code references the function, is - compiled again with its own compile command, at `-O0`. +2. Every file that sees the change is compiled again with its own compile command, at `-O0`. + The files considered are the analyzed ones and, with `--compile-commands`, every C or C++ file + of the compilation database, analyzed or not. A file sees the change when it reads a rewritten + file, when its analyzed code references the function, or, for a function outside any class, + when it names the function after preprocessing. 3. The suggestion is removed when one of these files: - no longer compiles; - gains one of these warnings: `incompatible-pointer-types-discards-qualifiers`, @@ -651,12 +654,20 @@ which nothing is written. Before reporting one, the analyzer checks the suggeste - changes what the methods of that name override; - generates different code. + A file that cannot be preprocessed, or does not compile as it is, cannot be checked: the + suggestion is then kept as not checked, unless another file refutes it. + Generated code is compared function by function and global by global, after these normalizations: -- debug information is removed; -- metadata are compared by the places that use them, not by their numbers or contents; +- debug information is not generated; +- two kinds of metadata that do not change what the program does are removed: the source position + of inline assembly (`!srcloc`), used only to report errors, and annotations (`!annotation`), + which the LLVM Language Reference defines as without effect on the program; +- alias scopes and their domains lose their descriptive names, which identify nothing: each one + stays a distinct scope; +- every other metadata, module flags included, is compared by contents, not by number; - attribute groups are compared by contents, not by numbers; -- comments of the printed IR are ignored; +- values are numbered, not named, and comments of the printed IR are ignored; - the symbols the rewrite renames are paired: the function, and what is named or instantiated inside it. A symbol the file only declares is paired only if it is the function itself. @@ -666,14 +677,18 @@ lists them: name; - a string constant whose only change is the signature that `__PRETTY_FUNCTION__` prints. -The check covers only the analyzed files, with their compile commands, and the code these +The check covers only these files, with their compile commands, and the code these configurations compile. The message says how the suggestion was checked: -- `checked in the analyzed files only, with their compile commands: ...`; +- `checked in the analyzed files only, with their compile commands: ...`, without a compilation + database; +- `checked in every file of the compilation database that sees the change, with its compile + command: ...`, with one; - `difference: ...`, for each accepted difference; - `ABI: ...`, for a C++ function with external linkage: its symbol changes, so code compiled separately must use the updated declaration and be recompiled; - `not checked with Clang: ...`, when the check could not run, for example on an LLVM IR input - (`.ll`, `.bc`), which has no source. The suggestion then rests on the IR analysis alone. + (`.ll`, `.bc`), which has no source, or for a file of the compilation database that cannot be + preprocessed. The suggestion then rests on the IR analysis alone. ### Example diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index 5b1b11c..cb60f1a 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -30,13 +31,21 @@ namespace ctrace::stack::analysis std::uint32_t reservedLanguagePadding = 0; }; + // What the preprocessing of one unit shows: the files it reads, and its identifiers (as + // 64-bit hashes: a collision only adds a recheck). + struct UnitScan + { + std::set files; + std::unordered_set identifiers; + }; + // The inputs analyzed together. struct AnalyzedUnits { std::vector units; - // The files each unit reads, by unit filename, found when first needed. - mutable std::mutex filesReadMutex; - mutable std::map> filesRead; + // The scan of each unit, by path, found when first needed. + mutable std::mutex scansMutex; + mutable std::map scans; }; // The one input of an analysis that loads it alone. diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index d0d6ff8..3568407 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -5,7 +5,9 @@ #include "analysis/ConstSuggestionCheck.hpp" #include "ConstSuggestionInternal.hpp" +#include "analysis/CompileCommands.hpp" #include "analysis/InputPipeline.hpp" +#include "analyzer/HotspotProfiler.hpp" #include #include @@ -13,6 +15,7 @@ #include #include #include +#include #include #include @@ -47,7 +50,7 @@ #include #include #include -#include +#include namespace ctrace::stack::analysis { @@ -56,11 +59,19 @@ namespace ctrace::stack::analysis using const_suggestion::variantOf; using const_suggestion::withoutConst; - constexpr const char* kChecked = - "checked in the analyzed files only, with their compile commands: they compile with " - "the suggested type, and their generated code matches once debug information, " - "metadata numbering and renamed symbols are normalized"; + // What the check covers: the analyzed files, or every file of the compilation database. + constexpr const char* kCheckedAnalyzed = + "checked in the analyzed files only, with their compile commands: "; + constexpr const char* kCheckedDatabase = + "checked in every file of the compilation database that sees the change, with its " + "compile command: "; + // What it verifies (README, "Const parameter suggestions checked with Clang"). + constexpr const char* kCheckedResult = + "they compile with the suggested type, and their generated code matches, metadata " + "included, once debug information, diagnostic metadata, descriptive names and " + "renamed symbols are set aside"; constexpr const char* kNotChecked = "not checked with Clang: "; + constexpr const char* kDoesNotCompileAsItIs = "a file does not compile with Clang as it is"; // Warnings that the C and C++ standards make constraint violations, or that a rewrite // turns on silently: the rewritten code must not add any. @@ -88,7 +99,10 @@ namespace ctrace::stack::analysis std::string problem; std::multiset overrides; unsigned paramIndex = 0; - std::uint32_t reservedPadding = 0; + // A free function can be declared by a file that reads none of the rewritten + // files; a method only where its class is defined. + bool freeFunction = false; + std::uint8_t reservedPadding[3] = {}; }; // What to look for in one compilation. @@ -108,6 +122,8 @@ namespace ctrace::stack::analysis struct Compilation { + bool timing = false; + std::uint8_t reservedPadding[7] = {}; std::string failure; // the compilation could not run std::multiset diagnostics; std::string firstDiagnostic; @@ -116,6 +132,15 @@ namespace ctrace::stack::analysis std::vector facts; }; + // A unit before the rewrite, prepared once: its diagnostics, what the methods of the + // function's name override, and its generated code ready to compare. + struct Original + { + std::multiset diagnostics; + std::multiset overrides; + std::shared_ptr code; + }; + std::string realPath(llvm::StringRef path) { llvm::SmallString<256> out; @@ -372,6 +397,7 @@ namespace ctrace::stack::analysis clang::CodeGenerator* generator = action_.getCodeGenerator(); for (std::size_t i = 0; i < targets_.size(); ++i) locate(i, generator); + const analyzer::ScopedHotspot hotspot(out_.timing, "const_suggestion.inspect"); TraverseDecl(context.getTranslationUnitDecl()); } @@ -526,6 +552,7 @@ namespace ctrace::stack::analysis void plan(Target& target, FunctionFacts& facts, const clang::FunctionDecl& function) { const auto* method = llvm::dyn_cast(&function); + facts.freeFunction = method == nullptr; const unsigned implicitThis = method && method->isInstance() ? 1 : 0; if (target.debugArgNo <= implicitThis) { @@ -640,8 +667,9 @@ namespace ctrace::stack::analysis Compilation compile(const UnitCommand& command, const std::map& rewritten, - std::vector& targets) + std::vector& targets, std::string_view step, bool timing) { + const analyzer::ScopedHotspot hotspot(timing, step); static std::once_flag targetsReady; std::call_once(targetsReady, [] @@ -651,6 +679,7 @@ namespace ctrace::stack::analysis llvm::InitializeAllTargetMCs(); }); Compilation out; + out.timing = timing; std::string error; const bool ran = runSerializedCompile( command.workingDir, @@ -682,6 +711,8 @@ namespace ctrace::stack::analysis // Values and blocks numbered, not named: a block that inlining names after // the inlined function carries the rewritten function's symbol. invocation->getCodeGenOpts().DiscardValueNames = true; + // The comparison removes debug information: do not generate it. + invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); // No "N warnings generated" on the analyzer's output. diagnostics.ShowCarets = false; @@ -714,10 +745,40 @@ namespace ctrace::stack::analysis return out; } - // The files a unit reads, from its preprocessing. - std::set filesRead(const UnitCommand& command) + // Preprocesses a unit and records every identifier, after macro expansion. + class ScanAction : public clang::PreprocessorFrontendAction { - std::set out; + public: + explicit ScanAction(std::unordered_set& identifiers) + : identifiers_(identifiers) + { + } + + protected: + void ExecuteAction() override + { + clang::Preprocessor& preprocessor = getCompilerInstance().getPreprocessor(); + preprocessor.EnterMainSourceFile(); + clang::Token token; + do + { + preprocessor.Lex(token); + if (token.is(clang::tok::identifier)) + identifiers_.insert( + llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); + } while (token.isNot(clang::tok::eof)); + } + + private: + std::unordered_set& identifiers_; + }; + + // The files a unit reads and its identifiers, from its preprocessing. Empty when the + // unit cannot be preprocessed without error: what it reads is then unknown. + UnitScan scan(const UnitCommand& command, bool timing) + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.preprocess"); + UnitScan out; std::string error; runSerializedCompile( command.workingDir, @@ -744,18 +805,34 @@ namespace ctrace::stack::analysis instance.setInvocation(std::move(invocation)); instance.createDiagnostics(*files, &ignore, false); instance.createFileManager(files); - clang::PreprocessOnlyAction action; + ScanAction action(out.identifiers); instance.ExecuteAction(action); - if (!instance.hasSourceManager()) + if (!instance.hasSourceManager() || + instance.getDiagnostics().hasErrorOccurred()) return; const clang::SourceManager& sm = instance.getSourceManager(); for (auto it = sm.fileinfo_begin(); it != sm.fileinfo_end(); ++it) - out.insert(realPath(it->first.getName())); + out.files.insert(realPath(it->first.getName())); }, error); return out; } + // An input as a real absolute path: relative inputs resolve from the input directory. + std::string canonicalPath(const std::string& filename, const AnalysisConfig& config) + { + llvm::SmallString<256> path(filename); + if (!llvm::sys::path::is_absolute(path)) + { + llvm::SmallString<256> base(config.inputBaseDir); + if (base.empty()) + llvm::sys::fs::current_path(base); + llvm::sys::path::append(base, filename); + path = base; + } + return realPath(path); + } + bool references(const llvm::Module& module, llvm::StringRef symbol) { const llvm::GlobalValue* value = module.getNamedValue(symbol); @@ -782,25 +859,44 @@ namespace ctrace::stack::analysis return out; } - // Checks one suggestion in one unit: compiled before and after the rewrite. - bool checkUnit(const UnitCommand& command, Compilation* before, const Target& planned, - const std::map& rewritten, + // A unit before the rewrite, for the function a target names: compiled once per call. + using OriginalCache = + std::map, std::optional>; + + const std::optional& originalOf(const UnitCommand& command, + const std::string& key, const Target& planned, + OriginalCache& cache, bool timing) + { + std::optional& out = cache[{key, planned.name}]; + if (out) + return out; + std::vector targets{planned}; + targets[0].plan = false; + targets[0].expectedType.clear(); + Compilation before = + compile(command, {}, targets, "const_suggestion.compile.other_before", timing); + if (!before.failure.empty() || !before.module) + return out; + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); + out = Original{before.diagnostics, + before.facts.empty() ? std::multiset() + : before.facts[0].overrides, + std::make_shared( + const_suggestion::prepare(*before.module))}; + return out; + } + + // Checks one suggestion in one unit, against the unit before the rewrite. + bool checkUnit(const UnitCommand& command, const std::optional& before, + const Target& planned, const std::map& rewritten, const std::set& symbolsBefore, const FunctionFacts& plan, PrintedSignature& signature, std::vector& accepted, - std::string& renamed, std::string& removed, std::string& notChecked) + std::string& renamed, std::string& removed, std::string& notChecked, + bool timing) { - Compilation own; if (!before) { - std::vector targets{planned}; - targets[0].plan = false; - targets[0].expectedType.clear(); - own = compile(command, {}, targets); - before = &own; - } - if (!before->failure.empty() || !before->module) - { - notChecked = "a file does not compile with Clang as it is"; + notChecked = kDoesNotCompileAsItIs; return false; } std::vector targets{planned}; @@ -811,7 +907,8 @@ namespace ctrace::stack::analysis targets[0].symbolsBefore = &symbolsBefore; for (const auto& [path, edits] : plan.edits) targets[0].rewrittenFiles.insert(path); - Compilation after = compile(command, rewritten, targets); + Compilation after = + compile(command, rewritten, targets, "const_suggestion.compile.after", timing); if (!after.failure.empty()) { notChecked = after.failure; @@ -843,8 +940,7 @@ namespace ctrace::stack::analysis removed = facts.problem; return true; } - const FunctionFacts& old = before->facts.empty() ? FunctionFacts() : before->facts[0]; - if (old.overrides != facts.overrides) + if (before->overrides != facts.overrides) { removed = "the methods it overrides change"; return true; @@ -852,9 +948,13 @@ namespace ctrace::stack::analysis if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; std::string renamedTo; - std::unique_ptr was = llvm::CloneModule(*before->module); - const std::optional difference = rewrittenCodeDifference( - *was, *after.module, planned.symbol, &signature, &accepted, &renamedTo); + std::optional difference; + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.compare"); + const const_suggestion::ModuleText now = const_suggestion::prepare(*after.module); + difference = const_suggestion::codeDifference(*before->code, now, planned.symbol, + &signature, &accepted, &renamedTo); + } if (difference) { removed = "the generated code changes: " + *difference; @@ -879,6 +979,8 @@ namespace ctrace::stack::analysis { if (issues.empty() || config.warningsOnly) return; + const bool timing = config.timing; + const analyzer::ScopedHotspot total(timing, "const_suggestion.total"); const AnalyzedUnits* units = config.analyzedUnits.get(); const AnalyzedUnit* self = nullptr; for (std::size_t i = 0; units && i < units->units.size(); ++i) @@ -920,7 +1022,8 @@ namespace ctrace::stack::analysis targets[i].debugArgNo = issues[i].debugArgNo; targets[i].plan = true; } - Compilation before = compile(command, {}, targets); + Compilation before = + compile(command, {}, targets, "const_suggestion.compile.defining_before", timing); if (!before.failure.empty() || !before.module || before.facts.size() != issues.size()) { notChecked("the file does not compile with Clang as it is"); @@ -929,6 +1032,36 @@ namespace ctrace::stack::analysis std::set symbolsBefore; for (const llvm::Function& function : *before.module) symbolsBefore.insert(function.getName().str()); + std::shared_ptr definingCode; + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); + definingCode = std::make_shared( + const_suggestion::prepare(*before.module)); + } + OriginalCache originals; + + // The other units a rewrite may reach: the analyzed ones and, with a compilation + // database, every C or C++ file it lists. + std::vector candidates; + std::set analyzed; + for (const AnalyzedUnit& unit : units->units) + { + analyzed.insert(canonicalPath(unit.filename, config)); + if (&unit != self) + candidates.push_back(unit); + } + if (config.compilationDatabase) + { + for (const std::string& file : config.compilationDatabase->listSourceFiles()) + { + const LanguageType language = detectFromExtension(file); + if ((language == LanguageType::C || language == LanguageType::CXX) && + !analyzed.count(canonicalPath(file, config))) + { + candidates.push_back({file, nullptr, language}); + } + } + } std::vector kept; for (std::size_t i = 0; i < issues.size(); ++i) @@ -963,83 +1096,104 @@ namespace ctrace::stack::analysis std::string renamed; std::string removed; std::string unchecked; - Compilation selfBefore; - selfBefore.module = llvm::CloneModule(*before.module); - selfBefore.diagnostics = before.diagnostics; - selfBefore.facts = {plan}; + const std::optional selfBefore = + Original{before.diagnostics, plan.overrides, definingCode}; std::size_t checkedUnits = 1; - bool done = checkUnit(command, &selfBefore, planned, rewritten, symbolsBefore, plan, - signature, accepted, renamed, removed, unchecked); - // Then every other analyzed unit that sees a rewritten file or references the - // function. - for (const AnalyzedUnit& unit : units->units) + const bool definingDone = + checkUnit(command, selfBefore, planned, rewritten, symbolsBefore, plan, signature, + accepted, renamed, removed, unchecked, timing); + // Then every other unit that sees a rewritten file or references the function: the + // analyzed ones and, with a compilation database, every file it lists, analyzed or + // not. One that cannot be checked leaves the suggestion unchecked, unless another + // one refutes it. + const auto cannotCheck = [&](const std::string& why) { - if (!done || !removed.empty() || &unit == self) - continue; + if (unchecked.empty()) + unchecked = why; + }; + for (const AnalyzedUnit& unit : candidates) + { + if (!definingDone || !removed.empty()) + break; const bool referencing = unit.module && references(*unit.module, issue.funcName); if (unit.language != LanguageType::C && unit.language != LanguageType::CXX) { if (referencing) - { - done = false; - unchecked = unit.filename + " uses the function but has no source"; - } + cannotCheck(unit.filename + " uses the function but has no source"); continue; } UnitCommand other; if (!commandFor(unit, config, other, error)) { - done = false; - unchecked = "no compile command for " + unit.filename; + cannotCheck("no compile command for " + unit.filename); continue; } - bool sees = referencing; - if (!sees) + const std::string key = canonicalPath(unit.filename, config); + UnitScan seen; + bool cached = false; { - std::set read; - { - std::lock_guard lock(units->filesReadMutex); - const auto cached = units->filesRead.find(unit.filename); - if (cached != units->filesRead.end()) - read = cached->second; - } - if (read.empty()) + std::lock_guard lock(units->scansMutex); + const auto found = units->scans.find(key); + if (found != units->scans.end()) { - read = filesRead(other); - std::lock_guard lock(units->filesReadMutex); - units->filesRead[unit.filename] = read; + seen = found->second; + cached = true; } - sees = std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), - [&](const std::string& path) { return read.count(path); }); } + if (!cached) + { + seen = scan(other, timing); + std::lock_guard lock(units->scansMutex); + units->scans[key] = seen; + } + if (seen.files.empty()) + { + cannotCheck(unit.filename + " cannot be preprocessed with Clang"); + continue; + } + const bool sees = + referencing || + std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), + [&](const std::string& path) { return seen.files.count(path); }) || + (plan.freeFunction && + seen.identifiers.count(llvm::xxh3_64bits(planned.name)) != 0); if (!sees) continue; std::vector otherAccepted; + std::string why; ++checkedUnits; - done = checkUnit(other, nullptr, planned, rewritten, symbolsBefore, plan, signature, - otherAccepted, renamed, removed, unchecked); + if (!checkUnit(other, originalOf(other, key, planned, originals, timing), planned, + rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, + removed, why, timing)) + { + cannotCheck(why == kDoesNotCompileAsItIs + ? unit.filename + " does not compile with Clang as it is" + : why); + } for (const std::string& line : otherAccepted) { if (std::find(accepted.begin(), accepted.end(), line) == accepted.end()) accepted.push_back(line); } } - if (!done) + if (!removed.empty()) { - issue.checkLines.push_back(kNotChecked + unchecked); - trace(issue, "not checked, " + unchecked); - kept.push_back(std::move(issue)); + trace(issue, "removed, " + removed); continue; } - if (!removed.empty()) + if (!unchecked.empty()) { - trace(issue, "removed, " + removed); + issue.checkLines.push_back(kNotChecked + unchecked); + trace(issue, "not checked, " + unchecked); + kept.push_back(std::move(issue)); continue; } trace(issue, "checked in " + std::to_string(checkedUnits) + " file(s)"); - issue.checkLines.push_back(accepted.empty() ? std::string(kChecked) - : std::string(kChecked) + - ", except for the differences below"); + const std::string checked = + std::string(config.compilationDatabase ? kCheckedDatabase : kCheckedAnalyzed) + + kCheckedResult; + issue.checkLines.push_back( + accepted.empty() ? checked : checked + ", except for the differences below"); issue.checkLines.insert(issue.checkLines.end(), accepted.begin(), accepted.end()); const llvm::Function* function = mod.getFunction(issue.funcName); if (function && function->hasExternalLinkage() && !renamed.empty() && diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp index b775f50..d824ee0 100644 --- a/src/analysis/ConstSuggestionCode.cpp +++ b/src/analysis/ConstSuggestionCode.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include @@ -66,18 +70,86 @@ namespace ctrace::stack::analysis return line; } - // The text of one module, keyed by what each piece defines. - struct ModuleText + using const_suggestion::ModuleText; + + // Removes what LLVM says carries no meaning for an execution, so that every other + // metadata can be compared by contents: + // - debug information (StripDebugInfo, which also removes heapallocsite, a debug type, + // and the debug locations of loop identifiers); + // - srcloc, the source position of inline assembly and dontcall diagnostics; + // - annotation, which "does not impact the semantics of the program"; + // - the descriptive string of an alias scope or domain identified by a self-reference. + void removeNonSemanticMetadata(llvm::Module& module) { - std::map globals; // functions, variables, aliases - std::map types; - std::map attributeGroups; // "#N" -> "{ ... }" - std::set defined; // has code or data here - }; + llvm::StripDebugInfo(module); + llvm::LLVMContext& context = module.getContext(); + const unsigned srcloc = context.getMDKindID("srcloc"); + llvm::MDBuilder builder(context); + std::map unnamed; + const auto selfNamed = [](const llvm::MDNode* node) + { return node->getNumOperands() > 0 && node->getOperand(0) == node; }; + std::function domain = [&](llvm::MDNode* node) + { + if (!selfNamed(node)) + return node; + llvm::MDNode*& out = unnamed[node]; + if (!out) + out = builder.createAnonymousAliasScopeDomain(); + return out; + }; + const auto scope = [&](llvm::MDNode* node) + { + auto* parent = node->getNumOperands() > 1 + ? llvm::dyn_cast(node->getOperand(1).get()) + : nullptr; + if (!selfNamed(node) || !parent) + return node; + llvm::MDNode*& out = unnamed[node]; + if (!out) + out = builder.createAnonymousAliasScope(domain(parent)); + return out; + }; + const auto list = [&](llvm::MDNode* node) + { + llvm::SmallVector scopes; + for (const llvm::MDOperand& operand : node->operands()) + { + auto* member = llvm::dyn_cast_or_null(operand.get()); + scopes.push_back(member ? scope(member) : operand.get()); + } + return llvm::MDNode::get(context, scopes); + }; + for (llvm::Function& function : module) + { + for (llvm::Instruction& instruction : llvm::instructions(function)) + { + instruction.setMetadata(srcloc, nullptr); + instruction.setMetadata(llvm::LLVMContext::MD_annotation, nullptr); + for (const unsigned kind : + {llvm::LLVMContext::MD_alias_scope, llvm::LLVMContext::MD_noalias}) + { + if (llvm::MDNode* scopes = instruction.getMetadata(kind)) + instruction.setMetadata(kind, list(scopes)); + } + auto* declaration = llvm::dyn_cast(&instruction); + if (declaration && declaration->getIntrinsicID() == + llvm::Intrinsic::experimental_noalias_scope_decl) + { + auto* operand = + llvm::cast(declaration->getArgOperand(0)); + if (auto* scopes = llvm::dyn_cast(operand->getMetadata())) + { + declaration->setArgOperand( + 0, llvm::MetadataAsValue::get(context, list(scopes))); + } + } + } + } + } ModuleText textOf(llvm::Module& module) { - llvm::StripDebugInfo(module); + removeNonSemanticMetadata(module); std::string printed; llvm::raw_string_ostream os(printed); module.print(os, nullptr); @@ -99,11 +171,24 @@ namespace ctrace::stack::analysis } continue; } + // Metadata: compared by contents where they are used, named ones apart. + if (line.starts_with("!")) + { + const std::size_t equal = line.find(" = "); + if (equal != llvm::StringRef::npos) + { + const std::string name = line.substr(0, equal).str(); + const bool numbered = + name.size() > 1 && std::isdigit(static_cast(name[1])); + (numbered ? out.metadata : out.namedMetadata)[name] = + line.substr(equal + 3).str(); + } + continue; + } // Comdat selections are left out: each definition names its comdat, renamed and // compared with it. - if (line.empty() || line.starts_with(";") || line.starts_with("!") || - line.starts_with("$") || line.starts_with("source_filename") || - line.starts_with("target ")) + if (line.empty() || line.starts_with(";") || line.starts_with("$") || + line.starts_with("source_filename") || line.starts_with("target ")) { continue; } @@ -154,69 +239,83 @@ namespace ctrace::stack::analysis return out; } - // Rewrites `text` symbol by symbol, attribute group by contents, and numbers its - // metadata by first use. Quoted strings are left alone. + // Rewrites `text` symbol by symbol and attribute group by contents. Each metadata it uses + // is numbered by first use and its definition appended, so metadata are compared by + // contents and by the places that use them, not by their numbers. Quoted strings are left + // alone. std::string normalized(const std::string& text, const std::map& rename, - const std::map& attributeGroups) + const std::map& attributeGroups, + const std::map& metadata) { std::string out; - std::map metadata; - bool quoted = false; - for (std::size_t i = 0; i < text.size();) + std::map numbers; + std::vector used; + const auto scan = [&](const std::string& piece) { - const char c = text[i]; - if (c == '"' && !(i > 0 && text[i - 1] == '@')) + bool quoted = false; + for (std::size_t i = 0; i < piece.size();) { - quoted = !quoted; - out += c; - ++i; - continue; - } - if (quoted) - { - out += c; - ++i; - continue; - } - if (c == '@' || c == '$') - { - const auto [name, length] = symbolAt(text, i + 1); - if (length != 0) + const char c = piece[i]; + if (c == '"' && !(i > 0 && piece[i - 1] == '@')) { - const auto renamed = rename.find(name); + quoted = !quoted; out += c; - out += spellSymbol(renamed == rename.end() ? name : renamed->second); - i += 1 + length; + ++i; continue; } + if (quoted) + { + out += c; + ++i; + continue; + } + if (c == '@' || c == '$') + { + const auto [name, length] = symbolAt(piece, i + 1); + if (length != 0) + { + const auto renamed = rename.find(name); + out += c; + out += spellSymbol(renamed == rename.end() ? name : renamed->second); + i += 1 + length; + continue; + } + } + const bool numbered = i + 1 < piece.size() && + std::isdigit(static_cast(piece[i + 1])); + if ((c == '!' || c == '#') && numbered) + { + std::size_t end = i + 1; + while (end < piece.size() && + std::isdigit(static_cast(piece[end]))) + ++end; + const std::string reference = piece.substr(i, end - i); + if (c == '!') + { + const auto [it, first] = numbers.try_emplace(reference, numbers.size()); + if (first) + used.push_back(reference); + out += "!m" + std::to_string(it->second); + } + else + { + const auto group = attributeGroups.find(reference); + out += group == attributeGroups.end() ? reference : "#" + group->second; + } + i = end; + continue; + } + out += c; + ++i; } - if (c == '!' && i + 1 < text.size() && - std::isdigit(static_cast(text[i + 1]))) - { - std::size_t end = i + 1; - while (end < text.size() && std::isdigit(static_cast(text[end]))) - ++end; - const auto [it, inserted] = - metadata.try_emplace(text.substr(i, end - i), metadata.size()); - out += "!m" + std::to_string(it->second); - i = end; - continue; - } - if (c == '#' && i + 1 < text.size() && - std::isdigit(static_cast(text[i + 1]))) - { - std::size_t end = i + 1; - while (end < text.size() && std::isdigit(static_cast(text[end]))) - ++end; - const auto group = attributeGroups.find(text.substr(i, end - i)); - out += group == attributeGroups.end() ? text.substr(i, end - i) - : "#" + group->second; - i = end; - continue; - } - out += c; - ++i; + }; + scan(text); + for (std::size_t i = 0; i < used.size(); ++i) + { + out += "\n!m" + std::to_string(i) + " = "; + const auto definition = metadata.find(used[i]); + scan(definition == metadata.end() ? "?" : definition->second); } return out; } @@ -324,16 +423,26 @@ namespace ctrace::stack::analysis return variant; } + const_suggestion::ModuleText const_suggestion::prepare(llvm::Module& module) + { + return textOf(module); + } + std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, llvm::StringRef rewritten, const PrintedSignature* signature, std::vector* accepted, std::string* renamedTo) { - using const_suggestion::variantOf; - using const_suggestion::withoutConst; - const ModuleText was = textOf(before); - const ModuleText now = textOf(after); + return const_suggestion::codeDifference(textOf(before), textOf(after), rewritten, signature, + accepted, renamedTo); + } + + std::optional + const_suggestion::codeDifference(const ModuleText& was, const ModuleText& now, + llvm::StringRef rewritten, const PrintedSignature* signature, + std::vector* accepted, std::string* renamedTo) + { const std::string function = rewritten.str(); std::vector removed; @@ -477,8 +586,10 @@ namespace ctrace::stack::analysis { const auto renamed = rename.find(name); const std::string newName = renamed == rename.end() ? name : renamed->second; - const std::string expected = normalized(text, rename, was.attributeGroups); - std::string actual = normalized(now.globals.at(newName), {}, now.attributeGroups); + const std::string expected = + normalized(text, rename, was.attributeGroups, was.metadata); + std::string actual = + normalized(now.globals.at(newName), {}, now.attributeGroups, now.metadata); if (expected == actual) continue; @@ -536,6 +647,23 @@ namespace ctrace::stack::analysis } return "code changed in " + name + ": " + firstDifference(expected, actual); } + for (const auto& [name, operands] : was.namedMetadata) + { + const auto other = now.namedMetadata.find(name); + const std::string expected = + normalized(operands, rename, was.attributeGroups, was.metadata); + const std::string actual = + other == now.namedMetadata.end() + ? "(none)" + : normalized(other->second, {}, now.attributeGroups, now.metadata); + if (expected != actual) + return "metadata " + name + " changed: " + firstDifference(expected, actual); + } + for (const auto& [name, operands] : now.namedMetadata) + { + if (!was.namedMetadata.count(name)) + return "metadata " + name + " only after the rewrite"; + } return std::nullopt; } } // namespace ctrace::stack::analysis diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 106a5a7..e00b2fa 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -1,7 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include +#include +#include #include +#include + +#include + +namespace llvm +{ + class Module; +} // namespace llvm + +namespace ctrace::stack::analysis +{ + struct PrintedSignature; +} // namespace ctrace::stack::analysis namespace ctrace::stack::analysis::const_suggestion { @@ -11,4 +27,26 @@ namespace ctrace::stack::analysis::const_suggestion // The constructor or destructor variant a symbol names, and whether it names a const method. std::string variantOf(const std::string& symbol); + + // The generated code of one module, ready to compare: what carries no meaning removed, and + // each piece keyed by what it defines. + struct ModuleText + { + std::map globals; // functions, variables, aliases + std::map types; + std::map attributeGroups; // "#N" -> "{ ... }" + std::map metadata; // "!N" -> its definition + std::map namedMetadata; // "!name" -> its operands + std::set defined; // has code or data here + }; + + // Prepares `module`, which it changes (debug information and other metadata removed). + ModuleText prepare(llvm::Module& module); + + // rewrittenCodeDifference, with the code before the rewrite already prepared. + std::optional codeDifference(const ModuleText& was, const ModuleText& now, + llvm::StringRef rewritten, + const PrintedSignature* signature, + std::vector* accepted, + std::string* renamedTo); } // namespace ctrace::stack::analysis::const_suggestion From 75043d61f6686e8aece6d135d316a64fdd5c8a5b Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 13 Oct 2026 23:55:00 +0200 Subject: [PATCH 005/152] test(const-param): pair specializations whose deduced type changes only if unique A specialization whose deduced type changes is paired only when unique, and its code compared. --- .../const-suggestion-deduction-kept.cpp | 40 +++++++++++++ .../const-suggestion-deduction-refused.cpp | 59 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp new file mode 100644 index 0000000..d896cf4 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: rewriting a forwarding reference as a const lvalue reference changes what an lvalue +// argument deduces: F is const G& before the rewrite and G after it. That specialization, and the +// lambda instantiated inside it, change their names, but not their code: they are paired, and both +// suggestions are kept. + +// strict-expectation-details: true + +// at line 25, column 0 +// [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_0&&)' is an rvalue reference and is never used to modify the referred object +// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ current type: &&f +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 25, column 0 +// [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_1&&)' is an rvalue reference and is never used to modify the referred object +// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ current type: &&f +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// The lvalue passes a const reference already: no suggestion for that specialization. +// not contains: in function 'int apply_twice +int apply_twice(F&& f) +{ + const auto twice = [&](int x) { return f(f(x)); }; + return twice(1); +} + +int from_temporaries() +{ + return apply_twice([](int x) { return x & 1; }) | apply_twice([](int x) { return x | 2; }); +} + +int from_lvalue() +{ + const auto g = [](int x) { return x ^ 4; }; + return apply_twice(g); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp new file mode 100644 index 0000000..1b62b39 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: specializations of the rewritten template whose deduced type changes are paired only when +// the correspondence is unique, and their code is still compared. +// - call: g passes G& and h passes const G&; after the rewrite both deduce G, so two +// specializations become one. No unique correspondence: both suggestions are removed. +// - pick: the lvalue's specialization is paired, but its code changes with what F deduces. + +// not contains: in function 'int call< +// not contains: in function 'int pick< + +template +int call(F&& f) +{ + return f(1); +} + +int from_temporary() +{ + return call([](int x) { return x & 1; }); +} + +int from_lvalues() +{ + auto g = [](int x) { return x | 2; }; + const auto& h = g; + return call(g) | call(h); +} + +template +struct is_reference +{ + static constexpr bool value = false; +}; + +template +struct is_reference +{ + static constexpr bool value = true; +}; + +template +int pick(F&& f) +{ + if constexpr (is_reference::value) + return f(4); + else + return f(8); +} + +int pick_temporary() +{ + return pick([](int x) { return x & 3; }); +} + +int pick_lvalue() +{ + const auto g = [](int x) { return x ^ 5; }; + return pick(g); +} From eb17a79fd93001be62b78aeae91deadb5c220d94 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 00:04:53 +0200 Subject: [PATCH 006/152] fix(const-param): pair specializations whose deduced type changes Keep a suggestion whose rewrite only changes what a forwarding reference deduces. --- README.md | 4 ++ src/analysis/ConstSuggestionCheck.cpp | 38 ++++++------- src/analysis/ConstSuggestionCode.cpp | 81 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e3708bf..6a059b6 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,10 @@ normalizations: - values are numbered, not named, and comments of the printed IR are ignored; - the symbols the rewrite renames are paired: the function, and what is named or instantiated inside it. A symbol the file only declares is paired only if it is the function itself. +- rewritten as `const T&`, a forwarding reference `T&&` deduces `G` instead of `G&` or `const G&` + for an lvalue argument: a specialization of the rewritten template that changes this way, and + what is named inside it, is paired when its name without `const` and references is carried by + exactly one symbol before the rewrite and one after. Its code is then compared like any other. Two differences that a program can observe are accepted, each only once verified, and the message lists them: diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 3568407..2bb8757 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -24,7 +24,7 @@ #include #include #include -#include +#include #include #include #include @@ -42,7 +42,6 @@ #include #include #include -#include #include #include #include @@ -344,6 +343,16 @@ namespace ctrace::stack::analysis FunctionFacts& facts_; }; + // A type written canonically. An anonymous type, such as a lambda's, is written without + // its location: the path of that location depends on how the file was named, relative + // before the rewrite and absolute once the rewritten file is remapped. + std::string writtenType(clang::ASTContext& context, clang::QualType type) + { + clang::PrintingPolicy policy(context.getLangOpts()); + policy.AnonymousTagLocations = false; + return type.getCanonicalType().getAsString(policy); + } + // The type the parameter must have once rewritten, written canonically. std::string expectedType(clang::ASTContext& context, clang::QualType type) { @@ -362,13 +371,7 @@ namespace ctrace::stack::analysis { return ""; } - return out.getCanonicalType().getAsString(clang::PrintingPolicy(context.getLangOpts())); - } - - std::string writtenType(clang::ASTContext& context, clang::QualType type) - { - return type.getCanonicalType().getAsString( - clang::PrintingPolicy(context.getLangOpts())); + return writtenType(context, out); } // Declarations of the function, explicit casts of its address, and what each method of @@ -389,9 +392,6 @@ namespace ctrace::stack::analysis void HandleTranslationUnit(clang::ASTContext& context) override { context_ = &context; - names_ = std::make_unique(context); - prefix_ = llvm::DataLayout(context.getTargetInfo().getDataLayoutString()) - .getGlobalPrefix(); out_.facts.resize(targets_.size()); located_.assign(targets_.size(), nullptr); clang::CodeGenerator* generator = action_.getCodeGenerator(); @@ -481,18 +481,18 @@ namespace ctrace::stack::analysis llvm::join(overridden, ", ")); } + // The symbol CodeGen gives the function: it numbers unnamed types, such as a lambda + // ($_0, $_1), in the order it mangles them, as the IR names them. std::string symbolOf(const clang::FunctionDecl& function) { - if (function.isDependentContext() || + clang::CodeGenerator* generator = action_.getCodeGenerator(); + if (!generator || function.isDependentContext() || llvm::isa(function) || llvm::isa(function)) { return ""; } - std::string name = names_->getName(&function); - if (prefix_ && !name.empty() && name.front() == prefix_) - name.erase(0, 1); - return name; + return generator->GetMangledName(clang::GlobalDecl(&function)).str(); } // Whether `function` is a declaration of the function target i names, in this @@ -590,13 +590,11 @@ namespace ctrace::stack::analysis } } - char prefix_ = 0; - std::uint8_t reservedPadding_[6] = {}; + std::uint8_t reservedPadding_[7] = {}; clang::CodeGenAction& action_; std::vector& targets_; Compilation& out_; clang::ASTContext* context_ = nullptr; - std::unique_ptr names_; std::vector located_; }; diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp index d824ee0..c8daff8 100644 --- a/src/analysis/ConstSuggestionCode.cpp +++ b/src/analysis/ConstSuggestionCode.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -374,6 +375,45 @@ namespace ctrace::stack::analysis const std::string now = i < b.size() ? b[i].trim().str() : "(end)"; return "'" + was.substr(0, 160) + "' became '" + now.substr(0, 160) + "'"; } + + // The function template `symbol` is a specialization of, as demangled names write it + // ("ns::f<"), or "" for a symbol that is no function template specialization. + std::string templateOf(const std::string& symbol) + { + llvm::ItaniumPartialDemangler demangler; + if (demangler.partialDemangle(symbol.c_str()) || !demangler.isFunction()) + return ""; + const auto text = [](char* buffer) + { + std::string out = buffer ? buffer : ""; + std::free(buffer); + return out; + }; + std::size_t size = 0; + const std::string base = text(demangler.getFunctionBaseName(nullptr, &size)); + const std::string scope = text(demangler.getFunctionDeclContextName(nullptr, &size)); + const std::string name = text(demangler.getFunctionName(nullptr, &size)); + const std::string qualified = scope.empty() ? base : scope + "::" + base; + if (base.empty() || !llvm::StringRef(name).starts_with(qualified + "<")) + return ""; + return qualified + "<"; + } + + // Whether the demangled `symbol` names `templateName` ("ns::f<") as a whole name. + bool namesTemplate(const std::string& symbol, const std::string& templateName) + { + const std::string text = llvm::demangle(symbol); + for (std::size_t at = text.find(templateName); at != std::string::npos; + at = text.find(templateName, at + 1)) + { + if (at == 0 || (!std::isalnum(static_cast(text[at - 1])) && + text[at - 1] != '_')) + { + return true; + } + } + return false; + } } // namespace std::string const_suggestion::withoutConst(const std::string& symbol) @@ -551,6 +591,47 @@ namespace ctrace::stack::analysis if (!paired) unpaired.push_back(name); } + + // Rewritten as const T&, a forwarding reference T&& deduces G instead of G& or const G& + // for an lvalue argument: the specializations of the rewritten template that change this + // way, and what is named inside them, change their names beyond const. One is paired + // when its demangled name without const and references is carried by exactly one symbol + // before the rewrite and one after, both with code to compare. + const std::string templateName = templateOf(function); + if (!templateName.empty()) + { + const auto loose = [](const std::string& name) + { + std::string key = withoutConst(name); + std::erase(key, '&'); + return key + "|" + variantOf(name); + }; + std::map> before; + std::map> after; + for (const std::string& name : unpaired) + { + if (namesTemplate(name, templateName)) + before[loose(name)].push_back(name); + } + for (const std::string& name : added) + { + if (!taken.count(name) && namesTemplate(name, templateName)) + after[loose(name)].push_back(name); + } + for (const auto& [key, names] : before) + { + const auto other = after.find(key); + if (names.size() != 1 || other == after.end() || other->second.size() != 1 || + !was.defined.count(names[0]) || !now.defined.count(other->second[0])) + { + continue; + } + rename[names[0]] = other->second[0]; + taken.insert(other->second[0]); + std::erase(unpaired, names[0]); + } + } + std::vector unexpected; for (const std::string& name : added) { From 5abdbae7b1c998895e84b3fd89042e1288e4c9fb Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 00:17:48 +0200 Subject: [PATCH 007/152] test(const-param): refuse collapsed specializations even with the same code Collapsed specializations with the same code must still be refused. --- .../const-suggestion-deduction-refused.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp index 1b62b39..194ab5a 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp @@ -2,7 +2,9 @@ // #217: specializations of the rewritten template whose deduced type changes are paired only when // the correspondence is unique, and their code is still compared. // - call: g passes G& and h passes const G&; after the rewrite both deduce G, so two -// specializations become one. No unique correspondence: both suggestions are removed. +// specializations become one. No unique correspondence: both suggestions are removed. The +// lambdas are noexcept, so that all three specializations get the same attributes: only the +// correspondence tells them apart. // - pick: the lvalue's specialization is paired, but its code changes with what F deduces. // not contains: in function 'int call< @@ -16,12 +18,12 @@ int call(F&& f) int from_temporary() { - return call([](int x) { return x & 1; }); + return call([](int x) noexcept { return x & 1; }); } int from_lvalues() { - auto g = [](int x) { return x | 2; }; + auto g = [](int x) noexcept { return x | 2; }; const auto& h = g; return call(g) | call(h); } From 5abb55518dce3c89f177101c6d0753458296b53d Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 00:23:36 +0200 Subject: [PATCH 008/152] perf(const-param): parse the defining file's preamble once for all its suggestions --- src/analysis/ConstSuggestionCheck.cpp | 225 +++++++++++++++++++++----- 1 file changed, 182 insertions(+), 43 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 2bb8757..a5a0e2b 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -10,6 +10,7 @@ #include "analyzer/HotspotProfiler.hpp" #include +#include #include #include #include @@ -36,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -122,7 +124,8 @@ namespace ctrace::stack::analysis struct Compilation { bool timing = false; - std::uint8_t reservedPadding[7] = {}; + bool preamble = false; // the compilation reused the unit's preamble + std::uint8_t reservedPadding[6] = {}; std::string failure; // the compilation could not run std::multiset diagnostics; std::string firstDiagnostic; @@ -663,9 +666,48 @@ namespace ctrace::stack::analysis return true; } + // The compiler invocation of a unit, with the options of every compilation of the check: + // nothing written, the AST kept for the inspection, values numbered, no debug + // information, the checked warnings enabled. + std::unique_ptr + invocationFor(const UnitCommand& command, + const llvm::IntrusiveRefCntPtr& files, + clang::DiagnosticConsumer& consumer) + { + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &consumer, false); + options.VFS = files; + options.ProbePrecompiled = false; + std::unique_ptr invocation = + clang::createInvocation(argv, options); + if (!invocation || invocation->getFrontendOpts().Inputs.empty()) + return nullptr; + // Nothing is written: no dependency file, no output. + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + // The inspection reads the AST after CodeGen: keep it for the backend. + invocation->getCodeGenOpts().ClearASTBeforeBackend = false; + // Values and blocks numbered, not named: a block that inlining names after the + // inlined function carries the rewritten function's symbol. + invocation->getCodeGenOpts().DiscardValueNames = true; + // The comparison removes debug information: do not generate it. + invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); + clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); + // No "N warnings generated" on the analyzer's output. + diagnostics.ShowCarets = false; + diagnostics.IgnoreWarnings = false; + for (const char* warning : kCheckedWarnings) + diagnostics.Warnings.push_back(warning); + return invocation; + } + Compilation compile(const UnitCommand& command, const std::map& rewritten, - std::vector& targets, std::string_view step, bool timing) + std::vector& targets, std::string_view step, bool timing, + const clang::PrecompiledPreamble* preamble = nullptr) { const analyzer::ScopedHotspot hotspot(timing, step); static std::once_flag targetsReady; @@ -686,50 +728,57 @@ namespace ctrace::stack::analysis llvm::IntrusiveRefCntPtr files = llvm::vfs::getRealFileSystem(); DiagnosticCollector collector(out); - std::vector argv; - for (const std::string& arg : command.args) - argv.push_back(arg.c_str()); - clang::CreateInvocationOptions options; - options.Diags = clang::CompilerInstance::createDiagnostics( - *files, new clang::DiagnosticOptions(), &collector, false); - options.VFS = files; - options.ProbePrecompiled = false; std::unique_ptr invocation = - clang::createInvocation(argv, options); + invocationFor(command, files, collector); if (!invocation) { out.failure = "the compile command is not understood: " + out.firstDiagnostic; return; } - // Nothing is written: no dependency file, no output. - invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); - // The inspection reads the AST after CodeGen: keep it for the backend. - invocation->getCodeGenOpts().ClearASTBeforeBackend = false; - // Values and blocks numbered, not named: a block that inlining names after - // the inlined function carries the rewritten function's symbol. - invocation->getCodeGenOpts().DiscardValueNames = true; - // The comparison removes debug information: do not generate it. - invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); - clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); - // No "N warnings generated" on the analyzer's output. - diagnostics.ShowCarets = false; - diagnostics.IgnoreWarnings = false; - for (const char* warning : kCheckedWarnings) - diagnostics.Warnings.push_back(warning); out.diagnostics.clear(); out.firstDiagnostic.clear(); + // The rewritten files, in memory. With a preamble, the main file is given + // apart: the preamble is reused only if Clang finds the main file's preamble + // bytes and every file the preamble read unchanged. + clang::PreprocessorOptions& remapped = invocation->getPreprocessorOpts(); + remapped.RetainRemappedFileBuffers = false; + const std::string mainFile = + invocation->getFrontendOpts().Inputs[0].getFile().str(); + const std::string mainPath = realPath(mainFile); + std::unique_ptr main; + for (const auto& [path, text] : rewritten) + { + if (preamble && path == mainPath) + main = llvm::MemoryBuffer::getMemBufferCopy(text, mainFile); + else + remapped.addRemappedFile( + path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); + } + if (preamble) + { + if (!main) + { + if (auto disk = files->getBufferForFile(mainFile)) + main = std::move(*disk); + } + out.preamble = + main && preamble->CanReuse( + *invocation, main->getMemBufferRef(), + clang::ComputePreambleBounds(invocation->getLangOpts(), + main->getMemBufferRef(), 0), + *files); + if (out.preamble) + preamble->AddImplicitPreamble(*invocation, files, main.release()); + else if (main && rewritten.count(mainPath)) + remapped.addRemappedFile(mainPath, main.release()); + } + clang::CompilerInstance instance; instance.setInvocation(std::move(invocation)); instance.createDiagnostics(*files, &collector, false); instance.createFileManager(files); - for (const auto& [path, text] : rewritten) - { - instance.getPreprocessorOpts().addRemappedFile( - path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); - } - instance.getPreprocessorOpts().RetainRemappedFileBuffers = false; instance.getFrontendOpts().DisableFree = false; instance.getCodeGenOpts().DisableFree = false; out.context = std::make_unique(); @@ -743,6 +792,49 @@ namespace ctrace::stack::analysis return out; } + // A unit's preamble, its leading directives, parsed once and kept in memory for the + // compilations of the unit with the same compile command. None when it is empty or does + // not parse without error: the compilations then parse the unit whole. + std::optional buildPreamble(const UnitCommand& command, + bool timing) + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.preamble"); + std::optional out; + std::string error; + runSerializedCompile( + command.workingDir, + [&] + { + llvm::IntrusiveRefCntPtr files = + llvm::vfs::getRealFileSystem(); + clang::IgnoringDiagConsumer ignore; + std::unique_ptr invocation = + invocationFor(command, files, ignore); + if (!invocation) + return; + auto main = + files->getBufferForFile(invocation->getFrontendOpts().Inputs[0].getFile()); + if (!main) + return; + const clang::PreambleBounds bounds = clang::ComputePreambleBounds( + invocation->getLangOpts(), (*main)->getMemBufferRef(), 0); + if (bounds.Size == 0) + return; + llvm::IntrusiveRefCntPtr diagnostics = + clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &ignore, false); + clang::PreambleCallbacks callbacks; + llvm::ErrorOr built = + clang::PrecompiledPreamble::Build( + *invocation, main->get(), bounds, *diagnostics, files, + std::make_shared(), true, "", callbacks); + if (built && !diagnostics->hasErrorOccurred()) + out.emplace(std::move(*built)); + }, + error); + return out; + } + // Preprocesses a unit and records every identifier, after macro expansion. class ScanAction : public clang::PreprocessorFrontendAction { @@ -885,14 +977,18 @@ namespace ctrace::stack::analysis } // Checks one suggestion in one unit, against the unit before the rewrite. - bool checkUnit(const UnitCommand& command, const std::optional& before, - const Target& planned, const std::map& rewritten, + // `beforeFor` gives the unit before the rewrite, compiled with its preamble or whole as + // the compilation after it: both are compared in the same mode. + bool checkUnit(const UnitCommand& command, + const std::function&(bool)>& beforeFor, + const clang::PrecompiledPreamble* preamble, const Target& planned, + const std::map& rewritten, const std::set& symbolsBefore, const FunctionFacts& plan, PrintedSignature& signature, std::vector& accepted, std::string& renamed, std::string& removed, std::string& notChecked, bool timing) { - if (!before) + if (!preamble && !beforeFor(false)) { notChecked = kDoesNotCompileAsItIs; return false; @@ -905,13 +1001,19 @@ namespace ctrace::stack::analysis targets[0].symbolsBefore = &symbolsBefore; for (const auto& [path, edits] : plan.edits) targets[0].rewrittenFiles.insert(path); - Compilation after = - compile(command, rewritten, targets, "const_suggestion.compile.after", timing); + Compilation after = compile(command, rewritten, targets, + "const_suggestion.compile.after", timing, preamble); if (!after.failure.empty()) { notChecked = after.failure; return false; } + const std::optional& before = beforeFor(after.preamble); + if (!before) + { + notChecked = kDoesNotCompileAsItIs; + return false; + } for (const std::string& diagnostic : after.diagnostics) { if (after.diagnostics.count(diagnostic) > before->diagnostics.count(diagnostic)) @@ -1020,8 +1122,14 @@ namespace ctrace::stack::analysis targets[i].debugArgNo = issues[i].debugArgNo; targets[i].plan = true; } + // The defining unit is compiled again for each suggestion: with several, its preamble is + // parsed once, and reused by each compilation whose rewrite leaves it untouched. + std::optional preamble; + if (issues.size() > 1) + preamble = buildPreamble(command, timing); Compilation before = - compile(command, {}, targets, "const_suggestion.compile.defining_before", timing); + compile(command, {}, targets, "const_suggestion.compile.defining_before", timing, + preamble ? &*preamble : nullptr); if (!before.failure.empty() || !before.module || before.facts.size() != issues.size()) { notChecked("the file does not compile with Clang as it is"); @@ -1037,6 +1145,12 @@ namespace ctrace::stack::analysis const_suggestion::prepare(*before.module)); } OriginalCache originals; + const clang::PrecompiledPreamble* reused = before.preamble ? &*preamble : nullptr; + // The defining unit compiled whole, for a rewrite that reaches its preamble: once. + std::optional, + std::shared_ptr>> + definingWhole; + bool definingWholeTried = false; // The other units a rewrite may reach: the analyzed ones and, with a compilation // database, every C or C++ file it lists. @@ -1096,10 +1210,34 @@ namespace ctrace::stack::analysis std::string unchecked; const std::optional selfBefore = Original{before.diagnostics, plan.overrides, definingCode}; + std::optional selfWhole; + const auto definingBefore = [&](bool withPreamble) -> const std::optional& + { + if (withPreamble == before.preamble) + return selfBefore; + if (!definingWholeTried) + { + definingWholeTried = true; + std::vector none; + Compilation whole = compile(command, {}, none, + "const_suggestion.compile.defining_whole", timing); + if (whole.failure.empty() && whole.module) + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); + definingWhole.emplace(whole.diagnostics, + std::make_shared( + const_suggestion::prepare(*whole.module))); + } + } + if (definingWhole && !selfWhole) + selfWhole = + Original{definingWhole->first, plan.overrides, definingWhole->second}; + return selfWhole; + }; std::size_t checkedUnits = 1; const bool definingDone = - checkUnit(command, selfBefore, planned, rewritten, symbolsBefore, plan, signature, - accepted, renamed, removed, unchecked, timing); + checkUnit(command, definingBefore, reused, planned, rewritten, symbolsBefore, plan, + signature, accepted, renamed, removed, unchecked, timing); // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or // not. One that cannot be checked leaves the suggestion unchecked, unless another @@ -1160,9 +1298,10 @@ namespace ctrace::stack::analysis std::vector otherAccepted; std::string why; ++checkedUnits; - if (!checkUnit(other, originalOf(other, key, planned, originals, timing), planned, - rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, - removed, why, timing)) + const auto otherBefore = [&](bool) -> const std::optional& + { return originalOf(other, key, planned, originals, timing); }; + if (!checkUnit(other, otherBefore, nullptr, planned, rewritten, symbolsBefore, plan, + signature, otherAccepted, renamed, removed, why, timing)) { cannotCheck(why == kDoesNotCompileAsItIs ? unit.filename + " does not compile with Clang as it is" From abfff76c3dc514f874e122fd6d69f7c8ef404737 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 02:05:03 +0200 Subject: [PATCH 009/152] test(const-param): a header's checked warning is not new after the rewrite A rewrite that reaches the preamble parses the file whole, and a header's warning is not new. --- .../const-suggestion-preamble.c | 46 +++++++++++++++++++ .../const-suggestion-preamble.h | 15 ++++++ 2 files changed, 61 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-preamble.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-preamble.h diff --git a/test/pointer_reference-const_correctness/const-suggestion-preamble.c b/test/pointer_reference-const_correctness/const-suggestion-preamble.c new file mode 100644 index 0000000..7b99e9e --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-preamble.c @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the file is compiled again for each suggestion. Its preamble, the header below, is parsed +// once and reused only while the rewrite leaves it untouched. Rewriting the declarations of +// first_value and second_value changes the header: those compilations parse the file whole, and +// are compared with the file before the rewrite parsed whole too. A preamble reused regardless +// would declare the old types; comparing a whole compilation with one that reused the preamble +// would count the header's warning as new. local_value is declared in this file: its +// compilations reuse the preamble. + +// strict-expectation-details: true + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'first_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 38, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'q' in function 'second_value' is never used to modify the pointed object +// ↳ current type: int *q +// ↳ suggested type: const int *q +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 43, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'r' in function 'local_value' is never used to modify the pointed object +// ↳ current type: int *r +// ↳ suggested type: const int *r +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +#include "const-suggestion-preamble.h" + +int local_value(int* r); + +int first_value(int* p) +{ + return *p; +} + +int second_value(int* q) +{ + return *q; +} + +int local_value(int* r) +{ + return *r; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-preamble.h b/test/pointer_reference-const_correctness/const-suggestion-preamble.h new file mode 100644 index 0000000..c3a1b80 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-preamble.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#ifndef CONST_SUGGESTION_PREAMBLE_H +#define CONST_SUGGESTION_PREAMBLE_H + +// A checked warning in the preamble: a compilation that reuses the preamble does not report it, +// one that parses the file whole does. +static inline int* without_const(const int* p) +{ + return p; +} + +int first_value(int* p); +int second_value(int* q); + +#endif From 8298c830a178101cf9c396b4132602c5ef9a6443 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 02:16:07 +0200 Subject: [PATCH 010/152] fix(const-param): name a diagnostic's file by its real path Name a diagnostic's file by its real path, so a header's warning is not counted as new. --- src/analysis/ConstSuggestionCheck.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index a5a0e2b..da6e2d8 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -180,7 +180,7 @@ namespace ctrace::stack::analysis sm.getPresumedLoc(sm.getExpansionLoc(info.getLocation())); if (presumed.isValid()) { - place = std::string(presumed.getFilename()) + ":" + + place = realPath(presumed.getFilename()) + ":" + std::to_string(presumed.getLine()); } } From 9dc33cf5acbb55f32a103790326c0897fc9e4c77 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 04:07:58 +0200 Subject: [PATCH 011/152] revert(const-param): drop the precompiled preamble Drop the precompiled preamble, its gain does not justify its invariants. --- src/analysis/ConstSuggestionCheck.cpp | 225 +++++--------------------- 1 file changed, 43 insertions(+), 182 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index da6e2d8..0f943e7 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -10,7 +10,6 @@ #include "analyzer/HotspotProfiler.hpp" #include -#include #include #include #include @@ -37,7 +36,6 @@ #include #include #include -#include #include #include #include @@ -124,8 +122,7 @@ namespace ctrace::stack::analysis struct Compilation { bool timing = false; - bool preamble = false; // the compilation reused the unit's preamble - std::uint8_t reservedPadding[6] = {}; + std::uint8_t reservedPadding[7] = {}; std::string failure; // the compilation could not run std::multiset diagnostics; std::string firstDiagnostic; @@ -666,48 +663,9 @@ namespace ctrace::stack::analysis return true; } - // The compiler invocation of a unit, with the options of every compilation of the check: - // nothing written, the AST kept for the inspection, values numbered, no debug - // information, the checked warnings enabled. - std::unique_ptr - invocationFor(const UnitCommand& command, - const llvm::IntrusiveRefCntPtr& files, - clang::DiagnosticConsumer& consumer) - { - std::vector argv; - for (const std::string& arg : command.args) - argv.push_back(arg.c_str()); - clang::CreateInvocationOptions options; - options.Diags = clang::CompilerInstance::createDiagnostics( - *files, new clang::DiagnosticOptions(), &consumer, false); - options.VFS = files; - options.ProbePrecompiled = false; - std::unique_ptr invocation = - clang::createInvocation(argv, options); - if (!invocation || invocation->getFrontendOpts().Inputs.empty()) - return nullptr; - // Nothing is written: no dependency file, no output. - invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); - // The inspection reads the AST after CodeGen: keep it for the backend. - invocation->getCodeGenOpts().ClearASTBeforeBackend = false; - // Values and blocks numbered, not named: a block that inlining names after the - // inlined function carries the rewritten function's symbol. - invocation->getCodeGenOpts().DiscardValueNames = true; - // The comparison removes debug information: do not generate it. - invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); - clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); - // No "N warnings generated" on the analyzer's output. - diagnostics.ShowCarets = false; - diagnostics.IgnoreWarnings = false; - for (const char* warning : kCheckedWarnings) - diagnostics.Warnings.push_back(warning); - return invocation; - } - Compilation compile(const UnitCommand& command, const std::map& rewritten, - std::vector& targets, std::string_view step, bool timing, - const clang::PrecompiledPreamble* preamble = nullptr) + std::vector& targets, std::string_view step, bool timing) { const analyzer::ScopedHotspot hotspot(timing, step); static std::once_flag targetsReady; @@ -728,57 +686,50 @@ namespace ctrace::stack::analysis llvm::IntrusiveRefCntPtr files = llvm::vfs::getRealFileSystem(); DiagnosticCollector collector(out); + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &collector, false); + options.VFS = files; + options.ProbePrecompiled = false; std::unique_ptr invocation = - invocationFor(command, files, collector); + clang::createInvocation(argv, options); if (!invocation) { out.failure = "the compile command is not understood: " + out.firstDiagnostic; return; } + // Nothing is written: no dependency file, no output. + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + // The inspection reads the AST after CodeGen: keep it for the backend. + invocation->getCodeGenOpts().ClearASTBeforeBackend = false; + // Values and blocks numbered, not named: a block that inlining names after + // the inlined function carries the rewritten function's symbol. + invocation->getCodeGenOpts().DiscardValueNames = true; + // The comparison removes debug information: do not generate it. + invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); + clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); + // No "N warnings generated" on the analyzer's output. + diagnostics.ShowCarets = false; + diagnostics.IgnoreWarnings = false; + for (const char* warning : kCheckedWarnings) + diagnostics.Warnings.push_back(warning); out.diagnostics.clear(); out.firstDiagnostic.clear(); - // The rewritten files, in memory. With a preamble, the main file is given - // apart: the preamble is reused only if Clang finds the main file's preamble - // bytes and every file the preamble read unchanged. - clang::PreprocessorOptions& remapped = invocation->getPreprocessorOpts(); - remapped.RetainRemappedFileBuffers = false; - const std::string mainFile = - invocation->getFrontendOpts().Inputs[0].getFile().str(); - const std::string mainPath = realPath(mainFile); - std::unique_ptr main; - for (const auto& [path, text] : rewritten) - { - if (preamble && path == mainPath) - main = llvm::MemoryBuffer::getMemBufferCopy(text, mainFile); - else - remapped.addRemappedFile( - path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); - } - if (preamble) - { - if (!main) - { - if (auto disk = files->getBufferForFile(mainFile)) - main = std::move(*disk); - } - out.preamble = - main && preamble->CanReuse( - *invocation, main->getMemBufferRef(), - clang::ComputePreambleBounds(invocation->getLangOpts(), - main->getMemBufferRef(), 0), - *files); - if (out.preamble) - preamble->AddImplicitPreamble(*invocation, files, main.release()); - else if (main && rewritten.count(mainPath)) - remapped.addRemappedFile(mainPath, main.release()); - } - clang::CompilerInstance instance; instance.setInvocation(std::move(invocation)); instance.createDiagnostics(*files, &collector, false); instance.createFileManager(files); + for (const auto& [path, text] : rewritten) + { + instance.getPreprocessorOpts().addRemappedFile( + path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); + } + instance.getPreprocessorOpts().RetainRemappedFileBuffers = false; instance.getFrontendOpts().DisableFree = false; instance.getCodeGenOpts().DisableFree = false; out.context = std::make_unique(); @@ -792,49 +743,6 @@ namespace ctrace::stack::analysis return out; } - // A unit's preamble, its leading directives, parsed once and kept in memory for the - // compilations of the unit with the same compile command. None when it is empty or does - // not parse without error: the compilations then parse the unit whole. - std::optional buildPreamble(const UnitCommand& command, - bool timing) - { - const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.preamble"); - std::optional out; - std::string error; - runSerializedCompile( - command.workingDir, - [&] - { - llvm::IntrusiveRefCntPtr files = - llvm::vfs::getRealFileSystem(); - clang::IgnoringDiagConsumer ignore; - std::unique_ptr invocation = - invocationFor(command, files, ignore); - if (!invocation) - return; - auto main = - files->getBufferForFile(invocation->getFrontendOpts().Inputs[0].getFile()); - if (!main) - return; - const clang::PreambleBounds bounds = clang::ComputePreambleBounds( - invocation->getLangOpts(), (*main)->getMemBufferRef(), 0); - if (bounds.Size == 0) - return; - llvm::IntrusiveRefCntPtr diagnostics = - clang::CompilerInstance::createDiagnostics( - *files, new clang::DiagnosticOptions(), &ignore, false); - clang::PreambleCallbacks callbacks; - llvm::ErrorOr built = - clang::PrecompiledPreamble::Build( - *invocation, main->get(), bounds, *diagnostics, files, - std::make_shared(), true, "", callbacks); - if (built && !diagnostics->hasErrorOccurred()) - out.emplace(std::move(*built)); - }, - error); - return out; - } - // Preprocesses a unit and records every identifier, after macro expansion. class ScanAction : public clang::PreprocessorFrontendAction { @@ -977,18 +885,14 @@ namespace ctrace::stack::analysis } // Checks one suggestion in one unit, against the unit before the rewrite. - // `beforeFor` gives the unit before the rewrite, compiled with its preamble or whole as - // the compilation after it: both are compared in the same mode. - bool checkUnit(const UnitCommand& command, - const std::function&(bool)>& beforeFor, - const clang::PrecompiledPreamble* preamble, const Target& planned, - const std::map& rewritten, + bool checkUnit(const UnitCommand& command, const std::optional& before, + const Target& planned, const std::map& rewritten, const std::set& symbolsBefore, const FunctionFacts& plan, PrintedSignature& signature, std::vector& accepted, std::string& renamed, std::string& removed, std::string& notChecked, bool timing) { - if (!preamble && !beforeFor(false)) + if (!before) { notChecked = kDoesNotCompileAsItIs; return false; @@ -1001,19 +905,13 @@ namespace ctrace::stack::analysis targets[0].symbolsBefore = &symbolsBefore; for (const auto& [path, edits] : plan.edits) targets[0].rewrittenFiles.insert(path); - Compilation after = compile(command, rewritten, targets, - "const_suggestion.compile.after", timing, preamble); + Compilation after = + compile(command, rewritten, targets, "const_suggestion.compile.after", timing); if (!after.failure.empty()) { notChecked = after.failure; return false; } - const std::optional& before = beforeFor(after.preamble); - if (!before) - { - notChecked = kDoesNotCompileAsItIs; - return false; - } for (const std::string& diagnostic : after.diagnostics) { if (after.diagnostics.count(diagnostic) > before->diagnostics.count(diagnostic)) @@ -1122,14 +1020,8 @@ namespace ctrace::stack::analysis targets[i].debugArgNo = issues[i].debugArgNo; targets[i].plan = true; } - // The defining unit is compiled again for each suggestion: with several, its preamble is - // parsed once, and reused by each compilation whose rewrite leaves it untouched. - std::optional preamble; - if (issues.size() > 1) - preamble = buildPreamble(command, timing); Compilation before = - compile(command, {}, targets, "const_suggestion.compile.defining_before", timing, - preamble ? &*preamble : nullptr); + compile(command, {}, targets, "const_suggestion.compile.defining_before", timing); if (!before.failure.empty() || !before.module || before.facts.size() != issues.size()) { notChecked("the file does not compile with Clang as it is"); @@ -1145,12 +1037,6 @@ namespace ctrace::stack::analysis const_suggestion::prepare(*before.module)); } OriginalCache originals; - const clang::PrecompiledPreamble* reused = before.preamble ? &*preamble : nullptr; - // The defining unit compiled whole, for a rewrite that reaches its preamble: once. - std::optional, - std::shared_ptr>> - definingWhole; - bool definingWholeTried = false; // The other units a rewrite may reach: the analyzed ones and, with a compilation // database, every C or C++ file it lists. @@ -1210,34 +1096,10 @@ namespace ctrace::stack::analysis std::string unchecked; const std::optional selfBefore = Original{before.diagnostics, plan.overrides, definingCode}; - std::optional selfWhole; - const auto definingBefore = [&](bool withPreamble) -> const std::optional& - { - if (withPreamble == before.preamble) - return selfBefore; - if (!definingWholeTried) - { - definingWholeTried = true; - std::vector none; - Compilation whole = compile(command, {}, none, - "const_suggestion.compile.defining_whole", timing); - if (whole.failure.empty() && whole.module) - { - const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - definingWhole.emplace(whole.diagnostics, - std::make_shared( - const_suggestion::prepare(*whole.module))); - } - } - if (definingWhole && !selfWhole) - selfWhole = - Original{definingWhole->first, plan.overrides, definingWhole->second}; - return selfWhole; - }; std::size_t checkedUnits = 1; const bool definingDone = - checkUnit(command, definingBefore, reused, planned, rewritten, symbolsBefore, plan, - signature, accepted, renamed, removed, unchecked, timing); + checkUnit(command, selfBefore, planned, rewritten, symbolsBefore, plan, signature, + accepted, renamed, removed, unchecked, timing); // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or // not. One that cannot be checked leaves the suggestion unchecked, unless another @@ -1298,10 +1160,9 @@ namespace ctrace::stack::analysis std::vector otherAccepted; std::string why; ++checkedUnits; - const auto otherBefore = [&](bool) -> const std::optional& - { return originalOf(other, key, planned, originals, timing); }; - if (!checkUnit(other, otherBefore, nullptr, planned, rewritten, symbolsBefore, plan, - signature, otherAccepted, renamed, removed, why, timing)) + if (!checkUnit(other, originalOf(other, key, planned, originals, timing), planned, + rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, + removed, why, timing)) { cannotCheck(why == kDoesNotCompileAsItIs ? unit.filename + " does not compile with Clang as it is" From 629344ed730c4b0d12e937a00101c61279188217 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 19:37:13 +0200 Subject: [PATCH 012/152] test(const-param): check const suggestions with Clang only under an option The Clang check of const suggestions runs only with its experimental option. --- run_test.py | 91 ++++++++++++++++++- .../const-suggestion-abi-none.cpp | 3 + .../const-suggestion-abi.cpp | 3 + .../const-suggestion-deduction-kept.cpp | 3 + .../const-suggestion-deduction-refused.cpp | 3 + .../const-suggestion-kept-blocks.cpp | 3 + .../const-suggestion-kept-inlined.cpp | 3 + .../const-suggestion-kept.c | 3 + .../const-suggestion-kept.cpp | 3 + .../const-suggestion-macros.c | 3 + .../const-suggestion-must-compile.c | 3 + .../const-suggestion-must-compile.cpp | 3 + .../const-suggestion-override-elsewhere.cpp | 3 + .../const-suggestion-preamble.c | 17 ++-- .../const-suggestion-preamble.h | 4 +- .../const-suggestion-printed-signature.c | 3 + .../const-suggestion-same-code.cpp | 3 + 17 files changed, 141 insertions(+), 13 deletions(-) diff --git a/run_test.py b/run_test.py index 20df933..95b0e50 100755 --- a/run_test.py +++ b/run_test.py @@ -99,6 +99,8 @@ def _get_file_hash(p: Path) -> str: _RE_RESOURCE_MODEL = re.compile(r"//\s*resource-model\s*[:=]\s*(\S+)", re.IGNORECASE) _RE_ESCAPE_MODEL = re.compile(r"//\s*escape-model\s*[:=]\s*(\S+)", re.IGNORECASE) _RE_BUFFER_MODEL = re.compile(r"//\s*buffer-model\s*[:=]\s*(\S+)", re.IGNORECASE) +# `// analyzer-arg: `: one more analyzer argument for both passes of the fixture. +_RE_ANALYZER_ARG = re.compile(r"//\s*analyzer-arg\s*[:=]\s*(\S+)", re.IGNORECASE) _RE_STRICT_DIAG = re.compile(r"//\s*strict-diagnostic-count\s*[:=]\s*(\S+)", re.IGNORECASE) _RE_STRICT_DETAILS = re.compile(r"//\s*strict-expectation-details\s*[:=]\s*(\S+)", re.IGNORECASE) # `// [smt-z3] not contains: ...` or `// [default] at line ...`: an expectation for one pass. @@ -361,6 +363,7 @@ def extract_expectations(c_path: Path): resource_model = None escape_model = None buffer_model = None + analyzer_args = [] strict_diag_count = None strict_details = None lines = c_path.read_text().splitlines() @@ -399,6 +402,11 @@ def parse_bool_directive(value: str): buffer_model = buffer_match.group(1) i += 1 continue + analyzer_arg_match = _RE_ANALYZER_ARG.match(stripped) + if analyzer_arg_match: + analyzer_args.append(analyzer_arg_match.group(1)) + i += 1 + continue strict_match = _RE_STRICT_DIAG.match(stripped) if strict_match: parsed = parse_bool_directive(strict_match.group(1)) @@ -467,6 +475,7 @@ def parse_bool_directive(value: str): strict_diag_count, strict_details, unknown_scopes, + analyzer_args, ) @@ -2765,11 +2774,12 @@ def _check_cross_tu_rule( rule_id: str, cases: list[tuple[str, list[str], list[str]]], copied_cases: list[tuple[str, list[str], list[str]]], + extra_args: tuple[str, ...] = (), ) -> bool: """ Runs each case in both passes: the analyzer over its fixtures, then the functions reported under rule_id. The analyzer sorts its inputs: copied_cases run on copies named so that the - files keep the listed order. + files keep the listed order. extra_args go to every run. """ print(f"=== Testing {title} ===") fixtures = RUN_CONFIG.test_dir / directory @@ -2811,7 +2821,7 @@ def reported(files: list[str], extra: list[str]) -> Optional[list[str]]: runs.append((f"{pass_name}, {label}", files, pass_args, expected)) for label, files, extra, expected in runs: - found = reported(files, extra) + found = reported(files, [*extra, *extra_args]) if found == expected: print(f" ✅ {label}: {found}") else: @@ -2880,6 +2890,7 @@ def check_const_suggestion_cross_tu() -> bool: ("def and an explicit cast", ["def.c", "cast.c"], []), ], [], + ("--experimental-const-suggestion-check",), ) @@ -2894,7 +2905,9 @@ def check_const_suggestion_without_source() -> bool: checked = "checked in the analyzed files only" def message(files: list[Path], function: str) -> Optional[str]: - result = run_analyzer([*map(str, files), "--format=json"]) + result = run_analyzer( + [*map(str, files), "--format=json", "--experimental-const-suggestion-check"] + ) try: diagnostics = json.loads(result.stdout or "").get("diagnostics", []) except json.JSONDecodeError: @@ -2995,7 +3008,12 @@ def message(label: str, listed: list[str], function: str) -> tuple[bool, Optiona ) # The database is an input the cache key does not cover. result = run_analyzer_uncached( - [str(files[0]), f"--compile-commands={database}", "--format=json"] + [ + str(files[0]), + f"--compile-commands={database}", + "--format=json", + "--experimental-const-suggestion-check", + ] ) try: diagnostics = json.loads(result.stdout or "").get("diagnostics", []) @@ -3070,6 +3088,67 @@ def message(label: str, listed: list[str], function: str) -> tuple[bool, Optiona return ok +def check_const_suggestion_check_is_opt_in() -> bool: + """ + #217: the Clang check of const suggestions is experimental and off by default. Without + --experimental-const-suggestion-check, the suggestions are reported as the IR analysis finds + them: one whose suggested type does not compile is still reported, and no line says how a + suggestion was checked. With the option, that suggestion is removed, and the one that compiles + says how it was checked. + """ + print("=== Testing the Clang check of const suggestions as an opt-in ===") + fixture = ( + RUN_CONFIG.test_dir + / "pointer_reference-const_correctness" + / "const-suggestion-must-compile.c" + ) + + def messages(extra: list[str]) -> Optional[dict[str, str]]: + result = run_analyzer([str(fixture), "--format=json", *extra]) + if result.returncode != 0: + return None + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return None + return { + str(d.get("location", {}).get("function", "")): str( + d.get("details", {}).get("message", "") + ) + for d in diagnostics + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified") + } + + without = messages([]) + checked = messages(["--experimental-const-suggestion-check"]) + claims = ("checked in the", "not checked with Clang") + ok = True + for label, good in ( + ( + "without the option, a suggested type that does not compile is reported", + without is not None and "writer" in without and "read_slot" in without, + ), + ( + "without the option, no suggestion says how it was checked", + without is not None + and not any(claim in text for text in without.values() for claim in claims), + ), + ( + "with the option, the suggested types that do not compile are removed", + checked is not None and "writer" not in checked and "read_slot" not in checked, + ), + ( + "with the option, the suggestion that compiles says how it was checked", + checked is not None + and "checked in the analyzed files only" in checked.get("read_value", ""), + ), + ): + print(f" {'✅' if good else '❌'} {label}") + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -5468,6 +5547,7 @@ def check_file(c_path: Path): strict_diag_count, strict_details, unknown_scopes, + analyzer_args, ) = extract_expectations(c_path) if unknown_scopes: known = ", ".join(f"[{p}]" for p in _EXPECTATION_PASSES) @@ -5606,6 +5686,7 @@ def evaluate_pass(pass_name: str, analyzer_output: str): resource_model=resource_model, escape_model=escape_model, buffer_model=buffer_model, + extra_args=tuple(analyzer_args), ) # With runner-level --smt args this run is neither the default pass nor the smt-z3 one: # only unscoped expectations describe it. @@ -5627,6 +5708,7 @@ def evaluate_pass(pass_name: str, analyzer_output: str): escape_model=escape_model, buffer_model=buffer_model, extra_args=( + *analyzer_args, "--smt=on", "--smt-backend=z3", "--smt-mode=single", @@ -5712,6 +5794,7 @@ def record_ok(ok: bool): check_const_suggestion_cross_tu, check_const_suggestion_without_source, check_const_suggestion_compilation_database, + check_const_suggestion_check_is_opt_in, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp index 9ad3575..d58272c 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-abi-none.cpp @@ -42,3 +42,6 @@ int use_internal_reads() int value = 1; return internal_read(&value) + anonymous_read(&value); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-abi.cpp b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp index b53ce31..aae8824 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-abi.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-abi.cpp @@ -14,3 +14,6 @@ int external_read(int* p) { return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp index d896cf4..1c8ab6a 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp @@ -38,3 +38,6 @@ int from_lvalue() const auto g = [](int x) { return x ^ 4; }; return apply_twice(g); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp index 194ab5a..4d6ca66 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp @@ -59,3 +59,6 @@ int pick_lvalue() const auto g = [](int x) { return x ^ 5; }; return pick(g); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp index a0f9b54..9200c23 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-blocks.cpp @@ -33,3 +33,6 @@ int read_and_log(int* p) log_text("value"); return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp index a1e8afe..f454298 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept-inlined.cpp @@ -22,3 +22,6 @@ int read_with_lambda(int* p) auto get = [p] { return *p; }; return read_once(get, false); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.c b/test/pointer_reference-const_correctness/const-suggestion-kept.c index 5f56f24..8688246 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept.c +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.c @@ -20,3 +20,6 @@ int read_restricted(int* p) { return load(p); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp index b7bee5e..ee98ca6 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-kept.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-kept.cpp @@ -21,3 +21,6 @@ int read_or_throw(int* p) throw Missing{}; return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-macros.c b/test/pointer_reference-const_correctness/const-suggestion-macros.c index 592f1b4..3c10254 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-macros.c +++ b/test/pointer_reference-const_correctness/const-suggestion-macros.c @@ -89,3 +89,6 @@ int run_callback(BYTE* buffer) { return apply(object_macro_callback, buffer); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.c b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c index ac3a808..cba23b5 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-must-compile.c +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.c @@ -67,3 +67,6 @@ int read_value(int* p) { return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp index 68f7b95..7797bda 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-must-compile.cpp @@ -48,3 +48,6 @@ int read_value(int* p) { return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp b/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp index ce22683..63613d5 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-override-elsewhere.cpp @@ -22,3 +22,6 @@ int Derived::read(int* p) { return *p; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-preamble.c b/test/pointer_reference-const_correctness/const-suggestion-preamble.c index 7b99e9e..1a2aee2 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-preamble.c +++ b/test/pointer_reference-const_correctness/const-suggestion-preamble.c @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// #217: the file is compiled again for each suggestion. Its preamble, the header below, is parsed -// once and reused only while the rewrite leaves it untouched. Rewriting the declarations of -// first_value and second_value changes the header: those compilations parse the file whole, and -// are compared with the file before the rewrite parsed whole too. A preamble reused regardless -// would declare the old types; comparing a whole compilation with one that reused the preamble -// would count the header's warning as new. local_value is declared in this file: its -// compilations reuse the preamble. +// #217: rewriting the declarations of first_value and second_value rewrites the header below, +// which holds a checked warning; local_value is declared in this file, and its rewrite leaves the +// header untouched. The header must be named the same way before and after a rewrite: with a +// relative input path, it was named relatively before and absolutely once the rewritten file was +// remapped, and its warning counted as new. The file name recalls the precompiled preamble, since +// removed, that this fixture also tested: the header sits in the file's preamble, its leading +// #include lines. // strict-expectation-details: true @@ -44,3 +44,6 @@ int local_value(int* r) { return *r; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-preamble.h b/test/pointer_reference-const_correctness/const-suggestion-preamble.h index c3a1b80..78fcd90 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-preamble.h +++ b/test/pointer_reference-const_correctness/const-suggestion-preamble.h @@ -2,8 +2,8 @@ #ifndef CONST_SUGGESTION_PREAMBLE_H #define CONST_SUGGESTION_PREAMBLE_H -// A checked warning in the preamble: a compilation that reuses the preamble does not report it, -// one that parses the file whole does. +// A checked warning, reported by every compilation of the file, before and after the rewrite: it +// must not count as new. static inline int* without_const(const int* p) { return p; diff --git a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c index e442161..cd7b616 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c +++ b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c @@ -24,3 +24,6 @@ int signature_size(int* p) { return *p ? (int)sizeof(__PRETTY_FUNCTION__) : 0; } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check diff --git a/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp index 15009fc..158e54e 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-same-code.cpp @@ -165,3 +165,6 @@ int call_twice(int* p) { return TWICE(p); } + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From cce6c258d4ff5056ca290158a308f8daaa6077e1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 19:38:13 +0200 Subject: [PATCH 013/152] feat(const-param): add --experimental-const-suggestion-check Check const suggestions with Clang only under --experimental-const-suggestion-check. --- README.md | 38 ++++++++++++++++++++++++--- include/StackUsageAnalyzer.hpp | 4 ++- main.cpp | 7 +++++ src/analysis/ConstSuggestionCheck.cpp | 2 +- src/cli/ArgParser.cpp | 16 +++++++++-- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6a059b6..4407ccd 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ A model path given to one of these three options that is not a readable file sto --no-resource-cross-tu disables cross-TU resource summaries --resource-summary-cache-dir= sets cache directory for cross-TU resource summaries (default: .cache/resource-lifetime) --resource-summary-cache-memory-only keeps cross-TU summary cache in memory only (process-local, no files) +--experimental-const-suggestion-check checks each const parameter suggestion with Clang before reporting it (experimental, off by default; see "Experimental: const parameter suggestions checked with Clang") --compile-ir-cache-dir= enables dependency-aware LLVM IR compile cache for unchanged source files --compile-ir-format=bc|ll selects source compilation IR format (`bc` default, `ll` for textual LLVM IR) --timing prints compile/analysis timings to stderr, including aggregated hotspot ranking @@ -342,6 +343,7 @@ Supported keys: - `uninitialized-cross-tu` - `resource-summary-cache-dir` - `resource-summary-cache-memory-only` +- `experimental-const-suggestion-check` - `compile-ir-cache-dir` - `compile-ir-format` (`bc` or `ll`) @@ -628,10 +630,28 @@ Example run: For test files, `run_test.py` also supports per-file model selection with: `// resource-model: `. -### Const parameter suggestions checked with Clang +### Experimental: const parameter suggestions checked with Clang `ConstParameterNotModified` finds, in the LLVM IR, the pointer and reference parameters through -which nothing is written. Before reporting one, the analyzer checks the suggested type with Clang: +which nothing is written. The IR does not show every constraint that C and C++ put on a parameter: +the suggested `const` type may not compile (a callback of the old type, `&p` passed as `int**`), +or may compile and select other code (an overload, an override, `sizeof`, `if constexpr`, +`_Generic`). + +Without `--experimental-const-suggestion-check`, the default, nothing beyond the IR analysis is +checked: every suggestion is reported as the IR analysis finds it, without a check line, including +those whose suggested type does not compile or changes the generated code. + +With `--experimental-const-suggestion-check` (or `experimental-const-suggestion-check=true` in a +config file), each suggestion is checked with Clang before it is reported. The check is +experimental: +- it compares the IR that Clang prints before and after the rewrite. A difference its + normalizations do not cover removes a suggestion that is valid; several such cases were found + and fixed, others may remain; +- it compiles the files that see the change again, once per suggestion: on this repository's + self-analysis, the analysis takes about 19% longer. + +The check runs these steps: 1. Every declaration of the function that the defining file sees, in that file and its headers, is rewritten in memory with the suggested type: @@ -681,8 +701,18 @@ lists them: name; - a string constant whose only change is the signature that `__PRETTY_FUNCTION__` prints. -The check covers only these files, with their compile commands, and the code these -configurations compile. The message says how the suggestion was checked: +The check covers only: +- the suggestions it checks. A suggestion marked `not checked with Clang: ...` rests on the IR + analysis alone: a declaration written by a macro definition, an LLVM IR input, a file that sees + the change but cannot be preprocessed or does not compile as it is; +- the files that see the change, with their compile commands, and the code those configurations + compile. Other configurations, other targets, code that the preprocessor excludes, and files + outside both the analysis and the compilation database are not checked. + +A CI job that enables the option checks the projects and configurations it analyzes, and nothing +else: an analysis run without the option, by a user or by another job, is not checked. + +The message says how the suggestion was checked: - `checked in the analyzed files only, with their compile commands: ...`, without a compilation database; - `checked in every file of the compilation database that sees the change, with its compile diff --git a/include/StackUsageAnalyzer.hpp b/include/StackUsageAnalyzer.hpp index 2eaaf07..4f2f45e 100644 --- a/include/StackUsageAnalyzer.hpp +++ b/include/StackUsageAnalyzer.hpp @@ -130,7 +130,9 @@ namespace ctrace::stack std::uint32_t resourceSummaryMemoryOnly : 1 = 0; std::uint32_t warningsOnly : 1 = 0; std::uint32_t assumeExternalFrame : 1 = 0; - std::uint32_t reservedFlags : 17 = 0; + // Experimental: check each const suggestion with Clang before reporting it. + std::uint32_t constSuggestionCheck : 1 = 0; + std::uint32_t reservedFlags : 16 = 0; }; // Per-function result diff --git a/main.cpp b/main.cpp index 5ad7ec0..232eadc 100644 --- a/main.cpp +++ b/main.cpp @@ -79,6 +79,11 @@ static void printHelp() << " --compile-ir-format=bc|ll Compilation IR format for source inputs (default: bc)\n" << " --resource-summary-cache-memory-only Use in-memory cache only for cross-TU " "summaries\n" + << " --experimental-const-suggestion-check Experimental: compile each const " + "suggestion\n" + << " with Clang and remove it if the code no longer compiles " + "or\n" + << " generates different code (off by default)\n" << " --uninitialized-cross-tu Enable cross-TU uninitialized summaries (default: on)\n" << " --no-uninitialized-cross-tu Disable cross-TU uninitialized summaries\n" << " --only-file= Only report functions from this source file\n" @@ -170,6 +175,8 @@ static void printEffectiveConfig(const ctrace::stack::cli::ParsedArguments& pars llvm::errs() << "warnings-only: " << (cfg.warningsOnly ? "true" : "false") << "\n"; llvm::errs() << "quiet: " << (cfg.quiet ? "true" : "false") << "\n"; llvm::errs() << "demangle: " << (cfg.demangle ? "true" : "false") << "\n"; + llvm::errs() << "experimental-const-suggestion-check: " + << (cfg.constSuggestionCheck ? "true" : "false") << "\n"; llvm::errs() << "resource-model: " << (cfg.resourceModelPath.empty() ? "" : cfg.resourceModelPath) << "\n"; llvm::errs() << "escape-model: " diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 0f943e7..4237a6f 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -975,7 +975,7 @@ namespace ctrace::stack::analysis void checkConstSuggestions(const llvm::Module& mod, std::vector& issues, const AnalysisConfig& config) { - if (issues.empty() || config.warningsOnly) + if (issues.empty() || config.warningsOnly || !config.constSuggestionCheck) return; const bool timing = config.timing; const analyzer::ScopedHotspot total(timing, "const_suggestion.total"); diff --git a/src/cli/ArgParser.cpp b/src/cli/ArgParser.cpp index 9348d6a..0c99ade 100644 --- a/src/cli/ArgParser.cpp +++ b/src/cli/ArgParser.cpp @@ -47,7 +47,7 @@ namespace ctrace::stack::cli } private: - static constexpr std::array kCandidates = { + static constexpr std::array kCandidates = { {{"-h", "-h"}, {"--help", "--help"}, {"--demangle", "--demangle"}, @@ -89,6 +89,7 @@ namespace ctrace::stack::cli {"--no-uninitialized-cross-tu", "--no-uninitialized-cross-tu"}, {"--resource-summary-cache-dir", "--resource-summary-cache-dir"}, {"--resource-summary-cache-memory-only", "--resource-summary-cache-memory-only"}, + {"--experimental-const-suggestion-check", "--experimental-const-suggestion-check"}, {"--compile-ir-cache-dir", "--compile-ir-cache-dir"}, {"--compile-ir-format", "--compile-ir-format"}, {"--compile-ir-format=bc", "--compile-ir-format=bc"}, @@ -822,12 +823,17 @@ namespace ctrace::stack::cli cfg.resourceSummaryMemoryOnly = value; } + void setConfigConstSuggestionCheck(AnalysisConfig& cfg, bool value) + { + cfg.constSuggestionCheck = value; + } + void setParsedIncludeCompdbDeps(ParsedArguments& parsed, bool value) { parsed.includeCompdbDeps = value; } - constexpr std::array, 7> kConfigBoolSpecs = {{ + constexpr std::array, 8> kConfigBoolSpecs = {{ {"timing", &setConfigTiming}, {"warnings-only", &setConfigWarningsOnly}, {"quiet", &setConfigQuiet}, @@ -835,6 +841,7 @@ namespace ctrace::stack::cli {"resource-cross-tu", &setConfigResourceCrossTU}, {"uninitialized-cross-tu", &setConfigUninitializedCrossTU}, {"resource-summary-cache-memory-only", &setConfigResourceSummaryMemoryOnly}, + {"experimental-const-suggestion-check", &setConfigConstSuggestionCheck}, }}; constexpr std::array, 1> kParsedBoolSpecs = {{ @@ -1560,6 +1567,11 @@ namespace ctrace::stack::cli cfg.resourceSummaryMemoryOnly = true; continue; } + if (argStr == "--experimental-const-suggestion-check") + { + cfg.constSuggestionCheck = true; + continue; + } if (argStr == "--compile-commands" || argStr == "--compdb") { if (i + 1 >= argc) From 035bf988dade615ed1c754b05561aac740a7be15 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 19:50:39 +0200 Subject: [PATCH 014/152] ci(const-param): run the experimental const suggestion check on Linux and macOS --- .../experimental-const-suggestion-check.yml | 102 ++++++++++++++++++ README.md | 10 ++ scripts/ci/run_const_suggestion_check.py | 81 ++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 .github/workflows/experimental-const-suggestion-check.yml create mode 100755 scripts/ci/run_const_suggestion_check.py diff --git a/.github/workflows/experimental-const-suggestion-check.yml b/.github/workflows/experimental-const-suggestion-check.yml new file mode 100644 index 0000000..4e34f03 --- /dev/null +++ b/.github/workflows/experimental-const-suggestion-check.yml @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# The experimental check of const parameter suggestions, enabled by +# --experimental-const-suggestion-check, is off by default; ci.yml checks the default behavior. +# This workflow runs the check's tests with the option, on Linux and macOS: the const-suggestion +# fixtures and checks of run_test.py, which expect suggestions kept once checked, suggestions +# removed, and suggestions kept as not checked. +# +# Scope: this repository's fixtures, with their compile commands, built with the LLVM 20 of each +# runner. It does not check this repository's sources, other projects or configurations, nor any +# analysis run without the option. +name: Experimental const suggestion check + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: const-suggestion-${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true + +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + +jobs: + const-suggestion-check: + name: Experimental const suggestion check on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # The toolchains of ci.yml. + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake python3 \ + ninja-build ccache lld libz3-dev pkg-config + wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - + UBUNTU_CODENAME=$(lsb_release -cs) + sudo apt-add-repository "deb http://apt.llvm.org/${UBUNTU_CODENAME}/ llvm-toolchain-${UBUNTU_CODENAME}-20 main" + sudo apt-get update + sudo apt-get install -y llvm-20 llvm-20-dev clang-20 libclang-20-dev + echo "LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm" >> $GITHUB_ENV + echo "Clang_DIR=/usr/lib/llvm-20/lib/cmake/clang" >> $GITHUB_ENV + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install cmake python llvm@20 ninja ccache z3 + echo "LLVM_DIR=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV + echo "Clang_DIR=$(brew --prefix llvm@20)/lib/cmake/clang" >> $GITHUB_ENV + echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH + + # Reads the ccache that ci.yml saves; saving it is left to ci.yml. + - name: Restore ccache + uses: actions/cache/restore@v4 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**') }} + restore-keys: | + ccache-${{ runner.os }}-${{ github.ref_name }}- + ccache-${{ runner.os }}- + + - name: Configure ccache + run: | + ccache --set-config=cache_dir=${{ env.CCACHE_DIR }} + ccache --set-config=max_size=500M + ccache --set-config=compression=true + ccache -z + + - name: Configure + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLLVM_DIR=${{ env.LLVM_DIR }} \ + -DClang_DIR=${{ env.Clang_DIR }} \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DUSE_SHARED_LIB=OFF \ + -DBUILD_TESTS=OFF \ + -DBUILD_ANALYZER_UNIT_TESTS=ON \ + ${{ runner.os == 'Linux' && '-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld -DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld' || '' }} + + - name: Build + run: cmake --build build --config Release --target stack_usage_analyzer + + - name: Check const suggestions with --experimental-const-suggestion-check + timeout-minutes: 20 + run: python3 -u scripts/ci/run_const_suggestion_check.py diff --git a/README.md b/README.md index 4407ccd..7c47a13 100644 --- a/README.md +++ b/README.md @@ -651,6 +651,9 @@ experimental: - it compiles the files that see the change again, once per suggestion: on this repository's self-analysis, the analysis takes about 19% longer. +The option is a trade-off, not a fix: with it, a valid suggestion can still be removed; without +it, suggestions whose type does not compile or changes the generated code are reported. + The check runs these steps: 1. Every declaration of the function that the defining file sees, in that file and its headers, @@ -712,6 +715,13 @@ The check covers only: A CI job that enables the option checks the projects and configurations it analyzes, and nothing else: an analysis run without the option, by a user or by another job, is not checked. +In this repository, `ci.yml` checks the default behavior, and the workflow +`experimental-const-suggestion-check.yml` runs the check's tests with the option, on Linux and +macOS (`scripts/ci/run_const_suggestion_check.py`): the const-suggestion fixtures and checks of +`run_test.py`, which expect suggestions kept once checked, suggestions removed, and suggestions kept +as not checked. It covers these fixtures, with their compile commands and the LLVM 20 of each +runner; it does not check this repository's sources, other projects or other configurations. + The message says how the suggestion was checked: - `checked in the analyzed files only, with their compile commands: ...`, without a compilation database; diff --git a/scripts/ci/run_const_suggestion_check.py b/scripts/ci/run_const_suggestion_check.py new file mode 100755 index 0000000..bb590f4 --- /dev/null +++ b/scripts/ci/run_const_suggestion_check.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Run the tests of the experimental check of const suggestions (--experimental-const-suggestion-check). + +The tests come from run_test.py: the const-suggestion fixtures, which enable the option with their +`// analyzer-arg:` line, and the checks that pass it. Together they expect suggestions that are +kept once checked, suggestions that are removed, and suggestions kept as not checked; this script +fails if the fixtures stop expecting any of the three. + +These tests cover only this repository's fixtures, with their compile commands, on the toolchain of +the machine that runs them: they say nothing of other projects or configurations, nor of analyses +run without the option. + +usage: run_const_suggestion_check.py (after a build: uses build/stack_usage_analyzer) +""" +from __future__ import annotations + +import importlib.util +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "test" / "pointer_reference-const_correctness" +CHECKS = ( + "check_const_suggestion_cross_tu", + "check_const_suggestion_without_source", + "check_const_suggestion_compilation_database", + "check_const_suggestion_check_is_opt_in", +) +# What the fixtures expect, by the expectation that says it. +OUTCOMES = { + "kept, checked": "// ↳ checked in ", + "removed": "// not contains: ", + "kept, not checked": "// ↳ not checked with Clang: ", +} + + +def load_run_test(): + spec = importlib.util.spec_from_file_location("run_test", ROOT / "run_test.py") + module = importlib.util.module_from_spec(spec) + sys.modules["run_test"] = module + spec.loader.exec_module(module) + return module + + +def main() -> int: + fixtures = sorted(p for p in FIXTURES.glob("const-suggestion-*") if p.suffix in (".c", ".cpp")) + counts = {outcome: 0 for outcome in OUTCOMES} + for fixture in fixtures: + for line in fixture.read_text(encoding="utf-8").splitlines(): + for outcome, marker in OUTCOMES.items(): + if line.strip().startswith(marker): + counts[outcome] += 1 + print(f"{len(fixtures)} fixtures; expectations: " + + ", ".join(f"{outcome} {n}" for outcome, n in counts.items())) + missing = [outcome for outcome, n in counts.items() if n == 0] + if missing: + print(f"::error::the fixtures no longer expect: {', '.join(missing)}") + return 1 + + os.chdir(ROOT) + run_test = load_run_test() + # The fixtures' results depend on headers that the cache key of run_test.py does not cover. + run_test.RUN_CONFIG.cache_enabled = False + ok = True + for fixture in fixtures: + passed, total, matched, report = run_test.check_file(fixture.relative_to(ROOT)) + print(f"{'PASS' if passed else 'FAIL'} {fixture.name}: {matched}/{total}") + if not passed: + print(report) + ok = ok and passed + for name in CHECKS: + passed = getattr(run_test, name)() + print(f"{'PASS' if passed else 'FAIL'} {name}") + ok = ok and passed + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 10258fe9b7a5b21d5cb504047a9f8db0613e8587 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 20:10:17 +0200 Subject: [PATCH 015/152] ci(const-param): say ci.yml runs these tests too, restore FetchContent sources --- .../experimental-const-suggestion-check.yml | 15 +++++++++++++-- README.md | 13 +++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/experimental-const-suggestion-check.yml b/.github/workflows/experimental-const-suggestion-check.yml index 4e34f03..addd194 100644 --- a/.github/workflows/experimental-const-suggestion-check.yml +++ b/.github/workflows/experimental-const-suggestion-check.yml @@ -1,8 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # # The experimental check of const parameter suggestions, enabled by -# --experimental-const-suggestion-check, is off by default; ci.yml checks the default behavior. -# This workflow runs the check's tests with the option, on Linux and macOS: the const-suggestion +# --experimental-const-suggestion-check, is off by default. ci.yml runs the whole run_test.py suite: +# the default behavior, and these tests among the others. This workflow runs only the check's tests, +# with the option, on Linux and macOS, so that their status shows apart: the const-suggestion # fixtures and checks of run_test.py, which expect suggestions kept once checked, suggestions # removed, and suggestions kept as not checked. # @@ -74,6 +75,16 @@ jobs: ccache-${{ runner.os }}-${{ github.ref_name }}- ccache-${{ runner.os }}- + - name: Restore FetchContent sources + uses: actions/cache/restore@v4 + with: + path: | + build/_deps/cc-src + build/_deps/coretrace-logger-src + key: fetchcontent-${{ runner.os }}-llvm20-${{ hashFiles('CMakeLists.txt', 'cmake/**') }} + restore-keys: | + fetchcontent-${{ runner.os }}-llvm20- + - name: Configure ccache run: | ccache --set-config=cache_dir=${{ env.CCACHE_DIR }} diff --git a/README.md b/README.md index 7c47a13..b2a2aab 100644 --- a/README.md +++ b/README.md @@ -715,12 +715,13 @@ The check covers only: A CI job that enables the option checks the projects and configurations it analyzes, and nothing else: an analysis run without the option, by a user or by another job, is not checked. -In this repository, `ci.yml` checks the default behavior, and the workflow -`experimental-const-suggestion-check.yml` runs the check's tests with the option, on Linux and -macOS (`scripts/ci/run_const_suggestion_check.py`): the const-suggestion fixtures and checks of -`run_test.py`, which expect suggestions kept once checked, suggestions removed, and suggestions kept -as not checked. It covers these fixtures, with their compile commands and the LLVM 20 of each -runner; it does not check this repository's sources, other projects or other configurations. +In this repository, `ci.yml` runs the whole `run_test.py` suite: the default behavior, and the +check's tests among the others. The workflow `experimental-const-suggestion-check.yml` runs only +the check's tests, with the option, on Linux and macOS (`scripts/ci/run_const_suggestion_check.py`): +the const-suggestion fixtures and checks of `run_test.py`, which expect suggestions kept once +checked, suggestions removed, and suggestions kept as not checked. It covers these fixtures, with +their compile commands and the LLVM 20 of each runner; it does not check this repository's sources, +other projects or other configurations. The message says how the suggestion was checked: - `checked in the analyzed files only, with their compile commands: ...`, without a compilation From f3599c34da8541bf742cc6bd0a2bc4d8f7158afe Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:27:23 +0200 Subject: [PATCH 016/152] test(const-param): homonyms, macros, void casts, CRLF, both modes, removal reasons Tests for the review of the experimental check: a static or anonymous-namespace homonym, a macro that writes several parameters, (void)f, an explicit object parameter, CRLF continuation lines, identifiers that contain const, the reason a removal gives; every const-suggestion input with and without the option; the human/JSON parity of a fixture with its own arguments. --- run_test.py | 198 +++++++++++++++++- .../const-suggestion-explicit-object.cpp | 35 ++++ .../const-suggestion-macro-params.c | 21 ++ .../const-suggestion-void-cast.c | 26 +++ ...oss-tu-const-suggestion-anonymous-read.cpp | 16 ++ ...ss-tu-const-suggestion-anonymous-write.cpp | 17 ++ .../cross-tu-const-suggestion-static-read.c | 13 ++ .../cross-tu-const-suggestion-static-write.c | 14 ++ test/unit/analyzer_module_unit_tests.cpp | 16 ++ 9 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-explicit-object.cpp create mode 100644 test/pointer_reference-const_correctness/const-suggestion-macro-params.c create mode 100644 test/pointer_reference-const_correctness/const-suggestion-void-cast.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-read.c create mode 100644 test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-write.c diff --git a/run_test.py b/run_test.py index 95b0e50..e42c632 100755 --- a/run_test.py +++ b/run_test.py @@ -1017,15 +1017,17 @@ def parse_human_diagnostic_messages(output: str): def _check_human_vs_json_parity_sample(sample: Path): lines = [] sample_ok = True + # The arguments the fixture declares with `// analyzer-arg:`, as check_file passes them. + *_, fixture_args = extract_expectations(sample) - human = run_analyzer([str(sample)]) + human = run_analyzer([str(sample), *fixture_args]) if human.returncode != 0: lines.append(f" ❌ human run failed for {sample} (code {human.returncode})") lines.append(human.stdout or "") lines.append(human.stderr or "") return False, "\n".join(lines).rstrip() + "\n" - structured = run_analyzer([str(sample), "--format=json"]) + structured = run_analyzer([str(sample), "--format=json", *fixture_args]) if structured.returncode != 0: lines.append(f" ❌ json run failed for {sample} (code {structured.returncode})") lines.append(structured.stdout or "") @@ -2874,8 +2876,9 @@ def check_const_suggestion_cross_tu() -> bool: #217: a suggested const type is reported only if every analyzed file still compiles with it, once every declaration the defining file sees is rewritten. Another analyzed file that stores the function where a non-const parameter is expected, declares it itself, or converts its - address with an explicit cast, removes the suggestion. A file that is not analyzed is not - checked. The callback and cast files sort before the definition, the redeclaration after it. + address with an explicit cast, removes the suggestion; a homonym with internal linkage in + another file does not. A file that is not analyzed is not checked. The callback and cast files + sort before the definition, the redeclaration after it. """ return _check_cross_tu_rule( "suggested const types checked in every analyzed file", @@ -2888,6 +2891,17 @@ def check_const_suggestion_cross_tu() -> bool: ("def and a callback of the old type", ["def.c", "callback.c"], []), ("def and a redeclaration", ["def.c", "redecl.c"], []), ("def and an explicit cast", ["def.c", "cast.c"], []), + # Another file's homonym with internal linkage is another function. + ( + "a static function and a static homonym", + ["static-read.c", "static-write.c"], + ["helper"], + ), + ( + "an anonymous namespace's function and its homonym", + ["anonymous-read.cpp", "anonymous-write.cpp"], + ["_ZN12_GLOBAL__N_16helperEPi"], + ), ], [], ("--experimental-const-suggestion-check",), @@ -3149,6 +3163,179 @@ def messages(extra: list[str]) -> Optional[dict[str, str]]: return ok +def check_const_suggestion_line_endings() -> bool: + """ + #217: a type written on a continuation line of a #define is in the macro definition, whether + the file ends its lines with LF or CRLF: rewriting it would change every expansion of the macro, + here a function that writes through p. The suggestion is kept as not checked with both. + """ + print("=== Testing a macro definition's continuation lines, with LF and CRLF ===") + source = ( + "#define PARAM(type) type p\n" + "#define DECLARE(name) \\\n" + " int name(PARAM(int*))\n" + "DECLARE(read_first) { return *p; }\n" + "DECLARE(write_second) { *p = 1; return 0; }\n" + ) + expected = "not checked with Clang: the declaration is written by a macro definition" + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_line_endings_") as tmp: + for label, newline in (("LF", "\n"), ("CRLF", "\r\n")): + path = Path(tmp).resolve() / f"reader-{label.lower()}.c" + path.write_bytes(source.replace("\n", newline).encode("utf-8")) + # The file's bytes are an input the cache key does not cover. + result = run_analyzer_uncached( + [str(path), "--format=json", "--experimental-const-suggestion-check"] + ) + text = None + try: + for d in json.loads(result.stdout or "").get("diagnostics", []): + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified") and str( + d.get("location", {}).get("function", "") + ) == "read_first": + text = str(d.get("details", {}).get("message", "")) + except json.JSONDecodeError: + pass + good = result.returncode == 0 and text is not None and expected in text + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {text!r}")) + ok = ok and good + print() + return ok + + +def check_const_suggestion_removal_reason() -> bool: + """ + #217: the trace of a removed suggestion names the first diagnostic that the rewrite adds, not + one the file had before it, and says whether it is an error or a checked warning: in C, a + qualifier lost in a nested pointer type is a warning, and the code still compiles. + """ + print("=== Testing the reason given for a removed const suggestion ===") + trace = re.compile(r"\[const-suggestion\] (\S+) parameter (\S+): (.*)") + + def reasons(path: Path) -> dict[str, str]: + result = run_analyzer_uncached( + [str(path), "--experimental-const-suggestion-check", "--verbose"] + ) + out = {} + for line in (result.stdout + result.stderr).splitlines(): + match = trace.search(line) + if match: + out[f"{match.group(1)}/{match.group(2)}"] = match.group(3) + return out + + fixture = ( + RUN_CONFIG.test_dir + / "pointer_reference-const_correctness" + / "const-suggestion-must-compile.c" + ).resolve() + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_reason_") as tmp: + before = Path(tmp).resolve() / "warning-before.c" + before.write_text( + "static int read_value(int* p) { return *p; }\n" + "int* drop_const(const int* q) { return q; }\n" + "int (*const reader)(int*) = read_value;\n" + ) + fixture_reasons = reasons(fixture) + before_reasons = reasons(before) + for label, text, expected in ( + ( + "a qualifier lost in a nested pointer type is a checked warning", + fixture_reasons.get("address_to_int_pp/p", ""), + f"removed, gains a checked warning: {fixture}:46:", + ), + ( + "a function pointer of the old type is an error", + fixture_reasons.get("read_slot/slot", ""), + f"removed, does not compile: {fixture}:36:", + ), + ( + "the diagnostic the file had before the rewrite is not the reason", + before_reasons.get("read_value/p", ""), + f"removed, does not compile: {before}:3:", + ), + ): + good = text.startswith(expected) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {text!r}")) + ok = ok and good + print() + return ok + + +def check_const_suggestion_both_modes() -> bool: + """ + #217: each const-suggestion fixture, and the IR input, analyzed without and with + --experimental-const-suggestion-check. Without the option, no suggestion says how it was + checked. With it, no suggestion appears that the IR analysis did not report, and each one kept + says how it was checked. Across these inputs, the option removes suggestions, keeps checked + ones, and keeps ones it could not check. + """ + print("=== Testing const suggestions without and with the experimental check ===") + directory = RUN_CONFIG.test_dir / "pointer_reference-const_correctness" + inputs = sorted( + p for p in directory.glob("const-suggestion-*") if p.suffix in (".c", ".cpp", ".ll") + ) + checked = "↳ checked in the" + not_checked = "↳ not checked with Clang" + claims = (checked, not_checked, "↳ ABI:", "↳ difference:") + parameter = re.compile(r"parameter '([^']+)' in function") + + def suggestions(path: Path, extra: list[str]) -> Optional[dict[tuple[str, str], str]]: + result = run_analyzer([str(path), "--format=json", *extra]) + if result.returncode != 0: + return None + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return None + out = {} + for d in diagnostics: + if not str(d.get("ruleId", "")).startswith("ConstParameterNotModified"): + continue + message = str(d.get("details", {}).get("message", "")) + match = parameter.search(message) + function = str(d.get("location", {}).get("function", "")) + out[(function, match.group(1) if match else "")] = message + return out + + option = "--experimental-const-suggestion-check" + ok = True + outcomes = {"removed": 0, "kept, checked": 0, "kept, not checked": 0} + for path in inputs: + # The fixture's other arguments (`// analyzer-arg:`), in both runs. + *_, declared = extract_expectations(path) + others = [arg for arg in declared if arg != option] + without = suggestions(path, others) + with_option = suggestions(path, [*others, option]) + problems = [] + if without is None or with_option is None: + problems.append("the analysis did not run") + else: + for key, text in without.items(): + if any(claim in text for claim in claims): + problems.append(f"without the option, {key} says how it was checked") + for key, text in with_option.items(): + if key not in without: + problems.append(f"{key} is reported only with the option") + elif not_checked in text: + outcomes["kept, not checked"] += 1 + elif checked in text: + outcomes["kept, checked"] += 1 + else: + problems.append(f"with the option, {key} does not say how it was checked") + outcomes["removed"] += sum(1 for key in without if key not in with_option) + print(f" {'❌' if problems else '✅'} {path.name}") + for problem in problems: + print(f" {problem}") + ok = ok and not problems + for outcome, count in outcomes.items(): + good = count > 0 + print(f" {'✅' if good else '❌'} the option has suggestions {outcome}: {count}") + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -5795,6 +5982,9 @@ def record_ok(ok: bool): check_const_suggestion_without_source, check_const_suggestion_compilation_database, check_const_suggestion_check_is_opt_in, + check_const_suggestion_line_endings, + check_const_suggestion_removal_reason, + check_const_suggestion_both_modes, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, diff --git a/test/pointer_reference-const_correctness/const-suggestion-explicit-object.cpp b/test/pointer_reference-const_correctness/const-suggestion-explicit-object.cpp new file mode 100644 index 0000000..7a94a02 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-explicit-object.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: an explicit object parameter takes the first place in the parameters' debug numbering, as +// an implicit `this` would not: the check must rewrite p, not self. read's address is stored with +// the old type, so read's suggested type does not compile and is removed; peek's is checked. + +// analyzer-arg: --experimental-const-suggestion-check +// analyzer-arg: --compile-arg=-std=c++23 +// not contains: in function 'Reader::read( +// strict-expectation-details: true + +// at line 22, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'Reader::peek(this Reader const&, int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct Reader +{ + int read(this const Reader& self, int* p) + { + return *p; + } + int peek(this const Reader& self, int* p) + { + return *p; + } +}; + +int (*stored)(const Reader&, int*) = &Reader::read; + +int use_reader() +{ + Reader reader; + int value = 1; + return reader.read(&value) == reader.peek(&value); +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-macro-params.c b/test/pointer_reference-const_correctness/const-suggestion-macro-params.c new file mode 100644 index 0000000..e3de7c7 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-macro-params.c @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a macro that writes several parameters cannot take `const` before its invocation: the +// qualifier would go to the first parameter it writes, n, not to q. The suggestion is kept as not +// checked. + +// strict-expectation-details: true + +// at line 17, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'q' in function 'read_both' is never used to modify the pointed object +// ↳ current type: int *q +// ↳ suggested type: const int *q +// ↳ not checked with Clang: the parameter's type is written by a macro that writes other parameters too + +// analyzer-arg: --experimental-const-suggestion-check +#define READ_ARGS int n, int* q + +int read_both(READ_ARGS) +{ + n = (*q != 0); + return n; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-void-cast.c b/test/pointer_reference-const_correctness/const-suggestion-void-cast.c new file mode 100644 index 0000000..97e0cbb --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-void-cast.c @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: `(void)read_value` converts the function to no other function type: the suggestion is +// checked and kept. An explicit cast to another function type removes it +// (cross-tu-const-suggestion-cast.c). + +// strict-expectation-details: true + +// at line 16, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// analyzer-arg: --experimental-const-suggestion-check + +static int read_value(int* p) +{ + (void)read_value; + return *p; +} + +int use_value(void) +{ + int value = 1; + return read_value(&value); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp new file mode 100644 index 0000000..8bc0554 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// A function in an anonymous namespace: no other file can name it, although its homonym in another +// file has the same mangled name. That homonym must not refute this one's suggestion. +namespace +{ +int helper(int* p) +{ + return *p; +} +} // namespace + +int read_through_helper() +{ + int value = 1; + return helper(&value); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp new file mode 100644 index 0000000..74af1c3 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// A homonym of cross-tu-const-suggestion-anonymous-read.cpp's helper, in this file's anonymous +// namespace, which writes through its parameter. +namespace +{ +int helper(int* p) +{ + *p = 2; + return 0; +} +} // namespace + +int write_through_helper() +{ + int value = 1; + return helper(&value); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-read.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-read.c new file mode 100644 index 0000000..51aa21c --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-read.c @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// A static function: no other file can name it. A static homonym in another file is another +// function, and must not refute this one's suggestion. +static int helper(int* p) +{ + return *p; +} + +int read_through_helper(void) +{ + int value = 1; + return helper(&value); +} diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-write.c b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-write.c new file mode 100644 index 0000000..ddee093 --- /dev/null +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-static-write.c @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// A static homonym of cross-tu-const-suggestion-static-read.c's helper, which writes through its +// parameter. +static int helper(int* p) +{ + *p = 2; + return 0; +} + +int write_through_helper(void) +{ + int value = 1; + return helper(&value); +} diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index 2e19c5b..0e3d9b5 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -2,6 +2,7 @@ #include "StackUsageAnalyzer.hpp" #include "analysis/AnalyzerUtils.hpp" #include "analysis/ConstSuggestionCheck.hpp" +#include "analysis/ConstSuggestionInternal.hpp" #include "app/CrossTUSummaryDriver.hpp" #include "app/AnalyzerApp.hpp" #include "cli/ArgParser.hpp" @@ -744,6 +745,20 @@ namespace return report.failures == 0; } + /// #217: a symbol's demangled name without const keeps the identifiers that contain "const": + /// get_const(int*) and get_(int*) are two functions. + bool testConstSuggestionWithoutConstKeepsIdentifiers(TestReport& report) + { + using ctrace::stack::analysis::const_suggestion::withoutConst; + report.expect(withoutConst("_Z9get_constPi") != withoutConst("_Z4get_Pi"), + "ConstSuggestionCode: get_const(int*) and get_(int*) stay distinct"); + report.expect(withoutConst("_Z14const_iteratorPKi") == "const_iterator(int*)", + "ConstSuggestionCode: const_iterator keeps its name, the qualifier goes"); + report.expect(withoutConst("_Z1fPKi") == withoutConst("_Z1fPi"), + "ConstSuggestionCode: f(const int*) and f(int*) have the same key"); + return report.failures == 0; + } + /// #217: the code generated before and after a const rewrite is compared once the symbols /// the rewrite renames are paired. A symbol the unit only declares has no code to compare: it /// is paired only if it is the rewritten function itself. @@ -2573,6 +2588,7 @@ int main(int argc, char** argv) (void)testSarifSecuritySeverity(report); (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); (void)testConstSuggestionCodeComparison(report); + (void)testConstSuggestionWithoutConstKeepsIdentifiers(report); (void)testConstSuggestionNormalizations(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); From ea14cccea987aedfe322e1a273243bd01a442a1d Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:27:57 +0200 Subject: [PATCH 017/152] fix(const-param): fix the review's findings on the experimental check A homonym with internal linkage in another file is another function. A macro that writes other parameters is not rewritten. (void)f is no conversion of the address. An explicit object parameter is numbered like the others. A continuation line ending in CRLF stays in its #define. An identifier that contains const keeps it. A removal names the first diagnostic the rewrite adds, error or checked warning. The compile command of each unit is resolved once per analysis. --- README.md | 7 +- include/analysis/ConstSuggestionCheck.hpp | 11 +- src/analysis/ConstSuggestionCheck.cpp | 121 ++++++++++++++++------ src/analysis/ConstSuggestionCode.cpp | 8 +- 4 files changed, 109 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b2a2aab..5a15c21 100644 --- a/README.md +++ b/README.md @@ -665,15 +665,16 @@ The check runs these steps: 2. Every file that sees the change is compiled again with its own compile command, at `-O0`. The files considered are the analyzed ones and, with `--compile-commands`, every C or C++ file of the compilation database, analyzed or not. A file sees the change when it reads a rewritten - file, when its analyzed code references the function, or, for a function outside any class, - when it names the function after preprocessing. + file, when its analyzed code references the function, or, for a function outside any class with + external linkage, when it names the function after preprocessing. A homonym with internal + linkage (`static`, anonymous namespace) in another file is another function. 3. The suggestion is removed when one of these files: - no longer compiles; - gains one of these warnings: `incompatible-pointer-types-discards-qualifiers`, `incompatible-pointer-types`, `incompatible-function-pointer-types`, `int-conversion`, `overloaded-virtual`; - declares the function, outside the rewritten files, with another parameter type; - - converts the function's address with an explicit cast; + - converts the function's address with an explicit cast, other than a cast to `void`; - changes what the methods of that name override; - generates different code. diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index cb60f1a..e292ec5 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -39,12 +39,21 @@ namespace ctrace::stack::analysis std::unordered_set identifiers; }; + // How the check compiles one unit. + struct UnitCommand + { + std::vector args; // the driver's, as compilerlib builds them + std::string workingDir; + }; + // The inputs analyzed together. struct AnalyzedUnits { std::vector units; - // The scan of each unit, by path, found when first needed. + // By path, found when first needed: the compile command of each unit (none when it has + // none), and its scan. mutable std::mutex scansMutex; + mutable std::map> commands; mutable std::map scans; }; diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 4237a6f..3709acb 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -98,9 +98,11 @@ namespace ctrace::stack::analysis std::string problem; std::multiset overrides; unsigned paramIndex = 0; - // A free function can be declared by a file that reads none of the rewritten - // files; a method only where its class is defined. - bool freeFunction = false; + // A free function with external linkage can be declared by a file that reads none + // of the rewritten files; a method only where its class is defined. One with internal + // linkage (static, anonymous namespace) cannot: another file's homonym is another + // function. + bool declarableElsewhere = false; std::uint8_t reservedPadding[3] = {}; }; @@ -119,12 +121,22 @@ namespace ctrace::stack::analysis std::uint8_t reservedPadding[7] = {}; }; + // An error or a checked warning, in the order Clang reports it. + struct Diagnostic + { + std::string key; // place and kind, as `diagnostics` holds it + std::string text; + bool error = false; + std::uint8_t reservedPadding[7] = {}; + }; + struct Compilation { bool timing = false; std::uint8_t reservedPadding[7] = {}; std::string failure; // the compilation could not run std::multiset diagnostics; + std::vector reported; std::string firstDiagnostic; std::unique_ptr context; std::unique_ptr module; @@ -181,13 +193,14 @@ namespace ctrace::stack::analysis std::to_string(presumed.getLine()); } } - out_.diagnostics.insert(place + " #" + std::to_string(info.getID())); + const std::string key = place + " #" + std::to_string(info.getID()); + out_.diagnostics.insert(key); + llvm::SmallString<160> text; + info.FormatDiagnostic(text); + out_.reported.push_back({key, place + ": " + text.str().str(), + level >= clang::DiagnosticsEngine::Error}); if (out_.firstDiagnostic.empty()) - { - llvm::SmallString<160> text; - info.FormatDiagnostic(text); - out_.firstDiagnostic = place + ": " + text.str().str(); - } + out_.firstDiagnostic = out_.reported.back().text; } private: @@ -199,7 +212,16 @@ namespace ctrace::stack::analysis { std::size_t start = buffer.rfind('\n', offset); start = start == llvm::StringRef::npos ? 0 : start + 1; - while (start >= 2 && buffer[start - 2] == '\\') + // Whether the line before the one at `start` ends with a backslash, before its + // line break, "\n" or "\r\n". + const auto continued = [&](std::size_t at) + { + std::size_t end = at - 1; + if (end > 0 && buffer[end - 1] == '\r') + --end; + return end > 0 && buffer[end - 1] == '\\'; + }; + while (start >= 2 && continued(start)) { const std::size_t previous = buffer.rfind('\n', start - 2); start = previous == llvm::StringRef::npos ? 0 : previous + 1; @@ -219,6 +241,7 @@ namespace ctrace::stack::analysis // Writes the suggested type where `param` writes its type. bool plan(const clang::FunctionDecl& function, const clang::ParmVarDecl& param) { + param_ = ¶m; const clang::TypeSourceInfo* written = param.getTypeSourceInfo(); if (!written) return fail("the parameter's type is not written in the source"); @@ -310,15 +333,25 @@ namespace ctrace::stack::analysis else { // Written by a macro: a macro is text, `const` goes before its - // invocation, unless that invocation writes the declaration too. + // invocation, unless that invocation writes the declaration too, or + // another parameter, which would take the qualifier. const clang::CharSourceRange invocation = sm_.getExpansionRange(at); - const clang::SourceLocation name = - sm_.getExpansionLoc(function.getLocation()); - const bool writesDeclaration = - !sm_.isBeforeInTranslationUnit(name, invocation.getBegin()) && - !sm_.isBeforeInTranslationUnit(invocation.getEnd(), name); - if (length != 0 || writesDeclaration) + const auto writes = [&](clang::SourceLocation place) + { + const clang::SourceLocation where = sm_.getExpansionLoc(place); + return !sm_.isBeforeInTranslationUnit(where, invocation.getBegin()) && + !sm_.isBeforeInTranslationUnit(invocation.getEnd(), where); + }; + if (length != 0 || writes(function.getLocation())) return fail("the declaration is written by a macro definition"); + for (const clang::ParmVarDecl* other : function.parameters()) + { + if (other != param_ && writes(other->getLocation())) + { + return fail("the parameter's type is written by a macro that " + "writes other parameters too"); + } + } at = invocation.getBegin(); } if (at.isMacroID()) @@ -341,6 +374,7 @@ namespace ctrace::stack::analysis clang::ASTContext& context_; const clang::SourceManager& sm_; FunctionFacts& facts_; + const clang::ParmVarDecl* param_ = nullptr; }; // A type written canonically. An anonymous type, such as a lambda's, is written without @@ -451,6 +485,9 @@ namespace ctrace::stack::analysis bool VisitExplicitCastExpr(clang::ExplicitCastExpr* cast) { + // `(void)f` converts the function to no other type. + if (cast->getType()->isVoidType()) + return true; const clang::Expr* operand = cast->getSubExpr()->IgnoreParenImpCasts(); if (const auto* address = llvm::dyn_cast(operand)) { @@ -552,8 +589,10 @@ namespace ctrace::stack::analysis void plan(Target& target, FunctionFacts& facts, const clang::FunctionDecl& function) { const auto* method = llvm::dyn_cast(&function); - facts.freeFunction = method == nullptr; - const unsigned implicitThis = method && method->isInstance() ? 1 : 0; + facts.declarableElsewhere = method == nullptr && function.isExternallyVisible(); + // An explicit object parameter (C++23) is numbered like the others. + const unsigned implicitThis = + method && method->isImplicitObjectMemberFunction() ? 1 : 0; if (target.debugArgNo <= implicitThis) { facts.notRewritten = "the parameter is not found in the source"; @@ -626,12 +665,6 @@ namespace ctrace::stack::analysis Compilation& out_; }; - struct UnitCommand - { - std::vector args; // the driver's, as compilerlib builds them - std::string workingDir; - }; - bool commandFor(const AnalyzedUnit& unit, const AnalysisConfig& config, UnitCommand& out, std::string& error) { @@ -718,6 +751,7 @@ namespace ctrace::stack::analysis for (const char* warning : kCheckedWarnings) diagnostics.Warnings.push_back(warning); out.diagnostics.clear(); + out.reported.clear(); out.firstDiagnostic.clear(); clang::CompilerInstance instance; @@ -912,11 +946,15 @@ namespace ctrace::stack::analysis notChecked = after.failure; return false; } - for (const std::string& diagnostic : after.diagnostics) + // The first diagnostic the rewrite adds, not one the unit had before it. + for (const Diagnostic& diagnostic : after.reported) { - if (after.diagnostics.count(diagnostic) > before->diagnostics.count(diagnostic)) + if (after.diagnostics.count(diagnostic.key) > + before->diagnostics.count(diagnostic.key)) { - removed = "does not compile: " + after.firstDiagnostic; + removed = + (diagnostic.error ? "does not compile: " : "gains a checked warning: ") + + diagnostic.text; return true; } } @@ -1120,13 +1158,32 @@ namespace ctrace::stack::analysis cannotCheck(unit.filename + " uses the function but has no source"); continue; } - UnitCommand other; - if (!commandFor(unit, config, other, error)) + const std::string key = canonicalPath(unit.filename, config); + std::optional known; + bool resolved = false; + { + std::lock_guard lock(units->scansMutex); + const auto found = units->commands.find(key); + if (found != units->commands.end()) + { + known = found->second; + resolved = true; + } + } + if (!resolved) + { + UnitCommand command; + if (commandFor(unit, config, command, error)) + known = std::move(command); + std::lock_guard lock(units->scansMutex); + units->commands[key] = known; + } + if (!known) { cannotCheck("no compile command for " + unit.filename); continue; } - const std::string key = canonicalPath(unit.filename, config); + const UnitCommand& other = *known; UnitScan seen; bool cached = false; { @@ -1153,7 +1210,7 @@ namespace ctrace::stack::analysis referencing || std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), [&](const std::string& path) { return seen.files.count(path); }) || - (plan.freeFunction && + (plan.declarableElsewhere && seen.identifiers.count(llvm::xxh3_64bits(planned.name)) != 0); if (!sees) continue; diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp index c8daff8..5d9410b 100644 --- a/src/analysis/ConstSuggestionCode.cpp +++ b/src/analysis/ConstSuggestionCode.cpp @@ -420,11 +420,15 @@ namespace ctrace::stack::analysis { const std::string text = llvm::demangle(symbol); std::string out; + // An identifier is made of letters, digits and underscores: const_iterator is no + // qualifier. + const auto inWord = [&](std::size_t i) + { return std::isalnum(static_cast(text[i])) || text[i] == '_'; }; for (std::size_t i = 0; i < text.size();) { - const bool wordStart = i == 0 || !std::isalnum(static_cast(text[i - 1])); + const bool wordStart = i == 0 || !inWord(i - 1); if (wordStart && text.compare(i, 5, "const") == 0 && - (i + 5 == text.size() || !std::isalnum(static_cast(text[i + 5])))) + (i + 5 == text.size() || !inWord(i + 5))) { i += 5; continue; From 9c1f00fe577a2ad45cc900244b6207aad5d407c4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:28:37 +0200 Subject: [PATCH 018/152] ci(const-param): drop the dedicated job, ci.yml runs the same tests ci.yml runs the whole run_test.py suite on Linux and macOS, with the same toolchains and configuration, the check's tests included: the dedicated job only ran them a second time. --- .../experimental-const-suggestion-check.yml | 113 ------------------ README.md | 13 +- scripts/ci/run_const_suggestion_check.py | 81 ------------- 3 files changed, 6 insertions(+), 201 deletions(-) delete mode 100644 .github/workflows/experimental-const-suggestion-check.yml delete mode 100755 scripts/ci/run_const_suggestion_check.py diff --git a/.github/workflows/experimental-const-suggestion-check.yml b/.github/workflows/experimental-const-suggestion-check.yml deleted file mode 100644 index addd194..0000000 --- a/.github/workflows/experimental-const-suggestion-check.yml +++ /dev/null @@ -1,113 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# -# The experimental check of const parameter suggestions, enabled by -# --experimental-const-suggestion-check, is off by default. ci.yml runs the whole run_test.py suite: -# the default behavior, and these tests among the others. This workflow runs only the check's tests, -# with the option, on Linux and macOS, so that their status shows apart: the const-suggestion -# fixtures and checks of run_test.py, which expect suggestions kept once checked, suggestions -# removed, and suggestions kept as not checked. -# -# Scope: this repository's fixtures, with their compile commands, built with the LLVM 20 of each -# runner. It does not check this repository's sources, other projects or configurations, nor any -# analysis run without the option. -name: Experimental const suggestion check - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -concurrency: - group: const-suggestion-${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: true - -env: - CCACHE_DIR: ${{ github.workspace }}/.ccache - -jobs: - const-suggestion-check: - name: Experimental const suggestion check on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - # The toolchains of ci.yml. - - name: Install dependencies (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y build-essential cmake python3 \ - ninja-build ccache lld libz3-dev pkg-config - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - - UBUNTU_CODENAME=$(lsb_release -cs) - sudo apt-add-repository "deb http://apt.llvm.org/${UBUNTU_CODENAME}/ llvm-toolchain-${UBUNTU_CODENAME}-20 main" - sudo apt-get update - sudo apt-get install -y llvm-20 llvm-20-dev clang-20 libclang-20-dev - echo "LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm" >> $GITHUB_ENV - echo "Clang_DIR=/usr/lib/llvm-20/lib/cmake/clang" >> $GITHUB_ENV - - - name: Install dependencies (macOS) - if: runner.os == 'macOS' - run: | - brew install cmake python llvm@20 ninja ccache z3 - echo "LLVM_DIR=$(brew --prefix llvm@20)/lib/cmake/llvm" >> $GITHUB_ENV - echo "Clang_DIR=$(brew --prefix llvm@20)/lib/cmake/clang" >> $GITHUB_ENV - echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH - - # Reads the ccache that ci.yml saves; saving it is left to ci.yml. - - name: Restore ccache - uses: actions/cache/restore@v4 - with: - path: ${{ env.CCACHE_DIR }} - key: ccache-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('CMakeLists.txt', 'src/**', 'include/**') }} - restore-keys: | - ccache-${{ runner.os }}-${{ github.ref_name }}- - ccache-${{ runner.os }}- - - - name: Restore FetchContent sources - uses: actions/cache/restore@v4 - with: - path: | - build/_deps/cc-src - build/_deps/coretrace-logger-src - key: fetchcontent-${{ runner.os }}-llvm20-${{ hashFiles('CMakeLists.txt', 'cmake/**') }} - restore-keys: | - fetchcontent-${{ runner.os }}-llvm20- - - - name: Configure ccache - run: | - ccache --set-config=cache_dir=${{ env.CCACHE_DIR }} - ccache --set-config=max_size=500M - ccache --set-config=compression=true - ccache -z - - - name: Configure - run: | - cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DLLVM_DIR=${{ env.LLVM_DIR }} \ - -DClang_DIR=${{ env.Clang_DIR }} \ - -DCMAKE_C_COMPILER_LAUNCHER=ccache \ - -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ - -DUSE_SHARED_LIB=OFF \ - -DBUILD_TESTS=OFF \ - -DBUILD_ANALYZER_UNIT_TESTS=ON \ - ${{ runner.os == 'Linux' && '-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld -DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld' || '' }} - - - name: Build - run: cmake --build build --config Release --target stack_usage_analyzer - - - name: Check const suggestions with --experimental-const-suggestion-check - timeout-minutes: 20 - run: python3 -u scripts/ci/run_const_suggestion_check.py diff --git a/README.md b/README.md index 5a15c21..d4da61a 100644 --- a/README.md +++ b/README.md @@ -716,13 +716,12 @@ The check covers only: A CI job that enables the option checks the projects and configurations it analyzes, and nothing else: an analysis run without the option, by a user or by another job, is not checked. -In this repository, `ci.yml` runs the whole `run_test.py` suite: the default behavior, and the -check's tests among the others. The workflow `experimental-const-suggestion-check.yml` runs only -the check's tests, with the option, on Linux and macOS (`scripts/ci/run_const_suggestion_check.py`): -the const-suggestion fixtures and checks of `run_test.py`, which expect suggestions kept once -checked, suggestions removed, and suggestions kept as not checked. It covers these fixtures, with -their compile commands and the LLVM 20 of each runner; it does not check this repository's sources, -other projects or other configurations. +In this repository, `ci.yml` runs the whole `run_test.py` suite on Linux and macOS. It checks the +default behavior, and the check's tests with the option: the const-suggestion fixtures declare it +(`// analyzer-arg:`), and the checks pass it. These tests expect suggestions kept once checked, +suggestions removed, and suggestions kept as not checked, and analyze the same inputs without the +option too. They cover these fixtures, with their compile commands and the LLVM 20 of each runner; +they do not check this repository's sources, other projects or other configurations. The message says how the suggestion was checked: - `checked in the analyzed files only, with their compile commands: ...`, without a compilation diff --git a/scripts/ci/run_const_suggestion_check.py b/scripts/ci/run_const_suggestion_check.py deleted file mode 100755 index bb590f4..0000000 --- a/scripts/ci/run_const_suggestion_check.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -"""Run the tests of the experimental check of const suggestions (--experimental-const-suggestion-check). - -The tests come from run_test.py: the const-suggestion fixtures, which enable the option with their -`// analyzer-arg:` line, and the checks that pass it. Together they expect suggestions that are -kept once checked, suggestions that are removed, and suggestions kept as not checked; this script -fails if the fixtures stop expecting any of the three. - -These tests cover only this repository's fixtures, with their compile commands, on the toolchain of -the machine that runs them: they say nothing of other projects or configurations, nor of analyses -run without the option. - -usage: run_const_suggestion_check.py (after a build: uses build/stack_usage_analyzer) -""" -from __future__ import annotations - -import importlib.util -import os -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -FIXTURES = ROOT / "test" / "pointer_reference-const_correctness" -CHECKS = ( - "check_const_suggestion_cross_tu", - "check_const_suggestion_without_source", - "check_const_suggestion_compilation_database", - "check_const_suggestion_check_is_opt_in", -) -# What the fixtures expect, by the expectation that says it. -OUTCOMES = { - "kept, checked": "// ↳ checked in ", - "removed": "// not contains: ", - "kept, not checked": "// ↳ not checked with Clang: ", -} - - -def load_run_test(): - spec = importlib.util.spec_from_file_location("run_test", ROOT / "run_test.py") - module = importlib.util.module_from_spec(spec) - sys.modules["run_test"] = module - spec.loader.exec_module(module) - return module - - -def main() -> int: - fixtures = sorted(p for p in FIXTURES.glob("const-suggestion-*") if p.suffix in (".c", ".cpp")) - counts = {outcome: 0 for outcome in OUTCOMES} - for fixture in fixtures: - for line in fixture.read_text(encoding="utf-8").splitlines(): - for outcome, marker in OUTCOMES.items(): - if line.strip().startswith(marker): - counts[outcome] += 1 - print(f"{len(fixtures)} fixtures; expectations: " - + ", ".join(f"{outcome} {n}" for outcome, n in counts.items())) - missing = [outcome for outcome, n in counts.items() if n == 0] - if missing: - print(f"::error::the fixtures no longer expect: {', '.join(missing)}") - return 1 - - os.chdir(ROOT) - run_test = load_run_test() - # The fixtures' results depend on headers that the cache key of run_test.py does not cover. - run_test.RUN_CONFIG.cache_enabled = False - ok = True - for fixture in fixtures: - passed, total, matched, report = run_test.check_file(fixture.relative_to(ROOT)) - print(f"{'PASS' if passed else 'FAIL'} {fixture.name}: {matched}/{total}") - if not passed: - print(report) - ok = ok and passed - for name in CHECKS: - passed = getattr(run_test, name)() - print(f"{'PASS' if passed else 'FAIL'} {name}") - ok = ok and passed - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main()) From 0239868dd94e877215e3dab82d1f6a8cc0b386e7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:31:34 +0200 Subject: [PATCH 019/152] docs(const-param): a qualifier dropped in C is a warning, not a compile error --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d4da61a..69f50dd 100644 --- a/README.md +++ b/README.md @@ -634,13 +634,13 @@ For test files, `run_test.py` also supports per-file model selection with: `ConstParameterNotModified` finds, in the LLVM IR, the pointer and reference parameters through which nothing is written. The IR does not show every constraint that C and C++ put on a parameter: -the suggested `const` type may not compile (a callback of the old type, `&p` passed as `int**`), -or may compile and select other code (an overload, an override, `sizeof`, `if constexpr`, -`_Generic`). +the suggested `const` type may not compile (a callback of the old type, or `&p` passed as `int**` +in C++), may only drop a qualifier with a warning (the same `&p` in C), or may compile and select +other code (an overload, an override, `sizeof`, `if constexpr`, `_Generic`). Without `--experimental-const-suggestion-check`, the default, nothing beyond the IR analysis is checked: every suggestion is reported as the IR analysis finds it, without a check line, including -those whose suggested type does not compile or changes the generated code. +those whose suggested type does not compile, drops a qualifier, or changes the generated code. With `--experimental-const-suggestion-check` (or `experimental-const-suggestion-check=true` in a config file), each suggestion is checked with Clang before it is reported. The check is @@ -652,7 +652,8 @@ experimental: self-analysis, the analysis takes about 19% longer. The option is a trade-off, not a fix: with it, a valid suggestion can still be removed; without -it, suggestions whose type does not compile or changes the generated code are reported. +it, suggestions whose type does not compile, drops a qualifier, or changes the generated code are +reported. The check runs these steps: From 334778524e531c738dca462b1b753c44c894ffcd Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:48:55 +0200 Subject: [PATCH 020/152] style(const-param): format the const-suggestion fixtures clang-format 20, as the format check of the CI runs it. In const-suggestion-deduction-kept.cpp, the template declaration now takes one line: its expectations move from line 25 to line 24. --- .../const-suggestion-deduction-kept.cpp | 7 +++---- .../const-suggestion-deduction-refused.cpp | 12 ++++-------- .../const-suggestion-macro-params.c | 2 +- .../cross-tu-const-suggestion-anonymous-read.cpp | 8 ++++---- .../cross-tu-const-suggestion-anonymous-write.cpp | 10 +++++----- 5 files changed, 17 insertions(+), 22 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp index 1c8ab6a..bad7033 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp @@ -6,13 +6,13 @@ // strict-expectation-details: true -// at line 25, column 0 +// at line 24, column 0 // [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_0&&)' is an rvalue reference and is never used to modify the referred object // ↳ consider passing by value ( f) or const reference (const &f) // ↳ current type: &&f // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside -// at line 25, column 0 +// at line 24, column 0 // [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_1&&)' is an rvalue reference and is never used to modify the referred object // ↳ consider passing by value ( f) or const reference (const &f) // ↳ current type: &&f @@ -21,8 +21,7 @@ // The lvalue passes a const reference already: no suggestion for that specialization. // not contains: in function 'int apply_twice -int apply_twice(F&& f) +template int apply_twice(F&& f) { const auto twice = [&](int x) { return f(f(x)); }; return twice(1); diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp index 4d6ca66..6c273ab 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp @@ -10,8 +10,7 @@ // not contains: in function 'int call< // not contains: in function 'int pick< -template -int call(F&& f) +template int call(F&& f) { return f(1); } @@ -28,20 +27,17 @@ int from_lvalues() return call(g) | call(h); } -template -struct is_reference +template struct is_reference { static constexpr bool value = false; }; -template -struct is_reference +template struct is_reference { static constexpr bool value = true; }; -template -int pick(F&& f) +template int pick(F&& f) { if constexpr (is_reference::value) return f(4); diff --git a/test/pointer_reference-const_correctness/const-suggestion-macro-params.c b/test/pointer_reference-const_correctness/const-suggestion-macro-params.c index e3de7c7..3f163c2 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-macro-params.c +++ b/test/pointer_reference-const_correctness/const-suggestion-macro-params.c @@ -12,7 +12,7 @@ // ↳ not checked with Clang: the parameter's type is written by a macro that writes other parameters too // analyzer-arg: --experimental-const-suggestion-check -#define READ_ARGS int n, int* q +#define READ_ARGS int n, int *q int read_both(READ_ARGS) { diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp index 8bc0554..074d9de 100644 --- a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-read.cpp @@ -3,10 +3,10 @@ // file has the same mangled name. That homonym must not refute this one's suggestion. namespace { -int helper(int* p) -{ - return *p; -} + int helper(int* p) + { + return *p; + } } // namespace int read_through_helper() diff --git a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp index 74af1c3..bd3aaf4 100644 --- a/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp +++ b/test/pointer_reference-const_correctness/cross-tu-const-suggestion-anonymous-write.cpp @@ -3,11 +3,11 @@ // namespace, which writes through its parameter. namespace { -int helper(int* p) -{ - *p = 2; - return 0; -} + int helper(int* p) + { + *p = 2; + return 0; + } } // namespace int write_through_helper() From 693590a60ef630c81e6d0823d08a99c3d3a96ad3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 14 Oct 2026 21:55:38 +0200 Subject: [PATCH 021/152] refactor(const-param): take three read-only parameters as const Code scanning alerts #268, #269 and #270 on #221: Inspector::plan's target, writtenType's context and the list lambda's node are only read. The experimental check keeps the three suggestions. --- src/analysis/ConstSuggestionCheck.cpp | 5 +++-- src/analysis/ConstSuggestionCode.cpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 3709acb..3e54479 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -380,7 +380,7 @@ namespace ctrace::stack::analysis // A type written canonically. An anonymous type, such as a lambda's, is written without // its location: the path of that location depends on how the file was named, relative // before the rewrite and absolute once the rewritten file is remapped. - std::string writtenType(clang::ASTContext& context, clang::QualType type) + std::string writtenType(const clang::ASTContext& context, clang::QualType type) { clang::PrintingPolicy policy(context.getLangOpts()); policy.AnonymousTagLocations = false; @@ -586,7 +586,8 @@ namespace ctrace::stack::analysis plan(target, facts, *function); } - void plan(Target& target, FunctionFacts& facts, const clang::FunctionDecl& function) + void plan(const Target& target, FunctionFacts& facts, + const clang::FunctionDecl& function) { const auto* method = llvm::dyn_cast(&function); facts.declarableElsewhere = method == nullptr && function.isExternallyVisible(); diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp index 5d9410b..4e3e255 100644 --- a/src/analysis/ConstSuggestionCode.cpp +++ b/src/analysis/ConstSuggestionCode.cpp @@ -110,7 +110,7 @@ namespace ctrace::stack::analysis out = builder.createAnonymousAliasScope(domain(parent)); return out; }; - const auto list = [&](llvm::MDNode* node) + const auto list = [&](const llvm::MDNode* node) { llvm::SmallVector scopes; for (const llvm::MDOperand& operand : node->operands()) From e72e517da74f884738866431d311d55276ad7377 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 15 Oct 2026 09:14:43 +0200 Subject: [PATCH 022/152] ci(concurrency): run the analyzer image v0.8.0, which has Clang's headers The image of v0.7.1 had LLVM's headers but not Clang's, so ConstSuggestionCheck.cpp did not compile in it and the concurrency job failed. v0.8.0 installs libclang-20-dev with clang-20 (CoreTrace/coretrace-concurrency-analyzer#204). Over this branch's sources, its arm64 image exits 0, with no unit skipped and no result. The image is pinned rather than left to the action's default, which moves with the v0 tag. --- .github/workflows/concurrency.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/concurrency.yml b/.github/workflows/concurrency.yml index 0405318..92001e7 100644 --- a/.github/workflows/concurrency.yml +++ b/.github/workflows/concurrency.yml @@ -58,5 +58,8 @@ jobs: - name: Analyze uses: CoreTrace/coretrace-concurrency-analyzer@v0 with: + # The first image with Clang's headers, which ConstSuggestionCheck.cpp + # includes (CoreTrace/coretrace-concurrency-analyzer#204). + version: v0.8.0 compile-commands: build/compile_commands.json fail-on: error From 7d96db19305085367059703e8fc6dc111ac51d37 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 16:24:58 +0200 Subject: [PATCH 023/152] docs(spec): design the second version of the const suggestion check T v1 finds the function by its decoded names and the parameter by its debug number, plans no scope check, and removes a suggestion on any unexplained code difference. The design gives T explicit identities, a plan validated before the rewrite, units made of a file and its configuration, a closed list of compared facts, established correspondences, and three verdicts: verified, refuted, indeterminate. Refs #217 --- ...-10-16-const-suggestion-check-v2-design.md | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md new file mode 100644 index 0000000..f5bf5ba --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -0,0 +1,348 @@ +# Vérification des suggestions const par Clang, version 2 (#217) + +Statut : conception validée en conversation (approche 1, sections A à D) ; à relire avant le plan +d'implémentation. N'autorise ni le code ni la modification des tests : chaque lot présentera ses +tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. +Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 +(identité d'un global entre fichiers). +Références au code : `2a5b0c1`, c'est-à-dire #221 avec `main` b80d127 fusionné. Bilan et preuves : +`coretrace-stack-analyzer-evidence/217/` (`bilan-final-2a5b0c1.md`, `final-review/`, `optin-cost/`). + +## 1. Constat + +T v1, derrière `--experimental-const-suggestion-check`, vérifie avec Clang chaque suggestion de +`ConstParameterNotModified` : il réécrit en mémoire les déclarations du paramètre, recompile les +fichiers qui voient le changement et compare leurs diagnostics et leur code généré. + +Sur `src/`, zlib, tinyxml2 et PCRE2 (même binaire, Linux x86_64), 97 suggestions : 36 vérifiées, +31 gardées non vérifiées, 30 retirées, aucune par un défaut de T. L'auto-analyse coûte +11,3 % avec +l'option, pour un objectif de +10 %. + +La revue finale a confirmé dix défauts à `2a5b0c1` (§6), dont deux suggestions fausses affichées +comme vérifiées. Leurs causes sont communes : +1. **Identité approchée.** La fonction est retrouvée par son symbole avant la réécriture, puis après + par ses noms décodés (`withoutConst`, `variantOf`), sans sa liaison et sans contrôle + d'ambiguïté : le dernier candidat trouvé l'emporte. +2. **Paramètre.** Son indice source est calculé `debugArgNo − 1 − this` : un paramètre artificiel + (`vtt`) le décale. La règle elle-même reçoit de `ParameterDebugBinding` un `vtt` typé comme le + premier paramètre utilisateur. +3. **Plan sans contrôle de portée.** Une modification peut atteindre un autre paramètre (argument de + macro réutilisé) ou d'autres fonctions (corps de macro mal reconnu). +4. **Retrait sur une différence non expliquée.** « Le code généré change » retire la suggestion, + même quand rien n'établit une violation. +5. **Unité réduite au fichier.** Une seule commande par fichier ; le type attendu est comparé en + texte, dans la langue du fichier de définition. +6. **Candidat affiché différent du candidat vérifié** pour `PointerConstOnly` et + `ReferenceRvaluePreferValue`. + +## 2. Objectif et périmètre + +**Objectif.** Chaque verdict est justifié et rattaché au candidat exact, aux unités et aux +configurations effectivement vérifiées. + +**Dans le périmètre (refonte interne de T) :** +- identités explicites, plan séparé de son exécution, unité définie par un fichier et une + configuration, faits R, O et K, correspondances établies, collisions, verdicts à trois états, + trace ; +- étape 2 : D6, D8, D10, D12 et D13 (§6) ; +- étape 3 : les coûts évitables mesurés et une seule construction de l'invocation Clang. + +**Hors périmètre :** +- une compilation commune avec coretrace-compiler, un cache persistant ; +- la refonte du comparateur, qui ne reçoit qu'une entrée nouvelle (§5.9) ; +- les configurations multiples par fichier et `ParameterDebugBinding` (P0) : deux changements + séparés, P0 étant un préalable ; +- des groupes d'édition de liens fournis par la construction ; +- le comportement sans l'option, inchangé. + +**Conservé de T v1 :** la séparation entre l'analyse LLVM et la vérification Clang, la réécriture en +mémoire, la prise en compte des autres fichiers, les normalisations et les exceptions acceptées du +comparateur, les tests. + +## 3. Définitions + +- **Suggestion** : un `ConstParamIssue` : symbole de la fonction, argument, règle, + `reliesOnCalleeConst`. +- **Candidat** : le type exact proposé pour un paramètre source et l'alternative qu'il représente, + construit une seule fois par le planificateur à partir des types de Clang : + + | Règle | Candidat | + |---|---| + | `Pointer` | pointé const : `const T *p` | + | `PointerConstOnly` | pointé const, const de tête conservé : `const T *const p` | + | `Reference` | `const T &r` | + | `ReferenceRvaluePreferValue` | l'alternative référence constante `const T &r` ; le passage par valeur n'est pas vérifié | + +- **Unité** : un fichier et une configuration, identifiée par l'empreinte de sa commande normalisée + (arguments et répertoire de travail). Le chargement actuel fournit au plus une configuration par + fichier ; T n'en revendique aucune autre. +- **Identités** : + - *source* : l'entité (fonction, méthode, constructeur, opérateur…), sa liaison (externe, + interne, aucune), ses redéclarations (fichier réel, position) et son origine de gabarit ; + - *paramètre* : son indice dans la liste des paramètres source, et le `ParmVarDecl` de chaque + redéclaration ; + - *ABI* : les symboles que CodeGen émet pour chaque variante (`GlobalDecl`), avant et après. +- **Faits R, O, K** : §5.4. +- **Correspondance établie** : un appariement déduit de faits de compilation (identité source, plan, + contextes d'instanciation), jamais d'un nom décodé. +- **Verdicts** : + - *vérifiée* : dans chaque unité vérifiée, aucune violation, toutes les correspondances + établies, et un code égal aux exceptions acceptées près ; + - *réfutée* : une violation du contrat est établie : erreur de compilation introduite (une fois + la portée validée), avertissement suivi introduit, ou fait R, O ou K changé ; + - *indéterminée* : identité ambiguë, correspondance manquante ou seulement supposée, unité non + vérifiable, portée inconnue ou non respectée, comparaison non concluante, conversion explicite + de l'adresse sans violation établie, collision possible entre fichiers. + +## 4. Sortie et trace + +- **Vérifiée** : la suggestion est gardée. Le type proposé affiché est le candidat (D12, D13). La + ligne « checked … » nomme le candidat, son alternative, la portée (fichiers analysés ou base de + compilation) et les configurations vérifiées, puis les différences acceptées et la ligne ABI. +- **Indéterminée** : gardée, avec « not checked with Clang: ». Le type affiché est le + candidat s'il existe, sinon la proposition de la règle. +- **Réfutée** : retirée des diagnostics publics. +- **Trace** (`--verbose`), pour chaque verdict : la suggestion (règle, fonction et symbole, + paramètre par son nom et son indice source), la transformation (fichier et ligne, type d'origine + et candidat, alternative), l'unité et sa configuration, le verdict et sa raison. +- **Sans l'option** : aucun changement. +- **#167** : les lignes liées à `reliesOnCalleeConst` restent inchangées. Un verdict de T porte sur + le changement de type ; il ne vaut jamais preuve d'absence d'écriture. + +## 5. Architecture + +Les responsabilités ci-dessous sont séparées dans le code, sans qu'aucune n'exige sa propre classe +ou interface. + +**Flux.** +1. Compilation initiale du fichier de définition, et collecte de ses faits « avant ». +2. Planification de chaque suggestion sur cette même compilation. +3. Compilation initiale de chaque autre unité concernée, une fois par unité et configuration, + réutilisée pour toutes les suggestions. +4. Compilation modifiée de chaque unité concernée. +5. Correspondance et jugement par unité, agrégation, rapport. + +### 5.1 Prise en charge + +Une suggestion devient : le paramètre source (indice fourni par P0, ou « artificiel » ou +« inconnu »), la règle et l'alternative attendue. + +### 5.2 Unités + +Le fichier de définition (avec la commande de l'entrée analysée), les autres entrées analysées et, +avec `--compile-commands`, les fichiers C et C++ de la base. Une unité par fichier et par +configuration fournie. Une unité sans commande, impossible à prétraiter ou qui ne compile pas telle +quelle est non vérifiable. Une unité voit le changement quand elle lit un fichier réécrit, quand son +code analysé référence l'entité (en tenant compte de la liaison), ou, pour une fonction libre à +liaison externe, quand elle en nomme l'identifiant après prétraitement. + +### 5.3 Compilation initiale et faits « avant » + +Une compilation par unité et configuration ; pour le fichier de définition, la même compilation +sert au plan. Elle relève : +- les déclarations de l'entité ; +- les faits R, O et K ; +- les symboles définis et leur liaison ; +- les conversions explicites de l'adresse de la fonction ; +- les erreurs et les avertissements suivis ; +- l'IR préparé pour le comparateur. + +Les avertissements suivis sont activés avec `-Wno-error=`, pour qu'un `-Werror` du projet +ne les change pas en erreurs (D6). + +### 5.4 Faits R, O et K + +Une liste fermée : +- **R, référence** : la déclaration que vise un appel ou une référence : fonctions, spécialisations, + constructeurs, opérateurs, conversions ; un appel virtuel par sa cible statique. Les opérandes non + évalués (`sizeof`, `decltype`) et les instanciations sont inclus. +- **O, override** : pour chaque méthode qui porte le nom de l'entité, les méthodes qu'elle surcharge. +- **K, constante** : la valeur de chaque condition `if constexpr`, et l'association que choisit + `_Generic`. + +Rien d'autre n'est relevé : ni le type des expressions, ni les conversions implicites, ni les autres +constantes. Une référence à l'entité réécrite doit viser la même entité avant et après, selon le +plan. + +**Clé d'un fait** : l'unité ; la chaîne des contextes d'instanciation (gabarit et arguments) ; la +position source, à la fois d'écriture et d'expansion, ramenée à l'original par les modifications du +plan dans les fichiers réécrits. + +### 5.5 Planification + +Sur la compilation initiale du fichier de définition : +- **Entité** : localisée par le symbole de la suggestion (CodeGen), puis décrite par son identité + source. +- **Paramètre** : indice source fourni par P0. Un paramètre artificiel ou introuvable donne + « indéterminée ». +- **Candidat** : calculé sur le type structuré (§3). +- **Modifications**, sur le `TypeLoc` de chaque redéclaration : + - un type écrit dans un argument de macro est réécrit là où il est écrit ; + - un type écrit par une macro qui n'écrit que ce paramètre reçoit `const` devant l'invocation ; + - une définition de macro, reconnue par le préprocesseur et non par le texte (D11), n'est jamais + réécrite : indéterminée ; + - un typedef de pointeur reçoit le candidat imprimé, et un qualificatif écrit à part est traité + (D10) ; sinon, indéterminée. +- **Portée du plan** : chaque modification ne sert que les paramètres prévus. Aucun autre paramètre + ni aucune autre déclaration ne s'écrit à la même position (D5). Sinon, indéterminée. + +### 5.6 Compilation modifiée et inspection « après » + +Les fichiers réécrits sont remappés ; les mêmes faits qu'en 5.3 sont relevés. La portée est vérifiée +dans chaque unité : +- aucune déclaration des fichiers réécrits ne change hors du plan ; +- les redéclarations visées ont le type du candidat, comparé dans la langue de l'unité (D8). + +Si les déclarations nécessaires n'ont pas pu être inspectées (erreur fatale, unité incomplète), la +portée est inconnue. + +### 5.7 Correspondance + +Par unité, entre ses compilations initiale et modifiée, jamais entre deux unités ni entre deux +configurations : +- **Symboles de l'entité** : chaque variante, par `GlobalDecl`. +- **Constructeurs hérités** : il faut savoir quels symboles émis, avant et après, sont les variantes + héritées du constructeur visé. Technique candidate : les constructeurs implicites qui en héritent + (`getInheritedConstructor()`), et leurs symboles par CodeGen. Elle doit être démontrée (cas `t8` + et un cas avec gabarit) avant d'être adoptée ; sans démonstration, ces cas restent indéterminés. +- **Entités imbriquées dans la cible** : lambdas, statiques locales, classes locales et noms RTTI. +- **Contextes d'instanciation dont les arguments changent** : appariés seulement si les faits qui + les demandent se correspondent (chacun avec son propre contexte), visent le même gabarit avant et + après, désignent tous la même paire, et forment une bijection. Sinon, indéterminée, et la raison + nomme le contexte. + +### 5.8 Jugement + +Par unité, dans cet ordre : +1. unité non vérifiable : indéterminée ; +2. inspection « après » incomplète, donc portée inconnue : indéterminée ; +3. portée du plan non respectée : indéterminée ; +4. erreur de compilation introduite : réfutée ; +5. avertissement suivi introduit : réfutée ; +6. fait R, O ou K changé, selon des correspondances établies : réfutée ; +7. comparaison de l'IR avec les paires établies : égalité aux exceptions acceptées près, vérifiée ; + différence que les faits n'expliquent pas, symbole non apparié ou paire seulement supposée, + indéterminée. + +Une conversion explicite de l'adresse est relevée et nommée ; seule, elle donne « indéterminée ». + +### 5.9 Comparateur + +Inchangé, à une entrée près : il reçoit les paires établies (5.7). +- Ses heuristiques (encodage masqué, `withoutConst`, `variantOf`, clé de gabarit) ne servent plus + qu'à situer une différence dans la trace, pour les symboles que ces paires ne couvrent pas. +- « Vérifiée » exige que chaque symbole qui disparaît ou apparaît soit couvert par une paire + établie. Une paire seulement supposée, ou une heuristique qui contredit une paire établie, donne + au mieux « indéterminée ». +- Les exceptions acceptées restent celles de T v1, chacune vérifiée et listée : le nom que `typeid` + donne à un type nommé d'après la fonction, et la signature imprimée par `__PRETTY_FUNCTION__`. + +### 5.10 Collisions, édition de liens et agrégation + +**Collisions.** Après la réécriture, un symbole que prend l'entité est-il défini par une autre +entité ? Une même entité définie dans plusieurs fichiers, comme une fonction `inline` de l'en-tête +réécrit, n'est pas une collision. + +| Liaison de l'autre définition | Règle à l'édition de liens | Verdict | +|---|---|---| +| locale (`static`, espace anonyme) | aucune portée entre fichiers | jamais de collision | +| forte, dans le même fichier | redéfinition | erreur de compilation, réfutée à l'étape 4 | +| forte, dans un autre fichier | définition multiple si les deux fichiers sont liés ensemble | indéterminée | +| `weak` | pas d'erreur ; la définition forte l'emporte en silence | indéterminée, en nommant laquelle l'emporterait | +| `weak_odr`, `linkonce_odr` | fusion sous promesse ODR | indéterminée | + +Conclure sur l'ODR exigerait l'équivalence des définitions source : T ne l'établit pas, et l'IR ne +tranche pas, ni par son égalité ni par sa différence. + +**Déclaration périmée.** Une unité qui déclare l'entité avec l'ancien type sans voir la réécriture +rend la suggestion indéterminée. + +**Hypothèse d'édition de liens.** T ne déduit jamais que des fichiers forment un même programme. +Toute raison qui en dépend énonce l'hypothèse et sa portée, par exemple : « `_Z1fPKi` est aussi +défini dans b.cpp ; si a.cpp et b.cpp sont liés ensemble, les deux définitions entrent en +collision ». + +**Agrégation.** La suggestion est réfutée si une unité la réfute ; sinon indéterminée si une unité ou +une collision l'est ; sinon vérifiée, dans les unités et configurations vérifiées. + +### 5.11 Rapport + +Selon §4. + +## 6. Défauts confirmés + +Cas dans `coretrace-stack-analyzer-evidence/217/final-review/cases/`, observés à `2a5b0c1`. « VP » : +suggestion valide. + +| # | Défaut (cas) | Fait observé | Verdict attendu | Test prévu | Étape | +|---|---|---|---|---|---| +| D1 | paramètre artificiel `vtt` d'un constructeur de base (t10) | la règle suggère `vtt` ; T réécrit `x` et l'affiche « checked » ; `x` (variante C2) est retiré | P0 : plus de suggestion sur `vtt`. T : `x` vérifiée. Un paramètre artificiel qui atteindrait T : indéterminée | P0 : fixture de la règle. T : fixture du constructeur | P0, 1 | +| D2 | opérateur membre utilisé dans un autre fichier (t1) | retirée : « the methods it overrides change » | vérifiée (VP) | contrôle run_test.py à deux fichiers | 1 | +| D3 | homonymes C `static` et externe (t2, deux sens) | retirée : « declared in b.c / d.c » | vérifiée (VP), dans les deux sens ; README corrigé | contrôle run_test.py, deux sens | 1 | +| D4 | `f(const int*)` défini dans un autre fichier (t9) | « checked in 2 file(s) » ; l'appliquer définit `_Z1fPKi` deux fois | indéterminée, en énonçant l'hypothèse d'édition de liens | contrôle run_test.py : fichiers analysés ensemble, et autre fichier venant seulement de la base | 1 | +| D5 | type écrit dans un argument de macro qui sert deux paramètres (t3/m.c) | retirée : erreur sur `dst` | indéterminée (VP gardée) : type écrit une seule fois pour `src` et `dst` | fixture | 1 | +| D7 | constructeur hérité (t8) | retirée : symboles `CI1`/`CI2` non appariés | vérifiée si 5.7 est démontrée, sinon indéterminée ; jamais retirée | fixture | 1 | +| D11 | `\` suivi d'espaces dans un `#define` (t13) | corps de macro réécrit, puis retirée | indéterminée (VP gardée) : déclaration écrite par une définition de macro | fixture | 1 | +| D14 | correspondance non unique traitée comme une réfutation (`const-suggestion-deduction-refused.cpp`, cas `call`) | retirée | indéterminée (gardée) ; `pick` reste réfutée (condition `if constexpr`) | attente modifiée de la fixture | 1 | +| D15 | conversion explicite de l'adresse traitée comme une réfutation (`cross-tu-const-suggestion-cast.c`) | retirée | indéterminée (gardée), sauf erreur ou effet établi | attentes modifiées des deux contrôles run_test.py qui l'utilisent | 1 | +| D6 | `-Werror` du projet et groupe suivi désactivé (t5) | « not checked: does not compile as it is » | vérifiée (VP) | fixture avec `--compile-arg` | 2 | +| D8 | définition C, utilisateur C++ via `extern "C"` (t4) | « not checked: the rewritten declaration does not get the suggested type » ; témoin C vérifié | vérifiée (VP) | contrôle run_test.py, avec le témoin | 2 | +| D10 | `const` écrit devant un typedef de pointeur (t3/t.c, t3/t.cpp) | réécrit en `const const int *` ; « not checked » | vérifiée (`const int *const`) ; indéterminée si le qualificatif vient d'une macro | fixtures C et C++ | 2 | +| D12 | alternative de `ReferenceRvaluePreferValue` | seul `const T &` est vérifié ; la ligne dit « the suggested type » | vérifiée ; le type affiché et la ligne nomment `const T &`, le passage par valeur n'est pas vérifié | attentes modifiées (`const-suggestion-deduction-kept.cpp` et autres) | 2 | +| D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | attentes modifiées des fixtures concernées | 2 | + +**Non retenus :** +- le cache « avant » indexé par l'identifiant (point 9 de la revue) : non reproduit (t12) ; les + caches de T v2 sont indexés par entité et par configuration ; +- `typedef int *const CP` (t14) : la règle n'y fait aucune suggestion. + +**Étape 3, sans changement de comportement :** la copie du scan de chaque unité pour chaque +suggestion, l'absence de cache pour une compilation « avant » qui échoue, et la construction de +l'invocation Clang en double. Les deux coûts ne sont traités que s'ils sont mesurables. + +## 7. Tests + +- **TDD, défaut par défaut.** Chaque test est soumis avec la preuve de son échec avant d'être ajouté. +- **Non-régression.** Tous les tests de T v1, sauf les attentes à modifier, elles aussi soumises : + D12, D13, D14 et D15. +- **Corpus** (`src/`, zlib, tinyxml2, PCRE2). Chaque suggestion est comparée entre trois sorties : + sans l'option, T v1 et T v2. Elle est classée vérifiée, gardée indéterminée, ou retirée sur une + réfutation établie. Chaque disparition par rapport à la sortie sans l'option est expliquée par sa + violation, et chaque écart avec T v1 l'est aussi. T v1 n'est pas une référence de justesse. +- **Plateformes.** macOS, Linux (WSL), puis la CI avec le tri du code scanning. + +## 8. Coût + +Le même binaire, sans puis avec l'option, en alternance (sans, avec, avec, sans), sur la même base et +les mêmes configurations : +- l'auto-analyse telle que la CI la lance (4 tranches, 2 à la fois, profil rapide, caches froids) ; +- zlib, tinyxml2 et PCRE2. + +Le critère reste +10 % sur l'auto-analyse. La validation de cette architecture ne vaut pas +acceptation d'un dépassement. + +## 9. Livraison + +1. **P0, `ParameterDebugBinding`** : issue et PR propres, depuis `main`, avec leurs tests. Préalable + à l'étape 1. +2. **Branche `217-t-v2`**, depuis `2a5b0c1`. T v1 reste une référence immuable (`217-t-v1`, #221, + bundle). Après la fusion de P0, `main` actualisé y est intégré et l'ensemble est validé. +3. **Étape 1** : flux, identités, plan et portée, faits R, O et K, correspondances, paires établies + pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14 et D15. +4. **Étape 2** : D6, D8, D10, D12 et D13. +5. **Étape 3** : les coûts mesurés et l'invocation Clang construite en un seul endroit. +6. **Comparaison des corpus, mesure du coût et README** ; une PR en brouillon. La revue finale porte + sur la base actualisée. + +Chaque étape est validée (tests, plateformes, CI) avant la suivante. Les configurations multiples +restent un changement séparé, hors de ce calendrier. + +## 10. Limites + +- Sans groupes d'édition de liens, une collision entre fichiers reste indéterminée. +- L'ODR n'est pas tranchée. +- Seules les configurations fournies par le chargement sont vérifiées. +- Une différence observable que les faits R, O et K ne couvrent pas rend la suggestion + indéterminée, pas réfutée. +- La technique d'identification des constructeurs hérités reste à démontrer. +- Un succès du cache IR ne fournit pas d'AST : T compile toujours depuis les sources. From 8b283a8a97e1ac1fa7bb06588407f5f0700a0f37 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 16:39:50 +0200 Subject: [PATCH 024/152] docs(spec): apply the review of the const suggestion check design - The checked warning groups are enabled with -W, then kept as warnings with -Wno-error=, before and after the rewrite. - The scope invariant covers the declarations written in the sources; instantiated signatures are left to the correspondences. - Heuristics have no say in a verdict. - D16: sizeof(__PRETTY_FUNCTION__) becomes indeterminate, and the limit this creates is documented. Refs #217 --- ...-10-16-const-suggestion-check-v2-design.md | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index f5bf5ba..1bc58cb 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -147,8 +147,10 @@ sert au plan. Elle relève : - les erreurs et les avertissements suivis ; - l'IR préparé pour le comparateur. -Les avertissements suivis sont activés avec `-Wno-error=`, pour qu'un `-Werror` du projet -ne les change pas en erreurs (D6). +Les groupes d'avertissements suivis sont activés explicitement par `-W`, puis maintenus comme +avertissements par `-Wno-error=`, avec la même politique dans les compilations initiale et +modifiée. Une commande de projet qui désactive un groupe (`-Wno-`) ou promeut les +avertissements en erreurs (`-Werror`) ne change donc ni leur relevé ni leur nature (D6). ### 5.4 Faits R, O et K @@ -190,7 +192,10 @@ Sur la compilation initiale du fichier de définition : Les fichiers réécrits sont remappés ; les mêmes faits qu'en 5.3 sont relevés. La portée est vérifiée dans chaque unité : -- aucune déclaration des fichiers réécrits ne change hors du plan ; +- aucune déclaration écrite dans les sources des fichiers réécrits ne change hors du plan. Les + signatures instanciées (spécialisations de gabarits) qui changent avec la réécriture ne relèvent + pas de cet invariant : elles sont examinées par les correspondances (5.7), comme dans + `const-suggestion-deduction-kept.cpp` ; - les redéclarations visées ont le type du candidat, comparé dans la langue de l'unité (D8). Si les déclarations nécessaires n'ont pas pu être inspectées (erreur fatale, unité incomplète), la @@ -232,8 +237,10 @@ Inchangé, à une entrée près : il reçoit les paires établies (5.7). - Ses heuristiques (encodage masqué, `withoutConst`, `variantOf`, clé de gabarit) ne servent plus qu'à situer une différence dans la trace, pour les symboles que ces paires ne couvrent pas. - « Vérifiée » exige que chaque symbole qui disparaît ou apparaît soit couvert par une paire - établie. Une paire seulement supposée, ou une heuristique qui contredit une paire établie, donne - au mieux « indéterminée ». + établie. Une paire seulement supposée donne au mieux « indéterminée ». +- Une heuristique n'a aucun pouvoir de verdict : elle ne peut ni établir une paire ni en invalider + une. Seuls des faits insuffisants ou incohérents empêchent d'établir une correspondance ; une + approximation de nom n'invalide jamais une preuve. - Les exceptions acceptées restent celles de T v1, chacune vérifiée et listée : le nom que `typeid` donne à un type nommé d'après la fonction, et la signature imprimée par `__PRETTY_FUNCTION__`. @@ -285,6 +292,7 @@ suggestion valide. | D11 | `\` suivi d'espaces dans un `#define` (t13) | corps de macro réécrit, puis retirée | indéterminée (VP gardée) : déclaration écrite par une définition de macro | fixture | 1 | | D14 | correspondance non unique traitée comme une réfutation (`const-suggestion-deduction-refused.cpp`, cas `call`) | retirée | indéterminée (gardée) ; `pick` reste réfutée (condition `if constexpr`) | attente modifiée de la fixture | 1 | | D15 | conversion explicite de l'adresse traitée comme une réfutation (`cross-tu-const-suggestion-cast.c`) | retirée | indéterminée (gardée), sauf erreur ou effet établi | attentes modifiées des deux contrôles run_test.py qui l'utilisent | 1 | +| D16 | `sizeof(__PRETTY_FUNCTION__)` (`const-suggestion-printed-signature.c`, cas `signature_size`) | retirée : le code généré change | indéterminée (gardée) : la différence n'est expliquée ni par R, ni par O, ni par K ; K n'est pas élargi pour conserver l'ancien verdict | attente modifiée de la fixture | 1 | | D6 | `-Werror` du projet et groupe suivi désactivé (t5) | « not checked: does not compile as it is » | vérifiée (VP) | fixture avec `--compile-arg` | 2 | | D8 | définition C, utilisateur C++ via `extern "C"` (t4) | « not checked: the rewritten declaration does not get the suggested type » ; témoin C vérifié | vérifiée (VP) | contrôle run_test.py, avec le témoin | 2 | | D10 | `const` écrit devant un typedef de pointeur (t3/t.c, t3/t.cpp) | réécrit en `const const int *` ; « not checked » | vérifiée (`const int *const`) ; indéterminée si le qualificatif vient d'une macro | fixtures C et C++ | 2 | @@ -304,7 +312,7 @@ l'invocation Clang en double. Les deux coûts ne sont traités que s'ils sont me - **TDD, défaut par défaut.** Chaque test est soumis avec la preuve de son échec avant d'être ajouté. - **Non-régression.** Tous les tests de T v1, sauf les attentes à modifier, elles aussi soumises : - D12, D13, D14 et D15. + D12, D13, D14, D15 et D16. - **Corpus** (`src/`, zlib, tinyxml2, PCRE2). Chaque suggestion est comparée entre trois sorties : sans l'option, T v1 et T v2. Elle est classée vérifiée, gardée indéterminée, ou retirée sur une réfutation établie. Chaque disparition par rapport à la sortie sans l'option est expliquée par sa @@ -328,7 +336,8 @@ acceptation d'un dépassement. 2. **Branche `217-t-v2`**, depuis `2a5b0c1`. T v1 reste une référence immuable (`217-t-v1`, #221, bundle). Après la fusion de P0, `main` actualisé y est intégré et l'ensemble est validé. 3. **Étape 1** : flux, identités, plan et portée, faits R, O et K, correspondances, paires établies - pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14 et D15. + pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14, D15 et + D16. 4. **Étape 2** : D6, D8, D10, D12 et D13. 5. **Étape 3** : les coûts mesurés et l'invocation Clang construite en un seul endroit. 6. **Comparaison des corpus, mesure du coût et README** ; une PR en brouillon. La revue finale porte @@ -342,7 +351,9 @@ restent un changement séparé, hors de ce calendrier. - Sans groupes d'édition de liens, une collision entre fichiers reste indéterminée. - L'ODR n'est pas tranchée. - Seules les configurations fournies par le chargement sont vérifiées. -- Une différence observable que les faits R, O et K ne couvrent pas rend la suggestion - indéterminée, pas réfutée. +- Une suggestion qui change réellement le comportement, d'une façon que R, O et K ne couvrent pas, + reste affichée comme non vérifiée, sans être retirée : par exemple `signature_size`, où + `sizeof(__PRETTY_FUNCTION__)` change. C'est la conséquence de la politique choisie : une + différence d'IR inexpliquée ne suffit plus à retirer une suggestion. - La technique d'identification des constructeurs hérités reste à démontrer. - Un succès du cache IR ne fournit pas d'AST : T compile toujours depuis les sources. From 1ed722e36465acec28de3dd48ca1d2e5a7c9e7cf Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 17:26:14 +0200 Subject: [PATCH 025/152] docs(plan): plan the second version of the const suggestion check --- .../2026-10-16-const-suggestion-check-v2.md | 726 ++++++++++++++++++ 1 file changed, 726 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md new file mode 100644 index 0000000..70a02ca --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -0,0 +1,726 @@ +# Const Suggestion Check v2 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `ParameterDebugBinding` on `main` (P0), then rework T, the experimental Clang check of `ConstParameterNotModified` suggestions, so that each verdict is justified and tied to the exact candidate, units and configurations it checked. + +**Architecture:** P0 binds each IR argument to its own debug declaration: its type, whether the source declares it, and its position among the source parameters. T v2 then runs a fixed flow on `217-t-v2`: initial compilation and planning in the defining file, initial and modified compilations of each unit that sees the change, correspondences established from the AST, a seven-step judgement per unit, aggregation, report. Verdicts are verified, refuted or indeterminate; the IR comparator only gains the established pairs as an input. + +**Tech Stack:** C++20, LLVM and Clang 20 (IR, AST, CodeGen `GlobalDecl`), `run_test.py` fixtures and checks, `stack_usage_analyzer_unit_tests`. + +**Spec:** `docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md`, approved at `8b283a8`. + +**Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. + +**Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype.diff`, `ir/`). + +## Global Constraints + +From the spec: +- T runs only with `--experimental-const-suggestion-check`. Without the option T changes nothing (§4); P0 is the only change to the rule itself, and Task P0.2 measures it. +- Three verdicts (§3): verified (kept, with the "checked …" line), refuted (removed from the public diagnostics), indeterminate (kept, with "not checked with Clang: "). An IR difference that R, O and K do not explain is indeterminate, never refuted. +- Refuted only in a unit whose plan scope is validated, on an introduced compile error, an introduced tracked warning, or a changed R, O or K fact (§5.8, steps 4 to 6). +- Facts are the closed list of §5.4: R (the declaration a call or a reference targets, unevaluated operands and instantiations included), O (what each method named like the entity overrides), K (each `if constexpr` condition, each `_Generic` association). +- Correspondences come from compilation facts only, per unit, between its initial and modified compilations. Heuristics (`withoutConst`, `variantOf`, masked encodings, template keys) have no verdict power (§5.7, §5.9). +- T never assumes that files are linked together and never decides ODR; collisions follow the table of §5.10. +- One candidate per suggestion, built once by the planner, checked and displayed (§3; D12, D13). +- Tracked warning groups: `-W`, then `-Wno-error=`, in the initial and modified compilations (§5.3, stage 2). +- The lines tied to `reliesOnCalleeConst` (#167) stay unchanged. +- Cost criterion: +10 % on the self-analysis, same binary without and with the option, order without, with, with, without (§8). Not accepted in advance. +- Out of scope: several configurations per file, a compilation shared with coretrace-compiler, a persistent cache, link groups, a rework of the comparator. + +T v1 texts stay verbatim, since tests pin them, except the changes that §6 and §7 list: +- `checked in the analyzed files only, with their compile commands: `, `checked in every file of the compilation database that sees the change, with its compile command: `, the sentence that follows them, `not checked with Clang: `; +- the reasons `a file does not compile with Clang as it is`, ` does not compile with Clang as it is`, ` cannot be preprocessed with Clang`, `the declaration is written by a macro definition`, `no source file, so whether the suggested type compiles was not verified`, ` uses the function but has no source`, `the rewritten declaration does not get the suggested type`; +- the trace line `[const-suggestion] parameter : `, with `removed, does not compile: ::` and `removed, gains a checked warning: ::` (`check_const_suggestion_removal_reason`). New trace fields go on further `[const-suggestion]` lines. + +Process: +- TDD. A new test, fixture, `run_test.py` check or unit test, and a changed expectation, are presented with their failure evidence and added only once the user authorizes them. +- Public actions (issue, push, pull request, comment, body edit, alert dismissal) only on the user's explicit go. Bodies are prepared as files and passed with `--body-file`. +- Commits authored by Hugo, conventional subjects, no co-author, no attribution line. `CLAUDE.md` is never committed. +- The user merges. #221 stays a draft and is never merged. T v1 stays an immutable reference: `217-t-v1` at `2a5b0c1`, `coretrace-stack-analyzer-evidence/217/archive/t-v1-2a5b0c1.bundle`. +- Worktrees under `coretrace-stack-analyzer-work/`; evidence under `coretrace-stack-analyzer-evidence/217/`, never in `/tmp`. The untracked files of the main checkout stay as they are. +- Shared Linux machines (WSL): long runs under `nohup`; never signal a process that is not ours; other jobs, containers and images stay as they are. + +Commands (macOS): +- Configure: `cmake -S . -B build -G Ninja -DLLVM_DIR=$(brew --prefix llvm@20)/lib/cmake/llvm -DClang_DIR=$(brew --prefix llvm@20)/lib/cmake/clang -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_ANALYZER_UNIT_TESTS=ON` +- Build: `cmake --build build --target stack_usage_analyzer stack_usage_analyzer_unit_tests ownership_engine_unit_tests -j 8` +- Unit tests: `./build/stack_usage_analyzer_unit_tests . && ./build/ownership_engine_unit_tests` +- Suite: `python3 run_test.py --jobs 8 --no-cache` (build with Z3) +- Format: `$(brew --prefix llvm@20)/bin/clang-format -i ` (20.1.2) + +## Review Focus + +1. **Windows targets (Microsoft C++ ABI).** A constructor of a class with a virtual base takes `is_most_derived` after the source parameters; `main` types it as the last source parameter and reports it (`double *is_most_derived`). It must be artificial, and the other parameters keep their types. Pinned in Task P0.1 (unit test, `--target=x86_64-pc-windows-msvc`). +2. **C++23 explicit object parameter.** Clang marks `self` an object pointer, not artificial, and the source declares it: in `pick(this const Picker& self, int* p)`, `p` has source index 1, and T rewrites `p`. Pinned in Task P0.1 (unit test) and Task 1.2 (`const-suggestion-explicit-object.cpp` unchanged). +3. **A function defined in a header that several analyzed files include** (`static inline` in C, `inline` in C++): one entity in each unit, never a collision with itself, verified when each unit verifies it. Pinned in Task 1.6 (new `run_test.py` check). +4. **A file that the compilation database lists twice, with different commands.** The loader keeps one; T checks only that configuration, never claims the other, and the trace names it. Pinned in Task 1.1 (new `run_test.py` check). +5. **`-Werror=` on a tracked group**, not plain `-Werror`: the group stays a warning in both compilations, and an introduced warning still refutes. Pinned in Task 2.1 (fixture variant). + +--- + +## File structure + +P0, from `main`: + +| File | Responsibility | +|---|---| +| `include/analysis/ParameterDebugBinding.hpp` | `ParameterDebugBinding::sourceIndex`. | +| `src/analysis/ParameterDebugBinding.cpp` | Entry in the subroutine type past the parameters it does not list; artificial from the parameter's own declaration; source index. | +| `test/unit/analyzer_module_unit_tests.cpp` | `testParameterDebugBinding`. | +| `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c` | Its inputs. | +| `test/pointer_reference-const_correctness/virtual-base-vtt.cpp` | What the rule reports. | + +T v2, on `217-t-v2`, starting from T v1's files: + +| File | Responsibility | +|---|---| +| `src/analysis/ConstSuggestionCheck.cpp` | Flow (§5, steps 1 to 5), units and their configurations, judgement, aggregation, report, trace. | +| `src/analysis/ConstSuggestionPlan.cpp` (new) | Planning (§5.5): entity and source identity, parameter, candidate, edits, plan scope. | +| `src/analysis/ConstSuggestionFacts.cpp` (new) | Inspection of one compilation (§5.3, §5.4, §5.6): declarations, R, O and K facts and their keys, defined symbols and linkage, explicit address casts, diagnostics, scope after the rewrite. | +| `src/analysis/ConstSuggestionMatch.cpp` (new) | Correspondences (§5.7) and collisions (§5.10). | +| `src/analysis/ConstSuggestionCode.cpp` | Comparator; one new input, the established pairs (§5.9). | +| `src/analysis/ConstSuggestionInternal.hpp` | Types the files above share. | +| `include/analysis/ConstSuggestionCheck.hpp` | `checkConstSuggestions` unchanged; `rewrittenCodeDifference` takes the pairs. | +| `src/analyzer/DiagnosticEmitter.cpp` | Lines of the three verdicts; displayed candidate (D12, D13). | +| `README.md` | D3 sentence corrected; limits of §10. | + +T v1's `ConstSuggestionCheck.cpp` holds 1 267 lines; the split follows the responsibilities of §5, which require no class or interface of their own. + +--- + +## Stage P0 — `ParameterDebugBinding`, its own issue and pull request from `main` + +Root cause, at `main` `d1d9271`. At `-O0`, `buildDebugParamLayout` indexes the function's `DISubroutineType` with the debug number (`arg:`) of the argument's spilled declaration. That number counts every parameter; the subroutine type lists `this` and a block's descriptor, but not what the C++ ABI adds: the `vtt` of a base-object constructor (C2) of a class with a virtual base, after `this`, or `is_most_derived` under the Microsoft ABI, after the parameters. Artificiality is read from the entry found there, not from the parameter's own declaration. Hence: +- `vtt` gets the type of the first source parameter, and is reported (`int *vtt`); +- the parameters after it get the type of the next one (`double *x` for `int* x`); +- a constructor that calls such a C2 trusts the shifted type as a `const` contract (#167): `Leaf::Leaf(int* p, …) : Writer(p, …)` reports `p` as "passed where a pointer to const is expected" although `Writer` writes through it; +- `is_most_derived` (an `int`) is reported as `double *is_most_derived`; +- a block's `.block_descriptor` is reported as ` *.block_descriptor`. + +Reproductions: `evidence/217/p0/repro/` (`main-d1d9271.txt`, `caller-main-d1d9271.txt`, `block-main-d1d9271.txt`). + +### Task P0.1: bind each parameter to its own declaration + +**Files:** +- Create: `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c` +- Create: `test/pointer_reference-const_correctness/virtual-base-vtt.cpp` +- Modify: `test/unit/analyzer_module_unit_tests.cpp` (includes, new test, its call in `main`) +- Modify: `include/analysis/ParameterDebugBinding.hpp`, `src/analysis/ParameterDebugBinding.cpp` + +**Interfaces:** +- Consumes: `loadModuleForAnalysis` through the unit tests' `loadModuleFromSource`; `AnalysisConfig::extraCompileArgs`. +- Produces, used by Task 1.2: + +```cpp +struct ParameterDebugBinding +{ + // ...existing fields... + // The parameter's position among those the source declares, from 0. None for one the + // source does not declare (`this`, vtt, a block's descriptor), or that -O0 code does not + // spill. + std::optional sourceIndex; + // ...existing fields... +}; +``` + +- [ ] **Step 1: Open the issue and the branch.** After the user's go: an issue labelled `bug`, titled `ConstParameterNotModified: the vtt of a base-object constructor is reported and shifts the parameters after it`, body prepared in `evidence/217/p0/issue-body.md` (root cause and reproductions above). Then: + +```bash +R=/Users/hugopayet/Desktop/CLaude/coretrace-stack-analyzer +git -C $R fetch origin +git -C $R worktree add /Users/hugopayet/Desktop/CLaude/coretrace-stack-analyzer-work/wt--binding -b -parameter-debug-binding origin/main +``` + +- [ ] **Step 2: Write the unit test inputs.** + +`test/unit/parameter_binding_input.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// Parameters for resolveParameterDebugBinding: a free function, a method, the constructors of a +// class with a virtual base, and a method with an explicit object parameter. Compiled with +// -std=c++23, once for each C++ ABI: under the Itanium ABI, the base-object constructor (C2) +// takes a vtt after `this`; under the Microsoft ABI, the constructor takes is_most_derived last. + +struct Base +{ + int seed; +}; + +struct Reader : virtual Base +{ + Reader(int* x, double* y); + long sum(int* p, double* q); +}; + +Reader::Reader(int* x, double* y) +{ + seed = *x + static_cast(*y); +} + +long Reader::sum(int* p, double* q) +{ + return *p + static_cast(*q); +} + +long free_function(int* a, double* b) +{ + return *a + static_cast(*b); +} + +// An explicit object parameter is declared in the source: p is the second parameter. +struct Picker +{ + int pick(this const Picker& self, int* p) + { + return *p; + } +}; + +int use_picker(int* p) +{ + return Picker{}.pick(p); +} +``` + +`test/unit/parameter_binding_block_input.c`: + +```c +// SPDX-License-Identifier: Apache-2.0 +// A block's invoke function takes the block first, as `.block_descriptor`: an artificial +// parameter that, unlike vtt, the debug type of the function lists. Compiled with -fblocks. + +int call_block(int (^block)(int*, double*), int* a, double* b) +{ + return block(a, b); +} + +int use_block(int* a, double* b) +{ + return call_block(^(int* x, double* y) { return *x + (int)*y; }, a, b); +} +``` + +- [ ] **Step 3: Add the unit test, without source indexes.** In `test/unit/analyzer_module_unit_tests.cpp`, add `#include "analysis/ParameterDebugBinding.hpp"` after `#include "analysis/IntRanges.hpp"`, and `#include ` after `#include `. Add, in the anonymous namespace, after `testCompileIRCacheWithoutCompilerRevision`: + +```cpp + /// resolveParameterDebugBinding: each parameter gets its own type. The parameters the source + /// does not declare are artificial: `this`, the vtt that the base-object constructor (C2) of a + /// class with a virtual base takes, is_most_derived under the Microsoft ABI, a block's + /// descriptor. + bool testParameterDebugBinding(const std::filesystem::path& repoRoot, TestReport& report) + { + using namespace ctrace::stack::analysis; + const auto describe = [](const llvm::Function& F) + { + std::vector parameters; + for (const llvm::Argument& Arg : F.args()) + { + const ParameterDebugBinding binding = resolveParameterDebugBinding(F, Arg); + std::string text = binding.name + ": "; + if (binding.isArtificial) + { + text += "artificial"; + } + else + { + const auto* pointer = llvm::dyn_cast_or_null(binding.type); + const llvm::DIType* pointee = pointer ? pointer->getBaseType() : nullptr; + text += (pointee ? pointee->getName().str() : "?") + " *"; + } + parameters.push_back(text); + } + return parameters; + }; + const auto check = [&](const LoadedModule& loaded, const char* symbol, + const std::vector& expected) + { + const llvm::Function* F = loaded.module->getFunction(symbol); + const std::vector actual = F ? describe(*F) : std::vector{}; + std::string text; + for (const std::string& parameter : actual) + text += " [" + parameter + "]"; + report.expect(actual == expected, + std::string("ParameterDebugBinding: ") + symbol + text); + }; + const auto load = + [&](const char* input, std::vector compileArgs, LoadedModule& out) + { + ctrace::stack::AnalysisConfig config; + config.extraCompileArgs = std::move(compileArgs); + std::string loadError; + if (loadModuleFromSource(repoRoot / "test/unit" / input, config, out, loadError)) + return true; + report.expect(false, + "ParameterDebugBinding setup: failed to load module: " + loadError); + return false; + }; + + LoadedModule itanium; + LoadedModule microsoft; + LoadedModule blocks; + if (!load("parameter_binding_input.cpp", {"-std=c++23"}, itanium) || + !load("parameter_binding_input.cpp", {"-std=c++23", "--target=x86_64-pc-windows-msvc"}, + microsoft) || + !load("parameter_binding_block_input.c", {"-fblocks"}, blocks)) + return false; + check(itanium, "_Z13free_functionPiPd", {"a: int *", "b: double *"}); + check(itanium, "_ZN6Reader3sumEPiPd", {"this: artificial", "p: int *", "q: double *"}); + check(itanium, "_ZN6ReaderC1EPiPd", {"this: artificial", "x: int *", "y: double *"}); + check(itanium, "_ZN6ReaderC2EPiPd", + {"this: artificial", "vtt: artificial", "x: int *", "y: double *"}); + check(itanium, "_ZNH6Picker4pickERKS_Pi", {"self: artificial", "p: int *"}); + check(microsoft, "??0Reader@@QEAA@PEAHPEAN@Z", + {"this: artificial", "x: int *", "y: double *", "is_most_derived: artificial"}); + check(blocks, "__use_block_block_invoke", + {".block_descriptor: artificial", "x: int *", "y: double *"}); + + // What the rule displays for x does not depend on the constructor variant. + const llvm::Function* complete = itanium.module->getFunction("_ZN6ReaderC1EPiPd"); + const llvm::Function* base = itanium.module->getFunction("_ZN6ReaderC2EPiPd"); + if (complete && base) + { + const ParameterDebugBinding inComplete = + resolveParameterDebugBinding(*complete, *complete->getArg(1)); + const ParameterDebugBinding inBase = + resolveParameterDebugBinding(*base, *base->getArg(2)); + report.expect(inComplete.name == inBase.name && inComplete.type == inBase.type && + inComplete.line == inBase.line && + inComplete.column == inBase.column && + inComplete.confidence == inBase.confidence, + "ParameterDebugBinding: x binds alike in C1 and C2"); + } + return report.failures == 0; + } +``` + +In `main`, after `(void)testLinkerSymbolName(report);`, add `(void)testParameterDebugBinding(repoRoot, report);`. + +- [ ] **Step 4: Add the fixture.** `test/pointer_reference-const_correctness/virtual-base-vtt.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// The base-object constructor (C2) of a class with a virtual base takes a vtt after `this`: a +// parameter that the source does not declare and the debug type of the function does not list. +// ConstParameterNotModified must not report it, nor give a parameter after it the type of the +// next one, in that constructor or in the constructors that call it. + +struct Base +{ + int seed; +}; + +struct Reader : virtual Base +{ + Reader(int* x, double* y); +}; + +// x is only read: both variants, C1 and C2, report it with its own type. +// not contains: current type: double *x +// at line 24, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'Reader::Reader(int*, double*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +Reader::Reader(int* x, double* y) +{ + seed = *x; + *y = 0; +} + +// Writer writes through p, so Leaf, which passes its own p there, must not report it. +struct Writer : virtual Base +{ + Writer(int* p, const double* q); +}; + +struct Leaf : Writer +{ + Leaf(int* p, const double* q); +}; + +Writer::Writer(int* p, const double* q) +{ + *p = static_cast(*q); +} + +// not contains: parameter 'p' in function 'Leaf::Leaf(int*, double const*)' +Leaf::Leaf(int* p, const double* q) : Writer(p, q) {} + +// not contains: parameter 'vtt' +``` + +`// not contains:` lines must not follow an `// at line` block directly: `run_test.py` would read them as part of the block. + +- [ ] **Step 5: Build and run, expect RED.** + +Run: the build command, `./build/stack_usage_analyzer_unit_tests . 2>&1 | grep ParameterDebugBinding`, then `python3 run_test.py --jobs 8 --no-cache 2>&1 | grep -A3 virtual-base-vtt`. + +Expected (recorded on `d1d9271` in `evidence/217/p0/harness/red-unit-runtime-main.txt` and `evidence/217/p0/fixture/red-fixture-main.txt`): + +``` +[FAIL] ParameterDebugBinding: _ZN6ReaderC2EPiPd [this: artificial] [vtt: int *] [x: double *] [y: double *] +[FAIL] ParameterDebugBinding: ??0Reader@@QEAA@PEAHPEAN@Z [this: artificial] [x: int *] [y: double *] [is_most_derived: double *] +[FAIL] ParameterDebugBinding: __use_block_block_invoke [.block_descriptor: ? *] [x: int *] [y: double *] +[FAIL] ParameterDebugBinding: x binds alike in C1 and C2 +``` + +The free function, the method, C1 and `Picker::pick` pass. The fixture finds its three forbidden texts (`current type: double *x`, Leaf's `p`, `vtt`) in both passes, default and smt-z3. + +- [ ] **Step 6: Commit.** + +```bash +git add test/unit/analyzer_module_unit_tests.cpp test/unit/parameter_binding_input.cpp test/unit/parameter_binding_block_input.c test/pointer_reference-const_correctness/virtual-base-vtt.cpp +git commit -m "test(const-param): an artificial parameter is neither reported nor shifts the others" +``` + +- [ ] **Step 7: Bind each parameter to its own declaration.** In `src/analysis/ParameterDebugBinding.cpp`: + +Add `#include ` before `#include `. Replace `DebugParamLayout` with: + +```cpp + struct DebugParamLayout + { + const llvm::DISubroutineType* subroutineType = nullptr; + bool hasCanonicalDebugIndex = false; + bool isHiddenAbiArg = false; + // Make alignment intent explicit before the 32-bit indexes. + std::uint16_t paddingBeforeDebugArgIndex = 0; + unsigned debugArgIndex = 0; // The debug parameter number (arg:): `this` and vtt count. + unsigned typeIndex = 0; // Its entry in the subroutine type, which omits vtt. + std::uint32_t paddingTail = 0; + }; +``` + +Add before `buildDebugParamLayout`: + +```cpp + // The entry of @p param in the subroutine type, or 0 when the type does not list it. + // Clang marks artificial the parameters that the source does not declare. The type lists + // `this` and a block's descriptor, but not what the C++ ABI adds after `this`, like the vtt + // of a constructor of a class with a virtual base: the artificial parameters other than + // `this` are unlisted when the type lacks exactly that many entries. + static unsigned subroutineTypeIndex(const llvm::Function& F, + const llvm::DILocalVariable& param, std::size_t listed) + { + const auto artificialOtherThanThis = [](const llvm::DILocalVariable& var) + { return var.isArtificial() && !var.isObjectPointer(); }; + unsigned last = 0; + unsigned artificial = 0; + unsigned artificialBefore = 0; + for (const llvm::Argument& other : F.args()) + { + const llvm::DILocalVariable* var = spilledParameter(other); + if (!var) + continue; + last = std::max(last, var->getArg()); + if (!artificialOtherThanThis(*var)) + continue; + ++artificial; + if (var->getArg() < param.getArg()) + ++artificialBefore; + } + if (artificial == 0 || last != listed + artificial) + return param.getArg(); + return artificialOtherThanThis(param) ? 0 : param.getArg() - artificialBefore; + } +``` + +In `buildDebugParamLayout`, replace the spilled branch with: + +```cpp + if (const llvm::DILocalVariable* spilled = spilledParameter(Arg); + spilled && spilled->getArg() != 0) + { + const unsigned typeIndex = subroutineTypeIndex(F, *spilled, typeArray.size() - 1); + if (typeIndex == 0) + { + layout.isHiddenAbiArg = true; + return layout; + } + if (typeIndex < typeArray.size()) + { + layout.debugArgIndex = spilled->getArg(); + layout.typeIndex = typeIndex; + layout.hasCanonicalDebugIndex = true; + return layout; + } + } +``` + +and, at the end of the function, after `layout.debugArgIndex = debugParamOffset + 1;`, add `layout.typeIndex = layout.debugArgIndex;`. + +In `resolveParameterDebugBinding`, replace `if (layout.isHiddenAbiArg)` with: + +```cpp + // The declaration of the spilled parameter says whether the source declares it. + const llvm::DILocalVariable* spilled = spilledParameter(Arg); + if (layout.isHiddenAbiArg || isSyntheticDebugVariable(spilled)) +``` + +and in the fallback on the subroutine type, index `typeArray` with `layout.typeIndex` instead of `layout.debugArgIndex` (the bound check and the subscript). + +- [ ] **Step 8: Build and run, expect GREEN.** The eight `ParameterDebugBinding` checks pass; the fixture passes in both passes; `./build/stack_usage_analyzer_unit_tests .` ends with `All analyzer module unit tests passed.` + +- [ ] **Step 9: Commit.** + +```bash +git add src/analysis/ParameterDebugBinding.cpp +git commit -m "fix(const-param): bind each parameter to its own debug declaration" +``` + +- [ ] **Step 10: Expect source indexes in the unit test.** In `describe`, after the `if`/`else`, add: + +```cpp + text += ", source " + + (binding.sourceIndex ? std::to_string(*binding.sourceIndex) : "none"); +``` + +Replace the expectations with: + +```cpp + check(itanium, "_Z13free_functionPiPd", {"a: int *, source 0", "b: double *, source 1"}); + check(itanium, "_ZN6Reader3sumEPiPd", + {"this: artificial, source none", "p: int *, source 0", "q: double *, source 1"}); + check(itanium, "_ZN6ReaderC1EPiPd", + {"this: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); + check(itanium, "_ZN6ReaderC2EPiPd", + {"this: artificial, source none", "vtt: artificial, source none", + "x: int *, source 0", "y: double *, source 1"}); + check(itanium, "_ZNH6Picker4pickERKS_Pi", + {"self: artificial, source 0", "p: int *, source 1"}); + check(microsoft, "??0Reader@@QEAA@PEAHPEAN@Z", + {"this: artificial, source none", "x: int *, source 0", "y: double *, source 1", + "is_most_derived: artificial, source none"}); + check(blocks, "__use_block_block_invoke", + {".block_descriptor: artificial, source none", "x: int *, source 0", + "y: double *, source 1"}); +``` + +and the comment above the test with: + +```cpp + /// resolveParameterDebugBinding: each parameter gets its own type, and its position among the + /// parameters the source declares. The others are artificial: `this`, the vtt that the + /// base-object constructor (C2) of a class with a virtual base takes, is_most_derived under the + /// Microsoft ABI, a block's descriptor. +``` + +`self` stays artificial, as on `main`: Clang marks it an object pointer, which the binding has always treated as synthetic. Its source index is 0 all the same, since the source declares it. + +- [ ] **Step 11: Build, expect RED.** Expected: `error: no member named 'sourceIndex' in 'ctrace::stack::analysis::ParameterDebugBinding'` (twice; recorded in `evidence/217/p0/harness/red-unit-compile-main.txt`). + +- [ ] **Step 12: Give each parameter its source index.** In `include/analysis/ParameterDebugBinding.hpp`, add `#include ` after `#include `, and after `unsigned column = 0;`: + +```cpp + // The parameter's position among those the source declares, from 0. None for one the + // source does not declare (`this`, vtt, a block's descriptor), or that -O0 code does not + // spill. + std::optional sourceIndex; +``` + +The struct keeps no implicit padding: `std::optional` takes 8 bytes at a 4-byte boundary, after `column`. + +In `src/analysis/ParameterDebugBinding.cpp`, add `#include ` after `#include `, and after `isSyntheticDebugVariable`: + +```cpp + // The position of @p param among the parameters the source declares: the artificial ones + // before it (`this`, vtt, a block's descriptor) are not declared. An explicit object + // parameter is: Clang marks it an object pointer, not artificial. + static std::optional sourceIndexOf(const llvm::Function& F, + const llvm::DILocalVariable& param) + { + unsigned artificialBefore = 0; + for (const llvm::Argument& other : F.args()) + { + const llvm::DILocalVariable* var = spilledParameter(other); + if (var && var->getArg() < param.getArg() && var->isArtificial()) + ++artificialBefore; + } + if (artificialBefore >= param.getArg()) + return std::nullopt; + return param.getArg() - 1 - artificialBefore; + } +``` + +In `resolveParameterDebugBinding`, before `finalizeBindingName(binding, Arg);`: + +```cpp + if (spilled && !spilled->isArtificial()) + binding.sourceIndex = sourceIndexOf(F, *spilled); +``` + +- [ ] **Step 13: Build and run, expect GREEN.** The eight checks pass with their source indexes (recorded on the prototype in `evidence/217/p0/harness/green-unit-prototype.txt`). + +- [ ] **Step 14: Format, full suite, warnings.** Run clang-format on the five C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed. + +- [ ] **Step 15: Commit.** + +```bash +git add include/analysis/ParameterDebugBinding.hpp src/analysis/ParameterDebugBinding.cpp test/unit/analyzer_module_unit_tests.cpp +git commit -m "feat(analysis): give a bound parameter its position among the source's" +``` + +This commit carries its test with its code, so that every commit builds; its failure evidence is the compile error of Step 11. + +**Validation of P0.1:** +- `testParameterDebugBinding`: 8 checks pass; both unit test executables pass. +- `virtual-base-vtt.cpp` passes in the default and smt-z3 passes. +- The suite passes on macOS with no expectation changed; no new warning; clang-format clean. + +### Task P0.2: measure the effect without the option, then deliver + +Expected effect on the rule, without the option: +- base-object constructors (C2, and CI2 for inherited ones) of a class with a virtual base: no suggestion on `vtt`; the parameters after it are reported with their own type, so a suggestion that the shifted type hid can appear, and one it caused disappears; +- constructors that call such a C2: the #167 contract reads the callee's real parameter types; a false "passed where a pointer to const is expected" disappears, and a true one can appear; +- Microsoft ABI targets: no suggestion on `is_most_derived`; +- blocks (`-fblocks`, the default on macOS): no suggestion on `.block_descriptor`; +- nothing else: line, column, confidence and name of every other parameter unchanged; the other rules use `spilledParameter` and `declaredVariable`, which do not change. + +- [ ] **Step 1: Compare every fixture's output.** + +```bash +D=/Users/hugopayet/Desktop/CLaude/coretrace-stack-analyzer-evidence/217/p0/effect +python3 $D/diff_fixture_outputs.py
$D/fixtures-branch +``` + +Expected: `changed: 0` among `main`'s fixtures (the script normalizes the logger's process id). The prototype gave `fixtures: 424, changed: 0`. + +- [ ] **Step 2: Linux (WSL), suite and corpora.** Build `main` and the branch; run the suite; run both binaries without the option on the self-analysis as CI does (4 chunks, 2 at a time, fast profile, cold caches), zlib `767c4c9`, tinyxml2 `8224e42` and PCRE2 `3e4036e` (without the #215 units). Expected: identical outputs; none of these corpora compiles a class with a virtual base or a block (zlib's `contrib/iostream` is not in its compilation database). The self-analysis time is reported as an indication. + +- [ ] **Step 3: Pull request.** After the user's go: push, pull request assigned to `SizzleUnrlsd`, label `bug`, `Fixes #`, body prepared in `evidence/217/p0/pr-body.md` (root cause, effect without the option, measurements). CI; code scanning triaged as `CLAUDE.md` describes, summary in the body. The user merges. + +**Validation of P0.2:** no existing fixture output changes; corpora identical without the option; CI green; triage summarized; the effect stated in the body matches the measurements. + +### Task I.1: integrate `main` into `217-t-v2`, once P0 is merged + +- [ ] **Step 1:** In `wt-217-v2`, `git fetch origin && git merge origin/main`. Conflicts expected in `src/analysis/ConstParamAnalysis.cpp`: keep #221's `issue.debugArgNo` until Task 1.2 removes it. +- [ ] **Step 2:** Build, both unit test executables, suite (T fixtures carry `// analyzer-arg: --experimental-const-suggestion-check`), on macOS and Linux. +- [ ] **Step 3:** Replay `evidence/217/final-review/repro.sh`; record which review cases change because of P0 (expected: `t10` loses its `vtt` suggestion). +- [ ] **Step 4:** Commit the merge (`Merge origin/main into 217-t-v2`). + +**Validation of I.1:** every test of #221 passes; only the differences that P0 explains in the review cases. + +--- + +## Stage 1 — flow, identities, plan and scope, facts, correspondences, collisions, trace (§9.3) + +Catalogue: D1 (T part), D2, D3, D4, D5, D7, D11, D14, D15, D16. Tests are submitted with their failure evidence at the start of each task. + +**Open point for the user (found while planning).** §5.10 makes a stale declaration indeterminate: "Une unité qui déclare l'entité avec l'ancien type sans voir la réécriture rend la suggestion indéterminée". T v1 removes that suggestion, and two checks pin it: `check_const_suggestion_cross_tu` (`("def and a redeclaration", ["def.c", "redecl.c"], [])`) and `check_const_suggestion_compilation_database` (`("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None)`). These two expectations change with §5.10, but §7 does not list them. Proposed: D17, "redeclaration with the old type in another file", indeterminate, the reason naming the file and the link-edition assumption; both expectations submitted with Task 1.6. To confirm before Task 1.6. + +Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from §3): +- `enum class Verdict : std::uint8_t { Verified, Refuted, Indeterminate };` and `struct Judgement { Verdict verdict; std::string reason; }`; +- `struct UnitConfiguration { std::string file; UnitCommand command; std::string fingerprint; }`, the fingerprint hashing the normalized arguments and working directory; +- `struct DeclarationSite { std::string file; unsigned offset; }` (real path, offset in the original text); +- `enum class SourceLinkage : std::uint8_t { None, Internal, External };` and `struct EntityIdentity { std::vector redeclarations; SourceLinkage linkage; std::optional templateOrigin; }`; +- `struct Candidate { std::string type; std::string alternative; }` (§3 table); +- `struct SuggestionPlan { EntityIdentity entity; unsigned sourceIndex; Candidate candidate; std::map> edits; std::optional indeterminate; }`; +- `struct FactKey { std::string unit; std::vector instantiationContexts; DeclarationSite spelling; DeclarationSite expansion; }`, `enum class FactKind { Reference, Override, Constant }`, `struct Fact { FactKind kind; FactKey key; std::string value; }`; +- `struct SymbolPair { std::string before; std::string after; }`. + +### Task 1.1: units, verdicts, aggregation, report + +**Files:** `ConstSuggestionCheck.cpp`, `ConstSuggestionInternal.hpp`, `DiagnosticEmitter.cpp`. +**Interfaces:** produces `Verdict`, `Judgement`, `UnitConfiguration`, `aggregate(const std::vector&) -> Judgement` (§5.10: refuted if a unit refutes; else indeterminate if a unit or a collision is; else verified). +**Work:** one unit per file and configuration that the loader provides; the units that see the change as §5.2 defines them, from T v1's scan; a unit without a command, that cannot be preprocessed or does not compile as it is, unverifiable (judgement step 1); initial compilations cached by unit and entity and shared by every suggestion (§5, step 3); T v1's outcomes mapped onto the three verdicts with T v1's texts; the trace names each unit's file and configuration fingerprint on its own `[const-suggestion]` line. +**Tests:** Review Focus 4, a new `run_test.py` check (a compilation database that lists `cross-tu-const-suggestion-def.c` twice, with and without `-DREAD_TWICE`; the trace names one configuration for that file; the "checked" line stays T v1's). +**Validation:** every T v1 test passes unchanged; the new check passes. + +### Task 1.2: the parameter from P0 + +**Files:** `ConstSuggestionCheck.cpp` (or `ConstSuggestionPlan.cpp` once Task 1.3 creates it), `ConstParamAnalysis.cpp` (#221's `debugArgNo` removed). +**Interfaces:** consumes `ParameterDebugBinding::sourceIndex` (Task P0.1). +**Work:** the parameter is `issue.binding.sourceIndex`; none, or an artificial binding, gives indeterminate (`the parameter is not one the source declares`, `the parameter's position in the source is unknown`). +**Tests:** D1, a new fixture `const-suggestion-virtual-base.cpp` (t10: `x` verified in C1 and C2; at I.1, T rewrites `y` for the suggestion on `x` in C2); Review Focus 2: `const-suggestion-explicit-object.cpp` unchanged. +**Validation:** D1 passes; explicit-object unchanged; every T v1 test passes. + +### Task 1.3: planning and plan scope (§5.5) + +**Files:** `ConstSuggestionPlan.cpp` (new; T v1's `Planner`, `writtenType`, `expectedType`, `inMacroDefinition` move here), `CMakeLists.txt` (new sources). +**Interfaces:** produces `std::optional plan(clang::ASTContext&, clang::Preprocessor&, const ConstParamIssue&, llvm::StringRef symbol)`. +**Work:** the entity located by the suggestion's symbol through CodeGen's mangling of each candidate declaration's `GlobalDecl` variants, then described by its source identity; the candidate built on the structured type; edits on each redeclaration's `TypeLoc` under the macro rules of §5.5, macro definitions recognized by the preprocessor (D11); the plan scope (D5). +**Tests:** D5, a new fixture (t3/m.c: a type written in a macro argument that `src` and `dst` share, indeterminate, the suggestion kept); D11, a new `run_test.py` case (t13: `\` followed by spaces, indeterminate); `const-suggestion-macros.c`, `const-suggestion-macro-params.c` and `check_const_suggestion_line_endings` unchanged. +**Validation:** D5 and D11 pass; T v1 macro tests unchanged. + +### Task 1.4: compilations, facts R, O and K, scope after the rewrite (§5.3, §5.4, §5.6), judgement steps 1 to 6 + +**Files:** `ConstSuggestionFacts.cpp` (new; T v1's `Inspector`, `InspectAction`, `compile`, `scan` move here). +**Interfaces:** produces `struct UnitFacts { bool complete; std::vector facts; std::map defined; std::vector addressCasts; std::vector diagnostics; std::shared_ptr code; std::vector entityDeclarations; }` and `UnitFacts inspect(const UnitConfiguration&, const std::map& remapped, const SuggestionPlan&)`. +**Work:** facts keyed by unit, instantiation contexts, spelling and expansion positions mapped back through the plan's edits; scope after the rewrite on source-written declarations only (instantiated signatures go to Task 1.5); an incomplete inspection gives an unknown scope; judgement steps 1 to 6. +**Tests:** D2, a new `run_test.py` check (t1: a member operator used in another file, verified in both files); `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `pick` in `const-suggestion-deduction-refused.cpp`, `generic_selection` in `const-suggestion-must-compile.c` unchanged. +**Validation:** D2 passes; the refutations by R, O and K of T v1's tests unchanged. + +### Task 1.5: correspondences and the comparator's pairs (§5.7, §5.9), judgement step 7 + +**Files:** `ConstSuggestionMatch.cpp` (new), `ConstSuggestionCode.cpp`, `include/analysis/ConstSuggestionCheck.hpp`. +**Interfaces:** produces `std::vector correspond(const UnitFacts& before, const UnitFacts& after, const SuggestionPlan&, std::string& missing)`; `codeDifference` gains `const std::vector& established`. +**Work:** the entity's variants by `GlobalDecl`; nested entities (lambdas, local statics, local classes, RTTI names); instantiation contexts whose arguments change, paired under the four conditions of §5.7; inherited constructors through `getInheritedConstructor()`, adopted only once shown on t8 and a template case; verified requires every appearing or disappearing symbol to be covered by an established pair; the heuristics only place a difference in the trace. +**Tests:** D7, a new fixture (t8: verified if the technique is shown, otherwise indeterminate, never removed); D14, changed expectation of `call` in `const-suggestion-deduction-refused.cpp` (indeterminate, kept); D16, changed expectation of `signature_size` in `const-suggestion-printed-signature.c` (indeterminate, kept); `const-suggestion-kept*.cpp`, `const-suggestion-abi*.cpp`, `const-suggestion-deduction-kept.cpp`, `print_and_read` unchanged. +**Validation:** D7, D14 and D16 pass; every T v1 "checked" stays verified. + +### Task 1.6: collisions, stale declarations, link-edition assumption (§5.10) + +**Files:** `ConstSuggestionMatch.cpp`, `README.md`. +**Work:** the linkage table of §5.10; a same entity defined in several units is no collision; a stale declaration gives indeterminate (D17, pending); every reason that depends on linking states the assumption and its scope. +**Tests:** D3, a new `run_test.py` check (t2: C `static` and external homonyms, both directions, verified; the README sentence corrected); D4, a new `run_test.py` check (t9: `f(const int*)` defined in another file, indeterminate with the assumption, files analyzed together and the other file only in the database); Review Focus 3, a new `run_test.py` check (a header defining `static inline` C and `inline` C++ functions, included by two analyzed files, verified); D17, the two changed expectations, if confirmed; the static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` unchanged. +**Validation:** D3, D4 and Review Focus 3 pass. + +### Task 1.7: explicit address casts (D15) + +**Files:** `ConstSuggestionFacts.cpp`, `ConstSuggestionCheck.cpp`. +**Work:** a cast of the entity's address is recorded and named; alone, it gives indeterminate. +**Tests:** changed expectations: `("def and an explicit cast", ["def.c", "cast.c"], ["read_value"])` in `check_const_suggestion_cross_tu`, and `("an explicit cast, not analyzed", …)` expecting a "not checked with Clang" reason naming `cross-tu-const-suggestion-cast.c` in `check_const_suggestion_compilation_database`. +**Validation:** both checks pass with their new expectations. + +### Task 1.8: trace and README + +**Files:** `ConstSuggestionCheck.cpp`, `README.md`. +**Work:** the trace of §4 for each verdict (rule, function and symbol, parameter by name and source index, transformation, unit and configuration, verdict and reason), on `[const-suggestion]` lines after T v1's; the README describes the three verdicts and the limits of §10. +**Tests:** `check_const_suggestion_removal_reason` unchanged; a new check of the trace fields on one verified, one refuted and one indeterminate suggestion. +**Validation:** both pass. + +**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16 and Review Focus 2 to 4 pass; every T v1 test passes, except the changed expectations of D14, D15, D16 (and D17 if confirmed); suite without and with the option, unit tests, on macOS and Linux; review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. + +## Stage 2 — D6, D8, D10, D12, D13 + +### Task 2.1: warning policy (D6) +**Work:** `-W` then `-Wno-error=` for each tracked group, in both compilations. +**Tests:** D6, a new fixture (t5) with `// analyzer-arg: --compile-arg=-Werror` and a tracked group disabled, verified; Review Focus 5, a variant with `--compile-arg=-Werror=incompatible-pointer-types-discards-qualifiers` where an introduced warning still refutes. + +### Task 2.2: the unit's language (D8) +**Work:** the targeted redeclarations compared with the candidate in the language of each unit. +**Tests:** D8, a new `run_test.py` check (t4: C definition, C++ user through an `extern "C"` header, verified; the C witness unchanged). + +### Task 2.3: typedef with a qualifier (D10) +**Work:** a pointer typedef receives the printed candidate, a separately written qualifier is folded in; one written by a macro gives indeterminate. +**Tests:** D10, new fixtures in C and C++ (t3/t.c, t3/t.cpp: verified with `const int *const`). + +### Task 2.4: one candidate, displayed (D12, D13) +**Work:** the displayed type and the "checked" line name the candidate: `const T &` for `ReferenceRvaluePreferValue` (passing by value is not checked), `const int *const p` for `PointerConstOnly`. +**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, the only T fixture with that rule; D13, a new fixture (t15: `int* const p` only read, displayed and verified as `const int *const p`). No T v1 fixture covers `PointerConstOnly`, so D13 adds a fixture where §6 expected changed expectations. + +**Validation of stage 2:** as stage 1, for D6, D8, D10, D12, D13 and Review Focus 5. + +## Stage 3 — measured costs, one Clang invocation + +### Task 3.1: measure +**Work:** the `const_suggestion.*` hotspots of `--timing` on the self-analysis with the option, as CI runs it; the per-suggestion copy of each unit's scan, the recompilation of a failing initial compilation, the duplicated construction of the invocation, each measured. +**Validation:** a measurement report in `evidence/217/v2-cost/`; only measurable costs go to Task 3.2. + +### Task 3.2: remove the measured costs; build the invocation once +**Work:** shared scans instead of copies; a failing initial compilation cached as failing; one function builds the Clang invocation for scanning and compiling, reusing the existing path normalization. +**Validation:** outputs identical before and after the stage (suite, review cases, corpora with the option); the gain measured. + +## Final phase + +### Task F.1: corpora +**Work:** on Linux, `src/`, zlib, tinyxml2 and PCRE2, three outputs: without the option, T v1, T v2. Each suggestion classified verified, kept indeterminate, or removed on an established refutation. Each disappearance against the output without the option is explained by its violation; each difference with T v1 is explained. + +### Task F.2: cost +**Work:** the protocol of §8. The criterion is +10 % on the self-analysis. + +### Task F.3: delivery +**Work:** README; a draft pull request after the user's go; CI; code scanning triaged; final review on the updated base. No merge. + +**Validation of the final phase:** every classification explained; cost reported against the criterion; CI green; triage summarized. From a0e3d4880f4d63a956d358fac090473d9f13eac9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 17:51:04 +0200 Subject: [PATCH 026/152] docs(plan): apply the review of the const suggestion check plan Count the parameters of a variadic function without its trailing entry, read the declared parameters from the debug declarations, and leave an unestablished layout without a type. Share each unit's initial compilation across entities. Record D17 and the fixture that D13 adds. --- .../2026-10-16-const-suggestion-check-v2.md | 362 +++++++++++++++--- ...-10-16-const-suggestion-check-v2-design.md | 16 +- 2 files changed, 323 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 70a02ca..fd89482 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -12,7 +12,9 @@ **Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. -**Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype.diff`, `ir/`). +**Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype-v1.diff`, `prototype-v2.diff`, `ir/`, `variadic/`). + +**Execution:** native (superpowers:executing-plans), as the user chose; an independent review of P0 before its merge (Task P0.2). Progress ledger: `wt-217-v2/.superpowers/sdd/2026-10-16-const-suggestion-check-v2/progress.md`. ## Global Constraints @@ -51,7 +53,7 @@ Commands (macOS): ## Review Focus -1. **Windows targets (Microsoft C++ ABI).** A constructor of a class with a virtual base takes `is_most_derived` after the source parameters; `main` types it as the last source parameter and reports it (`double *is_most_derived`). It must be artificial, and the other parameters keep their types. Pinned in Task P0.1 (unit test, `--target=x86_64-pc-windows-msvc`). +1. **Windows targets (Microsoft C++ ABI).** A constructor of a class with a virtual base takes `is_most_derived` after the source parameters, or second when it is variadic; `main` types it as the last source parameter and reports it (`double *is_most_derived`). It must be artificial, and the other parameters keep their types. Pinned in Task P0.1 (unit test, `--target=x86_64-pc-windows-msvc`). 2. **C++23 explicit object parameter.** Clang marks `self` an object pointer, not artificial, and the source declares it: in `pick(this const Picker& self, int* p)`, `p` has source index 1, and T rewrites `p`. Pinned in Task P0.1 (unit test) and Task 1.2 (`const-suggestion-explicit-object.cpp` unchanged). 3. **A function defined in a header that several analyzed files include** (`static inline` in C, `inline` in C++): one entity in each unit, never a collision with itself, verified when each unit verifies it. Pinned in Task 1.6 (new `run_test.py` check). 4. **A file that the compilation database lists twice, with different commands.** The loader keeps one; T checks only that configuration, never claims the other, and the trace names it. Pinned in Task 1.1 (new `run_test.py` check). @@ -91,19 +93,21 @@ T v1's `ConstSuggestionCheck.cpp` holds 1 267 lines; the split follows the respo ## Stage P0 — `ParameterDebugBinding`, its own issue and pull request from `main` -Root cause, at `main` `d1d9271`. At `-O0`, `buildDebugParamLayout` indexes the function's `DISubroutineType` with the debug number (`arg:`) of the argument's spilled declaration. That number counts every parameter; the subroutine type lists `this` and a block's descriptor, but not what the C++ ABI adds: the `vtt` of a base-object constructor (C2) of a class with a virtual base, after `this`, or `is_most_derived` under the Microsoft ABI, after the parameters. Artificiality is read from the entry found there, not from the parameter's own declaration. Hence: +Root cause, at `main` `d1d9271`. At `-O0`, `buildDebugParamLayout` indexes the function's `DISubroutineType` with the debug number (`arg:`) of the argument's spilled declaration. That number counts every parameter; the subroutine type lists `this` and a block's descriptor, but not what the C++ ABI adds: the `vtt` of a base-object constructor (C2) of a class with a virtual base, after `this`, or `is_most_derived` under the Microsoft ABI, last, or second in a variadic constructor. Artificiality is read from the entry found there, not from the parameter's own declaration. Hence: - `vtt` gets the type of the first source parameter, and is reported (`int *vtt`); - the parameters after it get the type of the next one (`double *x` for `int* x`); - a constructor that calls such a C2 trusts the shifted type as a `const` contract (#167): `Leaf::Leaf(int* p, …) : Writer(p, …)` reports `p` as "passed where a pointer to const is expected" although `Writer` writes through it; - `is_most_derived` (an `int`) is reported as `double *is_most_derived`; - a block's `.block_descriptor` is reported as ` *.block_descriptor`. +Two counts make the correction harder than an offset. A variadic function's subroutine type ends with a null entry for `...`, which no parameter takes: counted as a parameter, it shifts `x` and `y` again in `D(int* x, double* y, ...)`. An empty parameter takes no IR argument but keeps its debug declaration and its number: counting the spilled arguments misses it. The number of declared parameters is therefore read from the debug declarations, and a layout that the counts do not establish leaves the parameter without a type, never with a guessed one. + Reproductions: `evidence/217/p0/repro/` (`main-d1d9271.txt`, `caller-main-d1d9271.txt`, `block-main-d1d9271.txt`). ### Task P0.1: bind each parameter to its own declaration **Files:** -- Create: `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c` +- Create: `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c`, `test/unit/parameter_binding_unknown_input.ll` - Create: `test/pointer_reference-const_correctness/virtual-base-vtt.cpp` - Modify: `test/unit/analyzer_module_unit_tests.cpp` (includes, new test, its call in `main`) - Modify: `include/analysis/ParameterDebugBinding.hpp`, `src/analysis/ParameterDebugBinding.cpp` @@ -374,9 +378,213 @@ git add test/unit/analyzer_module_unit_tests.cpp test/unit/parameter_binding_inp git commit -m "test(const-param): an artificial parameter is neither reported nor shifts the others" ``` -- [ ] **Step 7: Bind each parameter to its own declaration.** In `src/analysis/ParameterDebugBinding.cpp`: +- [ ] **Step 7: Add the cases submitted after the plan's review, once the user authorizes them.** A variadic function's debug type ends with a null entry for `...`; an empty parameter takes no IR argument but keeps its debug declaration; a variadic function can return a struct through a hidden argument; and a layout that the debug information does not establish must leave the parameter without a type. + +Append to `test/unit/parameter_binding_input.cpp`: + +```cpp +// A variadic constructor: the debug type of the function ends with a null entry for `...`, which +// no parameter takes. Under the Microsoft ABI, is_most_derived comes second. +struct Varargs : virtual Base +{ + Varargs(int* x, double* y, ...); +}; + +Varargs::Varargs(int* x, double* y, ...) +{ + seed = *x + static_cast(*y); +} + +// An empty parameter takes no IR argument, but keeps its debug declaration and its number. +struct Tag +{ +}; + +struct Tagged : virtual Base +{ + Tagged(int* x, Tag tag); +}; + +Tagged::Tagged(int* x, Tag tag) +{ + (void)tag; + seed = *x; +} + +// A variadic function that returns a struct through a hidden argument. +struct Big +{ + long values[8]; +}; + +Big make_big(int* p, ...) +{ + Big big{}; + big.values[0] = *p; + return big; +} +``` + +Create `test/unit/parameter_binding_unknown_input.ll`: + +```llvm +; SPDX-License-Identifier: Apache-2.0 +; An artificial parameter other than `this`, in a function whose debug type lists three parameters: +; neither all the declared ones (2) nor all but the artificial one (1). Where the type lists x is not +; established, so x takes no type from it. + +define void @unestablished(ptr %hidden, ptr %x) !dbg !10 { +entry: + %hidden.addr = alloca ptr + %x.addr = alloca ptr + store ptr %hidden, ptr %hidden.addr + #dbg_declare(ptr %hidden.addr, !14, !DIExpression(), !16) + store ptr %x, ptr %x.addr + #dbg_declare(ptr %x.addr, !15, !DIExpression(), !16) + ret void, !dbg !16 +} + +!llvm.dbg.cu = !{!0} +!llvm.module.flags = !{!2, !3} + +!0 = distinct !DICompileUnit(language: DW_LANG_C_plus_plus_14, file: !1, producer: "hand-written", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug) +!1 = !DIFile(filename: "parameter_binding_unknown_input.ll", directory: ".") +!2 = !{i32 7, !"Dwarf Version", i32 5} +!3 = !{i32 2, !"Debug Info Version", i32 3} +!10 = distinct !DISubprogram(name: "unestablished", scope: !1, file: !1, line: 1, type: !11, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0) +!11 = !DISubroutineType(types: !12) +!12 = !{null, !13, !13, !13} +!13 = !DIDerivedType(tag: DW_TAG_pointer_type, baseType: !17, size: 64) +!14 = !DILocalVariable(name: "hidden", arg: 1, scope: !10, file: !1, line: 1, type: !13, flags: DIFlagArtificial) +!15 = !DILocalVariable(name: "x", arg: 2, scope: !10, file: !1, line: 1, type: !13) +!16 = !DILocation(line: 1, scope: !10) +!17 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed) +``` + +In `testParameterDebugBinding`, give `describe` a branch for a parameter without a type, between the artificial branch and the pointer one: + +```cpp + else if (!binding.type) + { + text += "no type"; + } +``` + +declare `LoadedModule unestablished;` after `LoadedModule blocks;`, load it last: + +```cpp + !load("parameter_binding_block_input.c", {"-fblocks"}, blocks) || + !load("parameter_binding_unknown_input.ll", {}, unestablished)) + return false; +``` + +and add, after the block's check: -Add `#include ` before `#include `. Replace `DebugParamLayout` with: +```cpp + // A variadic function's debug type ends with a null entry for `...`, which no parameter + // takes; an empty parameter takes no IR argument, but keeps its debug declaration. + check(itanium, "_ZN7VarargsC2EPiPdz", + {"this: artificial", "vtt: artificial", "x: int *", "y: double *"}); + check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial", "vtt: artificial", "x: int *"}); + check(itanium, "_Z8make_bigPiz", {"agg.result: artificial", "p: int *"}); + check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", + {"this: artificial", "is_most_derived: artificial", "x: int *", "y: double *"}); + // Where the debug type lists x is not established: x takes no type from it. + check(unestablished, "unestablished", {"hidden: artificial", "x: no type"}); +``` + +The fixture becomes: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// The base-object constructor (C2) of a class with a virtual base takes a vtt after `this`: a +// parameter that the source does not declare and the debug type of the function does not list. +// ConstParameterNotModified must not report it, nor give a parameter after it the type of the +// next one, in that constructor or in the constructors that call it. + +struct Base +{ + int seed; +}; + +struct Reader : virtual Base +{ + Reader(int* x, double* y); +}; + +struct Variadic : virtual Base +{ + Variadic(int* v, double* w, ...); +}; + +// x is only read: both variants, C1 and C2, report it with its own type. +// not contains: current type: double *x +// at line 28, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'Reader::Reader(int*, double*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +Reader::Reader(int* x, double* y) +{ + seed = *x; + *y = 0; +} + +// A variadic constructor: the `...` that ends the debug type is no parameter's. +// not contains: current type: double *v +// at line 40, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'v' in function 'Variadic::Variadic(int*, double*, ...)' is never used to modify the pointed object +// ↳ current type: int *v +// ↳ suggested type: const int *v +Variadic::Variadic(int* v, double* w, ...) +{ + seed = *v; + *w = 0; +} + +// Writer writes through p, so Leaf, which passes its own p there, must not report it. +struct Writer : virtual Base +{ + Writer(int* p, const double* q); +}; + +struct Leaf : Writer +{ + Leaf(int* p, const double* q); +}; + +Writer::Writer(int* p, const double* q) +{ + *p = static_cast(*q); +} + +// not contains: parameter 'p' in function 'Leaf::Leaf(int*, double const*)' +Leaf::Leaf(int* p, const double* q) : Writer(p, q) {} + +// not contains: parameter 'vtt' +``` + +- [ ] **Step 8: Build and run, expect RED.** Expected on `main` (`evidence/217/p0/harness/red-variadic-main.txt`, `evidence/217/p0/fixture/red-fixture-variadic-main.txt`): the four failures of Step 5, and + +``` +[FAIL] ParameterDebugBinding: _ZN7VarargsC2EPiPdz [this: artificial] [vtt: int *] [x: double *] [y: no type] +[FAIL] ParameterDebugBinding: _ZN6TaggedC2EPi3Tag [this: artificial] [vtt: int *] [x: ? *] +[FAIL] ParameterDebugBinding: _Z8make_bigPiz [agg.result: int *] [p: int *] +[FAIL] ParameterDebugBinding: ??0Varargs@@QEAA@PEAHPEANZZ [this: artificial] [is_most_derived: int *] [x: double *] [y: no type] +[FAIL] ParameterDebugBinding: unestablished [hidden: int *] [x: int *] +``` + +The fixture finds its four forbidden texts (`current type: double *x`, `current type: double *v`, Leaf's `p`, `vtt`) in both passes. The first prototype of the fix, which counted the `...` entry and the spilled arguments, failed the same five checks (`evidence/217/p0/harness/red-variadic-prototype-v1.txt`). + +- [ ] **Step 9: Commit.** + +```bash +git add test/unit/analyzer_module_unit_tests.cpp test/unit/parameter_binding_input.cpp test/unit/parameter_binding_unknown_input.ll test/pointer_reference-const_correctness/virtual-base-vtt.cpp +git commit -m "test(const-param): a parameter takes its type only from an established debug layout" +``` + +- [ ] **Step 10: Bind each parameter to its own declaration.** In `src/analysis/ParameterDebugBinding.cpp`: + +Add `#include ` before `#include `, and `#include ` after `#include `. Replace `DebugParamLayout` with: ```cpp struct DebugParamLayout @@ -395,60 +603,94 @@ Add `#include ` before `#include `. Replace `DebugParamLayou Add before `buildDebugParamLayout`: ```cpp - // The entry of @p param in the subroutine type, or 0 when the type does not list it. - // Clang marks artificial the parameters that the source does not declare. The type lists - // `this` and a block's descriptor, but not what the C++ ABI adds after `this`, like the vtt - // of a constructor of a class with a virtual base: the artificial parameters other than - // `this` are unlisted when the type lacks exactly that many entries. - static unsigned subroutineTypeIndex(const llvm::Function& F, - const llvm::DILocalVariable& param, std::size_t listed) + // The highest parameter number that F's debug declarations give. At -O0 Clang declares + // every parameter in the entry block, one that takes no IR argument (an empty struct) + // included. + static unsigned declaredParameters(const llvm::Function& F) + { + const llvm::DISubprogram* SP = F.getSubprogram(); + unsigned last = 0; + const auto declares = [&](const llvm::DILocalVariable* var) + { + if (var && var->isParameter() && var->getScope() == SP) + last = std::max(last, var->getArg()); + }; + for (const llvm::Instruction& I : F.getEntryBlock()) + { + for (const llvm::DbgVariableRecord& record : + llvm::filterDbgVars(I.getDbgRecordRange())) + declares(record.getVariable()); + if (const auto* intrinsic = llvm::dyn_cast(&I)) + declares(intrinsic->getVariable()); + } + return last; + } + + // The entry of @p param in the subroutine type: 0 when the type does not list it, none when + // the debug information does not establish it. Clang marks artificial the parameters that + // the source does not declare. The type lists `this` and a block's descriptor, but not what + // the C++ ABI adds, like the vtt of a constructor of a class with a virtual base: the + // artificial parameters other than `this` are unlisted when the type lacks exactly that + // many of the declared parameters, listed when it lacks none. + static std::optional subroutineTypeIndex(const llvm::Function& F, + const llvm::DILocalVariable& param, + unsigned listed) { const auto artificialOtherThanThis = [](const llvm::DILocalVariable& var) { return var.isArtificial() && !var.isObjectPointer(); }; - unsigned last = 0; unsigned artificial = 0; unsigned artificialBefore = 0; for (const llvm::Argument& other : F.args()) { const llvm::DILocalVariable* var = spilledParameter(other); - if (!var) - continue; - last = std::max(last, var->getArg()); - if (!artificialOtherThanThis(*var)) + if (!var || !artificialOtherThanThis(*var)) continue; ++artificial; if (var->getArg() < param.getArg()) ++artificialBefore; } - if (artificial == 0 || last != listed + artificial) + if (artificial == 0) return param.getArg(); + const unsigned declared = declaredParameters(F); + if (declared == listed) + return param.getArg(); + if (declared != listed + artificial) + return std::nullopt; return artificialOtherThanThis(param) ? 0 : param.getArg() - artificialBefore; } + ``` -In `buildDebugParamLayout`, replace the spilled branch with: +In `buildDebugParamLayout`, after `return layout; // [0] return type, [1..] params.`, replace the spilled branch with: ```cpp + // A variadic function's type ends with a null entry for `...`: no parameter's. + const unsigned listed = + static_cast(typeArray.size()) - (typeArray[typeArray.size() - 1] ? 1 : 2); + + // The spill slot names the source parameter whatever the ABI did to the list; the + // position below only serves arguments that are not spilled, like a hidden sret. if (const llvm::DILocalVariable* spilled = spilledParameter(Arg); spilled && spilled->getArg() != 0) { - const unsigned typeIndex = subroutineTypeIndex(F, *spilled, typeArray.size() - 1); - if (typeIndex == 0) + const std::optional typeIndex = subroutineTypeIndex(F, *spilled, listed); + if (typeIndex == 0u) { layout.isHiddenAbiArg = true; - return layout; } - if (typeIndex < typeArray.size()) + else if (typeIndex && *typeIndex <= listed) { layout.debugArgIndex = spilled->getArg(); - layout.typeIndex = typeIndex; + layout.typeIndex = *typeIndex; layout.hasCanonicalDebugIndex = true; - return layout; } + // Otherwise its entry stays unknown: the position below would only guess it. + return layout; } + ``` -and, at the end of the function, after `layout.debugArgIndex = debugParamOffset + 1;`, add `layout.typeIndex = layout.debugArgIndex;`. +then compute `debugParamCount` as `listed` (`const unsigned debugParamCount = listed;`, instead of `typeArray.size() - 1`), and after `layout.debugArgIndex = debugParamOffset + 1;`, add `layout.typeIndex = layout.debugArgIndex;`. In `resolveParameterDebugBinding`, replace `if (layout.isHiddenAbiArg)` with: @@ -460,16 +702,18 @@ In `resolveParameterDebugBinding`, replace `if (layout.isHiddenAbiArg)` with: and in the fallback on the subroutine type, index `typeArray` with `layout.typeIndex` instead of `layout.debugArgIndex` (the bound check and the subscript). -- [ ] **Step 8: Build and run, expect GREEN.** The eight `ParameterDebugBinding` checks pass; the fixture passes in both passes; `./build/stack_usage_analyzer_unit_tests .` ends with `All analyzer module unit tests passed.` +A spilled argument no longer falls back to the position of its IR argument: when the debug information does not establish its entry, it gets no type, and the rule does not report it. `declaredParameters` reads the entry block only for a function with an artificial parameter other than `this`. -- [ ] **Step 9: Commit.** +- [ ] **Step 11: Build and run, expect GREEN.** The 13 `ParameterDebugBinding` checks pass; the fixture passes in both passes (14 of 14); `./build/stack_usage_analyzer_unit_tests .` ends with `All analyzer module unit tests passed.` + +- [ ] **Step 12: Commit.** ```bash git add src/analysis/ParameterDebugBinding.cpp git commit -m "fix(const-param): bind each parameter to its own debug declaration" ``` -- [ ] **Step 10: Expect source indexes in the unit test.** In `describe`, after the `if`/`else`, add: +- [ ] **Step 13: Expect source indexes in the unit test.** In `describe`, after the `if`/`else` chain, add: ```cpp text += ", source " + @@ -495,6 +739,24 @@ Replace the expectations with: check(blocks, "__use_block_block_invoke", {".block_descriptor: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); + + // A variadic function's debug type ends with a null entry for `...`, which no parameter + // takes; an empty parameter takes no IR argument, but keeps its debug declaration. + check(itanium, "_ZN7VarargsC2EPiPdz", + {"this: artificial, source none", "vtt: artificial, source none", + "x: int *, source 0", "y: double *, source 1"}); + check(itanium, "_ZN6TaggedC2EPi3Tag", + {"this: artificial, source none", "vtt: artificial, source none", + "x: int *, source 0"}); + check(itanium, "_Z8make_bigPiz", + {"agg.result: artificial, source none", "p: int *, source 0"}); + check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", + {"this: artificial, source none", "is_most_derived: artificial, source none", + "x: int *, source 0", "y: double *, source 1"}); + // Where the debug type lists x is not established: x takes no type from it. + check(unestablished, "unestablished", + {"hidden: artificial, source none", "x: no type, source 0"}); + ``` and the comment above the test with: @@ -506,11 +768,11 @@ and the comment above the test with: /// Microsoft ABI, a block's descriptor. ``` -`self` stays artificial, as on `main`: Clang marks it an object pointer, which the binding has always treated as synthetic. Its source index is 0 all the same, since the source declares it. +`self` stays artificial, as on `main`: Clang marks it an object pointer, which the binding has always treated as synthetic. Its source index is 0 all the same, since the source declares it. `tag` takes no IR argument, so no check lists it. -- [ ] **Step 11: Build, expect RED.** Expected: `error: no member named 'sourceIndex' in 'ctrace::stack::analysis::ParameterDebugBinding'` (twice; recorded in `evidence/217/p0/harness/red-unit-compile-main.txt`). +- [ ] **Step 14: Build, expect RED.** Expected: `error: no member named 'sourceIndex' in 'ctrace::stack::analysis::ParameterDebugBinding'` (twice; `evidence/217/p0/harness/red-final-compile-main.txt`). -- [ ] **Step 12: Give each parameter its source index.** In `include/analysis/ParameterDebugBinding.hpp`, add `#include ` after `#include `, and after `unsigned column = 0;`: +- [ ] **Step 15: Give each parameter its source index.** In `include/analysis/ParameterDebugBinding.hpp`, add `#include ` after `#include `, and after `unsigned column = 0;`: ```cpp // The parameter's position among those the source declares, from 0. None for one the @@ -521,7 +783,7 @@ and the comment above the test with: The struct keeps no implicit padding: `std::optional` takes 8 bytes at a 4-byte boundary, after `column`. -In `src/analysis/ParameterDebugBinding.cpp`, add `#include ` after `#include `, and after `isSyntheticDebugVariable`: +In `src/analysis/ParameterDebugBinding.cpp`, after `isSyntheticDebugVariable`: ```cpp // The position of @p param among the parameters the source declares: the artificial ones @@ -550,21 +812,21 @@ In `resolveParameterDebugBinding`, before `finalizeBindingName(binding, Arg);`: binding.sourceIndex = sourceIndexOf(F, *spilled); ``` -- [ ] **Step 13: Build and run, expect GREEN.** The eight checks pass with their source indexes (recorded on the prototype in `evidence/217/p0/harness/green-unit-prototype.txt`). +- [ ] **Step 16: Build and run, expect GREEN.** The 13 checks pass with their source indexes (`evidence/217/p0/harness/green-final-prototype-v2.txt`). -- [ ] **Step 14: Format, full suite, warnings.** Run clang-format on the five C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed. +- [ ] **Step 17: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed. -- [ ] **Step 15: Commit.** +- [ ] **Step 18: Commit.** ```bash git add include/analysis/ParameterDebugBinding.hpp src/analysis/ParameterDebugBinding.cpp test/unit/analyzer_module_unit_tests.cpp git commit -m "feat(analysis): give a bound parameter its position among the source's" ``` -This commit carries its test with its code, so that every commit builds; its failure evidence is the compile error of Step 11. +This commit carries its test with its code, so that every commit builds; its failure evidence is the compile error of Step 14. **Validation of P0.1:** -- `testParameterDebugBinding`: 8 checks pass; both unit test executables pass. +- `testParameterDebugBinding`: 13 checks pass; both unit test executables pass. - `virtual-base-vtt.cpp` passes in the default and smt-z3 passes. - The suite passes on macOS with no expectation changed; no new warning; clang-format clean. @@ -575,6 +837,8 @@ Expected effect on the rule, without the option: - constructors that call such a C2: the #167 contract reads the callee's real parameter types; a false "passed where a pointer to const is expected" disappears, and a true one can appear; - Microsoft ABI targets: no suggestion on `is_most_derived`; - blocks (`-fblocks`, the default on macOS): no suggestion on `.block_descriptor`; +- variadic functions: the `...` entry is no parameter's, so the constructors above keep their types when variadic, and a variadic function that returns a struct through a hidden argument no longer types that argument as its first parameter; +- a parameter whose entry the debug information does not establish gets no type, so the rule does not report it, instead of reporting it with a guessed type (no such case in Clang's output so far); - nothing else: line, column, confidence and name of every other parameter unchanged; the other rules use `spilledParameter` and `declaredVariable`, which do not change. - [ ] **Step 1: Compare every fixture's output.** @@ -588,9 +852,11 @@ Expected: `changed: 0` among `main`'s fixtures (the script normalizes the logger - [ ] **Step 2: Linux (WSL), suite and corpora.** Build `main` and the branch; run the suite; run both binaries without the option on the self-analysis as CI does (4 chunks, 2 at a time, fast profile, cold caches), zlib `767c4c9`, tinyxml2 `8224e42` and PCRE2 `3e4036e` (without the #215 units). Expected: identical outputs; none of these corpora compiles a class with a virtual base or a block (zlib's `contrib/iostream` is not in its compilation database). The self-analysis time is reported as an indication. -- [ ] **Step 3: Pull request.** After the user's go: push, pull request assigned to `SizzleUnrlsd`, label `bug`, `Fixes #`, body prepared in `evidence/217/p0/pr-body.md` (root cause, effect without the option, measurements). CI; code scanning triaged as `CLAUDE.md` describes, summary in the body. The user merges. +- [ ] **Step 3: Independent review.** A fresh reviewer on the most capable model reviews the P0 branch against this plan and the spec (`review-package`, then `requesting-code-review`'s `code-reviewer.md`); its findings are sorted and fixed as superpowers:executing-plans says, each fix with a test that failed first, and reported to the user before the merge. + +- [ ] **Step 4: Pull request.** After the user's go: push, pull request assigned to `SizzleUnrlsd`, label `bug`, `Fixes #`, body prepared in `evidence/217/p0/pr-body.md` (root cause, effect without the option, measurements). CI; code scanning triaged as `CLAUDE.md` describes, summary in the body. The user merges. -**Validation of P0.2:** no existing fixture output changes; corpora identical without the option; CI green; triage summarized; the effect stated in the body matches the measurements. +**Validation of P0.2:** no existing fixture output changes; corpora identical without the option; independent review done and its findings handled; CI green; triage summarized; the effect stated in the body matches the measurements. ### Task I.1: integrate `main` into `217-t-v2`, once P0 is merged @@ -605,9 +871,9 @@ Expected: `changed: 0` among `main`'s fixtures (the script normalizes the logger ## Stage 1 — flow, identities, plan and scope, facts, correspondences, collisions, trace (§9.3) -Catalogue: D1 (T part), D2, D3, D4, D5, D7, D11, D14, D15, D16. Tests are submitted with their failure evidence at the start of each task. +Catalogue: D1 (T part), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17. Tests are submitted with their failure evidence at the start of each task. -**Open point for the user (found while planning).** §5.10 makes a stale declaration indeterminate: "Une unité qui déclare l'entité avec l'ancien type sans voir la réécriture rend la suggestion indéterminée". T v1 removes that suggestion, and two checks pin it: `check_const_suggestion_cross_tu` (`("def and a redeclaration", ["def.c", "redecl.c"], [])`) and `check_const_suggestion_compilation_database` (`("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None)`). These two expectations change with §5.10, but §7 does not list them. Proposed: D17, "redeclaration with the old type in another file", indeterminate, the reason naming the file and the link-edition assumption; both expectations submitted with Task 1.6. To confirm before Task 1.6. +**D17 (confirmed by the user).** §5.10 makes a stale declaration indeterminate. T v1 removes that suggestion, and two checks pin it: `check_const_suggestion_cross_tu` (`("def and a redeclaration", ["def.c", "redecl.c"], [])`) and `check_const_suggestion_compilation_database` (`("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None)`). Both expectations change in Task 1.6, submitted with their failure evidence; the spec's catalogue and §7 list D17. Indeterminate keeps the suggestion as not checked: it does not show that the suggestion is valid, and the reason says so through the file and the link-edition assumption. Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from §3): - `enum class Verdict : std::uint8_t { Verified, Refuted, Indeterminate };` and `struct Judgement { Verdict verdict; std::string reason; }`; @@ -623,7 +889,7 @@ Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from **Files:** `ConstSuggestionCheck.cpp`, `ConstSuggestionInternal.hpp`, `DiagnosticEmitter.cpp`. **Interfaces:** produces `Verdict`, `Judgement`, `UnitConfiguration`, `aggregate(const std::vector&) -> Judgement` (§5.10: refuted if a unit refutes; else indeterminate if a unit or a collision is; else verified). -**Work:** one unit per file and configuration that the loader provides; the units that see the change as §5.2 defines them, from T v1's scan; a unit without a command, that cannot be preprocessed or does not compile as it is, unverifiable (judgement step 1); initial compilations cached by unit and entity and shared by every suggestion (§5, step 3); T v1's outcomes mapped onto the three verdicts with T v1's texts; the trace names each unit's file and configuration fingerprint on its own `[const-suggestion]` line. +**Work:** one unit per file and configuration that the loader provides; the units that see the change as §5.2 defines them, from T v1's scan; a unit without a command, that cannot be preprocessed or does not compile as it is, unverifiable (judgement step 1); one initial compilation per unit and configuration, whose artifact (diagnostics, prepared IR, defined symbols, and the facts its inspection collects for every planned entity) every suggestion shares; what depends on the entity is derived from that artifact, never by compiling the unit again (§5, step 3). T v1 compiles a unit once per function name, so two functions of one file cost two initial compilations; T v1's outcomes mapped onto the three verdicts with T v1's texts; the trace names each unit's file and configuration fingerprint on its own `[const-suggestion]` line. **Tests:** Review Focus 4, a new `run_test.py` check (a compilation database that lists `cross-tu-const-suggestion-def.c` twice, with and without `-DREAD_TWICE`; the trace names one configuration for that file; the "checked" line stays T v1's). **Validation:** every T v1 test passes unchanged; the new check passes. @@ -662,8 +928,8 @@ Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from ### Task 1.6: collisions, stale declarations, link-edition assumption (§5.10) **Files:** `ConstSuggestionMatch.cpp`, `README.md`. -**Work:** the linkage table of §5.10; a same entity defined in several units is no collision; a stale declaration gives indeterminate (D17, pending); every reason that depends on linking states the assumption and its scope. -**Tests:** D3, a new `run_test.py` check (t2: C `static` and external homonyms, both directions, verified; the README sentence corrected); D4, a new `run_test.py` check (t9: `f(const int*)` defined in another file, indeterminate with the assumption, files analyzed together and the other file only in the database); Review Focus 3, a new `run_test.py` check (a header defining `static inline` C and `inline` C++ functions, included by two analyzed files, verified); D17, the two changed expectations, if confirmed; the static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` unchanged. +**Work:** the linkage table of §5.10; a same entity defined in several units is no collision; a stale declaration gives indeterminate (D17); every reason that depends on linking states the assumption and its scope. +**Tests:** D3, a new `run_test.py` check (t2: C `static` and external homonyms, both directions, verified; the README sentence corrected); D4, a new `run_test.py` check (t9: `f(const int*)` defined in another file, indeterminate with the assumption, files analyzed together and the other file only in the database); Review Focus 3, a new `run_test.py` check (a header defining `static inline` C and `inline` C++ functions, included by two analyzed files, verified); D17, the two changed expectations; the static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` unchanged. **Validation:** D3, D4 and Review Focus 3 pass. ### Task 1.7: explicit address casts (D15) @@ -680,7 +946,7 @@ Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from **Tests:** `check_const_suggestion_removal_reason` unchanged; a new check of the trace fields on one verified, one refuted and one indeterminate suggestion. **Validation:** both pass. -**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16 and Review Focus 2 to 4 pass; every T v1 test passes, except the changed expectations of D14, D15, D16 (and D17 if confirmed); suite without and with the option, unit tests, on macOS and Linux; review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. +**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17 and Review Focus 2 to 4 pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; suite without and with the option, unit tests, on macOS and Linux; review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. ## Stage 2 — D6, D8, D10, D12, D13 @@ -698,7 +964,7 @@ Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from ### Task 2.4: one candidate, displayed (D12, D13) **Work:** the displayed type and the "checked" line name the candidate: `const T &` for `ReferenceRvaluePreferValue` (passing by value is not checked), `const int *const p` for `PointerConstOnly`. -**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, the only T fixture with that rule; D13, a new fixture (t15: `int* const p` only read, displayed and verified as `const int *const p`). No T v1 fixture covers `PointerConstOnly`, so D13 adds a fixture where §6 expected changed expectations. +**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, the only T fixture with that rule; D13, a new fixture that enables T (t15: `int* const p` only read, displayed and verified as `const int *const p`), presented with its failure evidence; no T v1 fixture covers `PointerConstOnly`, and the existing test without the option stays unchanged. **Validation of stage 2:** as stage 1, for D6, D8, D10, D12, D13 and Review Focus 5. diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index 1bc58cb..48fb167 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -1,7 +1,7 @@ # Vérification des suggestions const par Clang, version 2 (#217) -Statut : conception validée en conversation (approche 1, sections A à D) ; à relire avant le plan -d'implémentation. N'autorise ni le code ni la modification des tests : chaque lot présentera ses +Statut : validée à `8b283a8`, puis complétée à la relecture du plan d'implémentation (D17, fixture +de D13). N'autorise ni le code ni la modification des tests : chaque lot présentera ses tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 (identité d'un global entre fichiers). @@ -283,7 +283,7 @@ suggestion valide. | # | Défaut (cas) | Fait observé | Verdict attendu | Test prévu | Étape | |---|---|---|---|---|---| -| D1 | paramètre artificiel `vtt` d'un constructeur de base (t10) | la règle suggère `vtt` ; T réécrit `x` et l'affiche « checked » ; `x` (variante C2) est retiré | P0 : plus de suggestion sur `vtt`. T : `x` vérifiée. Un paramètre artificiel qui atteindrait T : indéterminée | P0 : fixture de la règle. T : fixture du constructeur | P0, 1 | +| D1 | paramètre artificiel `vtt` d'un constructeur de base (t10) | la règle suggère `vtt` ; T réécrit `x` et l'affiche « checked » ; `x` (variante C2) est retiré | P0 : plus de suggestion sur `vtt`. T : `x` vérifiée. Un paramètre artificiel qui atteindrait T : indéterminée | P0 : test unitaire de la liaison et fixture de la règle. T : fixture du constructeur | P0, 1 | | D2 | opérateur membre utilisé dans un autre fichier (t1) | retirée : « the methods it overrides change » | vérifiée (VP) | contrôle run_test.py à deux fichiers | 1 | | D3 | homonymes C `static` et externe (t2, deux sens) | retirée : « declared in b.c / d.c » | vérifiée (VP), dans les deux sens ; README corrigé | contrôle run_test.py, deux sens | 1 | | D4 | `f(const int*)` défini dans un autre fichier (t9) | « checked in 2 file(s) » ; l'appliquer définit `_Z1fPKi` deux fois | indéterminée, en énonçant l'hypothèse d'édition de liens | contrôle run_test.py : fichiers analysés ensemble, et autre fichier venant seulement de la base | 1 | @@ -293,11 +293,12 @@ suggestion valide. | D14 | correspondance non unique traitée comme une réfutation (`const-suggestion-deduction-refused.cpp`, cas `call`) | retirée | indéterminée (gardée) ; `pick` reste réfutée (condition `if constexpr`) | attente modifiée de la fixture | 1 | | D15 | conversion explicite de l'adresse traitée comme une réfutation (`cross-tu-const-suggestion-cast.c`) | retirée | indéterminée (gardée), sauf erreur ou effet établi | attentes modifiées des deux contrôles run_test.py qui l'utilisent | 1 | | D16 | `sizeof(__PRETTY_FUNCTION__)` (`const-suggestion-printed-signature.c`, cas `signature_size`) | retirée : le code généré change | indéterminée (gardée) : la différence n'est expliquée ni par R, ni par O, ni par K ; K n'est pas élargi pour conserver l'ancien verdict | attente modifiée de la fixture | 1 | +| D17 | redéclaration avec l'ancien type dans un autre fichier (`cross-tu-const-suggestion-redecl.c`), relevée à la planification | retirée : le fichier déclare la fonction lui-même, sans l'en-tête | indéterminée (gardée non vérifiée, ce qui ne démontre pas qu'elle est valide) ; la raison nomme le fichier et l'hypothèse d'édition de liens (§5.10) | attentes modifiées de `check_const_suggestion_cross_tu` et de `check_const_suggestion_compilation_database` | 1 | | D6 | `-Werror` du projet et groupe suivi désactivé (t5) | « not checked: does not compile as it is » | vérifiée (VP) | fixture avec `--compile-arg` | 2 | | D8 | définition C, utilisateur C++ via `extern "C"` (t4) | « not checked: the rewritten declaration does not get the suggested type » ; témoin C vérifié | vérifiée (VP) | contrôle run_test.py, avec le témoin | 2 | | D10 | `const` écrit devant un typedef de pointeur (t3/t.c, t3/t.cpp) | réécrit en `const const int *` ; « not checked » | vérifiée (`const int *const`) ; indéterminée si le qualificatif vient d'une macro | fixtures C et C++ | 2 | | D12 | alternative de `ReferenceRvaluePreferValue` | seul `const T &` est vérifié ; la ligne dit « the suggested type » | vérifiée ; le type affiché et la ligne nomment `const T &`, le passage par valeur n'est pas vérifié | attentes modifiées (`const-suggestion-deduction-kept.cpp` et autres) | 2 | -| D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | attentes modifiées des fixtures concernées | 2 | +| D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | nouvelle fixture qui active T, aucune fixture de T v1 ne couvrant `PointerConstOnly` ; le test existant sans l'option reste inchangé | 2 | **Non retenus :** - le cache « avant » indexé par l'identifiant (point 9 de la revue) : non reproduit (t12) ; les @@ -312,7 +313,8 @@ l'invocation Clang en double. Les deux coûts ne sont traités que s'ils sont me - **TDD, défaut par défaut.** Chaque test est soumis avec la preuve de son échec avant d'être ajouté. - **Non-régression.** Tous les tests de T v1, sauf les attentes à modifier, elles aussi soumises : - D12, D13, D14, D15 et D16. + D12, D14, D15, D16 et D17. D13 ajoute une fixture qui active T ; le test existant sans l'option + reste inchangé. - **Corpus** (`src/`, zlib, tinyxml2, PCRE2). Chaque suggestion est comparée entre trois sorties : sans l'option, T v1 et T v2. Elle est classée vérifiée, gardée indéterminée, ou retirée sur une réfutation établie. Chaque disparition par rapport à la sortie sans l'option est expliquée par sa @@ -336,8 +338,8 @@ acceptation d'un dépassement. 2. **Branche `217-t-v2`**, depuis `2a5b0c1`. T v1 reste une référence immuable (`217-t-v1`, #221, bundle). Après la fusion de P0, `main` actualisé y est intégré et l'ensemble est validé. 3. **Étape 1** : flux, identités, plan et portée, faits R, O et K, correspondances, paires établies - pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14, D15 et - D16. + pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14, D15, D16 + et D17. 4. **Étape 2** : D6, D8, D10, D12 et D13. 5. **Étape 3** : les coûts mesurés et l'invocation Clang construite en un seul endroit. 6. **Comparaison des corpus, mesure du coût et README** ; une PR en brouillon. La revue finale porte From 49f1e7cbd45d0a546973f1a566242871a7c168cf Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 18:41:45 +0200 Subject: [PATCH 027/152] docs(plan): take a parameter's type from its own declaration in P0 The prototype binds every case from the declaration that spilledParameter finds, with no output change on the fixtures. The subroutine type stays a fallback for an untyped declaration, only where its entries are established; the position reconciliation is dropped. --- .../2026-10-16-const-suggestion-check-v2.md | 428 ++++++++---------- 1 file changed, 201 insertions(+), 227 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index fd89482..1d46c6d 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -12,7 +12,7 @@ **Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. -**Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype-v1.diff`, `prototype-v2.diff`, `ir/`, `variadic/`). +**Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype-v1.diff`, `prototype-v2.diff`, `prototype-v3.diff`, `ir/`, `variadic/`, `review-v3/`). **Execution:** native (superpowers:executing-plans), as the user chose; an independent review of P0 before its merge (Task P0.2). Progress ledger: `wt-217-v2/.superpowers/sdd/2026-10-16-const-suggestion-check-v2/progress.md`. @@ -100,14 +100,14 @@ Root cause, at `main` `d1d9271`. At `-O0`, `buildDebugParamLayout` indexes the f - `is_most_derived` (an `int`) is reported as `double *is_most_derived`; - a block's `.block_descriptor` is reported as ` *.block_descriptor`. -Two counts make the correction harder than an offset. A variadic function's subroutine type ends with a null entry for `...`, which no parameter takes: counted as a parameter, it shifts `x` and `y` again in `D(int* x, double* y, ...)`. An empty parameter takes no IR argument but keeps its debug declaration and its number: counting the spilled arguments misses it. The number of declared parameters is therefore read from the debug declarations, and a layout that the counts do not establish leaves the parameter without a type, never with a guessed one. +The fix takes each spilled parameter's type and artificiality from its own declaration, the one `spilledParameter` finds; this alone binds every case above. The subroutine type stays a fallback for a declaration that gives no type, only where its entries are established, and for arguments without a declaration, like a hidden sret, whose count no longer includes the null entry that ends a variadic function's type. A first prototype reconciled positions in the subroutine type instead (`evidence/217/p0/prototype-v2.diff`); it was dropped once the direct declaration was shown to bind every case, with no output change on the 424 fixtures. Reproductions: `evidence/217/p0/repro/` (`main-d1d9271.txt`, `caller-main-d1d9271.txt`, `block-main-d1d9271.txt`). ### Task P0.1: bind each parameter to its own declaration **Files:** -- Create: `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c`, `test/unit/parameter_binding_unknown_input.ll` +- Create: `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c`, `test/unit/parameter_binding_unknown_input.ll` (Step 12) - Create: `test/pointer_reference-const_correctness/virtual-base-vtt.cpp` - Modify: `test/unit/analyzer_module_unit_tests.cpp` (includes, new test, its call in `main`) - Modify: `include/analysis/ParameterDebugBinding.hpp`, `src/analysis/ParameterDebugBinding.cpp` @@ -378,9 +378,9 @@ git add test/unit/analyzer_module_unit_tests.cpp test/unit/parameter_binding_inp git commit -m "test(const-param): an artificial parameter is neither reported nor shifts the others" ``` -- [ ] **Step 7: Add the cases submitted after the plan's review, once the user authorizes them.** A variadic function's debug type ends with a null entry for `...`; an empty parameter takes no IR argument but keeps its debug declaration; a variadic function can return a struct through a hidden argument; and a layout that the debug information does not establish must leave the parameter without a type. +- [ ] **Step 7: Add the Clang cases the user authorized, one commit each.** A variadic function's debug type ends with a null entry for `...`; an empty parameter takes no IR argument but keeps its debug declaration; a variadic function can return a struct through a hidden argument. -Append to `test/unit/parameter_binding_input.cpp`: +Step 7a, variadic constructors. Append to `test/unit/parameter_binding_input.cpp`: ```cpp // A variadic constructor: the debug type of the function ends with a null entry for `...`, which @@ -395,6 +395,40 @@ Varargs::Varargs(int* x, double* y, ...) seed = *x + static_cast(*y); } +``` + +In `testParameterDebugBinding`, give `describe` a branch for a parameter without a type, between the artificial branch and the pointer one: + +```cpp + else if (!binding.type) + { + text += "no type"; + } +``` + +and add, after the block's check: + +```cpp + // A variadic function's debug type ends with a null entry for `...`, which no parameter + // takes; an empty parameter takes no IR argument, but keeps its debug declaration. + check(itanium, "_ZN7VarargsC2EPiPdz", + {"this: artificial", "vtt: artificial", "x: int *", "y: double *"}); + check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", + {"this: artificial", "is_most_derived: artificial", "x: int *", "y: double *"}); +``` + +Expected RED, besides the four failures of Step 5: + +``` +[FAIL] ParameterDebugBinding: _ZN7VarargsC2EPiPdz [this: artificial] [vtt: int *] [x: double *] [y: no type] +[FAIL] ParameterDebugBinding: ??0Varargs@@QEAA@PEAHPEANZZ [this: artificial] [is_most_derived: int *] [x: double *] [y: no type] +``` + +Commit: `test(const-param): a variadic constructor keeps the types of its parameters` (the input and the test file). + +Step 7b, an empty parameter. Append to the input: + +```cpp // An empty parameter takes no IR argument, but keeps its debug declaration and its number. struct Tag { @@ -411,6 +445,13 @@ Tagged::Tagged(int* x, Tag tag) seed = *x; } +``` + +and, after the variadic checks: `check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial", "vtt: artificial", "x: int *"});`. Expected RED: `[FAIL] ParameterDebugBinding: _ZN6TaggedC2EPi3Tag [this: artificial] [vtt: int *] [x: ? *]`. Commit: `test(const-param): an empty parameter shifts no other parameter`. + +Step 7c, a variadic hidden return. Append to the input: + +```cpp // A variadic function that returns a struct through a hidden argument. struct Big { @@ -425,75 +466,9 @@ Big make_big(int* p, ...) } ``` -Create `test/unit/parameter_binding_unknown_input.ll`: +and add `check(itanium, "_Z8make_bigPiz", {"agg.result: artificial", "p: int *"});`. Expected RED: `[FAIL] ParameterDebugBinding: _Z8make_bigPiz [agg.result: int *] [p: int *]`. Commit: `test(const-param): a variadic function's hidden return argument is artificial`. -```llvm -; SPDX-License-Identifier: Apache-2.0 -; An artificial parameter other than `this`, in a function whose debug type lists three parameters: -; neither all the declared ones (2) nor all but the artificial one (1). Where the type lists x is not -; established, so x takes no type from it. - -define void @unestablished(ptr %hidden, ptr %x) !dbg !10 { -entry: - %hidden.addr = alloca ptr - %x.addr = alloca ptr - store ptr %hidden, ptr %hidden.addr - #dbg_declare(ptr %hidden.addr, !14, !DIExpression(), !16) - store ptr %x, ptr %x.addr - #dbg_declare(ptr %x.addr, !15, !DIExpression(), !16) - ret void, !dbg !16 -} - -!llvm.dbg.cu = !{!0} -!llvm.module.flags = !{!2, !3} - -!0 = distinct !DICompileUnit(language: DW_LANG_C_plus_plus_14, file: !1, producer: "hand-written", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug) -!1 = !DIFile(filename: "parameter_binding_unknown_input.ll", directory: ".") -!2 = !{i32 7, !"Dwarf Version", i32 5} -!3 = !{i32 2, !"Debug Info Version", i32 3} -!10 = distinct !DISubprogram(name: "unestablished", scope: !1, file: !1, line: 1, type: !11, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0) -!11 = !DISubroutineType(types: !12) -!12 = !{null, !13, !13, !13} -!13 = !DIDerivedType(tag: DW_TAG_pointer_type, baseType: !17, size: 64) -!14 = !DILocalVariable(name: "hidden", arg: 1, scope: !10, file: !1, line: 1, type: !13, flags: DIFlagArtificial) -!15 = !DILocalVariable(name: "x", arg: 2, scope: !10, file: !1, line: 1, type: !13) -!16 = !DILocation(line: 1, scope: !10) -!17 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed) -``` - -In `testParameterDebugBinding`, give `describe` a branch for a parameter without a type, between the artificial branch and the pointer one: - -```cpp - else if (!binding.type) - { - text += "no type"; - } -``` - -declare `LoadedModule unestablished;` after `LoadedModule blocks;`, load it last: - -```cpp - !load("parameter_binding_block_input.c", {"-fblocks"}, blocks) || - !load("parameter_binding_unknown_input.ll", {}, unestablished)) - return false; -``` - -and add, after the block's check: - -```cpp - // A variadic function's debug type ends with a null entry for `...`, which no parameter - // takes; an empty parameter takes no IR argument, but keeps its debug declaration. - check(itanium, "_ZN7VarargsC2EPiPdz", - {"this: artificial", "vtt: artificial", "x: int *", "y: double *"}); - check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial", "vtt: artificial", "x: int *"}); - check(itanium, "_Z8make_bigPiz", {"agg.result: artificial", "p: int *"}); - check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", - {"this: artificial", "is_most_derived: artificial", "x: int *", "y: double *"}); - // Where the debug type lists x is not established: x takes no type from it. - check(unestablished, "unestablished", {"hidden: artificial", "x: no type"}); -``` - -The fixture becomes: +Step 7d, the fixture's variadic constructor. The fixture becomes: ```cpp // SPDX-License-Identifier: Apache-2.0 @@ -563,157 +538,38 @@ Leaf::Leaf(int* p, const double* q) : Writer(p, q) {} // not contains: parameter 'vtt' ``` -- [ ] **Step 8: Build and run, expect RED.** Expected on `main` (`evidence/217/p0/harness/red-variadic-main.txt`, `evidence/217/p0/fixture/red-fixture-variadic-main.txt`): the four failures of Step 5, and - -``` -[FAIL] ParameterDebugBinding: _ZN7VarargsC2EPiPdz [this: artificial] [vtt: int *] [x: double *] [y: no type] -[FAIL] ParameterDebugBinding: _ZN6TaggedC2EPi3Tag [this: artificial] [vtt: int *] [x: ? *] -[FAIL] ParameterDebugBinding: _Z8make_bigPiz [agg.result: int *] [p: int *] -[FAIL] ParameterDebugBinding: ??0Varargs@@QEAA@PEAHPEANZZ [this: artificial] [is_most_derived: int *] [x: double *] [y: no type] -[FAIL] ParameterDebugBinding: unestablished [hidden: int *] [x: int *] -``` - -The fixture finds its four forbidden texts (`current type: double *x`, `current type: double *v`, Leaf's `p`, `vtt`) in both passes. The first prototype of the fix, which counted the `...` entry and the spilled arguments, failed the same five checks (`evidence/217/p0/harness/red-variadic-prototype-v1.txt`). +Expected RED: the fixture also finds `current type: double *v`, in both passes. Commit: `test(const-param): report a variadic constructor's parameter with its own type`. -- [ ] **Step 9: Commit.** - -```bash -git add test/unit/analyzer_module_unit_tests.cpp test/unit/parameter_binding_input.cpp test/unit/parameter_binding_unknown_input.ll test/pointer_reference-const_correctness/virtual-base-vtt.cpp -git commit -m "test(const-param): a parameter takes its type only from an established debug layout" -``` - -- [ ] **Step 10: Bind each parameter to its own declaration.** In `src/analysis/ParameterDebugBinding.cpp`: - -Add `#include ` before `#include `, and `#include ` after `#include `. Replace `DebugParamLayout` with: +- [ ] **Step 8: Take a parameter's type and its artificiality from its own declaration.** In `resolveParameterDebugBinding`, replace `if (layout.isHiddenAbiArg)` with: ```cpp - struct DebugParamLayout - { - const llvm::DISubroutineType* subroutineType = nullptr; - bool hasCanonicalDebugIndex = false; - bool isHiddenAbiArg = false; - // Make alignment intent explicit before the 32-bit indexes. - std::uint16_t paddingBeforeDebugArgIndex = 0; - unsigned debugArgIndex = 0; // The debug parameter number (arg:): `this` and vtt count. - unsigned typeIndex = 0; // Its entry in the subroutine type, which omits vtt. - std::uint32_t paddingTail = 0; - }; + // The declaration of the spilled parameter says whether the source declares it. + const llvm::DILocalVariable* spilled = spilledParameter(Arg); + if (layout.isHiddenAbiArg || isSyntheticDebugVariable(spilled)) ``` -Add before `buildDebugParamLayout`: +and, before the fallback on the subroutine type (`if (!binding.type && layout.hasCanonicalDebugIndex && layout.subroutineType)`): ```cpp - // The highest parameter number that F's debug declarations give. At -O0 Clang declares - // every parameter in the entry block, one that takes no IR argument (an empty struct) - // included. - static unsigned declaredParameters(const llvm::Function& F) - { - const llvm::DISubprogram* SP = F.getSubprogram(); - unsigned last = 0; - const auto declares = [&](const llvm::DILocalVariable* var) - { - if (var && var->isParameter() && var->getScope() == SP) - last = std::max(last, var->getArg()); - }; - for (const llvm::Instruction& I : F.getEntryBlock()) - { - for (const llvm::DbgVariableRecord& record : - llvm::filterDbgVars(I.getDbgRecordRange())) - declares(record.getVariable()); - if (const auto* intrinsic = llvm::dyn_cast(&I)) - declares(intrinsic->getVariable()); - } - return last; - } - - // The entry of @p param in the subroutine type: 0 when the type does not list it, none when - // the debug information does not establish it. Clang marks artificial the parameters that - // the source does not declare. The type lists `this` and a block's descriptor, but not what - // the C++ ABI adds, like the vtt of a constructor of a class with a virtual base: the - // artificial parameters other than `this` are unlisted when the type lacks exactly that - // many of the declared parameters, listed when it lacks none. - static std::optional subroutineTypeIndex(const llvm::Function& F, - const llvm::DILocalVariable& param, - unsigned listed) - { - const auto artificialOtherThanThis = [](const llvm::DILocalVariable& var) - { return var.isArtificial() && !var.isObjectPointer(); }; - unsigned artificial = 0; - unsigned artificialBefore = 0; - for (const llvm::Argument& other : F.args()) - { - const llvm::DILocalVariable* var = spilledParameter(other); - if (!var || !artificialOtherThanThis(*var)) - continue; - ++artificial; - if (var->getArg() < param.getArg()) - ++artificialBefore; - } - if (artificial == 0) - return param.getArg(); - const unsigned declared = declaredParameters(F); - if (declared == listed) - return param.getArg(); - if (declared != listed + artificial) - return std::nullopt; - return artificialOtherThanThis(param) ? 0 : param.getArg() - artificialBefore; - } - + // The spilled parameter's own declaration gives its type first. + if (!binding.type && spilled) + binding.type = spilled->getType(); ``` -In `buildDebugParamLayout`, after `return layout; // [0] return type, [1..] params.`, replace the spilled branch with: +Expected: every check passes but `_Z8make_bigPiz`, whose hidden argument is not spilled; the fixture passes in both passes (14 of 14). Commit: `fix(const-param): take a parameter's type and artificiality from its own declaration`. + +- [ ] **Step 9: Do not count the `...` of a variadic function.** In `buildDebugParamLayout`, after `return layout; // [0] return type, [1..] params.`: ```cpp // A variadic function's type ends with a null entry for `...`: no parameter's. const unsigned listed = static_cast(typeArray.size()) - (typeArray[typeArray.size() - 1] ? 1 : 2); - // The spill slot names the source parameter whatever the ABI did to the list; the - // position below only serves arguments that are not spilled, like a hidden sret. - if (const llvm::DILocalVariable* spilled = spilledParameter(Arg); - spilled && spilled->getArg() != 0) - { - const std::optional typeIndex = subroutineTypeIndex(F, *spilled, listed); - if (typeIndex == 0u) - { - layout.isHiddenAbiArg = true; - } - else if (typeIndex && *typeIndex <= listed) - { - layout.debugArgIndex = spilled->getArg(); - layout.typeIndex = *typeIndex; - layout.hasCanonicalDebugIndex = true; - } - // Otherwise its entry stays unknown: the position below would only guess it. - return layout; - } - ``` -then compute `debugParamCount` as `listed` (`const unsigned debugParamCount = listed;`, instead of `typeArray.size() - 1`), and after `layout.debugArgIndex = debugParamOffset + 1;`, add `layout.typeIndex = layout.debugArgIndex;`. +and compute `debugParamCount` as `listed` (`const unsigned debugParamCount = listed;`). Expected: the 12 checks pass. Commit: `fix(const-param): do not count the ... of a variadic function as a parameter`. -In `resolveParameterDebugBinding`, replace `if (layout.isHiddenAbiArg)` with: - -```cpp - // The declaration of the spilled parameter says whether the source declares it. - const llvm::DILocalVariable* spilled = spilledParameter(Arg); - if (layout.isHiddenAbiArg || isSyntheticDebugVariable(spilled)) -``` - -and in the fallback on the subroutine type, index `typeArray` with `layout.typeIndex` instead of `layout.debugArgIndex` (the bound check and the subscript). - -A spilled argument no longer falls back to the position of its IR argument: when the debug information does not establish its entry, it gets no type, and the rule does not report it. `declaredParameters` reads the entry block only for a function with an artificial parameter other than `this`. - -- [ ] **Step 11: Build and run, expect GREEN.** The 13 `ParameterDebugBinding` checks pass; the fixture passes in both passes (14 of 14); `./build/stack_usage_analyzer_unit_tests .` ends with `All analyzer module unit tests passed.` - -- [ ] **Step 12: Commit.** - -```bash -git add src/analysis/ParameterDebugBinding.cpp -git commit -m "fix(const-param): bind each parameter to its own debug declaration" -``` - -- [ ] **Step 13: Expect source indexes in the unit test.** In `describe`, after the `if`/`else` chain, add: +- [ ] **Step 10: Expect source indexes in the unit test.** In `describe`, after the `if`/`else` chain, add: ```cpp text += ", source " + @@ -753,10 +609,6 @@ Replace the expectations with: check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", {"this: artificial, source none", "is_most_derived: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); - // Where the debug type lists x is not established: x takes no type from it. - check(unestablished, "unestablished", - {"hidden: artificial, source none", "x: no type, source 0"}); - ``` and the comment above the test with: @@ -768,11 +620,9 @@ and the comment above the test with: /// Microsoft ABI, a block's descriptor. ``` -`self` stays artificial, as on `main`: Clang marks it an object pointer, which the binding has always treated as synthetic. Its source index is 0 all the same, since the source declares it. `tag` takes no IR argument, so no check lists it. - -- [ ] **Step 14: Build, expect RED.** Expected: `error: no member named 'sourceIndex' in 'ctrace::stack::analysis::ParameterDebugBinding'` (twice; `evidence/217/p0/harness/red-final-compile-main.txt`). +`self` stays artificial, as on `main`: Clang marks it an object pointer, which the binding has always treated as synthetic. Its source index is 0 all the same, since the source declares it. `tag` takes no IR argument, so no check lists it. Expected RED: `error: no member named 'sourceIndex' in 'ctrace::stack::analysis::ParameterDebugBinding'`. -- [ ] **Step 15: Give each parameter its source index.** In `include/analysis/ParameterDebugBinding.hpp`, add `#include ` after `#include `, and after `unsigned column = 0;`: +- [ ] **Step 11: Give each parameter its source index.** In `include/analysis/ParameterDebugBinding.hpp`, add `#include ` after `#include `, and after `unsigned column = 0;`: ```cpp // The parameter's position among those the source declares, from 0. None for one the @@ -781,9 +631,7 @@ and the comment above the test with: std::optional sourceIndex; ``` -The struct keeps no implicit padding: `std::optional` takes 8 bytes at a 4-byte boundary, after `column`. - -In `src/analysis/ParameterDebugBinding.cpp`, after `isSyntheticDebugVariable`: +The struct keeps no implicit padding: `std::optional` takes 8 bytes at a 4-byte boundary, after `column`. In `src/analysis/ParameterDebugBinding.cpp`, add `#include ` after `#include `, and after `isSyntheticDebugVariable`: ```cpp // The position of @p param among the parameters the source declares: the artificial ones @@ -812,22 +660,148 @@ In `resolveParameterDebugBinding`, before `finalizeBindingName(binding, Arg);`: binding.sourceIndex = sourceIndexOf(F, *spilled); ``` -- [ ] **Step 16: Build and run, expect GREEN.** The 13 checks pass with their source indexes (`evidence/217/p0/harness/green-final-prototype-v2.txt`). +Expected: the 12 checks pass with their source indexes. Commit: `feat(analysis): give a bound parameter its position among the source's`; it carries its test with its code, so that every commit builds. -- [ ] **Step 17: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed. +- [ ] **Step 12: Untyped declarations, once the user authorizes these cases.** A declaration that gives no type does not occur in Clang's output; the subroutine type then serves only where its entries are established, which the hand-written IR below checks. Create `test/unit/parameter_binding_unknown_input.ll`: -- [ ] **Step 18: Commit.** +```llvm +; SPDX-License-Identifier: Apache-2.0 +; A parameter's declaration gives its type; when it gives none, the debug type of the function +; serves only where its entries are the declared parameters in order: when no artificial parameter +; other than `this` exists. vtt, is_most_derived or a block's descriptor make the position +; ambiguous, since the debug type lists some of them and not others. + +; An artificial parameter other than `this`: the debug type's entries are ambiguous. x's +; declaration gives its type, double *, which is kept. +define void @ambiguous_typed(ptr %hidden, ptr %x) !dbg !20 { +entry: + %hidden.addr = alloca ptr + %x.addr = alloca ptr + store ptr %hidden, ptr %hidden.addr + #dbg_declare(ptr %hidden.addr, !21, !DIExpression(), !23) + store ptr %x, ptr %x.addr + #dbg_declare(ptr %x.addr, !22, !DIExpression(), !23) + ret void, !dbg !23 +} -```bash -git add include/analysis/ParameterDebugBinding.hpp src/analysis/ParameterDebugBinding.cpp test/unit/analyzer_module_unit_tests.cpp -git commit -m "feat(analysis): give a bound parameter its position among the source's" +; The same, but x's declaration gives no type: x stays without one. +define void @ambiguous_untyped(ptr %hidden, ptr %x) !dbg !30 { +entry: + %hidden.addr = alloca ptr + %x.addr = alloca ptr + store ptr %hidden, ptr %hidden.addr + #dbg_declare(ptr %hidden.addr, !31, !DIExpression(), !33) + store ptr %x, ptr %x.addr + #dbg_declare(ptr %x.addr, !32, !DIExpression(), !33) + ret void, !dbg !33 +} + +; No artificial parameter: the debug type's entries are the parameters, in order. x's declaration +; gives no type, the debug type gives double *. +define void @established_untyped(ptr %x) !dbg !40 { +entry: + %x.addr = alloca ptr + store ptr %x, ptr %x.addr + #dbg_declare(ptr %x.addr, !41, !DIExpression(), !42) + ret void, !dbg !42 +} + +!llvm.dbg.cu = !{!0} +!llvm.module.flags = !{!2, !3} + +!0 = distinct !DICompileUnit(language: DW_LANG_C_plus_plus_14, file: !1, producer: "hand-written", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug) +!1 = !DIFile(filename: "parameter_binding_unknown_input.ll", directory: ".") +!2 = !{i32 7, !"Dwarf Version", i32 5} +!3 = !{i32 2, !"Debug Info Version", i32 3} +!10 = !DIBasicType(name: "int", size: 32, encoding: DW_ATE_signed) +!11 = !DIBasicType(name: "double", size: 64, encoding: DW_ATE_float) +!12 = !DIDerivedType(tag: DW_TAG_pointer_type, baseType: !10, size: 64) +!13 = !DIDerivedType(tag: DW_TAG_pointer_type, baseType: !11, size: 64) +!14 = !DISubroutineType(types: !15) +!15 = !{null, !12, !12, !12} +!16 = !DISubroutineType(types: !17) +!17 = !{null, !13} +!20 = distinct !DISubprogram(name: "ambiguous_typed", scope: !1, file: !1, line: 1, type: !14, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0) +!21 = !DILocalVariable(name: "hidden", arg: 1, scope: !20, file: !1, line: 1, type: !12, flags: DIFlagArtificial) +!22 = !DILocalVariable(name: "x", arg: 2, scope: !20, file: !1, line: 1, type: !13) +!23 = !DILocation(line: 1, scope: !20) +!30 = distinct !DISubprogram(name: "ambiguous_untyped", scope: !1, file: !1, line: 2, type: !14, scopeLine: 2, spFlags: DISPFlagDefinition, unit: !0) +!31 = !DILocalVariable(name: "hidden", arg: 1, scope: !30, file: !1, line: 2, type: !12, flags: DIFlagArtificial) +!32 = !DILocalVariable(name: "x", arg: 2, scope: !30, file: !1, line: 2) +!33 = !DILocation(line: 2, scope: !30) +!40 = distinct !DISubprogram(name: "established_untyped", scope: !1, file: !1, line: 3, type: !16, scopeLine: 3, spFlags: DISPFlagDefinition, unit: !0) +!41 = !DILocalVariable(name: "x", arg: 1, scope: !40, file: !1, line: 3) +!42 = !DILocation(line: 3, scope: !40) ``` -This commit carries its test with its code, so that every commit builds; its failure evidence is the compile error of Step 14. +Declare `LoadedModule unestablished;` after `LoadedModule blocks;`, load it last: + +```cpp + !load("parameter_binding_block_input.c", {"-fblocks"}, blocks) || + !load("parameter_binding_unknown_input.ll", {}, unestablished)) + return false; +``` + +and add, after the last check of Step 10: + +```cpp + // A declaration's type is kept; without one, the debug type serves only where its + // entries are established. + check(unestablished, "ambiguous_typed", + {"hidden: artificial, source none", "x: double *, source 0"}); + check(unestablished, "ambiguous_untyped", + {"hidden: artificial, source none", "x: no type, source 0"}); + check(unestablished, "established_untyped", {"x: double *, source 0"}); + +``` + +Expected RED: `[FAIL] ParameterDebugBinding: ambiguous_untyped [hidden: artificial, source none] [x: int *, source 0]`; `ambiguous_typed` passes since Step 8, `established_untyped` keeps `main`'s fallback. Commit: `test(const-param): an untyped declaration takes the debug type only where it is established`. + +- [ ] **Step 13: Use the debug type of the function only where its entries are established.** Add `#include ` before `#include `, and before `buildDebugParamLayout`: + +```cpp + // Whether F has an artificial parameter other than `this`: a vtt, an is_most_derived or a + // block's descriptor. The subroutine type lists the descriptor, not what the C++ ABI adds. + static bool hasArtificialOtherThanThis(const llvm::Function& F) + { + return llvm::any_of(F.args(), + [](const llvm::Argument& other) + { + const llvm::DILocalVariable* var = spilledParameter(other); + return var && var->isArtificial() && !var->isObjectPointer(); + }); + } + +``` + +In `buildDebugParamLayout`, replace the spilled branch with: + +```cpp + // The spill slot names the source parameter whatever the ABI did to the list; the + // position below only serves arguments that are not spilled, like a hidden sret. + if (const llvm::DILocalVariable* spilled = spilledParameter(Arg); + spilled && spilled->getArg() != 0) + { + // Its declaration gives its type. Without one, the subroutine type's entry at its + // debug number is its own only when no parameter makes that position ambiguous. + if (spilled->getType() || + (spilled->getArg() <= listed && !hasArtificialOtherThanThis(F))) + { + layout.debugArgIndex = spilled->getArg(); + layout.hasCanonicalDebugIndex = true; + } + return layout; + } + +``` + +Expected: the 15 checks pass. Mutants checked on the prototype: without the fallback for an untyped declaration, `established_untyped` fails; with a fallback even where the position is ambiguous, `ambiguous_untyped` fails. Commit: `fix(const-param): use the debug type of the function only where its entries are established`. + +- [ ] **Step 14: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed; a formatting change, if any, goes in its own commit. **Validation of P0.1:** -- `testParameterDebugBinding`: 13 checks pass; both unit test executables pass. -- `virtual-base-vtt.cpp` passes in the default and smt-z3 passes. +- `testParameterDebugBinding`: 15 checks pass (12 before Step 12); both unit test executables pass. +- `virtual-base-vtt.cpp` passes in the default and smt-z3 passes (14 of 14). - The suite passes on macOS with no expectation changed; no new warning; clang-format clean. ### Task P0.2: measure the effect without the option, then deliver @@ -838,7 +812,7 @@ Expected effect on the rule, without the option: - Microsoft ABI targets: no suggestion on `is_most_derived`; - blocks (`-fblocks`, the default on macOS): no suggestion on `.block_descriptor`; - variadic functions: the `...` entry is no parameter's, so the constructors above keep their types when variadic, and a variadic function that returns a struct through a hidden argument no longer types that argument as its first parameter; -- a parameter whose entry the debug information does not establish gets no type, so the rule does not report it, instead of reporting it with a guessed type (no such case in Clang's output so far); +- a parameter whose declaration gives no type, in a function with an artificial parameter other than `this`, gets no type instead of a guessed one, so the rule does not report it (Clang always gives the type); - nothing else: line, column, confidence and name of every other parameter unchanged; the other rules use `spilledParameter` and `declaredVariable`, which do not change. - [ ] **Step 1: Compare every fixture's output.** From de8510a21282cd88f2261f1d0ac85c3ac1f9847b Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 18:44:43 +0200 Subject: [PATCH 028/152] docs(plan): align P0's test steps with their commits --- .../plans/2026-10-16-const-suggestion-check-v2.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 1d46c6d..5a84039 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -410,7 +410,7 @@ and add, after the block's check: ```cpp // A variadic function's debug type ends with a null entry for `...`, which no parameter - // takes; an empty parameter takes no IR argument, but keeps its debug declaration. + // takes. check(itanium, "_ZN7VarargsC2EPiPdz", {"this: artificial", "vtt: artificial", "x: int *", "y: double *"}); check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", @@ -447,7 +447,7 @@ Tagged::Tagged(int* x, Tag tag) ``` -and, after the variadic checks: `check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial", "vtt: artificial", "x: int *"});`. Expected RED: `[FAIL] ParameterDebugBinding: _ZN6TaggedC2EPi3Tag [this: artificial] [vtt: int *] [x: ? *]`. Commit: `test(const-param): an empty parameter shifts no other parameter`. +and, after the variadic checks: `check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial", "vtt: artificial", "x: int *"});`; the comment above the variadic checks gains `; an empty parameter takes no IR argument, but keeps its debug declaration`. Expected RED: `[FAIL] ParameterDebugBinding: _ZN6TaggedC2EPi3Tag [this: artificial] [vtt: int *] [x: ? *]`. Commit: `test(const-param): an empty parameter shifts no other parameter`. Step 7c, a variadic hidden return. Append to the input: @@ -595,20 +595,19 @@ Replace the expectations with: check(blocks, "__use_block_block_invoke", {".block_descriptor: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); - // A variadic function's debug type ends with a null entry for `...`, which no parameter // takes; an empty parameter takes no IR argument, but keeps its debug declaration. check(itanium, "_ZN7VarargsC2EPiPdz", {"this: artificial, source none", "vtt: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); + check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", + {"this: artificial, source none", "is_most_derived: artificial, source none", + "x: int *, source 0", "y: double *, source 1"}); check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial, source none", "vtt: artificial, source none", "x: int *, source 0"}); check(itanium, "_Z8make_bigPiz", {"agg.result: artificial, source none", "p: int *, source 0"}); - check(microsoft, "??0Varargs@@QEAA@PEAHPEANZZ", - {"this: artificial, source none", "is_most_derived: artificial, source none", - "x: int *, source 0", "y: double *, source 1"}); ``` and the comment above the test with: From 90745aab58f93749e211b46d7f272fd3bcead2e2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 19:28:37 +0200 Subject: [PATCH 029/152] docs(plan): record the independent review of P0 Step 9 recognizes the hidden return argument by its sret attribute instead of leaving the ... entry out of the count, which misbound optimized variadic functions. The effects list the artificial parameters that are no longer reported; Step 14 holds the review's test cases. --- .../2026-10-16-const-suggestion-check-v2.md | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 5a84039..626bda6 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -68,7 +68,7 @@ P0, from `main`: | File | Responsibility | |---|---| | `include/analysis/ParameterDebugBinding.hpp` | `ParameterDebugBinding::sourceIndex`. | -| `src/analysis/ParameterDebugBinding.cpp` | Entry in the subroutine type past the parameters it does not list; artificial from the parameter's own declaration; source index. | +| `src/analysis/ParameterDebugBinding.cpp` | Type and artificiality from the parameter's own declaration; the hidden return argument recognized by its attribute; source index. | | `test/unit/analyzer_module_unit_tests.cpp` | `testParameterDebugBinding`. | | `test/unit/parameter_binding_input.cpp`, `test/unit/parameter_binding_block_input.c` | Its inputs. | | `test/pointer_reference-const_correctness/virtual-base-vtt.cpp` | What the rule reports. | @@ -100,7 +100,7 @@ Root cause, at `main` `d1d9271`. At `-O0`, `buildDebugParamLayout` indexes the f - `is_most_derived` (an `int`) is reported as `double *is_most_derived`; - a block's `.block_descriptor` is reported as ` *.block_descriptor`. -The fix takes each spilled parameter's type and artificiality from its own declaration, the one `spilledParameter` finds; this alone binds every case above. The subroutine type stays a fallback for a declaration that gives no type, only where its entries are established, and for arguments without a declaration, like a hidden sret, whose count no longer includes the null entry that ends a variadic function's type. A first prototype reconciled positions in the subroutine type instead (`evidence/217/p0/prototype-v2.diff`); it was dropped once the direct declaration was shown to bind every case, with no output change on the 424 fixtures. +The fix takes each spilled parameter's type and artificiality from its own declaration, the one `spilledParameter` finds; this alone binds every case above. The subroutine type stays a fallback for a declaration that gives no type, only where its entries are established, and for arguments without a declaration, the hidden return argument being recognized by its sret attribute wherever the ABI puts it. A first version left the null entry that ends a variadic function's type out of the positional count instead: the independent review showed it misbound optimized variadic functions, and it was reverted. A first prototype reconciled positions in the subroutine type instead (`evidence/217/p0/prototype-v2.diff`); it was dropped once the direct declaration was shown to bind every case, with no output change on the 424 fixtures. Reproductions: `evidence/217/p0/repro/` (`main-d1d9271.txt`, `caller-main-d1d9271.txt`, `block-main-d1d9271.txt`). @@ -558,16 +558,37 @@ and, before the fallback on the subroutine type (`if (!binding.type && layout.ha Expected: every check passes but `_Z8make_bigPiz`, whose hidden argument is not spilled; the fixture passes in both passes (14 of 14). Commit: `fix(const-param): take a parameter's type and artificiality from its own declaration`. -- [ ] **Step 9: Do not count the `...` of a variadic function.** In `buildDebugParamLayout`, after `return layout; // [0] return type, [1..] params.`: +- [ ] **Step 9: Leave the hidden return argument out of the positions.** In `buildDebugParamLayout`, before `const unsigned hiddenPrefix = irArgCount - debugParamCount;`, replace the computation of `irArgCount` and `debugParamCount` with: ```cpp - // A variadic function's type ends with a null entry for `...`: no parameter's. - const unsigned listed = - static_cast(typeArray.size()) - (typeArray[typeArray.size() - 1] ? 1 : 2); + // The hidden return argument is no source parameter, wherever the ABI puts it: first + // under the Itanium ABI, after `this` under the Microsoft one. Positions leave it out. + const llvm::Argument* hiddenReturn = nullptr; + for (const llvm::Argument& other : F.args()) + { + if (other.hasStructRetAttr()) + hiddenReturn = &other; + } + if (hiddenReturn == &Arg) + { + layout.isHiddenAbiArg = true; + return layout; + } + const unsigned irArgCount = + static_cast(F.arg_size()) - (hiddenReturn ? 1 : 0); + const unsigned argNo = + Arg.getArgNo() - + (hiddenReturn && hiddenReturn->getArgNo() < Arg.getArgNo() ? 1 : 0); + + const unsigned debugParamCount = static_cast(typeArray.size() - 1); + if (irArgCount < debugParamCount) + return layout; ``` -and compute `debugParamCount` as `listed` (`const unsigned debugParamCount = listed;`). Expected: the 12 checks pass. Commit: `fix(const-param): do not count the ... of a variadic function as a parameter`. +and use `argNo` instead of `Arg.getArgNo()` in the two lines that follow (`if (argNo < hiddenPrefix)`, `const unsigned debugParamOffset = argNo - hiddenPrefix;`). Expected: the 12 checks pass. Commit: `fix(const-param): leave a struct return argument out of the parameters' positions`. + +Executed history: a first Step 9 left the `...` entry out of the positional count (522344e); the independent review showed that optimized variadic functions were then misbound, so 21cfb67 reverts it, with `_Z8make_bigPiz` failing again, and 7cf76bf is this step. - [ ] **Step 10: Expect source indexes in the unit test.** In `describe`, after the `if`/`else` chain, add: @@ -796,10 +817,12 @@ In `buildDebugParamLayout`, replace the spilled branch with: Expected: the 15 checks pass. Mutants checked on the prototype: without the fallback for an untyped declaration, `established_untyped` fails; with a fallback even where the position is ambiguous, `ambiguous_untyped` fails. Commit: `fix(const-param): use the debug type of the function only where its entries are established`. -- [ ] **Step 14: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed; a formatting change, if any, goes in its own commit. +- [ ] **Step 14: Cases from the independent review, once the user authorizes them.** In `test/unit/parameter_binding_input.cpp`, a method of `Maker` returning `Big` (under the Microsoft ABI its hidden return argument comes after `this`) and `TagFirst(Tag tag, int* x)` (an empty first parameter); `test/unit/parameter_binding_o2_input.ll`, generated by Clang 20 at `-O2` for x86-64 Linux from a variadic constructor of a class with a virtual base and a variadic function taking a struct by value; `describe` names a type that is not a pointer; checks: `_ZN8TagFirstC2E3TagPi` (`x: int *, source 1`), `_ZN8VariadicC2EPiPdz` and `split_variadic` at `-O2` (each argument bound to its own declaration), and the Microsoft hidden return argument without a type. A fixture, `implicit-move-constructor.cpp`: an implicitly defined move constructor's parameter is not reported. Code and failure evidence: `evidence/217/p0/proposals/` and `harness/test_G.inc`. + +- [ ] **Step 15: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed; a formatting change, if any, goes in its own commit. **Validation of P0.1:** -- `testParameterDebugBinding`: 15 checks pass (12 before Step 12); both unit test executables pass. +- `testParameterDebugBinding`: every check passes (12 before Step 12, 15 after Step 13, 19 after Step 14); both unit test executables pass. - `virtual-base-vtt.cpp` passes in the default and smt-z3 passes (14 of 14). - The suite passes on macOS with no expectation changed; no new warning; clang-format clean. @@ -810,7 +833,8 @@ Expected effect on the rule, without the option: - constructors that call such a C2: the #167 contract reads the callee's real parameter types; a false "passed where a pointer to const is expected" disappears, and a true one can appear; - Microsoft ABI targets: no suggestion on `is_most_derived`; - blocks (`-fblocks`, the default on macOS): no suggestion on `.block_descriptor`; -- variadic functions: the `...` entry is no parameter's, so the constructors above keep their types when variadic, and a variadic function that returns a struct through a hidden argument no longer types that argument as its first parameter; +- variadic functions: the constructors above keep their types when variadic, and a hidden return argument is artificial wherever the ABI puts it; under the Microsoft ABI, the one after `this` no longer carries `this`'s const contract; +- parameters that Clang marks artificial are no longer reported: the parameter of an implicitly defined special member (an implicit move constructor's), the hidden argument that `pass_object_size` adds, the `__context` of a captured statement; - a parameter whose declaration gives no type, in a function with an artificial parameter other than `this`, gets no type instead of a guessed one, so the rule does not report it (Clang always gives the type); - nothing else: line, column, confidence and name of every other parameter unchanged; the other rules use `spilledParameter` and `declaredVariable`, which do not change. From 64f89a4545ba8a1dfa54e459e9325a6a0b4d6c30 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 16 Oct 2026 23:07:02 +0200 Subject: [PATCH 030/152] docs(plan): align P0's last commits with the commit checker --- .../plans/2026-10-16-const-suggestion-check-v2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 626bda6..b57d5ad 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -775,7 +775,7 @@ and add, after the last check of Step 10: ``` -Expected RED: `[FAIL] ParameterDebugBinding: ambiguous_untyped [hidden: artificial, source none] [x: int *, source 0]`; `ambiguous_typed` passes since Step 8, `established_untyped` keeps `main`'s fallback. Commit: `test(const-param): an untyped declaration takes the debug type only where it is established`. +Expected RED: `[FAIL] ParameterDebugBinding: ambiguous_untyped [hidden: artificial, source none] [x: int *, source 0]`; `ambiguous_typed` passes since Step 8, `established_untyped` keeps `main`'s fallback. Commit: `test(const-param): an untyped parameter takes the debug type only where established`. - [ ] **Step 13: Use the debug type of the function only where its entries are established.** Add `#include ` before `#include `, and before `buildDebugParamLayout`: @@ -815,9 +815,9 @@ In `buildDebugParamLayout`, replace the spilled branch with: ``` -Expected: the 15 checks pass. Mutants checked on the prototype: without the fallback for an untyped declaration, `established_untyped` fails; with a fallback even where the position is ambiguous, `ambiguous_untyped` fails. Commit: `fix(const-param): use the debug type of the function only where its entries are established`. +Expected: the 15 checks pass. Mutants checked on the prototype: without the fallback for an untyped declaration, `established_untyped` fails; with a fallback even where the position is ambiguous, `ambiguous_untyped` fails. Commit: `fix(const-param): use the debug type only where its entries are established`. -- [ ] **Step 14: Cases from the independent review, once the user authorizes them.** In `test/unit/parameter_binding_input.cpp`, a method of `Maker` returning `Big` (under the Microsoft ABI its hidden return argument comes after `this`) and `TagFirst(Tag tag, int* x)` (an empty first parameter); `test/unit/parameter_binding_o2_input.ll`, generated by Clang 20 at `-O2` for x86-64 Linux from a variadic constructor of a class with a virtual base and a variadic function taking a struct by value; `describe` names a type that is not a pointer; checks: `_ZN8TagFirstC2E3TagPi` (`x: int *, source 1`), `_ZN8VariadicC2EPiPdz` and `split_variadic` at `-O2` (each argument bound to its own declaration), and the Microsoft hidden return argument without a type. A fixture, `implicit-move-constructor.cpp`: an implicitly defined move constructor's parameter is not reported. Code and failure evidence: `evidence/217/p0/proposals/` and `harness/test_G.inc`. +- [ ] **Step 14: Cases from the independent review, once the user authorizes them; one commit each, subjects of at most 84 characters (`scripts/ci/commit_checker.py`).** In `test/unit/parameter_binding_input.cpp`, a method of `Maker` returning `Big` (under the Microsoft ABI its hidden return argument comes after `this`) and `TagFirst(Tag tag, int* x)` (an empty first parameter); `test/unit/parameter_binding_o2_input.ll`, generated by Clang 20 at `-O2` for x86-64 Linux from a variadic constructor of a class with a virtual base and a variadic function taking a struct by value; `describe` names a type that is not a pointer; checks: `_ZN8TagFirstC2E3TagPi` (`x: int *, source 1`), `_ZN8VariadicC2EPiPdz` and `split_variadic` at `-O2` (each argument bound to its own declaration), and the Microsoft hidden return argument without a type. A fixture, `implicit-move-constructor.cpp`: an implicitly defined move constructor's parameter is not reported. Code and failure evidence: `evidence/217/p0/proposals/` and `harness/test_G.inc`. - [ ] **Step 15: Format, full suite, warnings.** Run clang-format on the changed C++ files, the build (no warning: `-Wpadded` is on), both unit test executables, and the suite. Expected: everything passes, no expectation changed; a formatting change, if any, goes in its own commit. From a9c2e30ec97cb4e9dd883aafaa874631bad2335f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:37 +0200 Subject: [PATCH 031/152] docs(plan): detail stage 1's texts, rulings and shared types --- .../2026-10-16-const-suggestion-check-v2.md | 127 ++++++++++++++++-- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index b57d5ad..1d9b903 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -10,7 +10,7 @@ **Spec:** `docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md`, approved at `8b283a8`. -**Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. +**Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. Stage 1's detail was written at its start, once Task I.1 was validated. **Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype-v1.diff`, `prototype-v2.diff`, `prototype-v3.diff`, `ir/`, `variadic/`, `review-v3/`). @@ -868,19 +868,124 @@ Expected: `changed: 0` among `main`'s fixtures (the script normalizes the logger ## Stage 1 — flow, identities, plan and scope, facts, correspondences, collisions, trace (§9.3) -Catalogue: D1 (T part), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17. Tests are submitted with their failure evidence at the start of each task. +Catalogue: D1 (T part), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17. + +**Detail, written at the stage's start** (Task I.1 validated at `64ed37e`). Each new or changed test is written out in full with its failure at `64ed37e`. They were run from `evidence/217/stage1/tests/`: the proposed `run_test.py` (`patched/`), the fixtures (`fixtures/new/`, `fixtures/changed/`), the runner `run_red.py` and its output `red-64ed37e.txt`. A test enters the repository only once the user authorizes it; its task starts by running it again to watch it fail. For the code, this section gives the interfaces, the decision logic and every new text; T v1's code that only moves keeps its form and is named. + +Commands used by the steps, from the worktree root: +- one check: `python3 -c 'import sys, run_test as r; r.RUN_CONFIG.cache_enabled = False; sys.exit(not r.())'`; +- one fixture: `python3 -c 'import sys, run_test as r; from pathlib import Path; r.RUN_CONFIG.cache_enabled = False; ok, total, passed, report = r.check_file(Path("test/pointer_reference-const_correctness/")); print(report); sys.exit(not ok)'`; +- build, unit tests, suite and format: Global Constraints. **D17 (confirmed by the user).** §5.10 makes a stale declaration indeterminate. T v1 removes that suggestion, and two checks pin it: `check_const_suggestion_cross_tu` (`("def and a redeclaration", ["def.c", "redecl.c"], [])`) and `check_const_suggestion_compilation_database` (`("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None)`). Both expectations change in Task 1.6, submitted with their failure evidence; the spec's catalogue and §7 list D17. Indeterminate keeps the suggestion as not checked: it does not show that the suggestion is valid, and the reason says so through the file and the link-edition assumption. -Shared types, in `ConstSuggestionInternal.hpp` (fixed at the stage's start, from §3): -- `enum class Verdict : std::uint8_t { Verified, Refuted, Indeterminate };` and `struct Judgement { Verdict verdict; std::string reason; }`; -- `struct UnitConfiguration { std::string file; UnitCommand command; std::string fingerprint; }`, the fingerprint hashing the normalized arguments and working directory; -- `struct DeclarationSite { std::string file; unsigned offset; }` (real path, offset in the original text); -- `enum class SourceLinkage : std::uint8_t { None, Internal, External };` and `struct EntityIdentity { std::vector redeclarations; SourceLinkage linkage; std::optional templateOrigin; }`; -- `struct Candidate { std::string type; std::string alternative; }` (§3 table); -- `struct SuggestionPlan { EntityIdentity entity; unsigned sourceIndex; Candidate candidate; std::map> edits; std::optional indeterminate; }`; -- `struct FactKey { std::string unit; std::vector instantiationContexts; DeclarationSite spelling; DeclarationSite expansion; }`, `enum class FactKind { Reference, Override, Constant }`, `struct Fact { FactKind kind; FactKey key; std::string value; }`; -- `struct SymbolPair { std::string before; std::string after; }`. +### Texts + +Every new text, verbatim. `` is the name the diagnostic shows, `` a symbol, `` a real path, `''` a demangled name. + +Reasons of an indeterminate verdict, after `not checked with Clang: ` (shown in the diagnostic): + +| Reason | Task | When | +|---|---|---| +| `the parameter's type is written in a macro argument that writes other parameters too` | 1.3 | D5: the text an edit rewrites writes another parameter | +| `the parameter's type is written in a macro argument that writes other declarations too` | 1.3 | the same, for another declaration | +| ` could not be inspected once rewritten` | 1.4 | step 2 | +| `the rewrite also changes the type of (:)` | 1.4 | step 3 | +| `the generated code changes, and no reference, override or constant condition explains it: ` | 1.5 | step 7 (D16); `` is the comparator's text | +| `'' and '' both become '': their correspondence is not one to one` | 1.5 | §5.7, D14 | +| `'' becomes both '' and '': their correspondence is not one to one` | 1.5 | §5.7 | +| ` is also defined in ; if and are linked together, the two definitions collide` | 1.6 | §5.10, strong (D4) | +| ` is also defined, weak, in ; if and are linked together, the definition in is used and the other one is ignored` | 1.6 | §5.10, weak | +| ` is also defined in , as an inline or template definition; if and are linked together, only one of the two is kept, and the check does not establish that they are equivalent` | 1.6 | §5.10, `weak_odr`, `linkonce_odr` | +| `the rewrite gives the symbol of another declaration (:); if a file linked with defines it, the two definitions collide` | 1.6 | ruling 3 | +| ` declares with its current type and does not see the rewrite: if and are linked together, that declaration no longer matches the rewritten definition` | 1.6 | D17 | +| `: converts the address of with an explicit cast` | 1.7 | D15 | + +Reasons of a refuted verdict, in the trace only (a refuted suggestion is removed), after T v1's `removed, `: + +| Reason | Task | +|---|---| +| `: refers to '' instead of ''` | 1.4, R | +| `'' overrides instead of ` (`'', ''`, or `nothing`) | 1.4, O | +| `the condition at : becomes ` | 1.4, K (`if constexpr`) | +| `the _Generic selection at : chooses another association` | 1.4, K | + +T v1's texts stay, `does not compile: :: …` and `gains a checked warning: :: …` included. + +Trace (`--verbose`). T v1's line `[const-suggestion] parameter : ` comes first, unchanged. The new lines follow it; none matches T v1's pattern `(\S+) parameter (\S+): `, which `check_const_suggestion_removal_reason` reads: + +``` +[const-suggestion] parameter , suggestion: on , source index +[const-suggestion] parameter , transformation: becomes () +[const-suggestion] parameter , rewritten at: : +[const-suggestion] parameter , unit []: [: ] +[const-suggestion] parameter , verdict: [: ] +``` +- one `rewritten at` line per edit, none when the plan is not executed; one `unit` line per unit judged, in judgement order (Task 1.1 adds these lines, Task 1.8 the others); +- ``: `verified`, `refuted` or `indeterminate`; +- ``, from §3: `pointer to const`, `pointer to const, top-level const kept`, `reference to const`, `reference to const; passing by value is not checked`; +- ``: 16 lowercase hexadecimal digits, `llvm::xxh3_64bits` of the unit's arguments and working directory as T builds them, each followed by a NUL byte. + +### Rulings + +The spec leaves these open; each one is the user's to overturn. +1. **Which reason is shown.** A refuted suggestion gives the reason of the first unit that refutes it, in judgement order: the defining unit, then the others in T v1's order. An indeterminate one gives the reason of the first indeterminate unit in that order; if no unit is indeterminate, the first collision's, a definition before a declaration. Cost if wrong: another reason is shown; the verdict stays. +2. **The initial compilation of another unit is shared by the suggestions of one module.** T runs once per analyzed module (`checkConstSuggestions`): a unit that sees the changes of two modules is compiled once as it is for each. Sharing it across modules needs the facts of entities not planned yet; Task 3.1 measures what it would save. Cost if wrong: one more initial compilation per module and unit. +3. **A declaration of another entity that the rewrite merges into the entity** (t9 without `b.cpp`: `api.hpp` also declares `f(const int*)`) is a possible collision (§3, "collision possible entre fichiers"): indeterminate, with its own reason, shown after a definition's. Cost if wrong: a suggestion kept as not checked that a definition elsewhere would refute or not. +4. **R identifies a target by its declarations.** The value of an R fact is its target's declaration sites, mapped back to the original text; for a template specialization or an implicit function, which have no declaration of their own, its symbol, mapped back through the established pairs. An R fact is unchanged when its target after the rewrite keeps every declaration it had: a merge (ruling 3) adds the entity's, and the collisions judge it, not the facts. A target that loses a declaration, or another target, refutes. A symbol alone would not do: once t9 is rewritten, `_Z1fPKi` names both the entity and the overload it merges with. Cost if wrong: a merge refuted, or a reference to the wrong declaration missed. +5. **D3 is pinned in Task 1.4, not 1.6**: both directions are fixed by the source identity with its linkage, which Task 1.4 brings. Task 1.6 keeps D4, Review Focus 3 and D17. +6. **Task 1.1 gets a second test**, the shared initial compilation, counted with `--timing`: the plan named it as work but did not pin it. +7. **D7's fixture holds four cases**: t8, and as templates a base class template, a derived class template and a constructor template. The technique of §5.7 holds on all four in `evidence/217/stage1/d7-probe/` (`probe.cpp`, `probe-output.txt`): `getInheritedConstructor()` finds the constructor each inheriting constructor comes from, and the symbols CodeGen gives the inheriting constructors are the IR's, before and after. So D7 expects verified. +8. **The two reasons of Task 1.2 are not added.** After P0, the rule reports no artificial parameter, and every parameter of the `-O0` code the analyzer compiles from sources is spilled, so it has a source index: no input reaches those reasons, and no test could fail first. A binding without a source index, or artificial, keeps T v1's text `the parameter is not found in the source`. Cost if wrong: a less precise reason on an input not found. +9. **The comparator's name heuristics are removed** once the established pairs replace them (Task 1.5): the masked encodings, `withoutConst`, `variantOf` and the template keys. The difference text keeps listing the symbols left without a pair, which locates the difference, as §5.9 asks. Cost if wrong: a trace that no longer suggests a probable counterpart. +10. **D12 (Task 2.4) also changes the `ReferenceRvaluePreferValue` block that D14 adds** to `const-suggestion-deduction-refused.cpp`. +11. **Task 1.4 gets a check of the scope after the rewrite** (`check_const_suggestion_header_read_differently`): a header that another file reads as other declarations, through a macro it defines otherwise. Without it, step 3 of §5.8 would have no failing test: the plan's scope (Task 1.3) holds in the defining unit. +12. **No error limit, and step 2 without a test.** T's compilations set no error limit. Otherwise a rewrite that breaks more than 19 uses would end in "too many errors", which is fatal, and step 2 would keep as not checked a suggestion that T v1 removes. Step 2 then guards only a fatal error that the rewrite itself brings, which inserting `const` does not bring in any input of the suite: it has no failing test. Cost if wrong: an untested guard. +13. **Task 1.4 gets a fixture where R and K refute without a change of code** (`const-suggestion-facts-without-code-change.cpp`). T v1 compares only code and keeps both suggestions as checked; since every refutation of T v1's tests comes with a code difference, the facts of Task 1.4 would otherwise have no failing test. + +### Shared types + +In `ConstSuggestionInternal.hpp`, each added by the task that first needs it: +```cpp +enum class Verdict : std::uint8_t { Verified, Refuted, Indeterminate }; // 1.1 +struct Judgement { Verdict verdict = Verdict::Verified; std::string reason; }; // 1.1 +struct UnitConfiguration { std::string file; UnitCommand command; std::string fingerprint; }; // 1.1 +struct DeclarationSite // 1.4 +{ + std::string file; // real path + unsigned offset = 0; // in the original text + auto operator<=>(const DeclarationSite&) const = default; +}; +enum class SourceLinkage : std::uint8_t { None, Internal, External }; // 1.4 +struct EntityIdentity // 1.4 +{ + std::vector redeclarations; // the defining unit's, sorted + SourceLinkage linkage = SourceLinkage::None; + std::optional templateOrigin; // the pattern's first declaration + std::vector symbols; // every variant's, before the rewrite (CodeGen) +}; +struct Candidate { std::string current; std::string type; std::string alternative; }; // 1.8 +struct SuggestionPlan // 1.3 +{ + EntityIdentity entity; // 1.4 + unsigned sourceIndex = 0; + Candidate candidate; // 1.8 + std::string expectedType; // the candidate, written canonically (T v1's expectedType) + std::map> edits; // by real path + std::optional indeterminate; // why the plan is not executed +}; +enum class FactKind : std::uint8_t { Reference, Override, Constant }; // 1.4 +struct FactKey // 1.4 +{ + std::vector contexts; // the instantiations around the fact, outermost first + DeclarationSite spelling; + DeclarationSite expansion; + auto operator<=>(const FactKey&) const = default; +}; +struct Fact { FactKind kind; FactKey key; std::vector value; }; // 1.4 +struct SymbolPair { std::string before; std::string after; }; // 1.5 +``` +`FactKey` has no unit: each unit's facts are kept apart. `Fact::value` is sorted: for R, the target's declaration sites, or its symbol (ruling 4); for O, the overridden methods, each the same way; for K, `true`, `false` or the selected association's index. ### Task 1.1: units, verdicts, aggregation, report From 2f74b72d33a0e43d675f177d787477f2e282c3c2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 032/152] docs(plan): detail task 1.1, units and verdicts --- .../2026-10-16-const-suggestion-check-v2.md | 221 +++++++++++++++++- 1 file changed, 216 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 1d9b903..62a0552 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -989,11 +989,222 @@ struct SymbolPair { std::string before; std::string after; }; ### Task 1.1: units, verdicts, aggregation, report -**Files:** `ConstSuggestionCheck.cpp`, `ConstSuggestionInternal.hpp`, `DiagnosticEmitter.cpp`. -**Interfaces:** produces `Verdict`, `Judgement`, `UnitConfiguration`, `aggregate(const std::vector&) -> Judgement` (§5.10: refuted if a unit refutes; else indeterminate if a unit or a collision is; else verified). -**Work:** one unit per file and configuration that the loader provides; the units that see the change as §5.2 defines them, from T v1's scan; a unit without a command, that cannot be preprocessed or does not compile as it is, unverifiable (judgement step 1); one initial compilation per unit and configuration, whose artifact (diagnostics, prepared IR, defined symbols, and the facts its inspection collects for every planned entity) every suggestion shares; what depends on the entity is derived from that artifact, never by compiling the unit again (§5, step 3). T v1 compiles a unit once per function name, so two functions of one file cost two initial compilations; T v1's outcomes mapped onto the three verdicts with T v1's texts; the trace names each unit's file and configuration fingerprint on its own `[const-suggestion]` line. -**Tests:** Review Focus 4, a new `run_test.py` check (a compilation database that lists `cross-tu-const-suggestion-def.c` twice, with and without `-DREAD_TWICE`; the trace names one configuration for that file; the "checked" line stays T v1's). -**Validation:** every T v1 test passes unchanged; the new check passes. +**Files:** `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `run_test.py`. + +**Interfaces.** Produces `Verdict`, `Judgement`, `UnitConfiguration` (Shared types) and: +```cpp +// §5.10: refuted if a judgement refutes, else indeterminate if one is, else verified; with the +// reason of the first judgement that has that verdict (ruling 1). +Judgement aggregate(const std::vector& judgements); +// 16 hexadecimal digits: xxh3 of the arguments and the working directory, each NUL-terminated. +std::string fingerprintOf(const UnitCommand& command); +``` + +**Tests.** A helper that later tasks' checks share, and two checks, registered after `check_const_suggestion_both_modes`: + +```python +def _const_suggestion_run( + args: list[str], env_overrides: Optional[dict[str, str]] = None +) -> Optional[tuple[dict[str, list[str]], list[str], str]]: + """ + #217: runs the analyzer with the Clang check of const suggestions and its trace (--verbose), on + inputs the cache key does not cover. Returns the messages of the const suggestions by function + (a function reported twice, such as a constructor's two variants, has two), the trace's lines + without their "[const-suggestion] " prefix, and the whole output; None when the analysis did + not run. + """ + result = run_analyzer_uncached( + [*args, "--format=json", "--experimental-const-suggestion-check", "--verbose"], + env_overrides, + ) + if result.returncode != 0: + return None + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return None + messages: dict[str, list[str]] = {} + for d in diagnostics: + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified"): + function = str(d.get("location", {}).get("function", "")) + messages.setdefault(function, []).append(str(d.get("details", {}).get("message", ""))) + output = (result.stdout or "") + (result.stderr or "") + trace = [ + line.split("[const-suggestion] ", 1)[1] + for line in output.splitlines() + if "[const-suggestion] " in line + ] + return messages, trace, output +``` + +Review Focus 4, the one configuration of a file the database lists twice: + +```python +def check_const_suggestion_one_configuration_per_file() -> bool: + """ + #217: a compilation database can list a file twice, with different commands. The analysis + loads one command per file, and the Clang check of const suggestions compiles each file with + that one only: its trace names one configuration for each file, here the defining file, + analyzed, and a caller that only the database lists. + """ + print("=== Testing const suggestions with files the compilation database lists twice ===") + fixtures = (RUN_CONFIG.test_dir / "pointer_reference-const_correctness").resolve() + definition = fixtures / "cross-tu-const-suggestion-def.c" + caller = fixtures / "cross-tu-const-suggestion-caller.c" + unit = re.compile(r"read_value parameter p, unit (\S+) \[([0-9a-f]+)\]: ") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_twice_") as tmp: + database = Path(tmp).resolve() / "compile_commands.json" + # A macro the files do not use: the two commands differ, the code does not. + database.write_text( + json.dumps( + [ + { + "directory": str(fixtures), + "file": str(f), + "arguments": ["clang", *extra, "-I", str(fixtures), "-c", str(f)], + } + for f in (definition, caller) + for extra in ([], ["-DLISTED_TWICE"]) + ] + ) + ) + run = _const_suggestion_run([str(definition), f"--compile-commands={database}"]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + configurations: dict[str, set[str]] = {} + for line in trace: + match = unit.match(line) + if match: + configurations.setdefault(match.group(1), set()).add(match.group(2)) + checked = "checked in every file of the compilation database that sees the change" + ok = True + for label, good in ( + ( + "the suggestion is checked in the compilation database", + any(checked in text for text in messages.get("read_value", [])), + ), + ( + "one configuration of the defining file", + len(configurations.get(str(definition), ())) == 1, + ), + ("one configuration of the caller", len(configurations.get(str(caller), ())) == 1), + ): + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {configurations}")) + ok = ok and good + print() + return ok +``` + +Ruling 6, the initial compilation shared by the suggestions: + +```python +def check_const_suggestion_compiles_each_file_once() -> bool: + """ + #217: the Clang check compiles each file that sees a change once as it is, for all the + suggestions it checks there: here two functions, defined in one file and called in another. + Each file is compiled once before the rewrites (`--timing`), and both suggestions are checked. + """ + print("=== Testing that each file is compiled once as it is for several const suggestions ===") + sources = { + "readers.h": "int read_a(int* p);\nint read_b(int* q);\n", + "readers.c": '#include "readers.h"\n' + "int read_a(int* p)\n{\n return *p;\n}\n" + "int read_b(int* q)\n{\n return *q + 1;\n}\n", + "user.c": '#include "readers.h"\n' + "int use_both(void)\n{\n int v = 1;\n return read_a(&v) + read_b(&v);\n}\n", + } + calls = re.compile(r"const_suggestion\.compile\.(\w+) .*\bcalls=(\d+)") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_once_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + # Every hotspot, not the 20 longest. + run = _const_suggestion_run( + [str(root / "readers.c"), str(root / "user.c"), "--timing"], + {"CTRACE_HOTSPOT_TOP": "200"}, + ) + if run is None: + return fail_check("the analysis did not run") + messages, _, output = run + compiled = {m.group(1): int(m.group(2)) for m in calls.finditer(output)} + checked = "checked in the analyzed files only" + ok = True + for label, good in ( + ( + "both suggestions are checked", + all(any(checked in t for t in messages.get(f, [])) for f in ("read_a", "read_b")), + ), + ("the defining file is compiled once as it is", compiled.get("defining_before") == 1), + ("the other file is compiled once as it is", compiled.get("other_before") == 1), + ): + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {compiled}")) + ok = ok and good + print() + return ok +``` + +Failure at `64ed37e`: no trace line names a unit; the caller is compiled once per function name. + +```text +check_const_suggestion_one_configuration_per_file: FAIL +=== Testing const suggestions with files the compilation database lists twice === + ✅ the suggestion is checked in the compilation database + ❌ one configuration of the defining file: {} + ❌ one configuration of the caller: {} +``` + +```text +check_const_suggestion_compiles_each_file_once: FAIL +=== Testing that each file is compiled once as it is for several const suggestions === + ✅ both suggestions are checked + ✅ the defining file is compiled once as it is + ❌ the other file is compiled once as it is: {'after': 4, 'other_before': 2, 'defining_before': 1} +``` + +- [ ] **Step 1: Add the helper and the two checks, then run them.** + Run: the one-check command for `check_const_suggestion_one_configuration_per_file`, then for `check_const_suggestion_compiles_each_file_once`. + Expected: FAIL, as above. +- [ ] **Step 2: Commit the tests.** + `test(const-param): name the one configuration of a file listed twice`; + `test(const-param): compile each file once as it is for all its suggestions`. +- [ ] **Step 3: Judgements and aggregation.** Add `Verdict`, `Judgement`, `UnitConfiguration` and `aggregate` to `ConstSuggestionInternal.hpp` and `ConstSuggestionCheck.cpp`: + ```cpp + Judgement aggregate(const std::vector& judgements) + { + for (const Verdict verdict : {Verdict::Refuted, Verdict::Indeterminate}) + { + for (const Judgement& judgement : judgements) + { + if (judgement.verdict == verdict) + return judgement; + } + } + return {}; + } + ``` + `checkUnit` returns a `Judgement` instead of its `bool` and its `removed` and `notChecked` strings: T v1's `removed` text is a refutation's reason, its `notChecked` text an indeterminate one's, and a unit that passes is verified. The loop over the units keeps T v1's order and stops at the first refutation, as T v1 does. The outcome maps onto T v1's report: refuted removes the suggestion (`removed, `), indeterminate adds `kNotChecked + reason` (`not checked, `), verified adds the "checked" line and the ABI line, unchanged. + Commit: `refactor(const-param): judge each unit and aggregate three verdicts`. +- [ ] **Step 4: Units and their configuration.** A unit is a `UnitConfiguration`: the file's real path (`canonicalPath`), its `UnitCommand` (`commandFor`, cached as T v1 caches it in `AnalyzedUnits::commands`), and `fingerprintOf(command)`: + ```cpp + std::string fingerprintOf(const UnitCommand& command) + { + std::string text; + for (const std::string& arg : command.args) + text.append(arg).push_back('\0'); + text.append(command.workingDir).push_back('\0'); + return llvm::utohexstr(llvm::xxh3_64bits(text), /*LowerCase=*/true, /*Width=*/16); + } + ``` + After each unit's judgement, the trace gives a line ` parameter , unit []: [: ]`, through a second `coretrace::log` call next to T v1's `trace`. + Run: the one-check command for `check_const_suggestion_one_configuration_per_file`. Expected: PASS. + Commit: `feat(const-param): name each unit and its configuration in the trace`. +- [ ] **Step 5: One initial compilation per unit and configuration.** `originalOf` is keyed by the unit (`file` and `fingerprint`), no longer by `(unit, function name)`, and compiles the unit once with every target of the module: `Original` keeps one entry of facts per target (`std::vector`, indexed like `targets`). `checkUnit` reads the entry of its suggestion. + Run: the one-check command for `check_const_suggestion_compiles_each_file_once`. Expected: PASS. + Commit: `perf(const-param): compile each other file once as it is for all suggestions`. +- [ ] **Step 6: Task tests.** Build; unit tests; the T v1 checks and fixtures (`python3 run_test.py --jobs 8 --no-cache`). Expected: all pass; the trace lines of T v1 unchanged (`check_const_suggestion_removal_reason`). + +**Validation:** every T v1 test passes unchanged; both new checks pass. ### Task 1.2: the parameter from P0 From eda8b5b6487fb226b00073da3d8175ccd323fa18 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 033/152] docs(plan): detail task 1.2, the parameter from P0 --- .../2026-10-16-const-suggestion-check-v2.md | 93 ++++++++++++++++++- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 62a0552..3064d0d 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1208,10 +1208,95 @@ check_const_suggestion_compiles_each_file_once: FAIL ### Task 1.2: the parameter from P0 -**Files:** `ConstSuggestionCheck.cpp` (or `ConstSuggestionPlan.cpp` once Task 1.3 creates it), `ConstParamAnalysis.cpp` (#221's `debugArgNo` removed). -**Interfaces:** consumes `ParameterDebugBinding::sourceIndex` (Task P0.1). -**Work:** the parameter is `issue.binding.sourceIndex`; none, or an artificial binding, gives indeterminate (`the parameter is not one the source declares`, `the parameter's position in the source is unknown`). -**Tests:** D1, a new fixture `const-suggestion-virtual-base.cpp` (t10: `x` verified in C1 and C2; at I.1, T rewrites `y` for the suggestion on `x` in C2); Review Focus 2: `const-suggestion-explicit-object.cpp` unchanged. +**Files:** `src/analysis/ConstSuggestionCheck.cpp`, `include/analysis/ConstParamAnalysis.hpp`, `src/analysis/ConstParamAnalysis.cpp`, `test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp` (new). + +**Interfaces.** Consumes `ParameterDebugBinding::sourceIndex` and `isArtificial` (P0). Removes `ConstParamIssue::debugArgNo` (#221) and `Target::debugArgNo`. + +**Tests.** D1, a new fixture (t10). `x` is reported twice, in C2 then C1; the ABI line tells them apart: + +`test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// #217: the base-object constructor (C2) of a class with a virtual base takes a hidden `vtt` after +// `this`. The check rewrites the parameter the suggestion names, found by its index among the +// parameters the source declares: `x`, in both variants. `y`, which the constructor writes +// through, is not rewritten. + +// strict-expectation-details: true + +struct V +{ + int v = 0; +}; + +struct D : virtual V +{ + int a, b; + D(int* x, int* y); +}; + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'D::D(int*, int*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_ZN1DC2EPiS0_ -> _ZN1DC2EPKiPi): code compiled separately must use the updated declaration and be recompiled + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'D::D(int*, int*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_ZN1DC1EPiS0_ -> _ZN1DC1EPKiPi): code compiled separately must use the updated declaration and be recompiled +D::D(int* x, int* y) : a(*x), b(0) +{ + *y = 1; +} + +int make() +{ + int p = 1, q = 2; + D d(&p, &q); + return d.a + q; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +Failure at `64ed37e`: C1's block is found, C2's is missing: T rewrites `y` for the suggestion on `x` in C2, and removes it (`read-only variable is not assignable`). + +```text +new/const-suggestion-virtual-base.cpp: FAIL (4/6) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-virtual-base.cpp === + ❌ (default) expectation #1 MISSING + ✅ (default) expectation #2 FOUND + ❌ (smt-z3) expectation #1 MISSING + ✅ (smt-z3) expectation #2 FOUND +``` + +Review Focus 2: `const-suggestion-explicit-object.cpp` unchanged, passing at `64ed37e`. + +- [ ] **Step 1: Add the fixture and run it.** Run: the one-fixture command for `const-suggestion-virtual-base.cpp`. Expected: FAIL, expectation #1 (C2) missing. + Commit: `test(const-param): check the parameter a C2 constructor's suggestion names`. +- [ ] **Step 2: The source index.** In `Inspector::plan`, the parameter is P0's: + ```cpp + const ParameterDebugBinding& binding = target.binding; + if (binding.isArtificial || !binding.sourceIndex) + { + facts.notRewritten = "the parameter is not found in the source"; + return; + } + facts.paramIndex = *binding.sourceIndex; + ``` + `Target` carries the issue's `binding` instead of `debugArgNo`; the `implicitThis` arithmetic goes. Ruling 8: no new text. + Run: the one-fixture command. Expected: PASS. Then `const-suggestion-explicit-object.cpp`: PASS. + Commit: `fix(const-param): rewrite the parameter at the source index P0 gives`. +- [ ] **Step 3: Remove `debugArgNo`.** `ConstParamIssue::debugArgNo`, its padding and its assignment in `ConstParamAnalysis.cpp` go: nothing reads them. + Commit: `refactor(const-param): drop the debug number that the source index replaces`. +- [ ] **Step 4: Task tests.** As Task 1.1, Step 6. + **Validation:** D1 passes; explicit-object unchanged; every T v1 test passes. ### Task 1.3: planning and plan scope (§5.5) From e70e0fc454ef27fda71778ab773717af213b0723 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 034/152] docs(plan): detail task 1.3, planning and plan scope --- .../2026-10-16-const-suggestion-check-v2.md | 117 +++++++++++++++++- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 3064d0d..114ff26 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1301,11 +1301,118 @@ Review Focus 2: `const-suggestion-explicit-object.cpp` unchanged, passing at `64 ### Task 1.3: planning and plan scope (§5.5) -**Files:** `ConstSuggestionPlan.cpp` (new; T v1's `Planner`, `writtenType`, `expectedType`, `inMacroDefinition` move here), `CMakeLists.txt` (new sources). -**Interfaces:** produces `std::optional plan(clang::ASTContext&, clang::Preprocessor&, const ConstParamIssue&, llvm::StringRef symbol)`. -**Work:** the entity located by the suggestion's symbol through CodeGen's mangling of each candidate declaration's `GlobalDecl` variants, then described by its source identity; the candidate built on the structured type; edits on each redeclaration's `TypeLoc` under the macro rules of §5.5, macro definitions recognized by the preprocessor (D11); the plan scope (D5). -**Tests:** D5, a new fixture (t3/m.c: a type written in a macro argument that `src` and `dst` share, indeterminate, the suggestion kept); D11, a new `run_test.py` case (t13: `\` followed by spaces, indeterminate); `const-suggestion-macros.c`, `const-suggestion-macro-params.c` and `check_const_suggestion_line_endings` unchanged. -**Validation:** D5 and D11 pass; T v1 macro tests unchanged. +**Files:** `src/analysis/ConstSuggestionPlan.cpp` (new), `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `CMakeLists.txt`, `test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c` (new), `run_test.py`. + +**Interfaces.** Produces: +```cpp +// The ranges of the macro definitions a compilation reads, by real path: from the macro's name +// to its last token, as offsets (D11). +using MacroDefinitions = std::map>>; +// Records them, for the compilation that plans (§5.5). +std::unique_ptr recordMacroDefinitions(const clang::SourceManager& sm, + MacroDefinitions& out); +// Plans the rewrite of the parameter `binding` names, in `function`, located in the defining +// unit's initial compilation by the suggestion's symbol (T v1's GetDeclForMangledName). +SuggestionPlan plan(clang::ASTContext& context, const MacroDefinitions& macros, + const clang::FunctionDecl& function, const ParameterDebugBinding& binding); +``` +`Edit` moves to `ConstSuggestionInternal.hpp`. `SuggestionPlan` holds here `sourceIndex`, `expectedType`, `edits` and `indeterminate`; Task 1.4 adds `entity`, Task 1.8 `candidate`. + +**Tests.** D5, a new fixture (t3/m.c): + +`test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c`: + +```c +// SPDX-License-Identifier: Apache-2.0 +// #217: a macro argument can write the type of several parameters. Rewriting the type of `src` +// where it is written would rewrite `dst` too, which copy_one writes through: the rewrite would +// reach beyond the suggestion. The suggestion is kept, not checked. + +// strict-expectation-details: true + +#define DECLARE_COPY(name, T) int name(T src, T dst) + +// at line 15, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'src' in function 'copy_one' is never used to modify the pointed object +// ↳ current type: int *src +// ↳ suggested type: const int *src +// ↳ not checked with Clang: the parameter's type is written in a macro argument that writes other parameters too +DECLARE_COPY(copy_one, int*) +{ + *dst = *src; + return 0; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +D11 (t13), a third case of `check_const_suggestion_line_endings`: spaces between the backslash and the line break, which Clang reads as a continuation, with a warning. Run as a check, not a fixture: a fixture's trailing spaces would not survive formatting and review. + +```diff +--- a/run_test.py ++++ b/run_test.py +@@ -2,8 +2,10 @@ + """ + #217: a type written on a continuation line of a #define is in the macro definition, whether +- the file ends its lines with LF or CRLF: rewriting it would change every expansion of the macro, +- here a function that writes through p. The suggestion is kept as not checked with both. ++ the file ends its lines with LF or CRLF, or puts spaces between the backslash and the line ++ break, which Clang accepts with a warning: rewriting it would change every expansion of the ++ macro, here a function that writes through p. The suggestion is kept as not checked in each ++ case. + """ +- print("=== Testing a macro definition's continuation lines, with LF and CRLF ===") ++ print("=== Testing a macro definition's continuation lines, with LF, CRLF and spaces ===") + source = ( + "#define PARAM(type) type p\n" +@@ -16,7 +18,11 @@ + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_line_endings_") as tmp: +- for label, newline in (("LF", "\n"), ("CRLF", "\r\n")): +- path = Path(tmp).resolve() / f"reader-{label.lower()}.c" +- path.write_bytes(source.replace("\n", newline).encode("utf-8")) ++ for label, contents in ( ++ ("LF", source), ++ ("CRLF", source.replace("\n", "\r\n")), ++ ("spaces after the backslash", source.replace("\\\n", "\\ \n")), ++ ): ++ path = Path(tmp).resolve() / f"reader-{label.lower().replace(' ', '-')}.c" ++ path.write_bytes(contents.encode("utf-8")) + # The file's bytes are an input the cache key does not cover. + result = run_analyzer_uncached( +``` + +Failure at `64ed37e`: both suggestions are removed. D5: the edit of the macro argument rewrites `dst` too, which copy_one writes. D11: the text scan does not see the continuation, so the macro body is rewritten, and so is write_second. + +```text +new/const-suggestion-macro-shared-type.c: FAIL (2/4) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-macro-shared-type.c === + ❌ (default) expectation #1 MISSING + ❌ (smt-z3) expectation #1 MISSING +``` + +```text +check_const_suggestion_line_endings: FAIL +=== Testing a macro definition's continuation lines, with LF, CRLF and spaces === + ✅ LF + ✅ CRLF + ❌ spaces after the backslash: None +``` + +`const-suggestion-macros.c` and `const-suggestion-macro-params.c` stay unchanged and pass at `64ed37e`. + +- [ ] **Step 1: Add both tests and run them.** Run: the one-fixture command for `const-suggestion-macro-shared-type.c`, and the one-check command for `check_const_suggestion_line_endings`. Expected: FAIL, as above. + Commits: `test(const-param): keep a suggestion whose macro argument types two parameters`; `test(const-param): a backslash and spaces still continue a macro definition`. +- [ ] **Step 2: Move the planning.** `Planner`, `writtenType`, `expectedType` and the body of `Inspector::plan` move to `ConstSuggestionPlan.cpp`, behind `plan()`; `FunctionFacts` keeps what the other compilations fill. No behavior changes. + Run: the T v1 macro fixtures. Expected: PASS. Commit: `refactor(const-param): move the planning of a suggestion to its own file`. +- [ ] **Step 3: Macro definitions from the preprocessor (D11).** `recordMacroDefinitions` returns a `clang::PPCallbacks` whose `MacroDefined` records `[getDefinitionLoc(), getDefinitionEndLoc()]` of each macro written in a file (real path, offsets). `InspectAction::CreateASTConsumer` adds it to the preprocessor of the defining unit's initial compilation. `Planner::replace` asks the recorded ranges whether the offset it rewrites lies in a macro definition; the text scan `inMacroDefinition` goes. + Run: `check_const_suggestion_line_endings`. Expected: PASS. Commit: `fix(const-param): find macro definitions with the preprocessor, not the text`. +- [ ] **Step 4: The plan's scope (D5).** An edit whose position comes from a macro argument (`isMacroArgExpansion`) serves only the parameter planned. `plan()` walks the other parameters of each redeclaration with the same `TypeLoc` walk as `Planner::plan`: one whose type's spelling location is an edit's position gives `the parameter's type is written in a macro argument that writes other parameters too`. Then, only when an edit comes from a macro argument, a `RecursiveASTVisitor` over the translation unit checks every other `DeclaratorDecl` and `TypedefNameDecl`: one whose type starts at an edit's position gives `the parameter's type is written in a macro argument that writes other declarations too`. Either way the plan is indeterminate and nothing is rewritten. + Run: the one-fixture command for `const-suggestion-macro-shared-type.c`. Expected: PASS. Commit: `fix(const-param): leave a rewrite that reaches another declaration unchecked`. +- [ ] **Step 5: Task tests.** As Task 1.1, Step 6. + +**Validation:** D5 and D11 pass; T v1's macro tests unchanged. ### Task 1.4: compilations, facts R, O and K, scope after the rewrite (§5.3, §5.4, §5.6), judgement steps 1 to 6 From b1aa87f3099aaea214c8cb2514bbe9f507c59aa0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 035/152] docs(plan): detail task 1.4, facts and the scope after the rewrite --- .../2026-10-16-const-suggestion-check-v2.md | 271 +++++++++++++++++- 1 file changed, 266 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 114ff26..dd5fbf3 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1416,11 +1416,272 @@ check_const_suggestion_line_endings: FAIL ### Task 1.4: compilations, facts R, O and K, scope after the rewrite (§5.3, §5.4, §5.6), judgement steps 1 to 6 -**Files:** `ConstSuggestionFacts.cpp` (new; T v1's `Inspector`, `InspectAction`, `compile`, `scan` move here). -**Interfaces:** produces `struct UnitFacts { bool complete; std::vector facts; std::map defined; std::vector addressCasts; std::vector diagnostics; std::shared_ptr code; std::vector entityDeclarations; }` and `UnitFacts inspect(const UnitConfiguration&, const std::map& remapped, const SuggestionPlan&)`. -**Work:** facts keyed by unit, instantiation contexts, spelling and expansion positions mapped back through the plan's edits; scope after the rewrite on source-written declarations only (instantiated signatures go to Task 1.5); an incomplete inspection gives an unknown scope; judgement steps 1 to 6. -**Tests:** D2, a new `run_test.py` check (t1: a member operator used in another file, verified in both files); `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `pick` in `const-suggestion-deduction-refused.cpp`, `generic_selection` in `const-suggestion-must-compile.c` unchanged. -**Validation:** D2 passes; the refutations by R, O and K of T v1's tests unchanged. +**Files:** `src/analysis/ConstSuggestionFacts.cpp` (new), `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionPlan.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `CMakeLists.txt`, `run_test.py`, `test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp` (new). + +**Interfaces.** Adds `DeclarationSite`, `SourceLinkage`, `EntityIdentity`, `FactKind`, `FactKey`, `Fact` (Shared types) and `SuggestionPlan::entity`. Produces: +```cpp +// What one compilation shows (§5.3, §5.6). R and K cover the whole unit; the rest is kept for +// each plan given, in their order. +struct UnitFacts +{ + std::string failure; // the compilation could not run (T v1's Compilation::failure) + bool complete = false; // the AST holds the whole unit: no fatal error + std::vector diagnostics; // errors and tracked warnings, in Clang's order + std::shared_ptr code; // prepared for the comparator + std::map defined; // symbols with a body + std::vector facts; // R and K + std::map writtenTypes; // declarations of the rewritten files + struct Entity + { + bool found = false; + std::vector symbols; // every variant's, in this compilation + std::vector sites; // its declarations here + std::vector overrides; // O, for the methods named like it + std::vector casts; // explicit casts of its address (Task 1.7) + std::vector stale; // T v1's "declared in : with type " + std::string printedSignature; // T v1's, for __PRETTY_FUNCTION__ + }; + std::vector entities; +}; +// Compiles `unit` with `remapped` files, and inspects it for `plans`. +UnitFacts inspect(const UnitConfiguration& unit, const std::map& remapped, + const std::vector& plans, bool timing); +``` +The defining unit's initial compilation also plans: `inspect` locates each suggestion's function by its symbol there, calls `plan()`, then collects the facts with the identities planned. + +**Tests.** D2 (t1) and D3 (t2, ruling 5), two new checks, registered after the checks of Task 1.1: + +```python +def check_const_suggestion_operator_used_elsewhere() -> bool: + """ + #217: a function is found by its declaration in every file that sees the change, not by its + name: an operator has no identifier. Here a member operator and a method, declared in a header, + defined in one file and called in another: both suggestions are checked in both files. + """ + print("=== Testing const suggestions for a member operator used in another file ===") + sources = { + "vec.hpp": "struct Vec\n{\n int x;\n bool operator==(Vec& o) const;\n" + " int dot(Vec& o) const;\n};\n", + "vec.cpp": '#include "vec.hpp"\n' + "bool Vec::operator==(Vec& o) const\n{\n return x == o.x;\n}\n" + "int Vec::dot(Vec& o) const\n{\n return x * o.x;\n}\n", + "use.cpp": '#include "vec.hpp"\n' + "int use(Vec a, Vec b)\n{\n return (a == b) + a.dot(b);\n}\n", + } + checked = "checked in the analyzed files only" + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_operator_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + run = _const_suggestion_run([str(root / "vec.cpp"), str(root / "use.cpp")]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + ok = True + for label, function in (("operator==", "_ZNK3VeceqERS_"), ("dot", "_ZNK3Vec3dotERS_")): + texts = messages.get(function, []) + good = ( + len(texts) == 1 + and checked in texts[0] + and f"{function} parameter o: checked in 2 file(s)" in trace + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok +``` + +```python +def check_const_suggestion_homonyms_across_files() -> bool: + """ + #217: in C, a function with internal linkage and a function of the same name with external + linkage in another file are two functions: neither file declares the other's. Whichever of the + two gets the suggestion, the other file does not refute it, and it is checked. + """ + print("=== Testing const suggestions for C homonyms with internal and external linkage ===") + sources = { + "static-reads.c": "static int helper(int* p)\n{\n return *p;\n}\n" + "int use_helper(void)\n{\n int v = 1;\n return helper(&v);\n}\n", + "external-writes.c": "int helper(int* p)\n{\n *p = 2;\n return 0;\n}\n", + "external-reads.c": "int reader(int* p)\n{\n return *p;\n}\n", + "static-writes.c": "static int reader(int* p)\n{\n *p = 3;\n return 0;\n}\n" + "int use_reader(void)\n{\n int v = 1;\n return reader(&v);\n}\n", + } + checked = "checked in the analyzed files only" + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_homonyms_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, files, function in ( + ( + "a static reader, an external writer", + ["static-reads.c", "external-writes.c"], + "helper", + ), + ( + "an external reader, a static writer", + ["external-reads.c", "static-writes.c"], + "reader", + ), + ): + run = _const_suggestion_run([str(root / f) for f in files]) + texts = run[0].get(function, []) if run else [] + good = len(texts) == 1 and checked in texts[0] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok +``` + +The scope after the rewrite (§5.6), a third check: a header whose text another file reads as other declarations (ruling 11): + +```python +def check_const_suggestion_header_read_differently() -> bool: + """ + #217: a header can mean other declarations in another file, through a macro that file defines + otherwise. Here pair.h declares read_value in def.c, and a global pointer in slot.c: rewriting + the type pair.h writes changes that global's type too, although the generated code does not + show it. The rewrite reaches beyond the suggestion: it is kept, not checked. + """ + print("=== Testing a const suggestion whose header means other declarations elsewhere ===") + sources = { + "pair.h": "PAIR(read_value, int*)\n", + "def.c": '#define PAIR(name, type) int name(type p);\n#include "pair.h"\n#undef PAIR\n' + "int read_value(int* p)\n{\n return *p;\n}\n", + "slot.c": '#define PAIR(name, type) type name##_slot;\n#include "pair.h"\n', + } + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_xmacro_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + run = _const_suggestion_run([str(root / "def.c"), str(root / "slot.c")]) + expected = ( + "not checked with Clang: the rewrite also changes the type of read_value_slot " + f"({root / 'pair.h'}:1)" + ) + texts = run[0].get("read_value", []) if run else [] + good = len(texts) == 1 and expected in texts[0] + detail = "" if good else f": {texts}" + print(f" {'✅' if good else '❌'} the global slot.c declares{detail}") + print() + return good +``` + +A new fixture: facts refute without a change of the generated code. T v1 never sees these, since it compares only code: + +`test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// #217: a call or a constant condition that the suggested type changes refutes the suggestion, +// even when the generated code stays the same: +// - sized: sizeof names sized(bool) before the rewrite and sized(const int*) after it; both return +// int, so the size, and the code, stay the same; +// - typed: if constexpr tests the parameter's own type, and both branches are the same code. + +// not contains: parameter 'p' in function 'sized(int*)' +// not contains: parameter 'p' in function 'typed(int*)' +#include + +int sized(int* p) +{ + return *p; +} + +int sized(bool flag); + +int size_of_call(const int* q) +{ + return static_cast(sizeof(sized(q))); +} + +int typed(int* p) +{ + if constexpr (std::is_const_v>) + return *p; + else + return *p; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +Failure at `64ed37e`. D2: `operator==` is removed, "the methods it overrides change": T v1 recognizes a method by its identifier after the rewrite, and an operator has none. D3: both removed, "declared in b.c:1 / d.c:1 with type int *": T v1 takes a homonym for the function, whatever its linkage. The header: checked, since the global's new type leaves the IR as it was. The fixture: both kept as checked. + +```text +check_const_suggestion_operator_used_elsewhere: FAIL +=== Testing const suggestions for a member operator used in another file === + ❌ operator==: [] + ✅ dot +``` + +```text +check_const_suggestion_homonyms_across_files: FAIL +=== Testing const suggestions for C homonyms with internal and external linkage === + ❌ a static reader, an external writer: [] + ❌ an external reader, a static writer: [] +``` + +```text +check_const_suggestion_header_read_differently: FAIL +=== Testing a const suggestion whose header means other declarations elsewhere === + ❌ the global slot.c declares: ["\t[ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object\n\t\t ↳ current type: int *p\n\t\t ↳ suggested type: con … +``` + +```text +new/const-suggestion-facts-without-code-change.cpp: FAIL (2/6) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-facts-without-code-change.cpp === + ❌ (default) negative expectation #1 FOUND (unexpected) + ❌ (default) negative expectation #2 FOUND (unexpected) + ❌ (smt-z3) negative expectation #1 FOUND (unexpected) + ❌ (smt-z3) negative expectation #2 FOUND (unexpected) +``` + +Unchanged and passing at `64ed37e`: `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `pick` in `const-suggestion-deduction-refused.cpp`, `generic_selection` in `const-suggestion-must-compile.c`. + +- [ ] **Step 1: Add the four tests and run them.** Expected: FAIL, as above. + Commits: `test(const-param): check a member operator's suggestion in the file that calls it`; `test(const-param): a C homonym with another linkage is another function`; `test(const-param): a changed call or constant refutes even with the same code`; `test(const-param): keep a rewrite that changes another file's declaration`. +- [ ] **Step 2: Move the compilation.** `DiagnosticCollector`, `Inspector`, `InspectAction`, `compile` and `scan` move to `ConstSuggestionFacts.cpp`. No behavior changes. Run: the T v1 checks and fixtures. Expected: PASS. + Commit: `refactor(const-param): move the compilation and its inspection to their own file`. +- [ ] **Step 3: The entity's identity (§3, §5.5).** `plan()` describes the located function: its redeclarations' sites (the expansion location of each name, as a real path and an offset), its formal linkage (`External` for external or module linkage, `Internal` for internal or unique-external, `None` otherwise), its template pattern's first declaration, and its variants' symbols, from `CodeGenerator::GetMangledName` (a constructor's `Ctor_Complete` and `Ctor_Base`, a destructor's `Dtor_Deleting`, `Dtor_Complete` and `Dtor_Base`, otherwise the function's). + In every other compilation, the entity is the function that has one of those sites, once mapped back to the original text through the plan's edits: + ```cpp + // An offset of the rewritten text, in the original one. An offset inside inserted text maps + // to the edit's position. + std::size_t originalOffset(const std::vector& sortedEdits, std::size_t offset) + { + std::size_t inserted = 0; // text the edits before `offset` add + std::size_t removed = 0; // and remove + for (const Edit& edit : sortedEdits) + { + const std::size_t at = edit.offset + inserted - removed; + if (offset < at) + break; + if (offset < at + edit.text.size()) + return edit.offset; + inserted += edit.text.size(); + removed += edit.length; + } + return offset - inserted + removed; + } + ``` + A unit that shares none of those sites declares the entity only through the linker: a function with external linkage whose symbol is one of the entity's. T v1's name and symbol test (`isTarget`, `locate`'s `withoutConst` search) goes. A unit sees the change (§5.2) through a reference in its analyzed module only if the entity has external linkage. + Run: both checks. Expected: PASS. Commit: `fix(const-param): find the function by its declarations, not by its name`. +- [ ] **Step 4: Facts R, O and K (§5.4).** The `Inspector` visits the whole unit, template instantiations included: + - R: `DeclRefExpr`, `MemberExpr`, `CXXConstructExpr`, `CXXNewExpr` and `CXXDeleteExpr` that name a function. Key: the instantiations around the expression (each by its symbol, outermost first), and its spelling and expansion sites, mapped back. Value (ruling 4): the target's declaration sites, mapped back; for a template specialization or an implicit function, its symbol; + - O: for each method with the entity's `DeclarationName`, the methods it overrides (`overridden_methods()`), each by the same value as R. Key: the method's site; + - K: each `IfStmt` with `isConstexpr()` and a condition that is not value-dependent: `true` or `false`, from `EvaluateKnownConstInt`; each `GenericSelectionExpr` that is not result-dependent: `getResultIndex()`. + A symbol comes from `CodeGenerator::GetMangledName`, cached by declaration. A fact is compared only when its key's instantiations all map to the other compilation: here, through the entity's own symbol pairs (Task 1.5 adds the others). An R fact is unchanged when its value after contains its value before (ruling 4); O and K must be equal. T v1's comparison of overrides by qualified name goes. + Judgement step 6, after T v1's diagnostics (steps 4 and 5): the first changed fact refutes, with its text (Texts). + Run: the new fixture, then `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `const-suggestion-deduction-refused.cpp` and `const-suggestion-must-compile.c`. Expected: PASS. + Commit: `feat(const-param): refute a suggestion that changes a call, override or constant`. +- [ ] **Step 5: Scope after the rewrite (§5.6), steps 2 and 3.** Each compilation records `writtenTypes`: the canonical type (`writtenType`) of every declaration whose name's expansion location lies in a file that some plan rewrites (`DeclaratorDecl`, `TypedefNameDecl`), neither implicit nor instantiated, by its site. After the rewrite, every site keeps its type, except the planned parameters and the entity's redeclarations; a planned parameter must have the candidate (T v1's `the rewritten declaration does not get the suggested type`). Otherwise step 3: `the rewrite also changes the type of (:)`. A compilation with a fatal error (`hasFatalErrorOccurred()`) has an incomplete inspection, step 2: ` could not be inspected once rewritten`. T's compilations set no error limit (`DiagnosticOptions::ErrorLimit = 0`): "too many errors" would otherwise make a rewrite that breaks many uses incomplete instead of refuted, where T v1 refutes it. Step 2 then guards only a fatal error that a rewrite brings, such as a missing file: no input of the suite reaches it (ruling 12). The judgement now runs steps 1 to 6 in the order of §5.8; T v1's code comparison stays step 7 until Task 1.5. + Run: `check_const_suggestion_header_read_differently`. Expected: PASS. Commit: `feat(const-param): leave a rewrite that changes another declaration unchecked`. +- [ ] **Step 6: Task tests.** As Task 1.1, Step 6. + +**Validation:** D2, D3, the header check and the new fixture pass; the refutations by R, O and K of T v1's tests unchanged. ### Task 1.5: correspondences and the comparator's pairs (§5.7, §5.9), judgement step 7 From 0f90be0698ff4a6589996ca31d642b91d18b2409 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 036/152] docs(plan): detail task 1.5, correspondences and established pairs --- .../2026-10-16-const-suggestion-check-v2.md | 272 +++++++++++++++++- 1 file changed, 268 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index dd5fbf3..14e5cb0 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1685,10 +1685,274 @@ Unchanged and passing at `64ed37e`: `const-suggestion-same-code.cpp`, `const-sug ### Task 1.5: correspondences and the comparator's pairs (§5.7, §5.9), judgement step 7 -**Files:** `ConstSuggestionMatch.cpp` (new), `ConstSuggestionCode.cpp`, `include/analysis/ConstSuggestionCheck.hpp`. -**Interfaces:** produces `std::vector correspond(const UnitFacts& before, const UnitFacts& after, const SuggestionPlan&, std::string& missing)`; `codeDifference` gains `const std::vector& established`. -**Work:** the entity's variants by `GlobalDecl`; nested entities (lambdas, local statics, local classes, RTTI names); instantiation contexts whose arguments change, paired under the four conditions of §5.7; inherited constructors through `getInheritedConstructor()`, adopted only once shown on t8 and a template case; verified requires every appearing or disappearing symbol to be covered by an established pair; the heuristics only place a difference in the trace. -**Tests:** D7, a new fixture (t8: verified if the technique is shown, otherwise indeterminate, never removed); D14, changed expectation of `call` in `const-suggestion-deduction-refused.cpp` (indeterminate, kept); D16, changed expectation of `signature_size` in `const-suggestion-printed-signature.c` (indeterminate, kept); `const-suggestion-kept*.cpp`, `const-suggestion-abi*.cpp`, `const-suggestion-deduction-kept.cpp`, `print_and_read` unchanged. +**Files:** `src/analysis/ConstSuggestionMatch.cpp` (new), `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCode.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `include/analysis/ConstSuggestionCheck.hpp`, `CMakeLists.txt`, `test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp` (new), `const-suggestion-deduction-refused.cpp` and `const-suggestion-printed-signature.c` (expectations changed). + +**Interfaces.** Adds `SymbolPair`. `UnitFacts::Entity` gains what the correspondences read: +```cpp +// A symbol that the rewrite may rename with the entity, and what it is in this compilation. +struct Named +{ + std::string symbol; + std::string key; // what pairs it: its kind, its site mapped back, and its enclosing symbol +}; +std::vector variants; // the entity's: "C1", "C2", "D0"... or "" for a function +std::vector inheriting; // the constructors inheriting it: derived class and variant +std::vector nested; // lambdas, local classes (methods, _ZTI, _ZTS, _ZTV), static + // locals and their guards, in the entity or a specialization +``` +An R fact's value also carries `template:` when its target is a template specialization. Produces: +```cpp +// The pairs established between a unit's initial and modified compilations for one entity +// (§5.7). `missing` receives the first reason a correspondence could not be established. +std::vector correspond(const UnitFacts& before, const UnitFacts& after, + std::size_t entity, std::string& missing); +// ConstSuggestionCode: the pairs replace `rewritten` and `renamedTo` (ruling 9). +std::optional codeDifference(const ModuleText& was, const ModuleText& now, + const std::vector& established, + const PrintedSignature* signature, + std::vector* accepted); +``` +`rewrittenCodeDifference` in `ConstSuggestionCheck.hpp` takes the pairs the same way. + +**Tests.** D7, a new fixture (t8 and three template cases, ruling 7): + +`test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// #217: a class that inherits a constructor (`using Base::Base`) gets constructors of its own, +// whose symbols name the inherited constructor's parameter types: the rewrite renames them too. +// The check pairs them before and after the rewrite through the constructor they inherit, so +// each suggestion is checked: a plain class, a class template as the base, a class template as +// the derived class, and a constructor template. + +// strict-expectation-details: true + +// at line 18, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'Base::Base(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct Base +{ + int v; + Base(int* p) : v(*p) {} +}; + +struct Derived : Base +{ + using Base::Base; + int w = 0; +}; + +int make_derived(int x) +{ + Derived d(&x); + return d.v; +} + +// at line 41, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'TemplateBase::TemplateBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +template struct TemplateBase +{ + T v; + TemplateBase(T* p) : v(*p) {} +}; + +struct FromTemplate : TemplateBase +{ + using TemplateBase::TemplateBase; + int w = 0; +}; + +int make_from_template(int x) +{ + FromTemplate d(&x); + return d.v; +} + +// at line 64, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'PlainBase::PlainBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct PlainBase +{ + int v; + PlainBase(int* p) : v(*p) {} +}; + +template struct TemplateDerived : PlainBase +{ + using PlainBase::PlainBase; + T w = 0; +}; + +int make_template_derived(int x) +{ + TemplateDerived d(&x); + return d.v; +} + +// at line 87, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'GenericBase::GenericBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct GenericBase +{ + int v; + template GenericBase(T* p) : v(*p) {} +}; + +struct FromGeneric : GenericBase +{ + using GenericBase::GenericBase; + int w = 0; +}; + +int make_from_generic(int x) +{ + FromGeneric d(&x); + return d.v; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +D14, `call` in `const-suggestion-deduction-refused.cpp`: kept, not checked, the reason naming the specializations; `pick` stays removed: + +```diff +--- a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp ++++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp +@@ -1,14 +1,29 @@ + // SPDX-License-Identifier: Apache-2.0 + // #217: specializations of the rewritten template whose deduced type changes are paired only when +-// the correspondence is unique, and their code is still compared. ++// the correspondence is one to one, and what they select is still compared. + // - call: g passes G& and h passes const G&; after the rewrite both deduce G, so two +-// specializations become one. No unique correspondence: both suggestions are removed. The +-// lambdas are noexcept, so that all three specializations get the same attributes: only the +-// correspondence tells them apart. +-// - pick: the lvalue's specialization is paired, but its code changes with what F deduces. ++// specializations become one. Without a one-to-one correspondence, nothing shows that the ++// rewrite keeps or changes what they do: both suggestions are kept, not checked, and the reason ++// names the specializations. The lambdas are noexcept, so that all three specializations get ++// the same attributes: only the correspondence tells them apart. ++// - pick: the lvalue's specialization is paired, and the condition of its `if constexpr` changes ++// with what F deduces: the suggestion is removed. + +-// not contains: in function 'int call< ++// strict-expectation-details: true + // not contains: in function 'int pick< + ++// at line 28, column 0 ++// [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int call(from_temporary()::$_0&&)' is an rvalue reference and is only read, or passed where a reference to const is expected ++// ↳ consider passing by value ( f) or const reference (const &f) ++// ↳ current type: &&f ++// ↳ the callee's body is not checked: it may still modify the referred object through a cast ++// ↳ not checked with Clang: 'int call(from_lvalues()::$_0&)' and 'int call(from_lvalues()::$_0 const&)' both become 'int call(from_lvalues()::$_0 const&)': their correspondence is not one to one ++ ++// at line 28, column 0 ++// [ !Info! ] ConstParameterNotModified.Reference: parameter 'f' in function 'int call(from_lvalues()::$_0&)' is only read, or passed where a reference to const is expected ++// ↳ current type: &f ++// ↳ suggested type: const &f ++// ↳ the callee's body is not checked: it may still modify the referred object through a cast ++// ↳ not checked with Clang: 'int call(from_lvalues()::$_0&)' and 'int call(from_lvalues()::$_0 const&)' both become 'int call(from_lvalues()::$_0 const&)': their correspondence is not one to one + template int call(F&& f) + { +``` + +D16, `signature_size` in `const-suggestion-printed-signature.c`: kept, not checked. The expectation stops after `signature_size:`, the comparator's text that follows names IR values: + +```diff +--- a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c ++++ b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c +@@ -2,8 +2,8 @@ + // #217: __PRETTY_FUNCTION__ prints the function's signature, which the suggested type changes. The + // suggestion is kept, and the message says what the program prints differently. Any other use of +-// that text, such as its size, is a change of the generated code. ++// that text, such as its size, changes the generated code in a way that no reference, override or ++// constant condition explains: that suggestion is kept, not checked. + + // strict-expectation-details: true +-// not contains: parameter 'p' in function 'signature_size' + // not contains: ↳ ABI: + #include +@@ -21,4 +21,9 @@ + } + ++// at line 28, column 0 ++// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'signature_size' is never used to modify the pointed object ++// ↳ current type: int *p ++// ↳ suggested type: const int *p ++// ↳ not checked with Clang: the generated code changes, and no reference, override or constant condition explains it: code changed in signature_size: + int signature_size(int* p) + { +``` + +Failure at `64ed37e`: the four inherited constructors and both `call` suggestions are removed ("symbols no longer used"), `signature_size` too ("the generated code changes"). + +```text +new/const-suggestion-inheriting-constructor.cpp: FAIL (2/10) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-inheriting-constructor.cpp === + ❌ (default) expectation #1 MISSING + ❌ (default) expectation #2 MISSING + ❌ (default) expectation #3 MISSING + ❌ (default) expectation #4 MISSING + ❌ (smt-z3) expectation #1 MISSING + ❌ (smt-z3) expectation #2 MISSING + ❌ (smt-z3) expectation #3 MISSING + ❌ (smt-z3) expectation #4 MISSING +``` + +```text +changed/const-suggestion-deduction-refused.cpp: FAIL (4/8) +=== Testing evidence/217/stage1/tests/fixtures/changed/const-suggestion-deduction-refused.cpp === + ❌ (default) expectation #1 MISSING + ❌ (default) expectation #2 MISSING + ✅ (default) negative expectation #1 NOT FOUND (as expected) + ❌ (smt-z3) expectation #1 MISSING + ❌ (smt-z3) expectation #2 MISSING + ✅ (smt-z3) negative expectation #1 NOT FOUND (as expected) +``` + +```text +changed/const-suggestion-printed-signature.c: FAIL (6/8) +=== Testing evidence/217/stage1/tests/fixtures/changed/const-suggestion-printed-signature.c === + ✅ (default) expectation #1 FOUND + ❌ (default) expectation #2 MISSING + ✅ (default) negative expectation #1 NOT FOUND (as expected) + ✅ (smt-z3) expectation #1 FOUND + ❌ (smt-z3) expectation #2 MISSING + ✅ (smt-z3) negative expectation #1 NOT FOUND (as expected) +``` + +Unchanged and passing at `64ed37e`: `const-suggestion-kept*.cpp`, `const-suggestion-kept.c`, `const-suggestion-abi*.cpp`, `const-suggestion-deduction-kept.cpp`, `print_and_read` in `const-suggestion-printed-signature.c`. Their renamed symbols, measured before and after the rewrite (`evidence/217/stage1/nested-probe/symbols.txt`): the function; `_ZTI` and `_ZTS` of a local class; a lambda's call operator; the specializations of the rewritten template and the lambda instantiated in each. + +- [ ] **Step 1: Add the fixture, change the two expectations, run the three.** Expected: FAIL, as above. + Commits: `test(const-param): check a suggestion through the constructors that inherit it`; `test(const-param): keep the suggestions whose specializations merge, unchecked`; `test(const-param): keep a suggestion whose code change nothing explains, unchecked`. +- [ ] **Step 2: Symbols from compilation facts.** The `Inspector` records, in every compilation: + - `variants`: the entity's variants, by `GlobalDecl` (Task 1.4, Step 3), keyed by variant; + - `inheriting`: each `CXXConstructorDecl` with `isInheritingConstructor()` whose `getInheritedConstructor().getConstructor()` is the entity, or a specialization of its pattern; its `Ctor_Complete` and `Ctor_Base` symbols, keyed by the derived class (its mangled type, `MangleContext::mangleCanonicalTypeName`) and the variant. `evidence/217/stage1/d7-probe/` shows these symbols equal to the IR's, before and after, in the four cases of the fixture; + - `nested`: in the entity, and in each function that a specialization of its template defines, every lambda's call operator, every local class's methods (by variant) and its `_ZTI`, `_ZTS` and `_ZTV` (`MangleContext::mangleCXXRTTI`, `mangleCXXRTTIName`, `mangleCXXVTable`), every static local variable and its guard (`mangleStaticGuardVariable`); keyed by kind, site mapped back, and the enclosing function's symbol. `evidence/217/stage1/d7-probe/nested-ast.txt` shows these symbols equal to the IR's (`nested-output.txt`). + Commit: `feat(const-param): record the symbols a rewrite may rename, with their declarations`. +- [ ] **Step 3: `correspond` (§5.7)**, in `ConstSuggestionMatch.cpp`, in this order: + 1. the entity's variants, by variant; + 2. the inheriting constructors, by derived class and variant; + 3. the instantiation contexts, from the R facts whose instantiations already map. For each such fact whose target changes, the pair (target before, target after) is a candidate if both are specializations of one template (`template:` equal). The candidates of every fact are then checked together: a target before with two targets after, or two targets before with one after, gives `missing` (Texts, §5.7), and no pair for them. Each new pair maps more instantiations, and the step repeats until no pair is added; + 4. the nested symbols, by key, once their enclosing symbols are paired. + A pair is established only from these facts; the names of the symbols play no part. + Commit: `feat(const-param): establish the correspondences of a rewrite from its facts`. +- [ ] **Step 4: The comparator takes the pairs, step 7.** `codeDifference` renames the symbols of `established` before comparing; every symbol that only one side has, and that no pair covers, is listed in its difference, as T v1 lists it (`symbols no longer used: …; symbols used only after the rewrite: …`). The masked encodings, `withoutConst`, `variantOf`, `templateOf` and `namesTemplate` go (ruling 9). The ABI line takes the symbol after the rewrite from the pair of the suggestion's own symbol. In the judgement, `correspond` runs before step 6: the facts are compared through its pairs, and those in an instantiation it could not pair are left out. After step 6, its `missing` makes the unit indeterminate; otherwise a difference of the comparator makes it indeterminate with `the generated code changes, and no reference, override or constant condition explains it: `. Step 7 never refutes. + Run: the three tests of Step 1, then every T v1 fixture with the option. Expected: PASS; each T v1 "checked" stays checked; `pick` is refuted by its K fact, the R and O refutations of `const-suggestion-same-code.cpp` and `const-suggestion-override-elsewhere.cpp` by their facts. + Commit: `fix(const-param): compare the code through established pairs, never refuting`. +- [ ] **Step 5: Task tests.** As Task 1.1, Step 6. + **Validation:** D7, D14 and D16 pass; every T v1 "checked" stays verified. ### Task 1.6: collisions, stale declarations, link-edition assumption (§5.10) From 135ad078b71281a9f746d84b56db9462177f26e7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 037/152] docs(plan): detail task 1.6, collisions and stale declarations --- .../2026-10-16-const-suggestion-check-v2.md | 268 +++++++++++++++++- 1 file changed, 264 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 14e5cb0..b466d94 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1957,10 +1957,270 @@ Unchanged and passing at `64ed37e`: `const-suggestion-kept*.cpp`, `const-suggest ### Task 1.6: collisions, stale declarations, link-edition assumption (§5.10) -**Files:** `ConstSuggestionMatch.cpp`, `README.md`. -**Work:** the linkage table of §5.10; a same entity defined in several units is no collision; a stale declaration gives indeterminate (D17); every reason that depends on linking states the assumption and its scope. -**Tests:** D3, a new `run_test.py` check (t2: C `static` and external homonyms, both directions, verified; the README sentence corrected); D4, a new `run_test.py` check (t9: `f(const int*)` defined in another file, indeterminate with the assumption, files analyzed together and the other file only in the database); Review Focus 3, a new `run_test.py` check (a header defining `static inline` C and `inline` C++ functions, included by two analyzed files, verified); D17, the two changed expectations; the static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` unchanged. -**Validation:** D3, D4 and Review Focus 3 pass. +**Files:** `src/analysis/ConstSuggestionMatch.cpp`, `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `README.md`, `run_test.py`. + +**Interfaces.** `UnitFacts::Entity` gains: +```cpp +// After the rewrite: a symbol the entity takes that another entity defines here, or another +// entity's declaration merged into it (ruling 3). +struct Collision +{ + std::string symbol; + DeclarationSite site; // the other entity's definition, or declaration + std::optional linkage; // none for a declaration +}; +std::vector collisions; +``` +and produces, in `ConstSuggestionMatch.cpp`: +```cpp +// §5.10: the verdict of the collisions that every unit found, after the units' judgements. +Judgement judgeCollisions(const std::vector& units, std::size_t entity, + const std::string& definingFile); +``` + +**Tests.** D4 (t9), the other file analyzed or only in the compilation database: + +```python +def check_const_suggestion_overload_defined_elsewhere() -> bool: + """ + #217: rewritten, f(int*) takes the symbol of the overload f(const int*), which another file + defines. The two definitions collide only if the files are linked together, which the check + does not know: the suggestion is kept, not checked, and the reason states that assumption, + whether the other file is analyzed or only listed in the compilation database. + """ + print("=== Testing a const suggestion that takes the symbol of another file's overload ===") + sources = { + "api.hpp": "int f(int* p);\nint f(const int* p);\n", + "a.cpp": '#include "api.hpp"\nint f(int* p)\n{\n return *p;\n}\n', + "b.cpp": '#include "api.hpp"\nint f(const int* p)\n{\n return *p + 1;\n}\n', + } + claims = ("checked in the analyzed files only", "checked in every file of the compilation") + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_overload_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + database = root / "compile_commands.json" + database.write_text( + json.dumps( + [ + { + "directory": str(root), + "file": str(root / f), + "arguments": ["clang++", "-std=c++17", "-c", str(root / f)], + } + for f in ("a.cpp", "b.cpp") + ] + ) + ) + expected = ( + f"not checked with Clang: _Z1fPKi is also defined in {root / 'b.cpp'}; if " + f"{root / 'a.cpp'} and {root / 'b.cpp'} are linked together, the two definitions " + "collide" + ) + for label, args in ( + ("both files analyzed", [str(root / "a.cpp"), str(root / "b.cpp")]), + ( + "the other file only in the compilation database", + [str(root / "a.cpp"), f"--compile-commands={database}"], + ), + ): + run = _const_suggestion_run(args) + texts = run[0].get("_Z1fPi", []) if run else [] + good = ( + len(texts) == 1 + and expected in texts[0] + and not any(claim in texts[0] for claim in claims) + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok +``` + +Review Focus 3, a function defined in a header that two analyzed files include: + +```python +def check_const_suggestion_inline_in_a_header() -> bool: + """ + #217: a function defined in a header that two analyzed files include is defined in both: a C + `static inline` function, one function in each file, and a C++ `inline` function, one function + that both files define. Neither definition collides with the other: each suggestion is checked. + """ + print("=== Testing const suggestions for functions defined in a shared header ===") + sources = { + "peek.h": "static inline int peek_c(int* p)\n{\n return *p;\n}\n", + "one.c": '#include "peek.h"\n' + "int use_one(void)\n{\n int v = 1;\n return peek_c(&v);\n}\n", + "two.c": '#include "peek.h"\n' + "int use_two(void)\n{\n int v = 2;\n return peek_c(&v);\n}\n", + "peek.hpp": "inline int peek_cpp(int* p)\n{\n return *p;\n}\n", + "three.cpp": '#include "peek.hpp"\n' + "int use_three()\n{\n int v = 3;\n return peek_cpp(&v);\n}\n", + "four.cpp": '#include "peek.hpp"\n' + "int use_four()\n{\n int v = 4;\n return peek_cpp(&v);\n}\n", + } + checked = "checked in the analyzed files only" + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_inline_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, files, function in ( + ("C, static inline", ["one.c", "two.c"], "peek_c"), + ("C++, inline", ["three.cpp", "four.cpp"], "_Z8peek_cppPi"), + ): + run = _const_suggestion_run([str(root / f) for f in files]) + texts = run[0].get(function, []) if run else [] + # One suggestion for the definition in each file. + good = len(texts) == 2 and all(checked in text for text in texts) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok +``` + +D17, the two changed expectations: + +```diff +--- a/run_test.py ++++ b/run_test.py +@@ -3,8 +3,9 @@ + #217: a suggested const type is reported only if every analyzed file still compiles with it, + once every declaration the defining file sees is rewritten. Another analyzed file that stores +- the function where a non-const parameter is expected, declares it itself, or converts its +- address with an explicit cast, removes the suggestion; a homonym with internal linkage in +- another file does not. A file that is not analyzed is not checked. The callback and cast files +- sort before the definition, the redeclaration after it. ++ the function where a non-const parameter is expected, or converts its address with an explicit ++ cast, removes the suggestion. One that declares it itself, without the header, keeps it, not ++ checked: whether that matters depends on how the files are linked. A homonym with internal ++ linkage in another file does not change it. A file that is not analyzed is not checked. The ++ callback and cast files sort before the definition, the redeclaration after it. + """ + return _check_cross_tu_rule( +@@ -17,5 +18,5 @@ + ("def and a caller", ["def.c", "caller.c"], ["read_value"]), + ("def and a callback of the old type", ["def.c", "callback.c"], []), +- ("def and a redeclaration", ["def.c", "redecl.c"], []), ++ ("def and a redeclaration", ["def.c", "redecl.c"], ["read_value"]), + ("def and an explicit cast", ["def.c", "cast.c"], []), + # Another file's homonym with internal linkage is another function. +``` + +```diff +--- a/run_test.py ++++ b/run_test.py +@@ -3,11 +3,12 @@ + #217: with a compilation database, a suggested const type is checked in every file the + database lists that sees the change, analyzed or not, with its own compile command. A file +- that is not analyzed and stores the function where a non-const parameter is expected, declares +- it itself without the header, or converts its address with an explicit cast, removes the +- suggestion, even listed after a file that cannot be checked. So does a file that takes the +- address of a method with the old type: it sees the change through the header alone. A file +- that sees the change but does not compile as it is, or cannot be preprocessed, keeps the +- suggestion as not checked, without the claim of the check. A file that does not see the change +- is not compiled. ++ that is not analyzed and stores the function where a non-const parameter is expected, or ++ converts its address with an explicit cast, removes the suggestion, even listed after a file ++ that cannot be checked. So does a file that takes the address of a method with the old type: it ++ sees the change through the header alone. A file that declares the function itself, without ++ the header, keeps the suggestion as not checked, and the reason names it and the link it ++ assumes. A file that sees the change but does not compile as it is, or cannot be preprocessed, ++ keeps the suggestion as not checked, without the claim of the check. A file that does not see ++ the change is not compiled. + """ + print("=== Testing const suggestions checked in the compilation database ===") +@@ -87,5 +88,14 @@ + ), + # Without the header: only the name of the function shows that it sees the change. +- ("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None), ++ ( ++ "a redeclaration, not analyzed", ++ ["def.c", "redecl.c"], ++ "read_value", ++ "not checked with Clang: " + str(fixtures / f"{prefix}redecl.c") ++ + " declares read_value with its current type and does not see the rewrite: if " ++ + str(fixtures / f"{prefix}def.c") + " and " + str(fixtures / f"{prefix}redecl.c") ++ + " are linked together, that declaration no longer matches the rewritten " ++ "definition", ++ ), + ("an explicit cast, not analyzed", ["def.c", "cast.c"], "read_value", None), + ("a method alone", ["method.cpp"], method, checked), +``` + +Failure at `64ed37e`: t9 is "checked", in both runs; the redeclaration removes the suggestion, in both checks. Review Focus 3 passes at `64ed37e`: T v1 never sees a collision, so the test guards the exception of §5.10 ("a same entity defined in several files is no collision") against Task 1.6 itself. Its failure is shown on a mutant of this task's code (Step 4). + +```text +check_const_suggestion_overload_defined_elsewhere: FAIL +=== Testing a const suggestion that takes the symbol of another file's overload === + ❌ both files analyzed: ["\t[ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'f(int*)' is never used to modify the pointed object\n\t\t ↳ current type: int *p\n\t\t ↳ suggested type: const int *p\ … + ❌ the other file only in the compilation database: ["\t[ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'f(int*)' is never used to modify the pointed object\n\t\t ↳ current type: int *p\n\t\t ↳ s … +``` + +```text +check_const_suggestion_inline_in_a_header: PASS +=== Testing const suggestions for functions defined in a shared header === + ✅ C, static inline + ✅ C++, inline +``` + +```text +check_const_suggestion_cross_tu (Task 1.6 version): FAIL +=== Testing suggested const types checked in every analyzed file === + ✅ default, def alone: ['read_value'] + ✅ default, def and a caller: ['read_value'] + ✅ default, def and a callback of the old type: [] + ❌ default, def and a redeclaration: [], expected ['read_value'] + ✅ default, def and an explicit cast: [] + ✅ default, a static function and a static homonym: ['helper'] + ✅ default, an anonymous namespace's function and its homonym: ['_ZN12_GLOBAL__N_16helperEPi'] + ✅ smt-z3, def alone: ['read_value'] + ✅ smt-z3, def and a caller: ['read_value'] + ✅ smt-z3, def and a callback of the old type: [] + ❌ smt-z3, def and a redeclaration: [], expected ['read_value'] + ✅ smt-z3, def and an explicit cast: [] + ✅ smt-z3, a static function and a static homonym: ['helper'] + ✅ smt-z3, an anonymous namespace's function and its homonym: ['_ZN12_GLOBAL__N_16helperEPi'] +``` + +```text +check_const_suggestion_compilation_database (Task 1.6 version): FAIL +=== Testing const suggestions checked in the compilation database === + ✅ a caller + ✅ a callback of the old type, not analyzed + ❌ a redeclaration, not analyzed: None + ✅ an explicit cast, not analyzed + ✅ a method alone + ✅ a method's address of the old type, not analyzed + ✅ a file that does not compile as it is + ✅ a file that cannot be preprocessed + ✅ a file that cannot be checked, then a callback + ✅ a file that does not see the change +``` + +The static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` stay unchanged. + +- [ ] **Step 1: Add the two checks and the D17 expectations; run them.** Expected: D4 and the redeclaration cases FAIL, as above; Review Focus 3 passes. + Commits: `test(const-param): keep a rewrite that takes another file's symbol, unchecked`; `test(const-param): a function a shared header defines does not collide with itself`; `test(const-param): keep a suggestion that another file redeclares, unchecked`. +- [ ] **Step 2: Collisions (§5.10).** After the rewrite, each compilation looks up every symbol the entity takes (its variants after the rewrite) in its module: a definition whose declaration is not the entity's (Task 1.4's identity) is a collision, with the IR linkage of that definition. A declaration the entity's redeclarations gain, compared with the unit's initial compilation, is a merge (ruling 3). Once every unit is judged, and none refutes, `judgeCollisions` applies the table of §5.10: + - local linkage (`internal`, `private`): no collision; + - `external`: in the defining file, Clang reports a redefinition, refuted at step 4; in another file, indeterminate, `… the two definitions collide`; + - `weak`, `extern_weak`, `common`: indeterminate, naming the definition the linker would use: the strong one; + - `weak_odr`, `linkonce_odr`: indeterminate, `… only one of the two is kept …`; + - a merge with a declaration: indeterminate, after any definition (ruling 1). + A definition that is the entity's, such as an `inline` function of a rewritten header, is no collision. + Run: `check_const_suggestion_overload_defined_elsewhere`, `check_const_suggestion_inline_in_a_header`. Expected: PASS. + Commit: `feat(const-param): keep a rewrite that may collide with another file, unchecked`. +- [ ] **Step 3: Stale declarations (D17).** A unit that reads no rewritten file and declares the entity through the linker (Task 1.4, Step 3) makes the unit indeterminate, after steps 4 to 6: ` declares with its current type and does not see the rewrite: …`. T v1's removal `declared in : with type ` goes. + Run: `check_const_suggestion_cross_tu`, `check_const_suggestion_compilation_database`. Expected: PASS, but for the `cast.c` cases (Task 1.7). + Commit: `fix(const-param): keep a suggestion that another file redeclares, unchecked`. +- [ ] **Step 4: Failure of Review Focus 3 on a mutant.** Count the entity's own definition as another entity's in Step 2's lookup; run `check_const_suggestion_inline_in_a_header`. Expected: FAIL (`C++, inline`: indeterminate, `… only one of the two is kept …`). Restore the code; expected: PASS. Record both outputs in `evidence/217/stage1/`. +- [ ] **Step 5: README (D3).** In "Const parameter suggestions checked with Clang", step 2: "A homonym with internal linkage (`static`, anonymous namespace) in another file is another function." becomes "A function with internal linkage (`static`, anonymous namespace) and a function of the same name in another file are two functions, whichever of them has internal linkage." The rest of the section is rewritten with the verdicts in Task 1.8. + Commit: `docs(readme): a static function and its homonym elsewhere are two functions`. +- [ ] **Step 6: Task tests.** As Task 1.1, Step 6. + +**Validation:** D4, Review Focus 3 and the D17 expectations pass; Review Focus 3 failed on the mutant. ### Task 1.7: explicit address casts (D15) From f0cac43797afb692e69ff884e18e91382cdfc77c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 038/152] docs(plan): detail task 1.7, explicit address casts --- .../2026-10-16-const-suggestion-check-v2.md | 96 ++++++++++++++++++- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index b466d94..1412043 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2224,9 +2224,99 @@ The static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` ### Task 1.7: explicit address casts (D15) -**Files:** `ConstSuggestionFacts.cpp`, `ConstSuggestionCheck.cpp`. -**Work:** a cast of the entity's address is recorded and named; alone, it gives indeterminate. -**Tests:** changed expectations: `("def and an explicit cast", ["def.c", "cast.c"], ["read_value"])` in `check_const_suggestion_cross_tu`, and `("an explicit cast, not analyzed", …)` expecting a "not checked with Clang" reason naming `cross-tu-const-suggestion-cast.c` in `check_const_suggestion_compilation_database`. +**Files:** `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `run_test.py`. + +**Tests.** The two changed expectations, on top of Task 1.6's: + +```diff +--- a/run_test.py ++++ b/run_test.py +@@ -3,9 +3,10 @@ + #217: a suggested const type is reported only if every analyzed file still compiles with it, + once every declaration the defining file sees is rewritten. Another analyzed file that stores +- the function where a non-const parameter is expected, or converts its address with an explicit +- cast, removes the suggestion. One that declares it itself, without the header, keeps it, not +- checked: whether that matters depends on how the files are linked. A homonym with internal +- linkage in another file does not change it. A file that is not analyzed is not checked. The +- callback and cast files sort before the definition, the redeclaration after it. ++ the function where a non-const parameter is expected removes the suggestion. One that declares ++ it itself, without the header, or converts its address with an explicit cast, keeps it, not ++ checked: whether that matters depends on how the files are linked, or on what the cast hides. ++ A homonym with internal linkage in another file does not change it. A file that is not ++ analyzed is not checked. The callback and cast files sort before the definition, the ++ redeclaration after it. + """ + return _check_cross_tu_rule( +@@ -19,5 +20,5 @@ + ("def and a callback of the old type", ["def.c", "callback.c"], []), + ("def and a redeclaration", ["def.c", "redecl.c"], ["read_value"]), +- ("def and an explicit cast", ["def.c", "cast.c"], []), ++ ("def and an explicit cast", ["def.c", "cast.c"], ["read_value"]), + # Another file's homonym with internal linkage is another function. + ( +``` + +```diff +--- a/run_test.py ++++ b/run_test.py +@@ -3,12 +3,12 @@ + #217: with a compilation database, a suggested const type is checked in every file the + database lists that sees the change, analyzed or not, with its own compile command. A file +- that is not analyzed and stores the function where a non-const parameter is expected, or +- converts its address with an explicit cast, removes the suggestion, even listed after a file +- that cannot be checked. So does a file that takes the address of a method with the old type: it +- sees the change through the header alone. A file that declares the function itself, without +- the header, keeps the suggestion as not checked, and the reason names it and the link it +- assumes. A file that sees the change but does not compile as it is, or cannot be preprocessed, +- keeps the suggestion as not checked, without the claim of the check. A file that does not see +- the change is not compiled. ++ that is not analyzed and stores the function where a non-const parameter is expected removes ++ the suggestion, even listed after a file that cannot be checked. So does a file that takes the ++ address of a method with the old type: it sees the change through the header alone. A file ++ that declares the function itself, without the header, keeps the suggestion as not checked, ++ and the reason names it and the link it assumes; so does a file that converts the function's ++ address with an explicit cast, and the reason names the cast. A file that sees the change but ++ does not compile as it is, or cannot be preprocessed, keeps the suggestion as not checked, ++ without the claim of the check. A file that does not see the change is not compiled. + """ + print("=== Testing const suggestions checked in the compilation database ===") +@@ -98,5 +98,11 @@ + "definition", + ), +- ("an explicit cast, not analyzed", ["def.c", "cast.c"], "read_value", None), ++ ( ++ "an explicit cast, not analyzed", ++ ["def.c", "cast.c"], ++ "read_value", ++ "not checked with Clang: " + str(fixtures / f"{prefix}cast.c") ++ + ":10 converts the address of read_value with an explicit cast", ++ ), + ("a method alone", ["method.cpp"], method, checked), + ( +``` + +Failure at `64ed37e`: both remove the suggestion (`its address is converted by an explicit cast`). The cast cases of the two checks: + +```text +check_const_suggestion_cross_tu: FAIL +=== Testing suggested const types checked in every analyzed file === + ❌ default, def and an explicit cast: [], expected ['read_value'] + ❌ smt-z3, def and an explicit cast: [], expected ['read_value'] +``` + +```text +check_const_suggestion_compilation_database: FAIL +=== Testing const suggestions checked in the compilation database === + ❌ an explicit cast, not analyzed: None +``` + +- [ ] **Step 1: Change the two expectations and run both checks.** Expected: FAIL on the `cast.c` cases. + Commit: `test(const-param): keep a suggestion whose address another file casts, unchecked`. +- [ ] **Step 2: The cast, named.** `VisitExplicitCastExpr` (T v1's, cast to `void` still excluded) records the cast's site in `UnitFacts::Entity::casts` instead of a problem that refutes. After steps 4 to 6, a recorded cast makes the unit indeterminate: `: converts the address of with an explicit cast`, the first cast in source order. + Run: both checks. Expected: PASS. `const-suggestion-void-cast.c`: unchanged, PASS. + Commit: `fix(const-param): keep a suggestion whose address is cast, unchecked`. +- [ ] **Step 3: Task tests.** As Task 1.1, Step 6. + **Validation:** both checks pass with their new expectations. ### Task 1.8: trace and README From 2c524353acff06495cd201db879fac885fae889e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:42 +0200 Subject: [PATCH 039/152] docs(plan): detail task 1.8, trace and README --- .../2026-10-16-const-suggestion-check-v2.md | 104 +++++++++++++++++- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 1412043..6b40d3c 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2321,12 +2321,106 @@ check_const_suggestion_compilation_database: FAIL ### Task 1.8: trace and README -**Files:** `ConstSuggestionCheck.cpp`, `README.md`. -**Work:** the trace of §4 for each verdict (rule, function and symbol, parameter by name and source index, transformation, unit and configuration, verdict and reason), on `[const-suggestion]` lines after T v1's; the README describes the three verdicts and the limits of §10. -**Tests:** `check_const_suggestion_removal_reason` unchanged; a new check of the trace fields on one verified, one refuted and one indeterminate suggestion. -**Validation:** both pass. +**Files:** `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionPlan.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `README.md`, `run_test.py`. -**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17 and Review Focus 2 to 4 pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; suite without and with the option, unit tests, on macOS and Linux; review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. +**Interfaces.** Adds `Candidate` and `SuggestionPlan::candidate` (Shared types): `current` and `type` printed with the parameter's name (`QualType::print` with the name as placeholder, `const int *const p` for `PointerConstOnly`), `alternative` from the rule (Texts). + +**Tests.** A new check of the trace's fields, on one verified, one refuted and one indeterminate suggestion; `check_const_suggestion_removal_reason` unchanged: + +```python +def check_const_suggestion_trace() -> bool: + """ + #217: with --verbose, the trace gives each verdict's grounds after the line of its outcome: the + suggestion (rule, function, the parameter's index among the source's parameters), the + transformation (current type, candidate, alternative) and where it is written, each unit + checked with its configuration and its verdict, then the verdict and its reason. Here one + suggestion verified, one refuted and one not checked. + """ + print("=== Testing the trace of const suggestion verdicts ===") + source = ( + "int read_value(int* p)\n{\n return *p;\n}\n" + "static int read_slot(int* slot)\n{\n return *slot;\n}\n" + "int (*const stored)(int*) = read_slot;\n" + "#define DECLARE(name) int name(int* q)\n" + "DECLARE(read_macro)\n{\n return *q;\n}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_trace_") as tmp: + path = Path(tmp).resolve() / "verdicts.c" + path.write_text(source) + run = _const_suggestion_run([str(path)]) + if run is None: + return fail_check("the analysis did not run") + _, trace, _ = run + file = re.escape(str(path)) + unit = rf"unit {file} \[[0-9a-f]+\]" + rule = r"ConstParameterNotModified\.Pointer" + expected = { + "verified": [ + r"read_value parameter p: checked in 1 file\(s\)", + rf"read_value parameter p, suggestion: {rule} on read_value, source index 0", + r"read_value parameter p, transformation: int \*p becomes const int \*p " + r"\(pointer to const\)", + rf"read_value parameter p, rewritten at: {file}:1", + rf"read_value parameter p, {unit}: verified", + r"read_value parameter p, verdict: verified", + ], + "refuted": [ + rf"read_slot parameter slot: removed, does not compile: {file}:9:", + rf"read_slot parameter slot, suggestion: {rule} on read_slot, source index 0", + r"read_slot parameter slot, transformation: int \*slot becomes const int \*slot " + r"\(pointer to const\)", + rf"read_slot parameter slot, rewritten at: {file}:5", + rf"read_slot parameter slot, {unit}: refuted: does not compile: {file}:9:", + rf"read_slot parameter slot, verdict: refuted: does not compile: {file}:9:", + ], + "not checked": [ + r"read_macro parameter q: not checked, the declaration is written by a macro " + r"definition", + rf"read_macro parameter q, suggestion: {rule} on read_macro, source index 0", + r"read_macro parameter q, transformation: int \*q becomes const int \*q " + r"\(pointer to const\)", + r"read_macro parameter q, verdict: indeterminate: the declaration is written by a " + r"macro definition", + ], + } + ok = True + for label, patterns in expected.items(): + missing = [p for p in patterns if not any(re.match(p, line) for line in trace)] + good = not missing + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": missing {missing}")) + ok = ok and good + if not ok: + print(" trace:\n" + "\n".join(f" {line}" for line in trace)) + print() + return ok +``` + +Failure at `64ed37e`: only T v1's line per suggestion. + +```text +check_const_suggestion_trace: FAIL +=== Testing the trace of const suggestion verdicts === + ❌ verified: missing ['read_value parameter p, suggestion: ConstParameterNotModified\\.Pointer on read_value, source index 0', 'read_value parameter p, transformation: int \\*p becomes const int \\*p \\(pointer to const … + ❌ refuted: missing ['read_slot parameter slot, suggestion: ConstParameterNotModified\\.Pointer on read_slot, source index 0', 'read_slot parameter slot, transformation: int \\*slot becomes const int \\*slot \\(pointer … + ❌ not checked: missing ['read_macro parameter q, suggestion: ConstParameterNotModified\\.Pointer on read_macro, source index 0', 'read_macro parameter q, transformation: int \\*q becomes const int \\*q \\(pointer to co … +``` + +- [ ] **Step 1: Add the check and run it.** Expected: FAIL, as above. + Commit: `test(const-param): give each verdict's grounds in the trace`. +- [ ] **Step 2: The trace (§4).** After T v1's line, `checkConstSuggestions` logs the `suggestion`, `transformation`, `rewritten at` and `verdict` lines (Texts), around Task 1.1's `unit` lines. A suggestion not planned has no `rewritten at` and no `unit` line. + Run: `check_const_suggestion_trace`, `check_const_suggestion_removal_reason`. Expected: PASS. + Commit: `feat(const-param): trace the suggestion, its rewrite, its units and its verdict`. +- [ ] **Step 3: README.** "Const parameter suggestions checked with Clang" describes: + - the three verdicts (§3): verified, with the "checked" line; refuted, removed, on an error or a tracked warning introduced, or a changed call, override or constant condition; indeterminate, kept with `not checked with Clang: `; + - steps 1 to 3 as T v2 runs them: the plan and its scope, the units that see the change, the judgement of §5.8; + - the limits of §10: collisions between files and the ODR not decided, one configuration per file, a change of behavior that R, O and K do not cover kept as not checked (`signature_size`), a cost to measure (stage 3). + The experimental notice keeps its sense; the sentence "A difference its normalizations do not cover removes a suggestion that is valid" becomes "A difference that the facts do not explain keeps the suggestion as not checked". + Commit: `docs(readme): describe the three verdicts of the const suggestion check`. +- [ ] **Step 4: Task tests.** As Task 1.1, Step 6. + +**Validation:** both checks pass. + +**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17, Review Focus 2 to 4, and the tests that rulings 6, 11 and 13 add, pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; the suite without and with the option, the unit tests, on macOS and Linux; the review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. ## Stage 2 — D6, D8, D10, D12, D13 From 3b07f48ee2d66e93a10b3648c8459afa988761d0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 00:37:48 +0200 Subject: [PATCH 040/152] docs(plan): extend D12's expectations to the block D14 adds --- docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 6b40d3c..5b207c9 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2438,7 +2438,7 @@ check_const_suggestion_trace: FAIL ### Task 2.4: one candidate, displayed (D12, D13) **Work:** the displayed type and the "checked" line name the candidate: `const T &` for `ReferenceRvaluePreferValue` (passing by value is not checked), `const int *const p` for `PointerConstOnly`. -**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, the only T fixture with that rule; D13, a new fixture that enables T (t15: `int* const p` only read, displayed and verified as `const int *const p`), presented with its failure evidence; no T v1 fixture covers `PointerConstOnly`, and the existing test without the option stays unchanged. +**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, and of the one D14 adds to `const-suggestion-deduction-refused.cpp` (stage 1, ruling 10); D13, a new fixture that enables T (t15: `int* const p` only read, displayed and verified as `const int *const p`), presented with its failure evidence; no T v1 fixture covers `PointerConstOnly`, and the existing test without the option stays unchanged. **Validation of stage 2:** as stage 1, for D6, D8, D10, D12, D13 and Review Focus 5. From 4e9bf4820a3974f56d3cc4d3d6f5d3bb1e0e15e2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:25:43 +0200 Subject: [PATCH 041/152] docs(spec): limit the shared initial compilation to one module in stage 1 --- .../2026-10-16-const-suggestion-check-v2-design.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index 48fb167..7b53f55 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -1,7 +1,7 @@ # Vérification des suggestions const par Clang, version 2 (#217) Statut : validée à `8b283a8`, puis complétée à la relecture du plan d'implémentation (D17, fixture -de D13). N'autorise ni le code ni la modification des tests : chaque lot présentera ses +de D13), et au détail de l'étape 1 (mutualisation limitée au module, §5 et §10). N'autorise ni le code ni la modification des tests : chaque lot présentera ses tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 (identité d'un global entre fichiers). @@ -118,7 +118,10 @@ ou interface. 1. Compilation initiale du fichier de définition, et collecte de ses faits « avant ». 2. Planification de chaque suggestion sur cette même compilation. 3. Compilation initiale de chaque autre unité concernée, une fois par unité et configuration, - réutilisée pour toutes les suggestions. + réutilisée pour toutes les suggestions du même module analysé. À l'étape 1, la mutualisation + s'arrête là : T s'exécute une fois par module, et une unité qui voit les changements de deux + modules est compilée telle quelle une fois pour chacun. C'est moins que la mutualisation prévue + d'abord, pour toutes les suggestions de l'analyse (§10). 4. Compilation modifiée de chaque unité concernée. 5. Correspondance et jugement par unité, agrégation, rapport. @@ -359,3 +362,8 @@ restent un changement séparé, hors de ce calendrier. différence d'IR inexpliquée ne suffit plus à retirer une suggestion. - La technique d'identification des constructeurs hérités reste à démontrer. - Un succès du cache IR ne fournit pas d'AST : T compile toujours depuis les sources. +- La compilation initiale d'une unité n'est partagée qu'entre les suggestions d'un même module + (§5, étape 3) : la partager entre modules demanderait les faits d'entités pas encore planifiées. + Ce compromis, accepté provisoirement pour l'étape 1, réduit la mutualisation prévue ; son coût + entre modules est mesuré à l'étape 3 (tâche 3.1). Le critère de +10 % sur l'auto-analyse (§8) + reste inchangé. From e74d248dd255fe614a14dfa7d950bbebd92231bc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:22 +0200 Subject: [PATCH 042/152] docs(plan): correct stage 1's rulings and its texts on weak definitions --- .../2026-10-16-const-suggestion-check-v2.md | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 5b207c9..33fc549 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -889,14 +889,14 @@ Reasons of an indeterminate verdict, after `not checked with Clang: ` (shown in |---|---|---| | `the parameter's type is written in a macro argument that writes other parameters too` | 1.3 | D5: the text an edit rewrites writes another parameter | | `the parameter's type is written in a macro argument that writes other declarations too` | 1.3 | the same, for another declaration | -| ` could not be inspected once rewritten` | 1.4 | step 2 | +| `the rewritten code cannot be inspected entirely, a fatal error stops its compilation: :: ` | 1.4 | step 2 | | `the rewrite also changes the type of (:)` | 1.4 | step 3 | | `the generated code changes, and no reference, override or constant condition explains it: ` | 1.5 | step 7 (D16); `` is the comparator's text | | `'' and '' both become '': their correspondence is not one to one` | 1.5 | §5.7, D14 | | `'' becomes both '' and '': their correspondence is not one to one` | 1.5 | §5.7 | | ` is also defined in ; if and are linked together, the two definitions collide` | 1.6 | §5.10, strong (D4) | -| ` is also defined, weak, in ; if and are linked together, the definition in is used and the other one is ignored` | 1.6 | §5.10, weak | -| ` is also defined in , as an inline or template definition; if and are linked together, only one of the two is kept, and the check does not establish that they are equivalent` | 1.6 | §5.10, `weak_odr`, `linkonce_odr` | +| ` is also defined in ; if and are linked together, the strong definition, in , is used and the weak one, in , is ignored` | 1.6 | §5.10, one definition strong and the other weak, whichever is the entity's | +| ` is also defined in ; if and are linked together, the linker keeps one of the two weak definitions, and the check establishes neither which one nor that they are equivalent` | 1.6 | §5.10, both weak (`weak`, `weak_odr`, `linkonce`, `linkonce_odr`) | | `the rewrite gives the symbol of another declaration (:); if a file linked with defines it, the two definitions collide` | 1.6 | ruling 3 | | ` declares with its current type and does not see the rewrite: if and are linked together, that declaration no longer matches the rewritten definition` | 1.6 | D17 | | `: converts the address of with an explicit cast` | 1.7 | D15 | @@ -928,20 +928,22 @@ Trace (`--verbose`). T v1's line `[const-suggestion] parameter : ### Rulings -The spec leaves these open; each one is the user's to overturn. +The spec leaves these open. The user confirmed them on 2026-10-17, ruling 2 provisionally, and corrected rulings 7 and 12; rulings 14 and 15 come from that review. 1. **Which reason is shown.** A refuted suggestion gives the reason of the first unit that refutes it, in judgement order: the defining unit, then the others in T v1's order. An indeterminate one gives the reason of the first indeterminate unit in that order; if no unit is indeterminate, the first collision's, a definition before a declaration. Cost if wrong: another reason is shown; the verdict stays. -2. **The initial compilation of another unit is shared by the suggestions of one module.** T runs once per analyzed module (`checkConstSuggestions`): a unit that sees the changes of two modules is compiled once as it is for each. Sharing it across modules needs the facts of entities not planned yet; Task 3.1 measures what it would save. Cost if wrong: one more initial compilation per module and unit. +2. **The initial compilation of another unit is shared by the suggestions of one module.** T runs once per analyzed module (`checkConstSuggestions`): a unit that sees the changes of two modules is compiled once as it is for each. Sharing it across modules needs the facts of entities not planned yet. The user accepted this limit for stage 1, provisionally; the spec states it (§5, step 3; §10): it reduces the sharing planned at first. Task 3.1 measures its cost across modules; the +10 % criterion is unchanged. 3. **A declaration of another entity that the rewrite merges into the entity** (t9 without `b.cpp`: `api.hpp` also declares `f(const int*)`) is a possible collision (§3, "collision possible entre fichiers"): indeterminate, with its own reason, shown after a definition's. Cost if wrong: a suggestion kept as not checked that a definition elsewhere would refute or not. 4. **R identifies a target by its declarations.** The value of an R fact is its target's declaration sites, mapped back to the original text; for a template specialization or an implicit function, which have no declaration of their own, its symbol, mapped back through the established pairs. An R fact is unchanged when its target after the rewrite keeps every declaration it had: a merge (ruling 3) adds the entity's, and the collisions judge it, not the facts. A target that loses a declaration, or another target, refutes. A symbol alone would not do: once t9 is rewritten, `_Z1fPKi` names both the entity and the overload it merges with. Cost if wrong: a merge refuted, or a reference to the wrong declaration missed. 5. **D3 is pinned in Task 1.4, not 1.6**: both directions are fixed by the source identity with its linkage, which Task 1.4 brings. Task 1.6 keeps D4, Review Focus 3 and D17. 6. **Task 1.1 gets a second test**, the shared initial compilation, counted with `--timing`: the plan named it as work but did not pin it. -7. **D7's fixture holds four cases**: t8, and as templates a base class template, a derived class template and a constructor template. The technique of §5.7 holds on all four in `evidence/217/stage1/d7-probe/` (`probe.cpp`, `probe-output.txt`): `getInheritedConstructor()` finds the constructor each inheriting constructor comes from, and the symbols CodeGen gives the inheriting constructors are the IR's, before and after. So D7 expects verified. +7. **D7's fixture holds five cases**: t8, and as templates a base class template, a derived class template and a constructor template, inherited for `int*` and for `double*`. The technique of §5.7 holds on all of them in `evidence/217/stage1/d7-probe/` (`probe.cpp`, `probe-output.txt`) and `evidence/217/stage1/corrections/` (`generic-before.cpp`): `getInheritedConstructor()` finds the constructor each inheriting constructor comes from, and the symbols CodeGen gives the inheriting constructors are the IR's, before and after. So D7 expects verified. An inheriting constructor is paired by its derived class, its variant, and the specialization it inherits: one derived class inherits a constructor template once per specialization (`FromGeneric` for `int*` and `double*`), which the derived class and the variant alone confuse. 8. **The two reasons of Task 1.2 are not added.** After P0, the rule reports no artificial parameter, and every parameter of the `-O0` code the analyzer compiles from sources is spilled, so it has a source index: no input reaches those reasons, and no test could fail first. A binding without a source index, or artificial, keeps T v1's text `the parameter is not found in the source`. Cost if wrong: a less precise reason on an input not found. 9. **The comparator's name heuristics are removed** once the established pairs replace them (Task 1.5): the masked encodings, `withoutConst`, `variantOf` and the template keys. The difference text keeps listing the symbols left without a pair, which locates the difference, as §5.9 asks. Cost if wrong: a trace that no longer suggests a probable counterpart. 10. **D12 (Task 2.4) also changes the `ReferenceRvaluePreferValue` block that D14 adds** to `const-suggestion-deduction-refused.cpp`. 11. **Task 1.4 gets a check of the scope after the rewrite** (`check_const_suggestion_header_read_differently`): a header that another file reads as other declarations, through a macro it defines otherwise. Without it, step 3 of §5.8 would have no failing test: the plan's scope (Task 1.3) holds in the defining unit. -12. **No error limit, and step 2 without a test.** T's compilations set no error limit. Otherwise a rewrite that breaks more than 19 uses would end in "too many errors", which is fatal, and step 2 would keep as not checked a suggestion that T v1 removes. Step 2 then guards only a fatal error that the rewrite itself brings, which inserting `const` does not bring in any input of the suite: it has no failing test. Cost if wrong: an untested guard. +12. **No error limit; step 2 tested.** T's compilations set no error limit: otherwise a rewrite that breaks more than 19 uses would end in "too many errors", which is fatal, and step 2 would keep as not checked a suggestion that T v1 removes. A fatal error still comes from the rewrite itself when the suggested type selects a template whose instantiation never ends ("recursive template instantiation exceeded maximum depth"): `const-suggestion-incomplete-inspection.cpp` tests step 2 with it. 13. **Task 1.4 gets a fixture where R and K refute without a change of code** (`const-suggestion-facts-without-code-change.cpp`). T v1 compares only code and keeps both suggestions as checked; since every refutation of T v1's tests comes with a code difference, the facts of Task 1.4 would otherwise have no failing test. +14. **A declaration is known by where it is expanded and where it is written.** One macro invocation can declare several functions, whose names it expands at the same place: a `DeclarationSite` holds both positions, for the identity of the entity and for the scope after the rewrite. `const-suggestion-macro-two-functions.c` pins it: it passes at `64ed37e`, where T v1 finds a function by its symbol, and its failure is shown on a mutant of Task 1.4 that keeps only the expansion position. +15. **The comparator's unit tests are adapted in Task 1.5**, as changed tests submitted with the task: `testConstSuggestionCodeComparison` and `testConstSuggestionNormalizations` pass the established pairs, and keep their guarantees; `testConstSuggestionWithoutConstKeepsIdentifiers` goes with `withoutConst`, its guarantee (two functions whose names differ by `const` are not paired) now held by a case where a renamed symbol without a pair is a difference. ### Shared types @@ -950,10 +952,16 @@ In `ConstSuggestionInternal.hpp`, each added by the task that first needs it: enum class Verdict : std::uint8_t { Verified, Refuted, Indeterminate }; // 1.1 struct Judgement { Verdict verdict = Verdict::Verified; std::string reason; }; // 1.1 struct UnitConfiguration { std::string file; UnitCommand command; std::string fingerprint; }; // 1.1 -struct DeclarationSite // 1.4 +struct SourcePosition // 1.4 { std::string file; // real path unsigned offset = 0; // in the original text + auto operator<=>(const SourcePosition&) const = default; +}; +struct DeclarationSite // 1.4 +{ + SourcePosition expansion; // where Clang expands the name + SourcePosition spelling; // where it is written (ruling 14) auto operator<=>(const DeclarationSite&) const = default; }; enum class SourceLinkage : std::uint8_t { None, Internal, External }; // 1.4 @@ -978,12 +986,11 @@ enum class FactKind : std::uint8_t { Reference, Override, Constant }; struct FactKey // 1.4 { std::vector contexts; // the instantiations around the fact, outermost first - DeclarationSite spelling; - DeclarationSite expansion; + DeclarationSite site; // the expression's, both positions auto operator<=>(const FactKey&) const = default; }; struct Fact { FactKind kind; FactKey key; std::vector value; }; // 1.4 -struct SymbolPair { std::string before; std::string after; }; // 1.5 +struct SymbolPair { std::string before; std::string after; }; // 1.5, in ConstSuggestionCheck.hpp ``` `FactKey` has no unit: each unit's facts are kept apart. `Fact::value` is sorted: for R, the target's declaration sites, or its symbol (ruling 4); for O, the overridden methods, each the same way; for K, `true`, `false` or the selected association's index. From eb3ec097b90de48a33c7f4a00fb418111b69e734 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:23 +0200 Subject: [PATCH 043/152] docs(plan): know a declaration by both positions, test incomplete inspection --- .../2026-10-16-const-suggestion-check-v2.md | 136 ++++++++++++++++-- 1 file changed, 124 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 33fc549..f85ff04 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1425,7 +1425,7 @@ check_const_suggestion_line_endings: FAIL **Files:** `src/analysis/ConstSuggestionFacts.cpp` (new), `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionPlan.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `CMakeLists.txt`, `run_test.py`, `test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp` (new). -**Interfaces.** Adds `DeclarationSite`, `SourceLinkage`, `EntityIdentity`, `FactKind`, `FactKey`, `Fact` (Shared types) and `SuggestionPlan::entity`. Produces: +**Interfaces.** Adds `SourcePosition`, `DeclarationSite`, `SourceLinkage`, `EntityIdentity`, `FactKind`, `FactKey`, `Fact` (Shared types) and `SuggestionPlan::entity`. Produces: ```cpp // What one compilation shows (§5.3, §5.6). R and K cover the whole unit; the rest is kept for // each plan given, in their order. @@ -1437,7 +1437,7 @@ struct UnitFacts std::shared_ptr code; // prepared for the comparator std::map defined; // symbols with a body std::vector facts; // R and K - std::map writtenTypes; // declarations of the rewritten files + std::map writtenTypes; // declarations of the rewritten files, by both positions struct Entity { bool found = false; @@ -1615,7 +1615,102 @@ int typed(int* p) // analyzer-arg: --experimental-const-suggestion-check ``` -Failure at `64ed37e`. D2: `operator==` is removed, "the methods it overrides change": T v1 recognizes a method by its identifier after the rewrite, and an operator has none. D3: both removed, "declared in b.c:1 / d.c:1 with type int *": T v1 takes a homonym for the function, whatever its linkage. The header: checked, since the global's new type leaves the IR as it was. The fixture: both kept as checked. +Step 2 of §5.8, a new fixture (ruling 12): the suggested type selects a template whose instantiation never ends, and the compilation stops at that fatal error. The expectation stops after `compilation:`; the file, the line and Clang's message follow: + +`test/pointer_reference-const_correctness/const-suggestion-incomplete-inspection.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggested type can make a compilation stop at a fatal error, after which the code it +// would have parsed is not inspected. Here sizeof(sized(p)) selects sized(int*) before the rewrite, +// and the template once p points to const: deducing its return type instantiates Deep without end. +// What the rewrite changes is then unknown: the suggestion is kept, not checked. + +// strict-expectation-details: true + +template struct Deep +{ + static constexpr int value = Deep::value + 1; +}; + +int sized(int* p); + +template auto sized(const T* p) +{ + return Deep::value + *p; +} + +// at line 26, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_sized(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ not checked with Clang: the rewritten code cannot be inspected entirely, a fatal error stops its compilation: +int read_sized(int* p) +{ + return *p + static_cast(sizeof(sized(p))); +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +The identity by both positions, a new fixture (ruling 14): one macro invocation declares two functions; the one suggested comes first in one invocation, second in the other: + +`test/pointer_reference-const_correctness/const-suggestion-macro-two-functions.c`: + +```c +// SPDX-License-Identifier: Apache-2.0 +// #217: one macro invocation can declare several functions, whose names it expands at the same +// place. Each one is also known by where its name is written: the check rewrites the function the +// suggestion names and leaves the other one, which writes through its parameter, whichever the +// invocation declares first. + +// strict-expectation-details: true + +#define DECLARE_TWO(a, Ta, b, Tb) \ + int a(Ta p); \ + int b(Tb q); + +DECLARE_TWO(read_first, int*, write_first, int*) +DECLARE_TWO(write_second, int*, read_second, int*) + +// at line 21, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_first' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +int read_first(int* p) +{ + return *p; +} + +int write_first(int* q) +{ + *q = 1; + return 0; +} + +int write_second(int* p) +{ + *p = 2; + return 0; +} + +// at line 43, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'q' in function 'read_second' is never used to modify the pointed object +// ↳ current type: int *q +// ↳ suggested type: const int *q +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +int read_second(int* q) +{ + return *q; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check +``` + +Failure at `64ed37e`. D2: `operator==` is removed, "the methods it overrides change": T v1 recognizes a method by its identifier after the rewrite, and an operator has none. D3: both removed, "declared in b.c:1 / d.c:1 with type int *": T v1 takes a homonym for the function, whatever its linkage. The header: checked, since the global's new type leaves the IR as it was. The facts' fixture: both kept as checked. The incomplete inspection: removed, `does not compile: …: recursive template instantiation exceeded maximum depth of 1024`, which a fatal error does not establish (step 2 comes before step 4). The two functions of one invocation pass at `64ed37e`: T v1 finds a function by its symbol. Their failure is shown on a mutant, in Step 3. ```text check_const_suggestion_operator_used_elsewhere: FAIL @@ -1646,13 +1741,29 @@ new/const-suggestion-facts-without-code-change.cpp: FAIL (2/6) ❌ (smt-z3) negative expectation #2 FOUND (unexpected) ``` +```text +new/const-suggestion-incomplete-inspection.cpp: FAIL (2/4) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-incomplete-inspection.cpp === + ❌ (default) expectation #1 MISSING + ❌ (smt-z3) expectation #1 MISSING +``` + +```text +new/const-suggestion-macro-two-functions.c: PASS (6/6) +=== Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-macro-two-functions.c === + ✅ (default) expectation #1 FOUND + ✅ (default) expectation #2 FOUND + ✅ (smt-z3) expectation #1 FOUND + ✅ (smt-z3) expectation #2 FOUND +``` + Unchanged and passing at `64ed37e`: `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `pick` in `const-suggestion-deduction-refused.cpp`, `generic_selection` in `const-suggestion-must-compile.c`. -- [ ] **Step 1: Add the four tests and run them.** Expected: FAIL, as above. - Commits: `test(const-param): check a member operator's suggestion in the file that calls it`; `test(const-param): a C homonym with another linkage is another function`; `test(const-param): a changed call or constant refutes even with the same code`; `test(const-param): keep a rewrite that changes another file's declaration`. +- [ ] **Step 1: Add the six tests and run them.** Expected: FAIL, as above, but for the two functions of one invocation, which pass. + Commits: `test(const-param): check a member operator's suggestion in the file that calls it`; `test(const-param): a C homonym with another linkage is another function`; `test(const-param): a changed call or constant refutes even with the same code`; `test(const-param): keep a rewrite that changes another file's declaration`; `test(const-param): keep a suggestion whose rewrite stops at a fatal error, unchecked`; `test(const-param): tell apart two functions that one macro invocation declares`. - [ ] **Step 2: Move the compilation.** `DiagnosticCollector`, `Inspector`, `InspectAction`, `compile` and `scan` move to `ConstSuggestionFacts.cpp`. No behavior changes. Run: the T v1 checks and fixtures. Expected: PASS. Commit: `refactor(const-param): move the compilation and its inspection to their own file`. -- [ ] **Step 3: The entity's identity (§3, §5.5).** `plan()` describes the located function: its redeclarations' sites (the expansion location of each name, as a real path and an offset), its formal linkage (`External` for external or module linkage, `Internal` for internal or unique-external, `None` otherwise), its template pattern's first declaration, and its variants' symbols, from `CodeGenerator::GetMangledName` (a constructor's `Ctor_Complete` and `Ctor_Base`, a destructor's `Dtor_Deleting`, `Dtor_Complete` and `Dtor_Base`, otherwise the function's). +- [ ] **Step 3: The entity's identity (§3, §5.5).** `plan()` describes the located function: its redeclarations' sites (ruling 14: the expansion and the spelling location of each name, each as a real path and an offset), its formal linkage (`External` for external or module linkage, `Internal` for internal or unique-external, `None` otherwise), its template pattern's first declaration, and its variants' symbols, from `CodeGenerator::GetMangledName` (a constructor's `Ctor_Complete` and `Ctor_Base`, a destructor's `Dtor_Deleting`, `Dtor_Complete` and `Dtor_Base`, otherwise the function's). In every other compilation, the entity is the function that has one of those sites, once mapped back to the original text through the plan's edits: ```cpp // An offset of the rewritten text, in the original one. An offset inside inserted text maps @@ -1674,21 +1785,22 @@ Unchanged and passing at `64ed37e`: `const-suggestion-same-code.cpp`, `const-sug return offset - inserted + removed; } ``` - A unit that shares none of those sites declares the entity only through the linker: a function with external linkage whose symbol is one of the entity's. T v1's name and symbol test (`isTarget`, `locate`'s `withoutConst` search) goes. A unit sees the change (§5.2) through a reference in its analyzed module only if the entity has external linkage. - Run: both checks. Expected: PASS. Commit: `fix(const-param): find the function by its declarations, not by its name`. + Both positions of a site are mapped back; a site matches only when both do. A unit that shares none of those sites declares the entity only through the linker: a function with external linkage whose symbol is one of the entity's. T v1's name and symbol test (`isTarget`, `locate`'s `withoutConst` search) goes. A unit sees the change (§5.2) through a reference in its analyzed module only if the entity has external linkage. + Run: both checks, and `const-suggestion-macro-two-functions.c`. Expected: PASS. Mutant: compare only the expansion position of a site; run the fixture. Expected: FAIL, one of the two suggested functions confused with the function its invocation declares beside it. Restore; record both outputs in `evidence/217/stage1/`. + Commit: `fix(const-param): find the function by its declarations, not by its name`. - [ ] **Step 4: Facts R, O and K (§5.4).** The `Inspector` visits the whole unit, template instantiations included: - - R: `DeclRefExpr`, `MemberExpr`, `CXXConstructExpr`, `CXXNewExpr` and `CXXDeleteExpr` that name a function. Key: the instantiations around the expression (each by its symbol, outermost first), and its spelling and expansion sites, mapped back. Value (ruling 4): the target's declaration sites, mapped back; for a template specialization or an implicit function, its symbol; + - R: `DeclRefExpr`, `MemberExpr`, `CXXConstructExpr`, `CXXNewExpr` and `CXXDeleteExpr` that name a function. Key: the instantiations around the expression (each by its symbol, outermost first), and its site, both positions mapped back. Value (ruling 4): the target's declaration sites, mapped back; for a template specialization or an implicit function, its symbol; - O: for each method with the entity's `DeclarationName`, the methods it overrides (`overridden_methods()`), each by the same value as R. Key: the method's site; - K: each `IfStmt` with `isConstexpr()` and a condition that is not value-dependent: `true` or `false`, from `EvaluateKnownConstInt`; each `GenericSelectionExpr` that is not result-dependent: `getResultIndex()`. A symbol comes from `CodeGenerator::GetMangledName`, cached by declaration. A fact is compared only when its key's instantiations all map to the other compilation: here, through the entity's own symbol pairs (Task 1.5 adds the others). An R fact is unchanged when its value after contains its value before (ruling 4); O and K must be equal. T v1's comparison of overrides by qualified name goes. Judgement step 6, after T v1's diagnostics (steps 4 and 5): the first changed fact refutes, with its text (Texts). Run: the new fixture, then `const-suggestion-same-code.cpp`, `const-suggestion-override-elsewhere.cpp`, `const-suggestion-deduction-refused.cpp` and `const-suggestion-must-compile.c`. Expected: PASS. Commit: `feat(const-param): refute a suggestion that changes a call, override or constant`. -- [ ] **Step 5: Scope after the rewrite (§5.6), steps 2 and 3.** Each compilation records `writtenTypes`: the canonical type (`writtenType`) of every declaration whose name's expansion location lies in a file that some plan rewrites (`DeclaratorDecl`, `TypedefNameDecl`), neither implicit nor instantiated, by its site. After the rewrite, every site keeps its type, except the planned parameters and the entity's redeclarations; a planned parameter must have the candidate (T v1's `the rewritten declaration does not get the suggested type`). Otherwise step 3: `the rewrite also changes the type of (:)`. A compilation with a fatal error (`hasFatalErrorOccurred()`) has an incomplete inspection, step 2: ` could not be inspected once rewritten`. T's compilations set no error limit (`DiagnosticOptions::ErrorLimit = 0`): "too many errors" would otherwise make a rewrite that breaks many uses incomplete instead of refuted, where T v1 refutes it. Step 2 then guards only a fatal error that a rewrite brings, such as a missing file: no input of the suite reaches it (ruling 12). The judgement now runs steps 1 to 6 in the order of §5.8; T v1's code comparison stays step 7 until Task 1.5. - Run: `check_const_suggestion_header_read_differently`. Expected: PASS. Commit: `feat(const-param): leave a rewrite that changes another declaration unchecked`. +- [ ] **Step 5: Scope after the rewrite (§5.6), steps 2 and 3.** Each compilation records `writtenTypes`: the canonical type (`writtenType`) of every declaration whose name's expansion location lies in a file that some plan rewrites (`DeclaratorDecl`, `TypedefNameDecl`), neither implicit nor instantiated, by its site, both positions (ruling 14). After the rewrite, every site keeps its type, except the planned parameters and the entity's redeclarations; a planned parameter must have the candidate (T v1's `the rewritten declaration does not get the suggested type`). Otherwise step 3: `the rewrite also changes the type of (:)`. A compilation after the rewrite with a fatal error (`hasFatalErrorOccurred()`) has an incomplete inspection, step 2: `the rewritten code cannot be inspected entirely, a fatal error stops its compilation: :: `, the first fatal error. T's compilations set no error limit (`DiagnosticOptions::ErrorLimit = 0`): "too many errors" would otherwise make a rewrite that breaks many uses incomplete instead of refuted, where T v1 refutes it (ruling 12). The judgement now runs steps 1 to 6 in the order of §5.8; T v1's code comparison stays step 7 until Task 1.5. + Run: `check_const_suggestion_header_read_differently`, `const-suggestion-incomplete-inspection.cpp`. Expected: PASS. Commit: `feat(const-param): leave a rewrite that changes another declaration unchecked`. - [ ] **Step 6: Task tests.** As Task 1.1, Step 6. -**Validation:** D2, D3, the header check and the new fixture pass; the refutations by R, O and K of T v1's tests unchanged. +**Validation:** D2, D3, the header check and the three new fixtures pass; the mutant of Step 3 failed; the refutations by R, O and K of T v1's tests unchanged. ### Task 1.5: correspondences and the comparator's pairs (§5.7, §5.9), judgement step 7 From e4a736ce5df62b26b5acedc3a4824408f7969966 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:23 +0200 Subject: [PATCH 044/152] docs(plan): pair inherited constructors by specialization, adapt unit tests --- .../2026-10-16-const-suggestion-check-v2.md | 122 +++++++++++++++--- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index f85ff04..57a06dc 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -1804,9 +1804,9 @@ Unchanged and passing at `64ed37e`: `const-suggestion-same-code.cpp`, `const-sug ### Task 1.5: correspondences and the comparator's pairs (§5.7, §5.9), judgement step 7 -**Files:** `src/analysis/ConstSuggestionMatch.cpp` (new), `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCode.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `include/analysis/ConstSuggestionCheck.hpp`, `CMakeLists.txt`, `test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp` (new), `const-suggestion-deduction-refused.cpp` and `const-suggestion-printed-signature.c` (expectations changed). +**Files:** `src/analysis/ConstSuggestionMatch.cpp` (new), `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCode.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `include/analysis/ConstSuggestionCheck.hpp`, `CMakeLists.txt`, `test/unit/analyzer_module_unit_tests.cpp` (adapted, ruling 15), `test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp` (new), `const-suggestion-deduction-refused.cpp` and `const-suggestion-printed-signature.c` (expectations changed). -**Interfaces.** Adds `SymbolPair`. `UnitFacts::Entity` gains what the correspondences read: +**Interfaces.** Adds `SymbolPair` (Shared types). `UnitFacts::Entity` gains what the correspondences read: ```cpp // A symbol that the rewrite may rename with the entity, and what it is in this compilation. struct Named @@ -1815,7 +1815,8 @@ struct Named std::string key; // what pairs it: its kind, its site mapped back, and its enclosing symbol }; std::vector variants; // the entity's: "C1", "C2", "D0"... or "" for a function -std::vector inheriting; // the constructors inheriting it: derived class and variant +std::vector inheriting; // the constructors inheriting it: derived class, inherited + // specialization and variant (ruling 7) std::vector nested; // lambdas, local classes (methods, _ZTI, _ZTS, _ZTV), static // locals and their guards, in the entity or a specialization ``` @@ -1831,9 +1832,15 @@ std::optional codeDifference(const ModuleText& was, const ModuleTex const PrintedSignature* signature, std::vector* accepted); ``` -`rewrittenCodeDifference` in `ConstSuggestionCheck.hpp` takes the pairs the same way. +`SymbolPair` is declared in `ConstSuggestionCheck.hpp`, whose `rewrittenCodeDifference` takes the pairs the same way: +```cpp +std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, + const std::vector& established, + const PrintedSignature* signature = nullptr, + std::vector* accepted = nullptr); +``` -**Tests.** D7, a new fixture (t8 and three template cases, ruling 7): +**Tests.** D7, a new fixture (t8 and three template cases, the constructor template inherited for `int*` and for `double*`, ruling 7): `test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp`: @@ -1843,11 +1850,12 @@ std::optional codeDifference(const ModuleText& was, const ModuleTex // whose symbols name the inherited constructor's parameter types: the rewrite renames them too. // The check pairs them before and after the rewrite through the constructor they inherit, so // each suggestion is checked: a plain class, a class template as the base, a class template as -// the derived class, and a constructor template. +// the derived class, and a constructor template, inherited twice, for int* and for double*: the +// derived class inherits two constructors, one from each specialization. // strict-expectation-details: true -// at line 18, column 0 +// at line 19, column 0 // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'Base::Base(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p @@ -1870,7 +1878,7 @@ int make_derived(int x) return d.v; } -// at line 41, column 0 +// at line 42, column 0 // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'TemplateBase::TemplateBase(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p @@ -1893,7 +1901,7 @@ int make_from_template(int x) return d.v; } -// at line 64, column 0 +// at line 65, column 0 // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'PlainBase::PlainBase(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p @@ -1916,11 +1924,17 @@ int make_template_derived(int x) return d.v; } -// at line 87, column 0 +// at line 94, column 0 // [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'GenericBase::GenericBase(int*)' is never used to modify the pointed object // ↳ current type: int *p // ↳ suggested type: const int *p // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 94, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'GenericBase::GenericBase(double*)' is never used to modify the pointed object +// ↳ current type: double *p +// ↳ suggested type: const double *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside struct GenericBase { int v; @@ -1939,6 +1953,12 @@ int make_from_generic(int x) return d.v; } +int make_from_generic_double(double x) +{ + FromGeneric d(&x); + return d.v; +} + // The Clang check of const suggestions is experimental: it runs only with this option. // analyzer-arg: --experimental-const-suggestion-check ``` @@ -2014,19 +2034,87 @@ D16, `signature_size` in `const-suggestion-printed-signature.c`: kept, not check { ``` -Failure at `64ed37e`: the four inherited constructors and both `call` suggestions are removed ("symbols no longer used"), `signature_size` too ("the generated code changes"). +The comparator's unit tests, adapted to the established pairs (ruling 15): + +```diff +--- a/test/unit/analyzer_module_unit_tests.cpp ++++ b/test/unit/analyzer_module_unit_tests.cpp +@@ -751,24 +751,12 @@ + } + +- /// #217: a symbol's demangled name without const keeps the identifiers that contain "const": +- /// get_const(int*) and get_(int*) are two functions. +- bool testConstSuggestionWithoutConstKeepsIdentifiers(TestReport& report) +- { +- using ctrace::stack::analysis::const_suggestion::withoutConst; +- report.expect(withoutConst("_Z9get_constPi") != withoutConst("_Z4get_Pi"), +- "ConstSuggestionCode: get_const(int*) and get_(int*) stay distinct"); +- report.expect(withoutConst("_Z14const_iteratorPKi") == "const_iterator(int*)", +- "ConstSuggestionCode: const_iterator keeps its name, the qualifier goes"); +- report.expect(withoutConst("_Z1fPKi") == withoutConst("_Z1fPi"), +- "ConstSuggestionCode: f(const int*) and f(int*) have the same key"); +- return report.failures == 0; +- } +- + /// #217: the code generated before and after a const rewrite is compared once the symbols +- /// the rewrite renames are paired. A symbol the unit only declares has no code to compare: it +- /// is paired only if it is the rewritten function itself. ++ /// the rewrite renames are paired, by the pairs its correspondences established and by them ++ /// only: a symbol renamed without a pair, even one whose name differs only by const, is a ++ /// difference. + bool testConstSuggestionCodeComparison(TestReport& report) + { + using ctrace::stack::analysis::rewrittenCodeDifference; ++ using ctrace::stack::analysis::SymbolPair; + llvm::LLVMContext context; + llvm::SMDiagnostic error; +@@ -816,9 +804,13 @@ + return false; + } +- report.expect(!rewrittenCodeDifference(*calledBefore, *calledAfter, "_Z1fPi"), ++ const std::vector function = {{"_Z1fPi", "_Z1fPKi"}}; ++ report.expect(!rewrittenCodeDifference(*calledBefore, *calledAfter, function), + "ConstSuggestionCode: the rewritten function and its caller are renamed " + "together"); +- report.expect(rewrittenCodeDifference(*thrownBefore, *thrownAfter, "_Z1fPi").has_value(), +- "ConstSuggestionCode: typeinfo for int* and for int const*, only declared, " ++ report.expect(rewrittenCodeDifference(*calledBefore, *calledAfter, {}).has_value(), ++ "ConstSuggestionCode: a function renamed without an established pair is a " ++ "difference"); ++ report.expect(rewrittenCodeDifference(*thrownBefore, *thrownAfter, function).has_value(), ++ "ConstSuggestionCode: typeinfo for int* and for int const*, without a pair, " + "are not paired"); + return report.failures == 0; +@@ -841,5 +833,5 @@ + if (!was || !now) + return std::optional(); +- return std::optional(rewrittenCodeDifference(*was, *now, "f").has_value()); ++ return std::optional(rewrittenCodeDifference(*was, *now, {}).has_value()); + }; + const auto replaced = [](std::string text, const std::string& from, const std::string& to) +@@ -2894,5 +2886,4 @@ + (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); + (void)testConstSuggestionCodeComparison(report); +- (void)testConstSuggestionWithoutConstKeepsIdentifiers(report); + (void)testConstSuggestionNormalizations(report); + (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); +``` + +Failure at `64ed37e`: the five inherited constructors and both `call` suggestions are removed ("symbols no longer used"), `signature_size` too ("the generated code changes"). ```text -new/const-suggestion-inheriting-constructor.cpp: FAIL (2/10) +new/const-suggestion-inheriting-constructor.cpp: FAIL (2/12) === Testing evidence/217/stage1/tests/fixtures/new/const-suggestion-inheriting-constructor.cpp === ❌ (default) expectation #1 MISSING ❌ (default) expectation #2 MISSING ❌ (default) expectation #3 MISSING ❌ (default) expectation #4 MISSING + ❌ (default) expectation #5 MISSING ❌ (smt-z3) expectation #1 MISSING ❌ (smt-z3) expectation #2 MISSING ❌ (smt-z3) expectation #3 MISSING ❌ (smt-z3) expectation #4 MISSING + ❌ (smt-z3) expectation #5 MISSING ``` ```text @@ -2057,22 +2145,22 @@ Unchanged and passing at `64ed37e`: `const-suggestion-kept*.cpp`, `const-suggest Commits: `test(const-param): check a suggestion through the constructors that inherit it`; `test(const-param): keep the suggestions whose specializations merge, unchecked`; `test(const-param): keep a suggestion whose code change nothing explains, unchecked`. - [ ] **Step 2: Symbols from compilation facts.** The `Inspector` records, in every compilation: - `variants`: the entity's variants, by `GlobalDecl` (Task 1.4, Step 3), keyed by variant; - - `inheriting`: each `CXXConstructorDecl` with `isInheritingConstructor()` whose `getInheritedConstructor().getConstructor()` is the entity, or a specialization of its pattern; its `Ctor_Complete` and `Ctor_Base` symbols, keyed by the derived class (its mangled type, `MangleContext::mangleCanonicalTypeName`) and the variant. `evidence/217/stage1/d7-probe/` shows these symbols equal to the IR's, before and after, in the four cases of the fixture; + - `inheriting`: each `CXXConstructorDecl` with `isInheritingConstructor()` whose `getInheritedConstructor().getConstructor()` is the entity, or a specialization of its pattern; its `Ctor_Complete` and `Ctor_Base` symbols, keyed by the derived class (its mangled type, `MangleContext::mangleCanonicalTypeName`), the specialization it inherits (the template arguments of `getInheritedConstructor().getConstructor()`, each written canonically as `writtenType` does, none for a constructor that is no template) and the variant. `evidence/217/stage1/d7-probe/` shows these symbols equal to the IR's, before and after, in the five cases of the fixture; - `nested`: in the entity, and in each function that a specialization of its template defines, every lambda's call operator, every local class's methods (by variant) and its `_ZTI`, `_ZTS` and `_ZTV` (`MangleContext::mangleCXXRTTI`, `mangleCXXRTTIName`, `mangleCXXVTable`), every static local variable and its guard (`mangleStaticGuardVariable`); keyed by kind, site mapped back, and the enclosing function's symbol. `evidence/217/stage1/d7-probe/nested-ast.txt` shows these symbols equal to the IR's (`nested-output.txt`). Commit: `feat(const-param): record the symbols a rewrite may rename, with their declarations`. - [ ] **Step 3: `correspond` (§5.7)**, in `ConstSuggestionMatch.cpp`, in this order: 1. the entity's variants, by variant; - 2. the inheriting constructors, by derived class and variant; + 2. the inheriting constructors, by derived class, inherited specialization and variant; 3. the instantiation contexts, from the R facts whose instantiations already map. For each such fact whose target changes, the pair (target before, target after) is a candidate if both are specializations of one template (`template:` equal). The candidates of every fact are then checked together: a target before with two targets after, or two targets before with one after, gives `missing` (Texts, §5.7), and no pair for them. Each new pair maps more instantiations, and the step repeats until no pair is added; 4. the nested symbols, by key, once their enclosing symbols are paired. A pair is established only from these facts; the names of the symbols play no part. Commit: `feat(const-param): establish the correspondences of a rewrite from its facts`. -- [ ] **Step 4: The comparator takes the pairs, step 7.** `codeDifference` renames the symbols of `established` before comparing; every symbol that only one side has, and that no pair covers, is listed in its difference, as T v1 lists it (`symbols no longer used: …; symbols used only after the rewrite: …`). The masked encodings, `withoutConst`, `variantOf`, `templateOf` and `namesTemplate` go (ruling 9). The ABI line takes the symbol after the rewrite from the pair of the suggestion's own symbol. In the judgement, `correspond` runs before step 6: the facts are compared through its pairs, and those in an instantiation it could not pair are left out. After step 6, its `missing` makes the unit indeterminate; otherwise a difference of the comparator makes it indeterminate with `the generated code changes, and no reference, override or constant condition explains it: `. Step 7 never refutes. - Run: the three tests of Step 1, then every T v1 fixture with the option. Expected: PASS; each T v1 "checked" stays checked; `pick` is refuted by its K fact, the R and O refutations of `const-suggestion-same-code.cpp` and `const-suggestion-override-elsewhere.cpp` by their facts. +- [ ] **Step 4: The comparator takes the pairs, step 7.** First the unit tests: adapt `analyzer_module_unit_tests.cpp` as above; the unit-test target no longer compiles, `rewrittenCodeDifference` taking no pairs yet. Give `rewrittenCodeDifference` and `codeDifference` the pairs, still pairing by names as well (the mutant of this step): the unit tests build, and "a function renamed without an established pair is a difference" FAILS, the name heuristics pairing `_Z1fPi` and `_Z1fPKi`. Record that output in `evidence/217/stage1/`. Then `codeDifference` renames the symbols of `established` before comparing; every symbol that only one side has, and that no pair covers, is listed in its difference, as T v1 lists it (`symbols no longer used: …; symbols used only after the rewrite: …`). The masked encodings, `withoutConst`, `variantOf`, `templateOf` and `namesTemplate` go (ruling 9). The ABI line takes the symbol after the rewrite from the pair of the suggestion's own symbol. In the judgement, `correspond` runs before step 6: the facts are compared through its pairs, and those in an instantiation it could not pair are left out. After step 6, its `missing` makes the unit indeterminate; otherwise a difference of the comparator makes it indeterminate with `the generated code changes, and no reference, override or constant condition explains it: `. Step 7 never refutes. + Run: the unit tests, the three tests of Step 1, then every T v1 fixture with the option. Expected: PASS; each T v1 "checked" stays checked; `pick` is refuted by its K fact, the R and O refutations of `const-suggestion-same-code.cpp` and `const-suggestion-override-elsewhere.cpp` by their facts. Commit: `fix(const-param): compare the code through established pairs, never refuting`. - [ ] **Step 5: Task tests.** As Task 1.1, Step 6. -**Validation:** D7, D14 and D16 pass; every T v1 "checked" stays verified. +**Validation:** D7, D14, D16 and the adapted unit tests pass, the latter after failing on the mutant; every T v1 "checked" stays verified. ### Task 1.6: collisions, stale declarations, link-edition assumption (§5.10) From 8e8e8771d6d5da79df43825de299df692781f48c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:23 +0200 Subject: [PATCH 045/152] docs(plan): name the strong definition that a weak one yields to --- .../plans/2026-10-16-const-suggestion-check-v2.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 57a06dc..1ab8303 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2410,11 +2410,11 @@ The static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` - [ ] **Step 1: Add the two checks and the D17 expectations; run them.** Expected: D4 and the redeclaration cases FAIL, as above; Review Focus 3 passes. Commits: `test(const-param): keep a rewrite that takes another file's symbol, unchecked`; `test(const-param): a function a shared header defines does not collide with itself`; `test(const-param): keep a suggestion that another file redeclares, unchecked`. -- [ ] **Step 2: Collisions (§5.10).** After the rewrite, each compilation looks up every symbol the entity takes (its variants after the rewrite) in its module: a definition whose declaration is not the entity's (Task 1.4's identity) is a collision, with the IR linkage of that definition. A declaration the entity's redeclarations gain, compared with the unit's initial compilation, is a merge (ruling 3). Once every unit is judged, and none refutes, `judgeCollisions` applies the table of §5.10: +- [ ] **Step 2: Collisions (§5.10).** After the rewrite, each compilation looks up every symbol the entity takes (its variants after the rewrite) in its module: a definition whose declaration is not the entity's (Task 1.4's identity) is a collision, with the IR linkage of that definition; the defining unit gives the linkage of the entity's own. A declaration the entity's redeclarations gain, compared with the unit's initial compilation, is a merge (ruling 3). Once every unit is judged, and none refutes, `judgeCollisions` applies the table of §5.10: - local linkage (`internal`, `private`): no collision; - - `external`: in the defining file, Clang reports a redefinition, refuted at step 4; in another file, indeterminate, `… the two definitions collide`; - - `weak`, `extern_weak`, `common`: indeterminate, naming the definition the linker would use: the strong one; - - `weak_odr`, `linkonce_odr`: indeterminate, `… only one of the two is kept …`; + - both strong (`external`): in the defining file, Clang reports a redefinition, refuted at step 4; in another file, indeterminate, `… the two definitions collide`; + - one definition strong (`external`), the other weak (`weak`, `weak_odr`, `linkonce`, `linkonce_odr`), whichever of the two is the entity's: indeterminate, `… the strong definition, in , is used and the weak one, in , is ignored`; + - both weak: indeterminate, `… the linker keeps one of the two weak definitions, and the check establishes neither which one nor that they are equivalent`; - a merge with a declaration: indeterminate, after any definition (ruling 1). A definition that is the entity's, such as an `inline` function of a rewritten header, is no collision. Run: `check_const_suggestion_overload_defined_elsewhere`, `check_const_suggestion_inline_in_a_header`. Expected: PASS. @@ -2422,7 +2422,7 @@ The static and anonymous-namespace homonyms of `check_const_suggestion_cross_tu` - [ ] **Step 3: Stale declarations (D17).** A unit that reads no rewritten file and declares the entity through the linker (Task 1.4, Step 3) makes the unit indeterminate, after steps 4 to 6: ` declares with its current type and does not see the rewrite: …`. T v1's removal `declared in : with type ` goes. Run: `check_const_suggestion_cross_tu`, `check_const_suggestion_compilation_database`. Expected: PASS, but for the `cast.c` cases (Task 1.7). Commit: `fix(const-param): keep a suggestion that another file redeclares, unchecked`. -- [ ] **Step 4: Failure of Review Focus 3 on a mutant.** Count the entity's own definition as another entity's in Step 2's lookup; run `check_const_suggestion_inline_in_a_header`. Expected: FAIL (`C++, inline`: indeterminate, `… only one of the two is kept …`). Restore the code; expected: PASS. Record both outputs in `evidence/217/stage1/`. +- [ ] **Step 4: Failure of Review Focus 3 on a mutant.** Count the entity's own definition as another entity's in Step 2's lookup; run `check_const_suggestion_inline_in_a_header`. Expected: FAIL (`C++, inline`: indeterminate, `… the linker keeps one of the two weak definitions …`). Restore the code; expected: PASS. Record both outputs in `evidence/217/stage1/`. - [ ] **Step 5: README (D3).** In "Const parameter suggestions checked with Clang", step 2: "A homonym with internal linkage (`static`, anonymous namespace) in another file is another function." becomes "A function with internal linkage (`static`, anonymous namespace) and a function of the same name in another file are two functions, whichever of them has internal linkage." The rest of the section is rewritten with the verdicts in Task 1.8. Commit: `docs(readme): a static function and its homonym elsewhere are two functions`. - [ ] **Step 6: Task tests.** As Task 1.1, Step 6. From a93211c5a0ee2a88d9227825c1f626ebf10f4fb5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:31 +0200 Subject: [PATCH 046/152] docs(plan): count the tests the review added in stage 1's validation --- docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 1ab8303..d3b937b 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2627,7 +2627,7 @@ check_const_suggestion_trace: FAIL **Validation:** both checks pass. -**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17, Review Focus 2 to 4, and the tests that rulings 6, 11 and 13 add, pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; the suite without and with the option, the unit tests, on macOS and Linux; the review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. +**Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17, Review Focus 2 to 4, the tests that rulings 6, 11, 12, 13 and 14 add, and the unit tests that ruling 15 adapts, pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; the suite without and with the option, the unit tests, on macOS and Linux; the review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. ## Stage 2 — D6, D8, D10, D12, D13 From 668a849c629f842bb2d70e4bf4c5e1fcfa85feb4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:51 +0200 Subject: [PATCH 047/152] test(const-param): name the one configuration of a file listed twice --- run_test.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/run_test.py b/run_test.py index c9f0451..db49b00 100755 --- a/run_test.py +++ b/run_test.py @@ -3336,6 +3336,96 @@ def suggestions(path: Path, extra: list[str]) -> Optional[dict[tuple[str, str], return ok +def _const_suggestion_run( + args: list[str], env_overrides: Optional[dict[str, str]] = None +) -> Optional[tuple[dict[str, list[str]], list[str], str]]: + """ + #217: runs the analyzer with the Clang check of const suggestions and its trace (--verbose), on + inputs the cache key does not cover. Returns the messages of the const suggestions by function + (a function reported twice, such as a constructor's two variants, has two), the trace's lines + without their "[const-suggestion] " prefix, and the whole output; None when the analysis did + not run. + """ + result = run_analyzer_uncached( + [*args, "--format=json", "--experimental-const-suggestion-check", "--verbose"], + env_overrides, + ) + if result.returncode != 0: + return None + try: + diagnostics = json.loads(result.stdout or "").get("diagnostics", []) + except json.JSONDecodeError: + return None + messages: dict[str, list[str]] = {} + for d in diagnostics: + if str(d.get("ruleId", "")).startswith("ConstParameterNotModified"): + function = str(d.get("location", {}).get("function", "")) + messages.setdefault(function, []).append(str(d.get("details", {}).get("message", ""))) + output = (result.stdout or "") + (result.stderr or "") + trace = [ + line.split("[const-suggestion] ", 1)[1] + for line in output.splitlines() + if "[const-suggestion] " in line + ] + return messages, trace, output + + +def check_const_suggestion_one_configuration_per_file() -> bool: + """ + #217: a compilation database can list a file twice, with different commands. The analysis + loads one command per file, and the Clang check of const suggestions compiles each file with + that one only: its trace names one configuration for each file, here the defining file, + analyzed, and a caller that only the database lists. + """ + print("=== Testing const suggestions with files the compilation database lists twice ===") + fixtures = (RUN_CONFIG.test_dir / "pointer_reference-const_correctness").resolve() + definition = fixtures / "cross-tu-const-suggestion-def.c" + caller = fixtures / "cross-tu-const-suggestion-caller.c" + unit = re.compile(r"read_value parameter p, unit (\S+) \[([0-9a-f]+)\]: ") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_twice_") as tmp: + database = Path(tmp).resolve() / "compile_commands.json" + # A macro the files do not use: the two commands differ, the code does not. + database.write_text( + json.dumps( + [ + { + "directory": str(fixtures), + "file": str(f), + "arguments": ["clang", *extra, "-I", str(fixtures), "-c", str(f)], + } + for f in (definition, caller) + for extra in ([], ["-DLISTED_TWICE"]) + ] + ) + ) + run = _const_suggestion_run([str(definition), f"--compile-commands={database}"]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + configurations: dict[str, set[str]] = {} + for line in trace: + match = unit.match(line) + if match: + configurations.setdefault(match.group(1), set()).add(match.group(2)) + checked = "checked in every file of the compilation database that sees the change" + ok = True + for label, good in ( + ( + "the suggestion is checked in the compilation database", + any(checked in text for text in messages.get("read_value", [])), + ), + ( + "one configuration of the defining file", + len(configurations.get(str(definition), ())) == 1, + ), + ("one configuration of the caller", len(configurations.get(str(caller), ())) == 1), + ): + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {configurations}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6291,6 +6381,7 @@ def record_ok(ok: bool): check_const_suggestion_line_endings, check_const_suggestion_removal_reason, check_const_suggestion_both_modes, + check_const_suggestion_one_configuration_per_file, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 23be27dcc262ee6116dcc1eaef75aabd7fa7a860 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:31:51 +0200 Subject: [PATCH 048/152] test(const-param): compile each file once as it is for all its suggestions --- run_test.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/run_test.py b/run_test.py index db49b00..fd96e71 100755 --- a/run_test.py +++ b/run_test.py @@ -3426,6 +3426,51 @@ def check_const_suggestion_one_configuration_per_file() -> bool: return ok +def check_const_suggestion_compiles_each_file_once() -> bool: + """ + #217: the Clang check compiles each file that sees a change once as it is, for all the + suggestions it checks there: here two functions, defined in one file and called in another. + Each file is compiled once before the rewrites (`--timing`), and both suggestions are checked. + """ + print("=== Testing that each file is compiled once as it is for several const suggestions ===") + sources = { + "readers.h": "int read_a(int* p);\nint read_b(int* q);\n", + "readers.c": '#include "readers.h"\n' + "int read_a(int* p)\n{\n return *p;\n}\n" + "int read_b(int* q)\n{\n return *q + 1;\n}\n", + "user.c": '#include "readers.h"\n' + "int use_both(void)\n{\n int v = 1;\n return read_a(&v) + read_b(&v);\n}\n", + } + calls = re.compile(r"const_suggestion\.compile\.(\w+) .*\bcalls=(\d+)") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_once_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + # Every hotspot, not the 20 longest. + run = _const_suggestion_run( + [str(root / "readers.c"), str(root / "user.c"), "--timing"], + {"CTRACE_HOTSPOT_TOP": "200"}, + ) + if run is None: + return fail_check("the analysis did not run") + messages, _, output = run + compiled = {m.group(1): int(m.group(2)) for m in calls.finditer(output)} + checked = "checked in the analyzed files only" + ok = True + for label, good in ( + ( + "both suggestions are checked", + all(any(checked in t for t in messages.get(f, [])) for f in ("read_a", "read_b")), + ), + ("the defining file is compiled once as it is", compiled.get("defining_before") == 1), + ("the other file is compiled once as it is", compiled.get("other_before") == 1), + ): + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {compiled}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6382,6 +6427,7 @@ def record_ok(ok: bool): check_const_suggestion_removal_reason, check_const_suggestion_both_modes, check_const_suggestion_one_configuration_per_file, + check_const_suggestion_compiles_each_file_once, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From cb82201677f7744286a1680901a45aaa0c9424dc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:34:12 +0200 Subject: [PATCH 049/152] refactor(const-param): judge each unit and aggregate three verdicts --- src/analysis/ConstSuggestionCheck.cpp | 126 +++++++++++------------ src/analysis/ConstSuggestionInternal.hpp | 20 ++++ 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 3e54479..2619ead 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -55,7 +55,9 @@ namespace ctrace::stack::analysis { namespace { + using const_suggestion::Judgement; using const_suggestion::variantOf; + using const_suggestion::Verdict; using const_suggestion::withoutConst; // What the check covers: the analyzed files, or every file of the compilation database. @@ -919,19 +921,26 @@ namespace ctrace::stack::analysis return out; } + Judgement refuted(std::string why) + { + return {std::move(why), Verdict::Refuted}; + } + + Judgement indeterminate(std::string why) + { + return {std::move(why), Verdict::Indeterminate}; + } + // Checks one suggestion in one unit, against the unit before the rewrite. - bool checkUnit(const UnitCommand& command, const std::optional& before, - const Target& planned, const std::map& rewritten, - const std::set& symbolsBefore, const FunctionFacts& plan, - PrintedSignature& signature, std::vector& accepted, - std::string& renamed, std::string& removed, std::string& notChecked, - bool timing) + Judgement checkUnit(const UnitCommand& command, const std::optional& before, + const Target& planned, + const std::map& rewritten, + const std::set& symbolsBefore, const FunctionFacts& plan, + PrintedSignature& signature, std::vector& accepted, + std::string& renamed, bool timing) { if (!before) - { - notChecked = kDoesNotCompileAsItIs; - return false; - } + return indeterminate(kDoesNotCompileAsItIs); std::vector targets{planned}; targets[0].plan = false; targets[0].renamed = renamed; @@ -943,45 +952,29 @@ namespace ctrace::stack::analysis Compilation after = compile(command, rewritten, targets, "const_suggestion.compile.after", timing); if (!after.failure.empty()) - { - notChecked = after.failure; - return false; - } + return indeterminate(after.failure); // The first diagnostic the rewrite adds, not one the unit had before it. for (const Diagnostic& diagnostic : after.reported) { if (after.diagnostics.count(diagnostic.key) > before->diagnostics.count(diagnostic.key)) { - removed = + return refuted( (diagnostic.error ? "does not compile: " : "gains a checked warning: ") + - diagnostic.text; - return true; + diagnostic.text); } } if (!after.module) - { - removed = "does not compile: " + after.firstDiagnostic; - return true; - } + return refuted("does not compile: " + after.firstDiagnostic); if (renamed.empty()) renamed = targets[0].renamed; const FunctionFacts& facts = after.facts.empty() ? FunctionFacts() : after.facts[0]; if (facts.problem.starts_with("rewritten:")) - { - notChecked = "the rewritten declaration does not get the suggested type"; - return false; - } + return indeterminate("the rewritten declaration does not get the suggested type"); if (!facts.problem.empty()) - { - removed = facts.problem; - return true; - } + return refuted(facts.problem); if (before->overrides != facts.overrides) - { - removed = "the methods it overrides change"; - return true; - } + return refuted("the methods it overrides change"); if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; std::string renamedTo; @@ -993,16 +986,26 @@ namespace ctrace::stack::analysis &signature, &accepted, &renamedTo); } if (difference) - { - removed = "the generated code changes: " + *difference; - return true; - } + return refuted("the generated code changes: " + *difference); if (renamed.empty() || renamed == planned.symbol) renamed = renamedTo; - return true; + return {}; } } // namespace + Judgement const_suggestion::aggregate(const std::vector& judgements) + { + for (const Verdict verdict : {Verdict::Refuted, Verdict::Indeterminate}) + { + for (const Judgement& judgement : judgements) + { + if (judgement.verdict == verdict) + return judgement; + } + } + return {}; + } + std::shared_ptr analyzedUnitOf(const std::string& filename, const llvm::Module& module) { @@ -1131,27 +1134,24 @@ namespace ctrace::stack::analysis PrintedSignature signature{plan.printedSignature, ""}; std::vector accepted; std::string renamed; - std::string removed; - std::string unchecked; const std::optional selfBefore = Original{before.diagnostics, plan.overrides, definingCode}; - std::size_t checkedUnits = 1; - const bool definingDone = - checkUnit(command, selfBefore, planned, rewritten, symbolsBefore, plan, signature, - accepted, renamed, removed, unchecked, timing); + std::vector judgements{checkUnit(command, selfBefore, planned, rewritten, + symbolsBefore, plan, signature, accepted, + renamed, timing)}; // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or // not. One that cannot be checked leaves the suggestion unchecked, unless another // one refutes it. const auto cannotCheck = [&](const std::string& why) - { - if (unchecked.empty()) - unchecked = why; - }; + { judgements.push_back(indeterminate(why)); }; for (const AnalyzedUnit& unit : candidates) { - if (!definingDone || !removed.empty()) + if (judgements.front().verdict != Verdict::Verified || + judgements.back().verdict == Verdict::Refuted) + { break; + } const bool referencing = unit.module && references(*unit.module, issue.funcName); if (unit.language != LanguageType::C && unit.language != LanguageType::CXX) { @@ -1216,35 +1216,35 @@ namespace ctrace::stack::analysis if (!sees) continue; std::vector otherAccepted; - std::string why; - ++checkedUnits; - if (!checkUnit(other, originalOf(other, key, planned, originals, timing), planned, - rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, - removed, why, timing)) + Judgement judgement = checkUnit( + other, originalOf(other, key, planned, originals, timing), planned, rewritten, + symbolsBefore, plan, signature, otherAccepted, renamed, timing); + if (judgement.verdict == Verdict::Indeterminate && + judgement.reason == kDoesNotCompileAsItIs) { - cannotCheck(why == kDoesNotCompileAsItIs - ? unit.filename + " does not compile with Clang as it is" - : why); + judgement.reason = unit.filename + " does not compile with Clang as it is"; } + judgements.push_back(std::move(judgement)); for (const std::string& line : otherAccepted) { if (std::find(accepted.begin(), accepted.end(), line) == accepted.end()) accepted.push_back(line); } } - if (!removed.empty()) + const Judgement verdict = const_suggestion::aggregate(judgements); + if (verdict.verdict == Verdict::Refuted) { - trace(issue, "removed, " + removed); + trace(issue, "removed, " + verdict.reason); continue; } - if (!unchecked.empty()) + if (verdict.verdict == Verdict::Indeterminate) { - issue.checkLines.push_back(kNotChecked + unchecked); - trace(issue, "not checked, " + unchecked); + issue.checkLines.push_back(kNotChecked + verdict.reason); + trace(issue, "not checked, " + verdict.reason); kept.push_back(std::move(issue)); continue; } - trace(issue, "checked in " + std::to_string(checkedUnits) + " file(s)"); + trace(issue, "checked in " + std::to_string(judgements.size()) + " file(s)"); const std::string checked = std::string(config.compilationDatabase ? kCheckedDatabase : kCheckedAnalyzed) + kCheckedResult; diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index e00b2fa..4c9cd33 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include #include @@ -21,6 +22,25 @@ namespace ctrace::stack::analysis namespace ctrace::stack::analysis::const_suggestion { + // What the check concludes about a suggestion, in one unit or overall (#217 v2, §3). + enum class Verdict : std::uint8_t + { + Verified, + Refuted, + Indeterminate + }; + + struct Judgement + { + std::string reason; // empty when verified + Verdict verdict = Verdict::Verified; + std::uint8_t reservedPadding[7] = {}; + }; + + // Refuted if a judgement refutes, else indeterminate if one is, else verified; with the + // reason of the first judgement that has that verdict. + Judgement aggregate(const std::vector& judgements); + // The demangled form of a symbol without const and spaces, with && written &: the rewritten // function, and what is named inside it, keep this form across the rewrite. std::string withoutConst(const std::string& symbol); From 7772fac2c1aae737dc9d75fb17f236b8f4e2072e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:35:30 +0200 Subject: [PATCH 050/152] feat(const-param): name each unit and its configuration in the trace --- src/analysis/ConstSuggestionCheck.cpp | 52 +++++++++++++++++++++--- src/analysis/ConstSuggestionInternal.hpp | 6 +++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 2619ead..d998120 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -993,6 +993,15 @@ namespace ctrace::stack::analysis } } // namespace + std::string const_suggestion::fingerprintOf(const UnitCommand& command) + { + std::string text; + for (const std::string& arg : command.args) + text.append(arg).push_back('\0'); + text.append(command.workingDir).push_back('\0'); + return llvm::utohexstr(llvm::xxh3_64bits(text), /*LowerCase=*/true, /*Width=*/16); + } + Judgement const_suggestion::aggregate(const std::vector& judgements) { for (const Verdict verdict : {Verdict::Refuted, Verdict::Indeterminate}) @@ -1033,6 +1042,27 @@ namespace ctrace::stack::analysis coretrace::log(coretrace::Level::Debug, "[const-suggestion] {} parameter {}: {}\n", issue.funcName, issue.binding.name, outcome); }; + // Each unit judged, by its file and its configuration, after T v1's line. + const auto traceUnits = [](const ConstParamIssue& issue, + const std::vector& judged, + const std::vector& judgements) + { + static constexpr const char* kVerdicts[] = {"verified", "refuted", "indeterminate"}; + for (std::size_t k = 0; k < judgements.size(); ++k) + { + const Judgement& judgement = judgements[k]; + coretrace::log(coretrace::Level::Debug, + "[const-suggestion] {} parameter {}, unit {}: {}{}\n", + issue.funcName, issue.binding.name, judged[k], + kVerdicts[static_cast(judgement.verdict)], + judgement.reason.empty() ? "" : ": " + judgement.reason); + } + }; + const auto label = [](const std::string& file, const UnitCommand* command) + { + return file + " [" + (command ? const_suggestion::fingerprintOf(*command) : "none") + + "]"; + }; const auto notChecked = [&](const std::string& why) { for (ConstParamIssue& issue : issues) @@ -1139,12 +1169,17 @@ namespace ctrace::stack::analysis std::vector judgements{checkUnit(command, selfBefore, planned, rewritten, symbolsBefore, plan, signature, accepted, renamed, timing)}; + std::vector judged{label(canonicalPath(self->filename, config), &command)}; // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or // not. One that cannot be checked leaves the suggestion unchecked, unless another // one refutes it. - const auto cannotCheck = [&](const std::string& why) - { judgements.push_back(indeterminate(why)); }; + const auto cannotCheck = + [&](const std::string& unit, const UnitCommand* known, const std::string& why) + { + judged.push_back(label(unit, known)); + judgements.push_back(indeterminate(why)); + }; for (const AnalyzedUnit& unit : candidates) { if (judgements.front().verdict != Verdict::Verified || @@ -1156,7 +1191,10 @@ namespace ctrace::stack::analysis if (unit.language != LanguageType::C && unit.language != LanguageType::CXX) { if (referencing) - cannotCheck(unit.filename + " uses the function but has no source"); + { + cannotCheck(canonicalPath(unit.filename, config), nullptr, + unit.filename + " uses the function but has no source"); + } continue; } const std::string key = canonicalPath(unit.filename, config); @@ -1181,7 +1219,7 @@ namespace ctrace::stack::analysis } if (!known) { - cannotCheck("no compile command for " + unit.filename); + cannotCheck(key, nullptr, "no compile command for " + unit.filename); continue; } const UnitCommand& other = *known; @@ -1204,7 +1242,7 @@ namespace ctrace::stack::analysis } if (seen.files.empty()) { - cannotCheck(unit.filename + " cannot be preprocessed with Clang"); + cannotCheck(key, &other, unit.filename + " cannot be preprocessed with Clang"); continue; } const bool sees = @@ -1224,6 +1262,7 @@ namespace ctrace::stack::analysis { judgement.reason = unit.filename + " does not compile with Clang as it is"; } + judged.push_back(label(key, &other)); judgements.push_back(std::move(judgement)); for (const std::string& line : otherAccepted) { @@ -1235,16 +1274,19 @@ namespace ctrace::stack::analysis if (verdict.verdict == Verdict::Refuted) { trace(issue, "removed, " + verdict.reason); + traceUnits(issue, judged, judgements); continue; } if (verdict.verdict == Verdict::Indeterminate) { issue.checkLines.push_back(kNotChecked + verdict.reason); trace(issue, "not checked, " + verdict.reason); + traceUnits(issue, judged, judgements); kept.push_back(std::move(issue)); continue; } trace(issue, "checked in " + std::to_string(judgements.size()) + " file(s)"); + traceUnits(issue, judged, judgements); const std::string checked = std::string(config.compilationDatabase ? kCheckedDatabase : kCheckedAnalyzed) + kCheckedResult; diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 4c9cd33..d53d8a5 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -10,6 +10,8 @@ #include +#include "analysis/ConstSuggestionCheck.hpp" + namespace llvm { class Module; @@ -41,6 +43,10 @@ namespace ctrace::stack::analysis::const_suggestion // reason of the first judgement that has that verdict. Judgement aggregate(const std::vector& judgements); + // 16 hexadecimal digits: xxh3 of the arguments and the working directory, each followed by a + // NUL byte. + std::string fingerprintOf(const UnitCommand& command); + // The demangled form of a symbol without const and spaces, with && written &: the rewritten // function, and what is named inside it, keep this form across the rewrite. std::string withoutConst(const std::string& symbol); From ca49db06f42048bd48a4d62e720aa3996c8df7d2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 07:36:30 +0200 Subject: [PATCH 051/152] perf(const-param): compile each other file once as it is for all suggestions --- src/analysis/ConstSuggestionCheck.cpp | 62 ++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index d998120..4035670 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -145,12 +145,13 @@ namespace ctrace::stack::analysis std::vector facts; }; - // A unit before the rewrite, prepared once: its diagnostics, what the methods of the - // function's name override, and its generated code ready to compare. + // A unit before the rewrite, prepared once for every suggestion of the module: its + // diagnostics, what the methods of each function's name override (indexed like the + // targets), and its generated code ready to compare. struct Original { std::multiset diagnostics; - std::multiset overrides; + std::vector> overrides; std::shared_ptr code; }; @@ -894,28 +895,40 @@ namespace ctrace::stack::analysis return out; } - // A unit before the rewrite, for the function a target names: compiled once per call. - using OriginalCache = - std::map, std::optional>; + // The overrides that each target's inspection recorded, indexed like the targets. + std::vector> overridesOf(const Compilation& compilation) + { + std::vector> out; + for (const FunctionFacts& facts : compilation.facts) + out.push_back(facts.overrides); + return out; + } + + // A unit before the rewrite, by its file and its configuration: compiled once for every + // suggestion of the module (#217, §5, step 3). + using OriginalCache = std::map>; const std::optional& originalOf(const UnitCommand& command, - const std::string& key, const Target& planned, + const std::string& key, + const std::vector& planned, OriginalCache& cache, bool timing) { - std::optional& out = cache[{key, planned.name}]; + std::optional& out = + cache[key + '\0' + const_suggestion::fingerprintOf(command)]; if (out) return out; - std::vector targets{planned}; - targets[0].plan = false; - targets[0].expectedType.clear(); + std::vector targets = planned; + for (Target& target : targets) + { + target.plan = false; + target.expectedType.clear(); + } Compilation before = compile(command, {}, targets, "const_suggestion.compile.other_before", timing); if (!before.failure.empty() || !before.module) return out; const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - out = Original{before.diagnostics, - before.facts.empty() ? std::multiset() - : before.facts[0].overrides, + out = Original{before.diagnostics, overridesOf(before), std::make_shared( const_suggestion::prepare(*before.module))}; return out; @@ -931,9 +944,10 @@ namespace ctrace::stack::analysis return {std::move(why), Verdict::Indeterminate}; } - // Checks one suggestion in one unit, against the unit before the rewrite. + // Checks one suggestion, the target at `index`, in one unit, against the unit before the + // rewrite. Judgement checkUnit(const UnitCommand& command, const std::optional& before, - const Target& planned, + std::size_t index, const Target& planned, const std::map& rewritten, const std::set& symbolsBefore, const FunctionFacts& plan, PrintedSignature& signature, std::vector& accepted, @@ -973,7 +987,7 @@ namespace ctrace::stack::analysis return indeterminate("the rewritten declaration does not get the suggested type"); if (!facts.problem.empty()) return refuted(facts.problem); - if (before->overrides != facts.overrides) + if (before->overrides[index] != facts.overrides) return refuted("the methods it overrides change"); if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; @@ -1108,6 +1122,8 @@ namespace ctrace::stack::analysis definingCode = std::make_shared( const_suggestion::prepare(*before.module)); } + const std::optional definingBefore = + Original{before.diagnostics, overridesOf(before), definingCode}; OriginalCache originals; // The other units a rewrite may reach: the analyzed ones and, with a compilation @@ -1164,11 +1180,9 @@ namespace ctrace::stack::analysis PrintedSignature signature{plan.printedSignature, ""}; std::vector accepted; std::string renamed; - const std::optional selfBefore = - Original{before.diagnostics, plan.overrides, definingCode}; - std::vector judgements{checkUnit(command, selfBefore, planned, rewritten, - symbolsBefore, plan, signature, accepted, - renamed, timing)}; + std::vector judgements{checkUnit(command, definingBefore, i, planned, + rewritten, symbolsBefore, plan, signature, + accepted, renamed, timing)}; std::vector judged{label(canonicalPath(self->filename, config), &command)}; // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or @@ -1255,8 +1269,8 @@ namespace ctrace::stack::analysis continue; std::vector otherAccepted; Judgement judgement = checkUnit( - other, originalOf(other, key, planned, originals, timing), planned, rewritten, - symbolsBefore, plan, signature, otherAccepted, renamed, timing); + other, originalOf(other, key, targets, originals, timing), i, planned, + rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, timing); if (judgement.verdict == Verdict::Indeterminate && judgement.reason == kDoesNotCompileAsItIs) { From 1050ccafbf83628e7198b42b526b079055db92d7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:04:43 +0200 Subject: [PATCH 052/152] test(const-param): check the parameter a C2 constructor's suggestion names --- .../const-suggestion-virtual-base.cpp | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp b/test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp new file mode 100644 index 0000000..f3a336c --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-virtual-base.cpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the base-object constructor (C2) of a class with a virtual base takes a hidden `vtt` after +// `this`. The check rewrites the parameter the suggestion names, found by its index among the +// parameters the source declares: `x`, in both variants. `y`, which the constructor writes +// through, is not rewritten. + +// strict-expectation-details: true + +struct V +{ + int v = 0; +}; + +struct D : virtual V +{ + int a, b; + D(int* x, int* y); +}; + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'D::D(int*, int*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_ZN1DC2EPiS0_ -> _ZN1DC2EPKiPi): code compiled separately must use the updated declaration and be recompiled + +// at line 33, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'x' in function 'D::D(int*, int*)' is never used to modify the pointed object +// ↳ current type: int *x +// ↳ suggested type: const int *x +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_ZN1DC1EPiS0_ -> _ZN1DC1EPKiPi): code compiled separately must use the updated declaration and be recompiled +D::D(int* x, int* y) : a(*x), b(0) +{ + *y = 1; +} + +int make() +{ + int p = 1, q = 2; + D d(&p, &q); + return d.a + q; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 514605af85213b3b5a47ab8ffe8eb686851b30e9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:05:27 +0200 Subject: [PATCH 053/152] fix(const-param): rewrite the parameter at the source index P0 gives --- src/analysis/ConstSuggestionCheck.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 4035670..2b22c25 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -117,10 +117,12 @@ namespace ctrace::stack::analysis std::string expectedType; std::set rewrittenFiles; const std::set* symbolsBefore = nullptr; // to find the renamed function - unsigned debugArgNo = 0; + // The parameter's position among those the source declares (P0), none for one it + // does not declare. + std::optional sourceIndex; unsigned paramIndex = 0; bool plan = false; // before the rewrite, in the defining unit - std::uint8_t reservedPadding[7] = {}; + std::uint8_t reservedPadding[3] = {}; }; // An error or a checked warning, in the order Clang reports it. @@ -594,15 +596,12 @@ namespace ctrace::stack::analysis { const auto* method = llvm::dyn_cast(&function); facts.declarableElsewhere = method == nullptr && function.isExternallyVisible(); - // An explicit object parameter (C++23) is numbered like the others. - const unsigned implicitThis = - method && method->isImplicitObjectMemberFunction() ? 1 : 0; - if (target.debugArgNo <= implicitThis) + if (!target.sourceIndex) { facts.notRewritten = "the parameter is not found in the source"; return; } - facts.paramIndex = target.debugArgNo - 1 - implicitThis; + facts.paramIndex = *target.sourceIndex; if (facts.paramIndex >= function.getNumParams()) { facts.notRewritten = "the parameter is not found in the source"; @@ -1103,7 +1102,8 @@ namespace ctrace::stack::analysis for (std::size_t i = 0; i < issues.size(); ++i) { targets[i].symbol = issues[i].funcName; - targets[i].debugArgNo = issues[i].debugArgNo; + if (!issues[i].binding.isArtificial) + targets[i].sourceIndex = issues[i].binding.sourceIndex; targets[i].plan = true; } Compilation before = From 4b3f050128e85cdece24146b690316a88122d706 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:06:00 +0200 Subject: [PATCH 054/152] refactor(const-param): drop the debug number that the source index replaces --- include/analysis/ConstParamAnalysis.hpp | 3 --- src/analysis/ConstParamAnalysis.cpp | 2 -- 2 files changed, 5 deletions(-) diff --git a/include/analysis/ConstParamAnalysis.hpp b/include/analysis/ConstParamAnalysis.hpp index da98e71..e4b0eae 100644 --- a/include/analysis/ConstParamAnalysis.hpp +++ b/include/analysis/ConstParamAnalysis.hpp @@ -27,9 +27,6 @@ namespace ctrace::stack::analysis // Lines that say how the suggestion was checked with Clang, or why it could not be (#217). std::vector checkLines; double confidence = -1.0; - // The parameter's number in the debug information (1 is the first, `this` included). - std::uint32_t debugArgNo = 0; - std::uint32_t reservedArgPadding = 0; std::uint64_t pointerConstOnly : 1 = false; // ex: T * const param std::uint64_t isReference : 1 = false; std::uint64_t isRvalueRef : 1 = false; diff --git a/src/analysis/ConstParamAnalysis.cpp b/src/analysis/ConstParamAnalysis.cpp index 055ab66..9ea9844 100644 --- a/src/analysis/ConstParamAnalysis.cpp +++ b/src/analysis/ConstParamAnalysis.cpp @@ -719,8 +719,6 @@ namespace ctrace::stack::analysis ConstParamIssue issue; issue.funcName = F.getName().str(); issue.binding = binding; - if (const DILocalVariable* variable = spilledParameter(Arg)) - issue.debugArgNo = variable->getArg(); issue.confidence = computeIssueConfidence(binding, readOnlyByMetadata, anonymousType); issue.pointerConstOnly = From 229b2bf1c473c4bd74d867f6c1a6ee3e2ae62ea7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:22:44 +0200 Subject: [PATCH 055/152] test(const-param): keep a suggestion whose macro argument types two parameters --- .../const-suggestion-macro-shared-type.c | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c b/test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c new file mode 100644 index 0000000..47b51ff --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-macro-shared-type.c @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a macro argument can write the type of several parameters. Rewriting the type of `src` +// where it is written would rewrite `dst` too, which copy_one writes through: the rewrite would +// reach beyond the suggestion. The suggestion is kept, not checked. + +// strict-expectation-details: true + +#define DECLARE_COPY(name, T) int name(T src, T dst) + +// at line 15, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'src' in function 'copy_one' is never used to modify the pointed object +// ↳ current type: int *src +// ↳ suggested type: const int *src +// ↳ not checked with Clang: the parameter's type is written in a macro argument that writes other parameters too +DECLARE_COPY(copy_one, int*) +{ + *dst = *src; + return 0; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 89309547433944f5405bb149f395265753517ad6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:22:44 +0200 Subject: [PATCH 056/152] test(const-param): a backslash and spaces still continue a macro definition --- run_test.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/run_test.py b/run_test.py index fd96e71..1696a6c 100755 --- a/run_test.py +++ b/run_test.py @@ -3166,10 +3166,12 @@ def messages(extra: list[str]) -> Optional[dict[str, str]]: def check_const_suggestion_line_endings() -> bool: """ #217: a type written on a continuation line of a #define is in the macro definition, whether - the file ends its lines with LF or CRLF: rewriting it would change every expansion of the macro, - here a function that writes through p. The suggestion is kept as not checked with both. + the file ends its lines with LF or CRLF, or puts spaces between the backslash and the line + break, which Clang accepts with a warning: rewriting it would change every expansion of the + macro, here a function that writes through p. The suggestion is kept as not checked in each + case. """ - print("=== Testing a macro definition's continuation lines, with LF and CRLF ===") + print("=== Testing a macro definition's continuation lines, with LF, CRLF and spaces ===") source = ( "#define PARAM(type) type p\n" "#define DECLARE(name) \\\n" @@ -3180,9 +3182,13 @@ def check_const_suggestion_line_endings() -> bool: expected = "not checked with Clang: the declaration is written by a macro definition" ok = True with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_line_endings_") as tmp: - for label, newline in (("LF", "\n"), ("CRLF", "\r\n")): - path = Path(tmp).resolve() / f"reader-{label.lower()}.c" - path.write_bytes(source.replace("\n", newline).encode("utf-8")) + for label, contents in ( + ("LF", source), + ("CRLF", source.replace("\n", "\r\n")), + ("spaces after the backslash", source.replace("\\\n", "\\ \n")), + ): + path = Path(tmp).resolve() / f"reader-{label.lower().replace(' ', '-')}.c" + path.write_bytes(contents.encode("utf-8")) # The file's bytes are an input the cache key does not cover. result = run_analyzer_uncached( [str(path), "--format=json", "--experimental-const-suggestion-check"] From 066847afc3d670f3ba8aa0aa27782c071848c4c7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:25:46 +0200 Subject: [PATCH 057/152] refactor(const-param): move the planning of a suggestion to its own file --- CMakeLists.txt | 1 + src/analysis/ConstSuggestionCheck.cpp | 308 ++--------------------- src/analysis/ConstSuggestionInternal.hpp | 40 +++ src/analysis/ConstSuggestionPlan.cpp | 271 ++++++++++++++++++++ 4 files changed, 338 insertions(+), 282 deletions(-) create mode 100644 src/analysis/ConstSuggestionPlan.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ec80cd..316de58 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ set(STACK_ANALYZER_SOURCES src/analysis/ConstParamAnalysis.cpp src/analysis/ConstSuggestionCheck.cpp src/analysis/ConstSuggestionCode.cpp + src/analysis/ConstSuggestionPlan.cpp src/analysis/CommandInjectionAnalysis.cpp src/analysis/DuplicateIfCondition.cpp src/analysis/DynamicAlloca.cpp diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 2b22c25..1963291 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -26,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -37,7 +36,6 @@ #include #include #include -#include #include #include #include @@ -55,10 +53,14 @@ namespace ctrace::stack::analysis { namespace { + using const_suggestion::Edit; using const_suggestion::Judgement; + using const_suggestion::realPath; + using const_suggestion::SuggestionPlan; using const_suggestion::variantOf; using const_suggestion::Verdict; using const_suggestion::withoutConst; + using const_suggestion::writtenType; // What the check covers: the analyzed files, or every file of the compilation database. constexpr const char* kCheckedAnalyzed = @@ -80,32 +82,15 @@ namespace ctrace::stack::analysis "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual"}; - // `length` bytes at `offset` replaced with `text`. - struct Edit - { - std::size_t offset = 0; - std::size_t length = 0; - std::string text; - }; - // What one compilation found about the function a suggestion names. struct FunctionFacts { - // Before the rewrite, in the unit that defines it: the rewrite, and what it must give. - std::map> edits; // by real path - std::string notRewritten; - std::string expectedType; + // Before the rewrite, in the unit that defines it. + SuggestionPlan plan; // In every compilation. std::string printedSignature; std::string problem; std::multiset overrides; - unsigned paramIndex = 0; - // A free function with external linkage can be declared by a file that reads none - // of the rewritten files; a method only where its class is defined. One with internal - // linkage (static, anonymous namespace) cannot: another file's homonym is another - // function. - bool declarableElsewhere = false; - std::uint8_t reservedPadding[3] = {}; }; // What to look for in one compilation. @@ -157,14 +142,6 @@ namespace ctrace::stack::analysis std::shared_ptr code; }; - std::string realPath(llvm::StringRef path) - { - llvm::SmallString<256> out; - if (llvm::sys::fs::real_path(path, out)) - return path.str(); - return out.str().str(); - } - // Errors, and the checked warnings, by place and kind: their texts name types that the // rewrite changes. class DiagnosticCollector : public clang::DiagnosticConsumer @@ -212,207 +189,6 @@ namespace ctrace::stack::analysis Compilation& out_; }; - // Whether `offset` of `buffer` lies on a #define line, continuations included. - bool inMacroDefinition(llvm::StringRef buffer, std::size_t offset) - { - std::size_t start = buffer.rfind('\n', offset); - start = start == llvm::StringRef::npos ? 0 : start + 1; - // Whether the line before the one at `start` ends with a backslash, before its - // line break, "\n" or "\r\n". - const auto continued = [&](std::size_t at) - { - std::size_t end = at - 1; - if (end > 0 && buffer[end - 1] == '\r') - --end; - return end > 0 && buffer[end - 1] == '\\'; - }; - while (start >= 2 && continued(start)) - { - const std::size_t previous = buffer.rfind('\n', start - 2); - start = previous == llvm::StringRef::npos ? 0 : previous + 1; - } - const llvm::StringRef line = buffer.substr(start, offset - start).ltrim(); - return line.starts_with("#") && line.drop_front().ltrim().starts_with("define"); - } - - class Planner - { - public: - Planner(clang::ASTContext& context, FunctionFacts& facts) - : context_(context), sm_(context.getSourceManager()), facts_(facts) - { - } - - // Writes the suggested type where `param` writes its type. - bool plan(const clang::FunctionDecl& function, const clang::ParmVarDecl& param) - { - param_ = ¶m; - const clang::TypeSourceInfo* written = param.getTypeSourceInfo(); - if (!written) - return fail("the parameter's type is not written in the source"); - clang::TypeLoc loc = written->getTypeLoc(); - for (;;) - { - loc = loc.getUnqualifiedLoc(); - if (auto attributed = loc.getAs()) - loc = attributed.getModifiedLoc(); - else if (auto paren = loc.getAs()) - loc = paren.getInnerLoc(); - else if (auto macro = loc.getAs()) - loc = macro.getInnerLoc(); - else if (auto decayed = loc.getAs()) - loc = decayed.getOriginalLoc(); - else - break; - } - if (auto pointer = loc.getAs()) - return insertConst(function, pointer.getPointeeLoc().getBeginLoc()); - if (auto reference = loc.getAs()) - return insertConst(function, reference.getPointeeLoc().getBeginLoc()); - if (auto reference = loc.getAs()) - { - // The suggestion names the const lvalue reference. - return insertConst(function, reference.getPointeeLoc().getBeginLoc()) && - replace(function, reference.getAmpAmpLoc(), 2, "&"); - } - if (auto array = loc.getAs()) - return insertConst(function, array.getElementLoc().getBeginLoc()); - // A typedef of a pointer: its desugared type, qualified. - clang::TypeLoc named = loc; - if (auto elaborated = loc.getAs()) - named = elaborated.getNamedTypeLoc(); - if (named.getAs() || named.getAs()) - { - const auto* pointer = loc.getType()->getAs(); - if (!pointer || pointer->getPointeeType()->isFunctionType() || - pointer->getPointeeType()->isArrayType()) - { - return fail("the parameter's type is a typedef of no plain pointer"); - } - clang::PrintingPolicy policy(context_.getLangOpts()); - const std::string text = - pointer->getPointeeType().withConst().getAsString(policy) + " *"; - if (text.find("(unnamed") != std::string::npos || - text.find("(anonymous") != std::string::npos || - text.find("(lambda") != std::string::npos) - { - return fail("the pointed type has no name to write"); - } - const clang::SourceLocation begin = loc.getBeginLoc(); - const clang::SourceLocation end = loc.getEndLoc(); - if (begin.isMacroID() || end.isMacroID()) - return fail("the parameter's typedef is written by a macro"); - const unsigned length = sm_.getFileOffset(clang::Lexer::getLocForEndOfToken( - end, 0, sm_, context_.getLangOpts())) - - sm_.getFileOffset(begin); - return replace(function, begin, length, text); - } - return fail("the parameter's type has no form this check rewrites"); - } - - private: - bool fail(std::string why) - { - if (facts_.notRewritten.empty()) - facts_.notRewritten = std::move(why); - return false; - } - - bool insertConst(const clang::FunctionDecl& function, clang::SourceLocation at) - { - return replace(function, at, 0, "const "); - } - - bool replace(const clang::FunctionDecl& function, clang::SourceLocation at, - unsigned length, const std::string& text) - { - if (at.isInvalid()) - return fail("the parameter's type has no position"); - if (at.isMacroID()) - { - if (sm_.isMacroArgExpansion(at)) - { - // Written in a macro argument: rewritten where it is written. - at = sm_.getSpellingLoc(at); - } - else - { - // Written by a macro: a macro is text, `const` goes before its - // invocation, unless that invocation writes the declaration too, or - // another parameter, which would take the qualifier. - const clang::CharSourceRange invocation = sm_.getExpansionRange(at); - const auto writes = [&](clang::SourceLocation place) - { - const clang::SourceLocation where = sm_.getExpansionLoc(place); - return !sm_.isBeforeInTranslationUnit(where, invocation.getBegin()) && - !sm_.isBeforeInTranslationUnit(invocation.getEnd(), where); - }; - if (length != 0 || writes(function.getLocation())) - return fail("the declaration is written by a macro definition"); - for (const clang::ParmVarDecl* other : function.parameters()) - { - if (other != param_ && writes(other->getLocation())) - { - return fail("the parameter's type is written by a macro that " - "writes other parameters too"); - } - } - at = invocation.getBegin(); - } - if (at.isMacroID()) - return fail("the declaration is written by a macro definition"); - } - const auto [file, offset] = sm_.getDecomposedLoc(at); - const clang::OptionalFileEntryRef entry = sm_.getFileEntryRefForID(file); - if (!entry) - return fail("the parameter's type is not written in a file"); - if (inMacroDefinition(sm_.getBufferData(file), offset)) - return fail("the declaration is written by a macro definition"); - std::vector& edits = facts_.edits[realPath(entry->getName())]; - const bool seen = std::any_of(edits.begin(), edits.end(), [&](const Edit& edit) - { return edit.offset == offset; }); - if (!seen) - edits.push_back({offset, length, text}); - return true; - } - - clang::ASTContext& context_; - const clang::SourceManager& sm_; - FunctionFacts& facts_; - const clang::ParmVarDecl* param_ = nullptr; - }; - - // A type written canonically. An anonymous type, such as a lambda's, is written without - // its location: the path of that location depends on how the file was named, relative - // before the rewrite and absolute once the rewritten file is remapped. - std::string writtenType(const clang::ASTContext& context, clang::QualType type) - { - clang::PrintingPolicy policy(context.getLangOpts()); - policy.AnonymousTagLocations = false; - return type.getCanonicalType().getAsString(policy); - } - - // The type the parameter must have once rewritten, written canonically. - std::string expectedType(clang::ASTContext& context, clang::QualType type) - { - clang::QualType out; - if (const auto* pointer = type->getAs()) - { - out = context.getQualifiedType( - context.getPointerType(pointer->getPointeeType().withConst()), - type.getQualifiers()); - } - else if (type->isReferenceType()) - { - out = context.getLValueReferenceType(type.getNonReferenceType().withConst()); - } - else - { - return ""; - } - return writtenType(context, out); - } - // Declarations of the function, explicit casts of its address, and what each method of // its name overrides. class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor @@ -588,48 +364,7 @@ namespace ctrace::stack::analysis if (target.name.empty() && function->getIdentifier()) target.name = function->getIdentifier()->getName().str(); if (target.plan) - plan(target, facts, *function); - } - - void plan(const Target& target, FunctionFacts& facts, - const clang::FunctionDecl& function) - { - const auto* method = llvm::dyn_cast(&function); - facts.declarableElsewhere = method == nullptr && function.isExternallyVisible(); - if (!target.sourceIndex) - { - facts.notRewritten = "the parameter is not found in the source"; - return; - } - facts.paramIndex = *target.sourceIndex; - if (facts.paramIndex >= function.getNumParams()) - { - facts.notRewritten = "the parameter is not found in the source"; - return; - } - facts.expectedType = - expectedType(*context_, function.getParamDecl(facts.paramIndex)->getType()); - if (facts.expectedType.empty()) - { - facts.notRewritten = "the parameter is no pointer or reference"; - return; - } - const clang::FunctionDecl* pattern = function.getTemplateInstantiationPattern(); - if (!pattern) - pattern = &function; - Planner planner(*context_, facts); - for (const clang::FunctionDecl* declaration : pattern->redecls()) - { - if (declaration->isImplicit()) - continue; - if (facts.paramIndex >= declaration->getNumParams()) - { - facts.notRewritten = "the declarations disagree on the parameters"; - return; - } - if (!planner.plan(*declaration, *declaration->getParamDecl(facts.paramIndex))) - return; - } + facts.plan = const_suggestion::plan(*context_, *function, target.sourceIndex); } std::uint8_t reservedPadding_[7] = {}; @@ -874,10 +609,10 @@ namespace ctrace::stack::analysis return value && !value->hasLocalLinkage(); } - std::map rewrite(const FunctionFacts& facts) + std::map rewrite(const SuggestionPlan& plan) { std::map out; - for (const auto& [path, edits] : facts.edits) + for (const auto& [path, edits] : plan.edits) { llvm::ErrorOr> buffer = llvm::MemoryBuffer::getFile(path); @@ -948,7 +683,7 @@ namespace ctrace::stack::analysis Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, - const std::set& symbolsBefore, const FunctionFacts& plan, + const std::set& symbolsBefore, const SuggestionPlan& plan, PrintedSignature& signature, std::vector& accepted, std::string& renamed, bool timing) { @@ -958,7 +693,7 @@ namespace ctrace::stack::analysis targets[0].plan = false; targets[0].renamed = renamed; targets[0].expectedType = plan.expectedType; - targets[0].paramIndex = plan.paramIndex; + targets[0].paramIndex = plan.sourceIndex; targets[0].symbolsBefore = &symbolsBefore; for (const auto& [path, edits] : plan.edits) targets[0].rewrittenFiles.insert(path); @@ -1015,6 +750,14 @@ namespace ctrace::stack::analysis return llvm::utohexstr(llvm::xxh3_64bits(text), /*LowerCase=*/true, /*Width=*/16); } + std::string const_suggestion::realPath(llvm::StringRef path) + { + llvm::SmallString<256> out; + if (llvm::sys::fs::real_path(path, out)) + return path.str(); + return out.str().str(); + } + Judgement const_suggestion::aggregate(const std::vector& judgements) { for (const Verdict verdict : {Verdict::Refuted, Verdict::Indeterminate}) @@ -1153,8 +896,9 @@ namespace ctrace::stack::analysis for (std::size_t i = 0; i < issues.size(); ++i) { ConstParamIssue& issue = issues[i]; - const FunctionFacts& plan = before.facts[i]; - if (plan.printedSignature.empty() && plan.notRewritten.empty()) + const FunctionFacts& found = before.facts[i]; + const SuggestionPlan& plan = found.plan; + if (found.printedSignature.empty() && !plan.indeterminate) { issue.checkLines.push_back(kNotChecked + std::string("the function is not found in the source")); @@ -1162,10 +906,10 @@ namespace ctrace::stack::analysis kept.push_back(std::move(issue)); continue; } - if (!plan.notRewritten.empty()) + if (plan.indeterminate) { - issue.checkLines.push_back(kNotChecked + plan.notRewritten); - trace(issue, "not checked, " + plan.notRewritten); + issue.checkLines.push_back(kNotChecked + *plan.indeterminate); + trace(issue, "not checked, " + *plan.indeterminate); kept.push_back(std::move(issue)); continue; } @@ -1177,7 +921,7 @@ namespace ctrace::stack::analysis // The defining unit first: it gives the function's symbol after the rewrite. Target planned = targets[i]; planned.plan = false; - PrintedSignature signature{plan.printedSignature, ""}; + PrintedSignature signature{found.printedSignature, ""}; std::vector accepted; std::string renamed; std::vector judgements{checkUnit(command, definingBefore, i, planned, diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index d53d8a5..573923f 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -12,6 +12,13 @@ #include "analysis/ConstSuggestionCheck.hpp" +namespace clang +{ + class ASTContext; + class FunctionDecl; + class QualType; +} // namespace clang + namespace llvm { class Module; @@ -47,6 +54,39 @@ namespace ctrace::stack::analysis::const_suggestion // NUL byte. std::string fingerprintOf(const UnitCommand& command); + // A file's real path, or the path as given when it has none. + std::string realPath(llvm::StringRef path); + + // `length` bytes at `offset` replaced with `text`. + struct Edit + { + std::size_t offset = 0; + std::size_t length = 0; + std::string text; + }; + + // The rewrite of one suggestion, planned on the defining unit's initial compilation (§5.5). + struct SuggestionPlan + { + std::map> edits; // by real path + std::string expectedType; // the parameter's type once rewritten, written canonically + std::optional indeterminate; // why the plan is not executed + unsigned sourceIndex = 0; + // A free function with external linkage can be declared by a file that reads none of the + // rewritten files; a method only where its class is defined. One with internal linkage + // (static, anonymous namespace) cannot: another file's homonym is another function. + bool declarableElsewhere = false; + std::uint8_t reservedPadding[3] = {}; + }; + + // A type written canonically, an anonymous type without its location. + std::string writtenType(const clang::ASTContext& context, clang::QualType type); + + // Plans the rewrite of the parameter at `sourceIndex` of `function`, located in the defining + // unit's initial compilation by the suggestion's symbol. + SuggestionPlan plan(clang::ASTContext& context, const clang::FunctionDecl& function, + std::optional sourceIndex); + // The demangled form of a symbol without const and spaces, with && written &: the rewritten // function, and what is named inside it, keep this form across the rewrite. std::string withoutConst(const std::string& symbol); diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp new file mode 100644 index 0000000..4a6fffc --- /dev/null +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 +// Plans the Clang check of a const suggestion (#217, §5.5): the parameter, the type it must get, +// and the edits that write that type in every declaration the defining file sees. +#include "ConstSuggestionInternal.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ctrace::stack::analysis +{ + namespace + { + using const_suggestion::Edit; + using const_suggestion::realPath; + using const_suggestion::SuggestionPlan; + using const_suggestion::writtenType; + + // Whether `offset` of `buffer` lies on a #define line, continuations included. + bool inMacroDefinition(llvm::StringRef buffer, std::size_t offset) + { + std::size_t start = buffer.rfind('\n', offset); + start = start == llvm::StringRef::npos ? 0 : start + 1; + // Whether the line before the one at `start` ends with a backslash, before its + // line break, "\n" or "\r\n". + const auto continued = [&](std::size_t at) + { + std::size_t end = at - 1; + if (end > 0 && buffer[end - 1] == '\r') + --end; + return end > 0 && buffer[end - 1] == '\\'; + }; + while (start >= 2 && continued(start)) + { + const std::size_t previous = buffer.rfind('\n', start - 2); + start = previous == llvm::StringRef::npos ? 0 : previous + 1; + } + const llvm::StringRef line = buffer.substr(start, offset - start).ltrim(); + return line.starts_with("#") && line.drop_front().ltrim().starts_with("define"); + } + + class Planner + { + public: + Planner(clang::ASTContext& context, SuggestionPlan& plan) + : context_(context), sm_(context.getSourceManager()), plan_(plan) + { + } + + // Writes the suggested type where `param` writes its type. + bool plan(const clang::FunctionDecl& function, const clang::ParmVarDecl& param) + { + param_ = ¶m; + const clang::TypeSourceInfo* written = param.getTypeSourceInfo(); + if (!written) + return fail("the parameter's type is not written in the source"); + clang::TypeLoc loc = written->getTypeLoc(); + for (;;) + { + loc = loc.getUnqualifiedLoc(); + if (auto attributed = loc.getAs()) + loc = attributed.getModifiedLoc(); + else if (auto paren = loc.getAs()) + loc = paren.getInnerLoc(); + else if (auto macro = loc.getAs()) + loc = macro.getInnerLoc(); + else if (auto decayed = loc.getAs()) + loc = decayed.getOriginalLoc(); + else + break; + } + if (auto pointer = loc.getAs()) + return insertConst(function, pointer.getPointeeLoc().getBeginLoc()); + if (auto reference = loc.getAs()) + return insertConst(function, reference.getPointeeLoc().getBeginLoc()); + if (auto reference = loc.getAs()) + { + // The suggestion names the const lvalue reference. + return insertConst(function, reference.getPointeeLoc().getBeginLoc()) && + replace(function, reference.getAmpAmpLoc(), 2, "&"); + } + if (auto array = loc.getAs()) + return insertConst(function, array.getElementLoc().getBeginLoc()); + // A typedef of a pointer: its desugared type, qualified. + clang::TypeLoc named = loc; + if (auto elaborated = loc.getAs()) + named = elaborated.getNamedTypeLoc(); + if (named.getAs() || named.getAs()) + { + const auto* pointer = loc.getType()->getAs(); + if (!pointer || pointer->getPointeeType()->isFunctionType() || + pointer->getPointeeType()->isArrayType()) + { + return fail("the parameter's type is a typedef of no plain pointer"); + } + clang::PrintingPolicy policy(context_.getLangOpts()); + const std::string text = + pointer->getPointeeType().withConst().getAsString(policy) + " *"; + if (text.find("(unnamed") != std::string::npos || + text.find("(anonymous") != std::string::npos || + text.find("(lambda") != std::string::npos) + { + return fail("the pointed type has no name to write"); + } + const clang::SourceLocation begin = loc.getBeginLoc(); + const clang::SourceLocation end = loc.getEndLoc(); + if (begin.isMacroID() || end.isMacroID()) + return fail("the parameter's typedef is written by a macro"); + const unsigned length = sm_.getFileOffset(clang::Lexer::getLocForEndOfToken( + end, 0, sm_, context_.getLangOpts())) - + sm_.getFileOffset(begin); + return replace(function, begin, length, text); + } + return fail("the parameter's type has no form this check rewrites"); + } + + private: + bool fail(std::string why) + { + if (!plan_.indeterminate) + plan_.indeterminate = std::move(why); + return false; + } + + bool insertConst(const clang::FunctionDecl& function, clang::SourceLocation at) + { + return replace(function, at, 0, "const "); + } + + bool replace(const clang::FunctionDecl& function, clang::SourceLocation at, + unsigned length, const std::string& text) + { + if (at.isInvalid()) + return fail("the parameter's type has no position"); + if (at.isMacroID()) + { + if (sm_.isMacroArgExpansion(at)) + { + // Written in a macro argument: rewritten where it is written. + at = sm_.getSpellingLoc(at); + } + else + { + // Written by a macro: a macro is text, `const` goes before its + // invocation, unless that invocation writes the declaration too, or + // another parameter, which would take the qualifier. + const clang::CharSourceRange invocation = sm_.getExpansionRange(at); + const auto writes = [&](clang::SourceLocation place) + { + const clang::SourceLocation where = sm_.getExpansionLoc(place); + return !sm_.isBeforeInTranslationUnit(where, invocation.getBegin()) && + !sm_.isBeforeInTranslationUnit(invocation.getEnd(), where); + }; + if (length != 0 || writes(function.getLocation())) + return fail("the declaration is written by a macro definition"); + for (const clang::ParmVarDecl* other : function.parameters()) + { + if (other != param_ && writes(other->getLocation())) + { + return fail("the parameter's type is written by a macro that " + "writes other parameters too"); + } + } + at = invocation.getBegin(); + } + if (at.isMacroID()) + return fail("the declaration is written by a macro definition"); + } + const auto [file, offset] = sm_.getDecomposedLoc(at); + const clang::OptionalFileEntryRef entry = sm_.getFileEntryRefForID(file); + if (!entry) + return fail("the parameter's type is not written in a file"); + if (inMacroDefinition(sm_.getBufferData(file), offset)) + return fail("the declaration is written by a macro definition"); + std::vector& edits = plan_.edits[realPath(entry->getName())]; + const bool seen = std::any_of(edits.begin(), edits.end(), [&](const Edit& edit) + { return edit.offset == offset; }); + if (!seen) + edits.push_back({offset, length, text}); + return true; + } + + clang::ASTContext& context_; + const clang::SourceManager& sm_; + SuggestionPlan& plan_; + const clang::ParmVarDecl* param_ = nullptr; + }; + + // The type the parameter must have once rewritten, written canonically. + std::string expectedType(clang::ASTContext& context, clang::QualType type) + { + clang::QualType out; + if (const auto* pointer = type->getAs()) + { + out = context.getQualifiedType( + context.getPointerType(pointer->getPointeeType().withConst()), + type.getQualifiers()); + } + else if (type->isReferenceType()) + { + out = context.getLValueReferenceType(type.getNonReferenceType().withConst()); + } + else + { + return ""; + } + return writtenType(context, out); + } + } // namespace + + // A type written canonically. An anonymous type, such as a lambda's, is written without + // its location: the path of that location depends on how the file was named, relative + // before the rewrite and absolute once the rewritten file is remapped. + std::string const_suggestion::writtenType(const clang::ASTContext& context, + clang::QualType type) + { + clang::PrintingPolicy policy(context.getLangOpts()); + policy.AnonymousTagLocations = false; + return type.getCanonicalType().getAsString(policy); + } + + SuggestionPlan const_suggestion::plan(clang::ASTContext& context, + const clang::FunctionDecl& function, + std::optional sourceIndex) + { + SuggestionPlan out; + const auto* method = llvm::dyn_cast(&function); + out.declarableElsewhere = method == nullptr && function.isExternallyVisible(); + if (!sourceIndex) + { + out.indeterminate = "the parameter is not found in the source"; + return out; + } + out.sourceIndex = *sourceIndex; + if (out.sourceIndex >= function.getNumParams()) + { + out.indeterminate = "the parameter is not found in the source"; + return out; + } + out.expectedType = expectedType(context, function.getParamDecl(out.sourceIndex)->getType()); + if (out.expectedType.empty()) + { + out.indeterminate = "the parameter is no pointer or reference"; + return out; + } + const clang::FunctionDecl* pattern = function.getTemplateInstantiationPattern(); + if (!pattern) + pattern = &function; + Planner planner(context, out); + for (const clang::FunctionDecl* declaration : pattern->redecls()) + { + if (declaration->isImplicit()) + continue; + if (out.sourceIndex >= declaration->getNumParams()) + { + out.indeterminate = "the declarations disagree on the parameters"; + return out; + } + if (!planner.plan(*declaration, *declaration->getParamDecl(out.sourceIndex))) + return out; + } + return out; + } +} // namespace ctrace::stack::analysis From 84462e217f7159905fe61f82f8463997ee7d365c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:26:59 +0200 Subject: [PATCH 058/152] fix(const-param): find macro definitions with the preprocessor, not the text --- src/analysis/ConstSuggestionCheck.cpp | 11 +++- src/analysis/ConstSuggestionInternal.hpp | 16 +++++- src/analysis/ConstSuggestionPlan.cpp | 69 ++++++++++++++++-------- 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 1963291..208ac95 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,7 @@ namespace ctrace::stack::analysis std::unique_ptr context; std::unique_ptr module; std::vector facts; + const_suggestion::MacroDefinitions macros; // read only when the compilation plans }; // A unit before the rewrite, prepared once for every suggestion of the module: its @@ -364,7 +366,8 @@ namespace ctrace::stack::analysis if (target.name.empty() && function->getIdentifier()) target.name = function->getIdentifier()->getName().str(); if (target.plan) - facts.plan = const_suggestion::plan(*context_, *function, target.sourceIndex); + facts.plan = const_suggestion::plan(*context_, out_.macros, *function, + target.sourceIndex); } std::uint8_t reservedPadding_[7] = {}; @@ -395,6 +398,12 @@ namespace ctrace::stack::analysis std::vector> consumers; consumers.push_back(std::move(generator)); consumers.push_back(std::make_unique(*this, targets_, out_)); + if (std::any_of(targets_.begin(), targets_.end(), + [](const Target& target) { return target.plan; })) + { + ci.getPreprocessor().addPPCallbacks(const_suggestion::recordMacroDefinitions( + ci.getSourceManager(), out_.macros)); + } return std::make_unique(std::move(consumers)); } diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 573923f..1b58a31 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -15,8 +16,11 @@ namespace clang { class ASTContext; + class FileID; class FunctionDecl; + class PPCallbacks; class QualType; + class SourceManager; } // namespace clang namespace llvm @@ -82,10 +86,18 @@ namespace ctrace::stack::analysis::const_suggestion // A type written canonically, an anonymous type without its location. std::string writtenType(const clang::ASTContext& context, clang::QualType type); + // The macro definitions a compilation reads, by file: from each macro's name to its last + // token, as offsets (D11). + using MacroDefinitions = std::map>>; + + // Records them, in the compilation that plans. + std::unique_ptr recordMacroDefinitions(const clang::SourceManager& sm, + MacroDefinitions& out); + // Plans the rewrite of the parameter at `sourceIndex` of `function`, located in the defining // unit's initial compilation by the suggestion's symbol. - SuggestionPlan plan(clang::ASTContext& context, const clang::FunctionDecl& function, - std::optional sourceIndex); + SuggestionPlan plan(clang::ASTContext& context, const MacroDefinitions& macros, + const clang::FunctionDecl& function, std::optional sourceIndex); // The demangled form of a symbol without const and spaces, with && written &: the rewritten // function, and what is named inside it, keep this form across the rewrite. diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 4a6fffc..92ce93c 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -13,44 +13,63 @@ #include #include #include +#include +#include namespace ctrace::stack::analysis { namespace { using const_suggestion::Edit; + using const_suggestion::MacroDefinitions; using const_suggestion::realPath; using const_suggestion::SuggestionPlan; using const_suggestion::writtenType; - // Whether `offset` of `buffer` lies on a #define line, continuations included. - bool inMacroDefinition(llvm::StringRef buffer, std::size_t offset) + // Records where each macro definition lies, by file. + class MacroRecorder : public clang::PPCallbacks { - std::size_t start = buffer.rfind('\n', offset); - start = start == llvm::StringRef::npos ? 0 : start + 1; - // Whether the line before the one at `start` ends with a backslash, before its - // line break, "\n" or "\r\n". - const auto continued = [&](std::size_t at) + public: + MacroRecorder(const clang::SourceManager& sm, MacroDefinitions& out) + : sm_(sm), out_(out) { - std::size_t end = at - 1; - if (end > 0 && buffer[end - 1] == '\r') - --end; - return end > 0 && buffer[end - 1] == '\\'; - }; - while (start >= 2 && continued(start)) + } + + void MacroDefined(const clang::Token&, const clang::MacroDirective* directive) override { - const std::size_t previous = buffer.rfind('\n', start - 2); - start = previous == llvm::StringRef::npos ? 0 : previous + 1; + const clang::MacroInfo* info = directive->getMacroInfo(); + const clang::SourceLocation begin = info->getDefinitionLoc(); + const clang::SourceLocation end = info->getDefinitionEndLoc(); + if (!begin.isFileID() || !end.isFileID()) + return; + const auto [file, from] = sm_.getDecomposedLoc(begin); + if (sm_.getFileID(end) == file) + out_[file].push_back({from, sm_.getFileOffset(end)}); } - const llvm::StringRef line = buffer.substr(start, offset - start).ltrim(); - return line.starts_with("#") && line.drop_front().ltrim().starts_with("define"); + + private: + const clang::SourceManager& sm_; + MacroDefinitions& out_; + }; + + // Whether `offset` of `file` lies in a macro definition, as the preprocessor read it: + // continuation lines included, whatever spaces follow their backslash. + bool inMacroDefinition(const MacroDefinitions& macros, clang::FileID file, + std::size_t offset) + { + const auto found = macros.find(file); + return found != macros.end() && + std::any_of(found->second.begin(), found->second.end(), + [&](const std::pair& range) + { return range.first <= offset && offset <= range.second; }); } class Planner { public: - Planner(clang::ASTContext& context, SuggestionPlan& plan) - : context_(context), sm_(context.getSourceManager()), plan_(plan) + Planner(clang::ASTContext& context, const MacroDefinitions& macros, + SuggestionPlan& plan) + : context_(context), sm_(context.getSourceManager()), macros_(macros), plan_(plan) { } @@ -177,7 +196,7 @@ namespace ctrace::stack::analysis const clang::OptionalFileEntryRef entry = sm_.getFileEntryRefForID(file); if (!entry) return fail("the parameter's type is not written in a file"); - if (inMacroDefinition(sm_.getBufferData(file), offset)) + if (inMacroDefinition(macros_, file, offset)) return fail("the declaration is written by a macro definition"); std::vector& edits = plan_.edits[realPath(entry->getName())]; const bool seen = std::any_of(edits.begin(), edits.end(), [&](const Edit& edit) @@ -189,6 +208,7 @@ namespace ctrace::stack::analysis clang::ASTContext& context_; const clang::SourceManager& sm_; + const MacroDefinitions& macros_; SuggestionPlan& plan_; const clang::ParmVarDecl* param_ = nullptr; }; @@ -226,7 +246,14 @@ namespace ctrace::stack::analysis return type.getCanonicalType().getAsString(policy); } + std::unique_ptr + const_suggestion::recordMacroDefinitions(const clang::SourceManager& sm, MacroDefinitions& out) + { + return std::make_unique(sm, out); + } + SuggestionPlan const_suggestion::plan(clang::ASTContext& context, + const MacroDefinitions& macros, const clang::FunctionDecl& function, std::optional sourceIndex) { @@ -253,7 +280,7 @@ namespace ctrace::stack::analysis const clang::FunctionDecl* pattern = function.getTemplateInstantiationPattern(); if (!pattern) pattern = &function; - Planner planner(context, out); + Planner planner(context, macros, out); for (const clang::FunctionDecl* declaration : pattern->redecls()) { if (declaration->isImplicit()) From bdeff08160510aea67bcc5bafb1cc2bf1ab0449c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:30:06 +0200 Subject: [PATCH 059/152] fix(const-param): leave a rewrite that reaches another parameter unchecked --- src/analysis/ConstSuggestionPlan.cpp | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 92ce93c..b41ae28 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -4,12 +4,14 @@ #include "ConstSuggestionInternal.hpp" #include +#include #include #include #include #include #include +#include #include #include #include @@ -158,12 +160,14 @@ namespace ctrace::stack::analysis { if (at.isInvalid()) return fail("the parameter's type has no position"); + bool inArgument = false; if (at.isMacroID()) { if (sm_.isMacroArgExpansion(at)) { // Written in a macro argument: rewritten where it is written. at = sm_.getSpellingLoc(at); + inArgument = true; } else { @@ -203,16 +207,75 @@ namespace ctrace::stack::analysis { return edit.offset == offset; }); if (!seen) edits.push_back({offset, length, text}); + if (inArgument) + inArguments_.push_back(at); return true; } + public: + // The edits written in a macro argument, which other declarations may share. + const std::vector& inArguments() const + { + return inArguments_; + } + + private: clang::ASTContext& context_; const clang::SourceManager& sm_; const MacroDefinitions& macros_; SuggestionPlan& plan_; + std::vector inArguments_; const clang::ParmVarDecl* param_ = nullptr; }; + // Whether a parameter other than `planned` has its type written at one of `edits`, the + // edits written in a macro argument: a macro argument can write the type of several + // parameters (D5). + class SharedTypes : public clang::RecursiveASTVisitor + { + public: + SharedTypes(const clang::SourceManager& sm, + const std::vector& edits, + const std::set& planned) + : sm_(sm), edits_(edits), planned_(planned) + { + } + + bool VisitParmVarDecl(clang::ParmVarDecl* parameter) + { + if (!shared && !planned_.count(parameter)) + shared = writtenAtAnEdit(parameter->getTypeSourceInfo()); + return true; + } + + bool shared = false; + std::uint8_t reservedPadding_[7] = {}; + + private: + // Whether a part of the type, written in a macro argument, is written at an edit. + bool writtenAtAnEdit(const clang::TypeSourceInfo* written) const + { + if (!written) + return false; + for (clang::TypeLoc loc = written->getTypeLoc(); !loc.isNull(); + loc = loc.getNextTypeLoc()) + { + const clang::SourceLocation at = loc.getBeginLoc(); + if (at.isMacroID() && sm_.isMacroArgExpansion(at) && + std::find(edits_.begin(), edits_.end(), sm_.getSpellingLoc(at)) != + edits_.end()) + { + return true; + } + } + return false; + } + + const clang::SourceManager& sm_; + const std::vector& edits_; + const std::set& planned_; + }; + // The type the parameter must have once rewritten, written canonically. std::string expectedType(clang::ASTContext& context, clang::QualType type) { @@ -293,6 +356,24 @@ namespace ctrace::stack::analysis if (!planner.plan(*declaration, *declaration->getParamDecl(out.sourceIndex))) return out; } + // The plan's scope (§5.5): an edit written in a macro argument serves only the parameter + // planned. + if (!planner.inArguments().empty()) + { + std::set planned; + for (const clang::FunctionDecl* declaration : pattern->redecls()) + { + if (out.sourceIndex < declaration->getNumParams()) + planned.insert(declaration->getParamDecl(out.sourceIndex)); + } + SharedTypes shared(context.getSourceManager(), planner.inArguments(), planned); + shared.TraverseDecl(context.getTranslationUnitDecl()); + if (shared.shared) + { + out.indeterminate = "the parameter's type is written in a macro argument that " + "writes other parameters too"; + } + } return out; } } // namespace ctrace::stack::analysis From e178353df239dab65aed50feb3c8d5434319a6b3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 17 Oct 2026 08:30:30 +0200 Subject: [PATCH 060/152] docs(plan): record task 1.3's macro index and parameter-only scope --- .../2026-10-16-const-suggestion-check-v2.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index d3b937b..149558c 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -888,7 +888,6 @@ Reasons of an indeterminate verdict, after `not checked with Clang: ` (shown in | Reason | Task | When | |---|---|---| | `the parameter's type is written in a macro argument that writes other parameters too` | 1.3 | D5: the text an edit rewrites writes another parameter | -| `the parameter's type is written in a macro argument that writes other declarations too` | 1.3 | the same, for another declaration | | `the rewritten code cannot be inspected entirely, a fatal error stops its compilation: :: ` | 1.4 | step 2 | | `the rewrite also changes the type of (:)` | 1.4 | step 3 | | `the generated code changes, and no reference, override or constant condition explains it: ` | 1.5 | step 7 (D16); `` is the comparator's text | @@ -944,6 +943,7 @@ The spec leaves these open. The user confirmed them on 2026-10-17, ruling 2 prov 13. **Task 1.4 gets a fixture where R and K refute without a change of code** (`const-suggestion-facts-without-code-change.cpp`). T v1 compares only code and keeps both suggestions as checked; since every refutation of T v1's tests comes with a code difference, the facts of Task 1.4 would otherwise have no failing test. 14. **A declaration is known by where it is expanded and where it is written.** One macro invocation can declare several functions, whose names it expands at the same place: a `DeclarationSite` holds both positions, for the identity of the entity and for the scope after the rewrite. `const-suggestion-macro-two-functions.c` pins it: it passes at `64ed37e`, where T v1 finds a function by its symbol, and its failure is shown on a mutant of Task 1.4 that keeps only the expansion position. 15. **The comparator's unit tests are adapted in Task 1.5**, as changed tests submitted with the task: `testConstSuggestionCodeComparison` and `testConstSuggestionNormalizations` pass the established pairs, and keep their guarantees; `testConstSuggestionWithoutConstKeepsIdentifiers` goes with `withoutConst`, its guarantee (two functions whose names differ by `const` are not paired) now held by a case where a renamed symbol without a pair is a difference. +16. **At planning, the scope of D5 covers the parameters only.** Another declaration whose type a macro argument shares with the parameter, such as a variable that the same invocation declares, is caught after the rewrite by the scope of Task 1.4 (step 3, `the rewrite also changes the type of (:)`), which `check_const_suggestion_header_read_differently` pins; at planning, no test would reach it. Cost if wrong: that case gets the step-3 reason. ### Shared types @@ -1312,18 +1312,19 @@ Review Focus 2: `const-suggestion-explicit-object.cpp` unchanged, passing at `64 **Interfaces.** Produces: ```cpp -// The ranges of the macro definitions a compilation reads, by real path: from the macro's name -// to its last token, as offsets (D11). -using MacroDefinitions = std::map>>; +// The macro definitions a compilation reads, by file: from each macro's name to its last token, +// as offsets (D11). A FileID, not a real path: the planning reads them in the same compilation. +using MacroDefinitions = std::map>>; // Records them, for the compilation that plans (§5.5). std::unique_ptr recordMacroDefinitions(const clang::SourceManager& sm, MacroDefinitions& out); -// Plans the rewrite of the parameter `binding` names, in `function`, located in the defining -// unit's initial compilation by the suggestion's symbol (T v1's GetDeclForMangledName). +// Plans the rewrite of the parameter at `sourceIndex` (P0's, none for an artificial binding) of +// `function`, located in the defining unit's initial compilation by the suggestion's symbol +// (T v1's GetDeclForMangledName). SuggestionPlan plan(clang::ASTContext& context, const MacroDefinitions& macros, - const clang::FunctionDecl& function, const ParameterDebugBinding& binding); + const clang::FunctionDecl& function, std::optional sourceIndex); ``` -`Edit` moves to `ConstSuggestionInternal.hpp`. `SuggestionPlan` holds here `sourceIndex`, `expectedType`, `edits` and `indeterminate`; Task 1.4 adds `entity`, Task 1.8 `candidate`. +`Edit`, `realPath` and `writtenType` move to `ConstSuggestionInternal.hpp`. `SuggestionPlan` holds here `edits`, `expectedType`, `indeterminate`, `sourceIndex` and `declarableElsewhere`, which leave `FunctionFacts`; Task 1.4 adds `entity`, Task 1.8 `candidate`. **Tests.** D5, a new fixture (t3/m.c): @@ -1413,10 +1414,10 @@ check_const_suggestion_line_endings: FAIL Commits: `test(const-param): keep a suggestion whose macro argument types two parameters`; `test(const-param): a backslash and spaces still continue a macro definition`. - [ ] **Step 2: Move the planning.** `Planner`, `writtenType`, `expectedType` and the body of `Inspector::plan` move to `ConstSuggestionPlan.cpp`, behind `plan()`; `FunctionFacts` keeps what the other compilations fill. No behavior changes. Run: the T v1 macro fixtures. Expected: PASS. Commit: `refactor(const-param): move the planning of a suggestion to its own file`. -- [ ] **Step 3: Macro definitions from the preprocessor (D11).** `recordMacroDefinitions` returns a `clang::PPCallbacks` whose `MacroDefined` records `[getDefinitionLoc(), getDefinitionEndLoc()]` of each macro written in a file (real path, offsets). `InspectAction::CreateASTConsumer` adds it to the preprocessor of the defining unit's initial compilation. `Planner::replace` asks the recorded ranges whether the offset it rewrites lies in a macro definition; the text scan `inMacroDefinition` goes. +- [ ] **Step 3: Macro definitions from the preprocessor (D11).** `recordMacroDefinitions` returns a `clang::PPCallbacks` whose `MacroDefined` records `[getDefinitionLoc(), getDefinitionEndLoc()]` of each macro written in a file (its FileID, offsets): no real path per macro. `InspectAction::CreateASTConsumer` adds it to the preprocessor of the defining unit's initial compilation. `Planner::replace` asks the recorded ranges whether the offset it rewrites lies in a macro definition; the text scan `inMacroDefinition` goes. Run: `check_const_suggestion_line_endings`. Expected: PASS. Commit: `fix(const-param): find macro definitions with the preprocessor, not the text`. -- [ ] **Step 4: The plan's scope (D5).** An edit whose position comes from a macro argument (`isMacroArgExpansion`) serves only the parameter planned. `plan()` walks the other parameters of each redeclaration with the same `TypeLoc` walk as `Planner::plan`: one whose type's spelling location is an edit's position gives `the parameter's type is written in a macro argument that writes other parameters too`. Then, only when an edit comes from a macro argument, a `RecursiveASTVisitor` over the translation unit checks every other `DeclaratorDecl` and `TypedefNameDecl`: one whose type starts at an edit's position gives `the parameter's type is written in a macro argument that writes other declarations too`. Either way the plan is indeterminate and nothing is rewritten. - Run: the one-fixture command for `const-suggestion-macro-shared-type.c`. Expected: PASS. Commit: `fix(const-param): leave a rewrite that reaches another declaration unchecked`. +- [ ] **Step 4: The plan's scope (D5).** An edit whose position comes from a macro argument (`isMacroArgExpansion`) serves only the parameter planned. Only then, a `RecursiveASTVisitor` over the translation unit checks every other `ParmVarDecl`: one with a part of its type (`TypeLoc::getNextTypeLoc` chain) written in a macro argument at an edit's spelling location gives `the parameter's type is written in a macro argument that writes other parameters too`; the plan is then indeterminate and nothing is rewritten. A comparison of spelled ranges does not do: a type whose first token a macro definition writes (`BYTE`) and whose last one the use site writes spans the file, and `argument_macro` of `const-suggestion-macros.c` then fails. Another declaration than a parameter (a variable the invocation declares) is left to the scope after the rewrite, Task 1.4's step 3, which its test pins (ruling 16). + Run: the one-fixture command for `const-suggestion-macro-shared-type.c`. Expected: PASS. Commit: `fix(const-param): leave a rewrite that reaches another parameter unchecked`. - [ ] **Step 5: Task tests.** As Task 1.1, Step 6. **Validation:** D5 and D11 pass; T v1's macro tests unchanged. From 232dfb35dba84a9dfbc856976ea635e628ec2d04 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:23 +0200 Subject: [PATCH 061/152] test(const-param): check a member operator's suggestion in the file that calls it --- run_test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/run_test.py b/run_test.py index 1696a6c..36ece34 100755 --- a/run_test.py +++ b/run_test.py @@ -3477,6 +3477,45 @@ def check_const_suggestion_compiles_each_file_once() -> bool: return ok +def check_const_suggestion_operator_used_elsewhere() -> bool: + """ + #217: a function is found by its declaration in every file that sees the change, not by its + name: an operator has no identifier. Here a member operator and a method, declared in a header, + defined in one file and called in another: both suggestions are checked in both files. + """ + print("=== Testing const suggestions for a member operator used in another file ===") + sources = { + "vec.hpp": "struct Vec\n{\n int x;\n bool operator==(Vec& o) const;\n" + " int dot(Vec& o) const;\n};\n", + "vec.cpp": '#include "vec.hpp"\n' + "bool Vec::operator==(Vec& o) const\n{\n return x == o.x;\n}\n" + "int Vec::dot(Vec& o) const\n{\n return x * o.x;\n}\n", + "use.cpp": '#include "vec.hpp"\n' + "int use(Vec a, Vec b)\n{\n return (a == b) + a.dot(b);\n}\n", + } + checked = "checked in the analyzed files only" + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_operator_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + run = _const_suggestion_run([str(root / "vec.cpp"), str(root / "use.cpp")]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + ok = True + for label, function in (("operator==", "_ZNK3VeceqERS_"), ("dot", "_ZNK3Vec3dotERS_")): + texts = messages.get(function, []) + good = ( + len(texts) == 1 + and checked in texts[0] + and f"{function} parameter o: checked in 2 file(s)" in trace + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6434,6 +6473,7 @@ def record_ok(ok: bool): check_const_suggestion_both_modes, check_const_suggestion_one_configuration_per_file, check_const_suggestion_compiles_each_file_once, + check_const_suggestion_operator_used_elsewhere, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 957213c874921379c15067b92b9f4ba00d37f486 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:24 +0200 Subject: [PATCH 062/152] test(const-param): a C homonym with another linkage is another function --- run_test.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/run_test.py b/run_test.py index 36ece34..93f9a02 100755 --- a/run_test.py +++ b/run_test.py @@ -3516,6 +3516,48 @@ def check_const_suggestion_operator_used_elsewhere() -> bool: return ok +def check_const_suggestion_homonyms_across_files() -> bool: + """ + #217: in C, a function with internal linkage and a function of the same name with external + linkage in another file are two functions: neither file declares the other's. Whichever of the + two gets the suggestion, the other file does not refute it, and it is checked. + """ + print("=== Testing const suggestions for C homonyms with internal and external linkage ===") + sources = { + "static-reads.c": "static int helper(int* p)\n{\n return *p;\n}\n" + "int use_helper(void)\n{\n int v = 1;\n return helper(&v);\n}\n", + "external-writes.c": "int helper(int* p)\n{\n *p = 2;\n return 0;\n}\n", + "external-reads.c": "int reader(int* p)\n{\n return *p;\n}\n", + "static-writes.c": "static int reader(int* p)\n{\n *p = 3;\n return 0;\n}\n" + "int use_reader(void)\n{\n int v = 1;\n return reader(&v);\n}\n", + } + checked = "checked in the analyzed files only" + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_homonyms_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, files, function in ( + ( + "a static reader, an external writer", + ["static-reads.c", "external-writes.c"], + "helper", + ), + ( + "an external reader, a static writer", + ["external-reads.c", "static-writes.c"], + "reader", + ), + ): + run = _const_suggestion_run([str(root / f) for f in files]) + texts = run[0].get(function, []) if run else [] + good = len(texts) == 1 and checked in texts[0] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6474,6 +6516,7 @@ def record_ok(ok: bool): check_const_suggestion_one_configuration_per_file, check_const_suggestion_compiles_each_file_once, check_const_suggestion_operator_used_elsewhere, + check_const_suggestion_homonyms_across_files, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 7288562ebf34b72b1bc8aad9a3c6b025b1663e58 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:25 +0200 Subject: [PATCH 063/152] test(const-param): a changed call or constant refutes even with the same code --- ...t-suggestion-facts-without-code-change.cpp | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp b/test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp new file mode 100644 index 0000000..08fab07 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-facts-without-code-change.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a call or a constant condition that the suggested type changes refutes the suggestion, +// even when the generated code stays the same: +// - sized: sizeof names sized(bool) before the rewrite and sized(const int*) after it; both return +// int, so the size, and the code, stay the same; +// - typed: if constexpr tests the parameter's own type, and both branches are the same code. + +// not contains: parameter 'p' in function 'sized(int*)' +// not contains: parameter 'p' in function 'typed(int*)' +#include + +int sized(int* p) +{ + return *p; +} + +int sized(bool flag); + +int size_of_call(const int* q) +{ + return static_cast(sizeof(sized(q))); +} + +int typed(int* p) +{ + if constexpr (std::is_const_v>) + return *p; + else + return *p; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 5b84829c82eafa9dbff0e0537931e2266cac3aae Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:25 +0200 Subject: [PATCH 064/152] test(const-param): keep a rewrite that changes another file's declaration --- run_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/run_test.py b/run_test.py index 93f9a02..fd9f8d8 100755 --- a/run_test.py +++ b/run_test.py @@ -3558,6 +3558,37 @@ def check_const_suggestion_homonyms_across_files() -> bool: return ok +def check_const_suggestion_header_read_differently() -> bool: + """ + #217: a header can mean other declarations in another file, through a macro that file defines + otherwise. Here pair.h declares read_value in def.c, and a global pointer in slot.c: rewriting + the type pair.h writes changes that global's type too, although the generated code does not + show it. The rewrite reaches beyond the suggestion: it is kept, not checked. + """ + print("=== Testing a const suggestion whose header means other declarations elsewhere ===") + sources = { + "pair.h": "PAIR(read_value, int*)\n", + "def.c": '#define PAIR(name, type) int name(type p);\n#include "pair.h"\n#undef PAIR\n' + "int read_value(int* p)\n{\n return *p;\n}\n", + "slot.c": '#define PAIR(name, type) type name##_slot;\n#include "pair.h"\n', + } + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_xmacro_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + run = _const_suggestion_run([str(root / "def.c"), str(root / "slot.c")]) + expected = ( + "not checked with Clang: the rewrite also changes the type of read_value_slot " + f"({root / 'pair.h'}:1)" + ) + texts = run[0].get("read_value", []) if run else [] + good = len(texts) == 1 and expected in texts[0] + detail = "" if good else f": {texts}" + print(f" {'✅' if good else '❌'} the global slot.c declares{detail}") + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6517,6 +6548,7 @@ def record_ok(ok: bool): check_const_suggestion_compiles_each_file_once, check_const_suggestion_operator_used_elsewhere, check_const_suggestion_homonyms_across_files, + check_const_suggestion_header_read_differently, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 05a7bc070874f14c42fbf203b8212e468e7402fe Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:25 +0200 Subject: [PATCH 065/152] test(const-param): keep a suggestion whose rewrite stops at a fatal error, unchecked --- ...const-suggestion-incomplete-inspection.cpp | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-incomplete-inspection.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-incomplete-inspection.cpp b/test/pointer_reference-const_correctness/const-suggestion-incomplete-inspection.cpp new file mode 100644 index 0000000..c25c28b --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-incomplete-inspection.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: the suggested type can make a compilation stop at a fatal error, after which the code it +// would have parsed is not inspected. Here sizeof(sized(p)) selects sized(int*) before the rewrite, +// and the template once p points to const: deducing its return type instantiates Deep without end. +// What the rewrite changes is then unknown: the suggestion is kept, not checked. + +// strict-expectation-details: true + +template struct Deep +{ + static constexpr int value = Deep::value + 1; +}; + +int sized(int* p); + +template auto sized(const T* p) +{ + return Deep::value + *p; +} + +// at line 26, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_sized(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ not checked with Clang: the rewritten code cannot be inspected entirely, a fatal error stops its compilation: +int read_sized(int* p) +{ + return *p + static_cast(sizeof(sized(p))); +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 53d6f0063104bc09413cfb0d29041580dfa356e9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:36:26 +0200 Subject: [PATCH 066/152] test(const-param): tell apart two functions that one macro invocation declares --- .../const-suggestion-macro-two-functions.c | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-macro-two-functions.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-macro-two-functions.c b/test/pointer_reference-const_correctness/const-suggestion-macro-two-functions.c new file mode 100644 index 0000000..1eeeaf3 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-macro-two-functions.c @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: one macro invocation can declare several functions, whose names it expands at the same +// place. Each one is also known by where its name is written: the check rewrites the function the +// suggestion names and leaves the other one, which writes through its parameter, whichever the +// invocation declares first. + +// strict-expectation-details: true + +#define DECLARE_TWO(a, Ta, b, Tb) \ + int a(Ta p); \ + int b(Tb q); + +DECLARE_TWO(read_first, int*, write_first, int*) +DECLARE_TWO(write_second, int*, read_second, int*) + +// at line 21, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_first' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +int read_first(int* p) +{ + return *p; +} + +int write_first(int* q) +{ + *q = 1; + return 0; +} + +int write_second(int* p) +{ + *p = 2; + return 0; +} + +// at line 43, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'q' in function 'read_second' is never used to modify the pointed object +// ↳ current type: int *q +// ↳ suggested type: const int *q +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +int read_second(int* q) +{ + return *q; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 641f7cfec32e9544ad990e88ae71408188f56c78 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 22:40:06 +0200 Subject: [PATCH 067/152] refactor(const-param): move the compilation and its inspection to their own file --- CMakeLists.txt | 1 + src/analysis/ConstSuggestionCheck.cpp | 509 +---------------------- src/analysis/ConstSuggestionFacts.cpp | 486 ++++++++++++++++++++++ src/analysis/ConstSuggestionInternal.hpp | 64 ++- 4 files changed, 556 insertions(+), 504 deletions(-) create mode 100644 src/analysis/ConstSuggestionFacts.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 316de58..ef13dfa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ set(STACK_ANALYZER_SOURCES src/analysis/ConstParamAnalysis.cpp src/analysis/ConstSuggestionCheck.cpp src/analysis/ConstSuggestionCode.cpp + src/analysis/ConstSuggestionFacts.cpp src/analysis/ConstSuggestionPlan.cpp src/analysis/CommandInjectionAnalysis.cpp src/analysis/DuplicateIfCondition.cpp diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 208ac95..48087ae 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -19,25 +19,6 @@ #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include #include @@ -46,22 +27,23 @@ #include #include #include -#include -#include #include namespace ctrace::stack::analysis { namespace { + using const_suggestion::Compilation; + using const_suggestion::compile; + using const_suggestion::Diagnostic; using const_suggestion::Edit; + using const_suggestion::FunctionFacts; using const_suggestion::Judgement; using const_suggestion::realPath; + using const_suggestion::scan; using const_suggestion::SuggestionPlan; - using const_suggestion::variantOf; + using const_suggestion::Target; using const_suggestion::Verdict; - using const_suggestion::withoutConst; - using const_suggestion::writtenType; // What the check covers: the analyzed files, or every file of the compilation database. constexpr const char* kCheckedAnalyzed = @@ -77,63 +59,6 @@ namespace ctrace::stack::analysis constexpr const char* kNotChecked = "not checked with Clang: "; constexpr const char* kDoesNotCompileAsItIs = "a file does not compile with Clang as it is"; - // Warnings that the C and C++ standards make constraint violations, or that a rewrite - // turns on silently: the rewritten code must not add any. - constexpr const char* kCheckedWarnings[] = { - "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", - "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual"}; - - // What one compilation found about the function a suggestion names. - struct FunctionFacts - { - // Before the rewrite, in the unit that defines it. - SuggestionPlan plan; - // In every compilation. - std::string printedSignature; - std::string problem; - std::multiset overrides; - }; - - // What to look for in one compilation. - struct Target - { - std::string symbol; // the function's symbol before the rewrite - std::string renamed; // and after, once known - std::string name; // its identifier - std::string expectedType; - std::set rewrittenFiles; - const std::set* symbolsBefore = nullptr; // to find the renamed function - // The parameter's position among those the source declares (P0), none for one it - // does not declare. - std::optional sourceIndex; - unsigned paramIndex = 0; - bool plan = false; // before the rewrite, in the defining unit - std::uint8_t reservedPadding[3] = {}; - }; - - // An error or a checked warning, in the order Clang reports it. - struct Diagnostic - { - std::string key; // place and kind, as `diagnostics` holds it - std::string text; - bool error = false; - std::uint8_t reservedPadding[7] = {}; - }; - - struct Compilation - { - bool timing = false; - std::uint8_t reservedPadding[7] = {}; - std::string failure; // the compilation could not run - std::multiset diagnostics; - std::vector reported; - std::string firstDiagnostic; - std::unique_ptr context; - std::unique_ptr module; - std::vector facts; - const_suggestion::MacroDefinitions macros; // read only when the compilation plans - }; - // A unit before the rewrite, prepared once for every suggestion of the module: its // diagnostics, what the methods of each function's name override (indexed like the // targets), and its generated code ready to compare. @@ -144,274 +69,6 @@ namespace ctrace::stack::analysis std::shared_ptr code; }; - // Errors, and the checked warnings, by place and kind: their texts name types that the - // rewrite changes. - class DiagnosticCollector : public clang::DiagnosticConsumer - { - public: - explicit DiagnosticCollector(Compilation& out) : out_(out) {} - - void HandleDiagnostic(clang::DiagnosticsEngine::Level level, - const clang::Diagnostic& info) override - { - clang::DiagnosticConsumer::HandleDiagnostic(level, info); - bool kept = level >= clang::DiagnosticsEngine::Error; - if (level == clang::DiagnosticsEngine::Warning) - { - const llvm::StringRef group = - info.getDiags()->getDiagnosticIDs()->getWarningOptionForDiag(info.getID()); - kept = std::find(std::begin(kCheckedWarnings), std::end(kCheckedWarnings), - group) != std::end(kCheckedWarnings); - } - if (!kept) - return; - std::string place = "?"; - if (info.hasSourceManager() && info.getLocation().isValid()) - { - const clang::SourceManager& sm = info.getSourceManager(); - const clang::PresumedLoc presumed = - sm.getPresumedLoc(sm.getExpansionLoc(info.getLocation())); - if (presumed.isValid()) - { - place = realPath(presumed.getFilename()) + ":" + - std::to_string(presumed.getLine()); - } - } - const std::string key = place + " #" + std::to_string(info.getID()); - out_.diagnostics.insert(key); - llvm::SmallString<160> text; - info.FormatDiagnostic(text); - out_.reported.push_back({key, place + ": " + text.str().str(), - level >= clang::DiagnosticsEngine::Error}); - if (out_.firstDiagnostic.empty()) - out_.firstDiagnostic = out_.reported.back().text; - } - - private: - Compilation& out_; - }; - - // Declarations of the function, explicit casts of its address, and what each method of - // its name overrides. - class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor - { - public: - Inspector(clang::CodeGenAction& action, std::vector& targets, Compilation& out) - : action_(action), targets_(targets), out_(out) - { - } - - bool shouldVisitTemplateInstantiations() const - { - return true; - } - - void HandleTranslationUnit(clang::ASTContext& context) override - { - context_ = &context; - out_.facts.resize(targets_.size()); - located_.assign(targets_.size(), nullptr); - clang::CodeGenerator* generator = action_.getCodeGenerator(); - for (std::size_t i = 0; i < targets_.size(); ++i) - locate(i, generator); - const analyzer::ScopedHotspot hotspot(out_.timing, "const_suggestion.inspect"); - TraverseDecl(context.getTranslationUnitDecl()); - } - - bool VisitFunctionDecl(clang::FunctionDecl* function) - { - for (std::size_t i = 0; i < targets_.size(); ++i) - { - if (!isTarget(i, *function)) - continue; - const Target& target = targets_[i]; - FunctionFacts& facts = out_.facts[i]; - if (!target.expectedType.empty() && facts.problem.empty() && - !function->isDependentContext() && - target.paramIndex < function->getNumParams()) - { - const std::string type = writtenType( - *context_, function->getParamDecl(target.paramIndex)->getType()); - if (type != target.expectedType) - { - const clang::SourceManager& sm = context_->getSourceManager(); - const clang::SourceLocation at = - sm.getExpansionLoc(function->getLocation()); - const clang::OptionalFileEntryRef entry = - sm.getFileEntryRefForID(sm.getFileID(at)); - const std::string file = entry ? realPath(entry->getName()) : ""; - facts.problem = - target.rewrittenFiles.count(file) - ? "rewritten:" + type - : "declared in " + llvm::sys::path::filename(file).str() + ":" + - std::to_string(sm.getExpansionLineNumber(at)) + - " with type " + type; - } - } - if (const auto* method = llvm::dyn_cast(function)) - recordOverrides(facts, *method); - } - if (const auto* method = llvm::dyn_cast(function)) - { - for (std::size_t i = 0; i < targets_.size(); ++i) - { - if (method->getIdentifier() && - method->getIdentifier()->getName() == targets_[i].name && - !isTarget(i, *function)) - { - recordOverrides(out_.facts[i], *method); - } - } - } - return true; - } - - bool VisitExplicitCastExpr(clang::ExplicitCastExpr* cast) - { - // `(void)f` converts the function to no other type. - if (cast->getType()->isVoidType()) - return true; - const clang::Expr* operand = cast->getSubExpr()->IgnoreParenImpCasts(); - if (const auto* address = llvm::dyn_cast(operand)) - { - if (address->getOpcode() == clang::UO_AddrOf) - operand = address->getSubExpr()->IgnoreParenImpCasts(); - } - const auto* reference = llvm::dyn_cast(operand); - const auto* function = - reference ? llvm::dyn_cast(reference->getDecl()) : nullptr; - if (!function) - return true; - for (std::size_t i = 0; i < targets_.size(); ++i) - { - if (out_.facts[i].problem.empty() && isTarget(i, *function)) - out_.facts[i].problem = "its address is converted by an explicit cast"; - } - return true; - } - - private: - void recordOverrides(FunctionFacts& facts, const clang::CXXMethodDecl& method) - { - std::vector overridden; - for (const clang::CXXMethodDecl* base : method.overridden_methods()) - overridden.push_back(base->getQualifiedNameAsString()); - std::sort(overridden.begin(), overridden.end()); - facts.overrides.insert(method.getQualifiedNameAsString() + " -> " + - llvm::join(overridden, ", ")); - } - - // The symbol CodeGen gives the function: it numbers unnamed types, such as a lambda - // ($_0, $_1), in the order it mangles them, as the IR names them. - std::string symbolOf(const clang::FunctionDecl& function) - { - clang::CodeGenerator* generator = action_.getCodeGenerator(); - if (!generator || function.isDependentContext() || - llvm::isa(function) || - llvm::isa(function)) - { - return ""; - } - return generator->GetMangledName(clang::GlobalDecl(&function)).str(); - } - - // Whether `function` is a declaration of the function target i names, in this - // compilation: the one CodeGen emitted under its symbol, a redeclaration of it, or a - // declaration with its symbol before or after the rewrite. - bool isTarget(std::size_t i, const clang::FunctionDecl& function) - { - const clang::FunctionDecl* found = located_[i]; - if (found && function.getCanonicalDecl() == found->getCanonicalDecl()) - return true; - const Target& target = targets_[i]; - if (!function.getIdentifier() || function.getIdentifier()->getName() != target.name) - return false; - const std::string symbol = symbolOf(function); - return !symbol.empty() && (symbol == target.symbol || symbol == target.renamed); - } - - void locate(std::size_t i, clang::CodeGenerator* generator) - { - Target& target = targets_[i]; - FunctionFacts& facts = out_.facts[i]; - if (!generator) - return; - std::string symbol = target.symbol; - if (!target.plan && target.symbolsBefore && generator->GetModule()) - { - // After the rewrite, the function may carry a new symbol: the one this - // compilation defines that it did not before, with the same name once const - // is left out. - const std::string key = withoutConst(target.symbol); - const std::string variant = variantOf(target.symbol); - for (const llvm::Function& emitted : *generator->GetModule()) - { - const std::string name = emitted.getName().str(); - if (!emitted.isDeclaration() && !target.symbolsBefore->count(name) && - withoutConst(name) == key && variantOf(name) == variant) - { - symbol = name; - } - } - if (target.renamed.empty()) - target.renamed = symbol; - } - const auto* function = llvm::dyn_cast_or_null( - generator->GetDeclForMangledName(symbol)); - located_[i] = function; - if (!function) - return; - facts.printedSignature = clang::PredefinedExpr::ComputeName( - clang::PredefinedIdentKind::PrettyFunction, function); - if (target.name.empty() && function->getIdentifier()) - target.name = function->getIdentifier()->getName().str(); - if (target.plan) - facts.plan = const_suggestion::plan(*context_, out_.macros, *function, - target.sourceIndex); - } - - std::uint8_t reservedPadding_[7] = {}; - clang::CodeGenAction& action_; - std::vector& targets_; - Compilation& out_; - clang::ASTContext* context_ = nullptr; - std::vector located_; - }; - - class InspectAction : public clang::EmitLLVMOnlyAction - { - public: - InspectAction(llvm::LLVMContext* context, std::vector& targets, - Compilation& out) - : clang::EmitLLVMOnlyAction(context), targets_(targets), out_(out) - { - } - - protected: - std::unique_ptr CreateASTConsumer(clang::CompilerInstance& ci, - llvm::StringRef file) override - { - std::unique_ptr generator = - clang::EmitLLVMOnlyAction::CreateASTConsumer(ci, file); - if (!generator) - return nullptr; - std::vector> consumers; - consumers.push_back(std::move(generator)); - consumers.push_back(std::make_unique(*this, targets_, out_)); - if (std::any_of(targets_.begin(), targets_.end(), - [](const Target& target) { return target.plan; })) - { - ci.getPreprocessor().addPPCallbacks(const_suggestion::recordMacroDefinitions( - ci.getSourceManager(), out_.macros)); - } - return std::make_unique(std::move(consumers)); - } - - private: - std::vector& targets_; - Compilation& out_; - }; - bool commandFor(const AnalyzedUnit& unit, const AnalysisConfig& config, UnitCommand& out, std::string& error) { @@ -443,160 +100,6 @@ namespace ctrace::stack::analysis return true; } - Compilation compile(const UnitCommand& command, - const std::map& rewritten, - std::vector& targets, std::string_view step, bool timing) - { - const analyzer::ScopedHotspot hotspot(timing, step); - static std::once_flag targetsReady; - std::call_once(targetsReady, - [] - { - llvm::InitializeAllTargetInfos(); - llvm::InitializeAllTargets(); - llvm::InitializeAllTargetMCs(); - }); - Compilation out; - out.timing = timing; - std::string error; - const bool ran = runSerializedCompile( - command.workingDir, - [&] - { - llvm::IntrusiveRefCntPtr files = - llvm::vfs::getRealFileSystem(); - DiagnosticCollector collector(out); - std::vector argv; - for (const std::string& arg : command.args) - argv.push_back(arg.c_str()); - clang::CreateInvocationOptions options; - options.Diags = clang::CompilerInstance::createDiagnostics( - *files, new clang::DiagnosticOptions(), &collector, false); - options.VFS = files; - options.ProbePrecompiled = false; - std::unique_ptr invocation = - clang::createInvocation(argv, options); - if (!invocation) - { - out.failure = - "the compile command is not understood: " + out.firstDiagnostic; - return; - } - // Nothing is written: no dependency file, no output. - invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); - // The inspection reads the AST after CodeGen: keep it for the backend. - invocation->getCodeGenOpts().ClearASTBeforeBackend = false; - // Values and blocks numbered, not named: a block that inlining names after - // the inlined function carries the rewritten function's symbol. - invocation->getCodeGenOpts().DiscardValueNames = true; - // The comparison removes debug information: do not generate it. - invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); - clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); - // No "N warnings generated" on the analyzer's output. - diagnostics.ShowCarets = false; - diagnostics.IgnoreWarnings = false; - for (const char* warning : kCheckedWarnings) - diagnostics.Warnings.push_back(warning); - out.diagnostics.clear(); - out.reported.clear(); - out.firstDiagnostic.clear(); - - clang::CompilerInstance instance; - instance.setInvocation(std::move(invocation)); - instance.createDiagnostics(*files, &collector, false); - instance.createFileManager(files); - for (const auto& [path, text] : rewritten) - { - instance.getPreprocessorOpts().addRemappedFile( - path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); - } - instance.getPreprocessorOpts().RetainRemappedFileBuffers = false; - instance.getFrontendOpts().DisableFree = false; - instance.getCodeGenOpts().DisableFree = false; - out.context = std::make_unique(); - InspectAction action(out.context.get(), targets, out); - instance.ExecuteAction(action); - out.module = action.takeModule(); - }, - error); - if (!ran) - out.failure = error; - return out; - } - - // Preprocesses a unit and records every identifier, after macro expansion. - class ScanAction : public clang::PreprocessorFrontendAction - { - public: - explicit ScanAction(std::unordered_set& identifiers) - : identifiers_(identifiers) - { - } - - protected: - void ExecuteAction() override - { - clang::Preprocessor& preprocessor = getCompilerInstance().getPreprocessor(); - preprocessor.EnterMainSourceFile(); - clang::Token token; - do - { - preprocessor.Lex(token); - if (token.is(clang::tok::identifier)) - identifiers_.insert( - llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); - } while (token.isNot(clang::tok::eof)); - } - - private: - std::unordered_set& identifiers_; - }; - - // The files a unit reads and its identifiers, from its preprocessing. Empty when the - // unit cannot be preprocessed without error: what it reads is then unknown. - UnitScan scan(const UnitCommand& command, bool timing) - { - const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.preprocess"); - UnitScan out; - std::string error; - runSerializedCompile( - command.workingDir, - [&] - { - llvm::IntrusiveRefCntPtr files = - llvm::vfs::getRealFileSystem(); - clang::IgnoringDiagConsumer ignore; - std::vector argv; - for (const std::string& arg : command.args) - argv.push_back(arg.c_str()); - clang::CreateInvocationOptions options; - options.Diags = clang::CompilerInstance::createDiagnostics( - *files, new clang::DiagnosticOptions(), &ignore, false); - options.VFS = files; - options.ProbePrecompiled = false; - std::unique_ptr invocation = - clang::createInvocation(argv, options); - if (!invocation) - return; - invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); - invocation->getDiagnosticOpts().ShowCarets = false; - clang::CompilerInstance instance; - instance.setInvocation(std::move(invocation)); - instance.createDiagnostics(*files, &ignore, false); - instance.createFileManager(files); - ScanAction action(out.identifiers); - instance.ExecuteAction(action); - if (!instance.hasSourceManager() || - instance.getDiagnostics().hasErrorOccurred()) - return; - const clang::SourceManager& sm = instance.getSourceManager(); - for (auto it = sm.fileinfo_begin(); it != sm.fileinfo_end(); ++it) - out.files.insert(realPath(it->first.getName())); - }, - error); - return out; - } - // An input as a real absolute path: relative inputs resolve from the input directory. std::string canonicalPath(const std::string& filename, const AnalysisConfig& config) { diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp new file mode 100644 index 0000000..2ae92d5 --- /dev/null +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: Apache-2.0 +// Compiles one unit for the Clang check of const suggestions (#217) and inspects it: its +// diagnostics, its generated code, and what it shows of the functions the suggestions name. +#include "ConstSuggestionInternal.hpp" +#include "analysis/InputPipeline.hpp" +#include "analyzer/HotspotProfiler.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ctrace::stack::analysis +{ + namespace + { + using const_suggestion::Compilation; + using const_suggestion::FunctionFacts; + using const_suggestion::realPath; + using const_suggestion::Target; + using const_suggestion::variantOf; + using const_suggestion::withoutConst; + using const_suggestion::writtenType; + + // Warnings that the C and C++ standards make constraint violations, or that a rewrite + // turns on silently: the rewritten code must not add any. + constexpr const char* kCheckedWarnings[] = { + "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", + "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual"}; + + // Errors, and the checked warnings, by place and kind: their texts name types that the + // rewrite changes. + class DiagnosticCollector : public clang::DiagnosticConsumer + { + public: + explicit DiagnosticCollector(Compilation& out) : out_(out) {} + + void HandleDiagnostic(clang::DiagnosticsEngine::Level level, + const clang::Diagnostic& info) override + { + clang::DiagnosticConsumer::HandleDiagnostic(level, info); + bool kept = level >= clang::DiagnosticsEngine::Error; + if (level == clang::DiagnosticsEngine::Warning) + { + const llvm::StringRef group = + info.getDiags()->getDiagnosticIDs()->getWarningOptionForDiag(info.getID()); + kept = std::find(std::begin(kCheckedWarnings), std::end(kCheckedWarnings), + group) != std::end(kCheckedWarnings); + } + if (!kept) + return; + std::string place = "?"; + if (info.hasSourceManager() && info.getLocation().isValid()) + { + const clang::SourceManager& sm = info.getSourceManager(); + const clang::PresumedLoc presumed = + sm.getPresumedLoc(sm.getExpansionLoc(info.getLocation())); + if (presumed.isValid()) + { + place = realPath(presumed.getFilename()) + ":" + + std::to_string(presumed.getLine()); + } + } + const std::string key = place + " #" + std::to_string(info.getID()); + out_.diagnostics.insert(key); + llvm::SmallString<160> text; + info.FormatDiagnostic(text); + out_.reported.push_back({key, place + ": " + text.str().str(), + level >= clang::DiagnosticsEngine::Error}); + if (out_.firstDiagnostic.empty()) + out_.firstDiagnostic = out_.reported.back().text; + } + + private: + Compilation& out_; + }; + + // Declarations of the function, explicit casts of its address, and what each method of + // its name overrides. + class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor + { + public: + Inspector(clang::CodeGenAction& action, std::vector& targets, Compilation& out) + : action_(action), targets_(targets), out_(out) + { + } + + bool shouldVisitTemplateInstantiations() const + { + return true; + } + + void HandleTranslationUnit(clang::ASTContext& context) override + { + context_ = &context; + out_.facts.resize(targets_.size()); + located_.assign(targets_.size(), nullptr); + clang::CodeGenerator* generator = action_.getCodeGenerator(); + for (std::size_t i = 0; i < targets_.size(); ++i) + locate(i, generator); + const analyzer::ScopedHotspot hotspot(out_.timing, "const_suggestion.inspect"); + TraverseDecl(context.getTranslationUnitDecl()); + } + + bool VisitFunctionDecl(clang::FunctionDecl* function) + { + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (!isTarget(i, *function)) + continue; + const Target& target = targets_[i]; + FunctionFacts& facts = out_.facts[i]; + if (!target.expectedType.empty() && facts.problem.empty() && + !function->isDependentContext() && + target.paramIndex < function->getNumParams()) + { + const std::string type = writtenType( + *context_, function->getParamDecl(target.paramIndex)->getType()); + if (type != target.expectedType) + { + const clang::SourceManager& sm = context_->getSourceManager(); + const clang::SourceLocation at = + sm.getExpansionLoc(function->getLocation()); + const clang::OptionalFileEntryRef entry = + sm.getFileEntryRefForID(sm.getFileID(at)); + const std::string file = entry ? realPath(entry->getName()) : ""; + facts.problem = + target.rewrittenFiles.count(file) + ? "rewritten:" + type + : "declared in " + llvm::sys::path::filename(file).str() + ":" + + std::to_string(sm.getExpansionLineNumber(at)) + + " with type " + type; + } + } + if (const auto* method = llvm::dyn_cast(function)) + recordOverrides(facts, *method); + } + if (const auto* method = llvm::dyn_cast(function)) + { + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (method->getIdentifier() && + method->getIdentifier()->getName() == targets_[i].name && + !isTarget(i, *function)) + { + recordOverrides(out_.facts[i], *method); + } + } + } + return true; + } + + bool VisitExplicitCastExpr(clang::ExplicitCastExpr* cast) + { + // `(void)f` converts the function to no other type. + if (cast->getType()->isVoidType()) + return true; + const clang::Expr* operand = cast->getSubExpr()->IgnoreParenImpCasts(); + if (const auto* address = llvm::dyn_cast(operand)) + { + if (address->getOpcode() == clang::UO_AddrOf) + operand = address->getSubExpr()->IgnoreParenImpCasts(); + } + const auto* reference = llvm::dyn_cast(operand); + const auto* function = + reference ? llvm::dyn_cast(reference->getDecl()) : nullptr; + if (!function) + return true; + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (out_.facts[i].problem.empty() && isTarget(i, *function)) + out_.facts[i].problem = "its address is converted by an explicit cast"; + } + return true; + } + + private: + void recordOverrides(FunctionFacts& facts, const clang::CXXMethodDecl& method) + { + std::vector overridden; + for (const clang::CXXMethodDecl* base : method.overridden_methods()) + overridden.push_back(base->getQualifiedNameAsString()); + std::sort(overridden.begin(), overridden.end()); + facts.overrides.insert(method.getQualifiedNameAsString() + " -> " + + llvm::join(overridden, ", ")); + } + + // The symbol CodeGen gives the function: it numbers unnamed types, such as a lambda + // ($_0, $_1), in the order it mangles them, as the IR names them. + std::string symbolOf(const clang::FunctionDecl& function) + { + clang::CodeGenerator* generator = action_.getCodeGenerator(); + if (!generator || function.isDependentContext() || + llvm::isa(function) || + llvm::isa(function)) + { + return ""; + } + return generator->GetMangledName(clang::GlobalDecl(&function)).str(); + } + + // Whether `function` is a declaration of the function target i names, in this + // compilation: the one CodeGen emitted under its symbol, a redeclaration of it, or a + // declaration with its symbol before or after the rewrite. + bool isTarget(std::size_t i, const clang::FunctionDecl& function) + { + const clang::FunctionDecl* found = located_[i]; + if (found && function.getCanonicalDecl() == found->getCanonicalDecl()) + return true; + const Target& target = targets_[i]; + if (!function.getIdentifier() || function.getIdentifier()->getName() != target.name) + return false; + const std::string symbol = symbolOf(function); + return !symbol.empty() && (symbol == target.symbol || symbol == target.renamed); + } + + void locate(std::size_t i, clang::CodeGenerator* generator) + { + Target& target = targets_[i]; + FunctionFacts& facts = out_.facts[i]; + if (!generator) + return; + std::string symbol = target.symbol; + if (!target.plan && target.symbolsBefore && generator->GetModule()) + { + // After the rewrite, the function may carry a new symbol: the one this + // compilation defines that it did not before, with the same name once const + // is left out. + const std::string key = withoutConst(target.symbol); + const std::string variant = variantOf(target.symbol); + for (const llvm::Function& emitted : *generator->GetModule()) + { + const std::string name = emitted.getName().str(); + if (!emitted.isDeclaration() && !target.symbolsBefore->count(name) && + withoutConst(name) == key && variantOf(name) == variant) + { + symbol = name; + } + } + if (target.renamed.empty()) + target.renamed = symbol; + } + const auto* function = llvm::dyn_cast_or_null( + generator->GetDeclForMangledName(symbol)); + located_[i] = function; + if (!function) + return; + facts.printedSignature = clang::PredefinedExpr::ComputeName( + clang::PredefinedIdentKind::PrettyFunction, function); + if (target.name.empty() && function->getIdentifier()) + target.name = function->getIdentifier()->getName().str(); + if (target.plan) + facts.plan = const_suggestion::plan(*context_, out_.macros, *function, + target.sourceIndex); + } + + std::uint8_t reservedPadding_[7] = {}; + clang::CodeGenAction& action_; + std::vector& targets_; + Compilation& out_; + clang::ASTContext* context_ = nullptr; + std::vector located_; + }; + + class InspectAction : public clang::EmitLLVMOnlyAction + { + public: + InspectAction(llvm::LLVMContext* context, std::vector& targets, + Compilation& out) + : clang::EmitLLVMOnlyAction(context), targets_(targets), out_(out) + { + } + + protected: + std::unique_ptr CreateASTConsumer(clang::CompilerInstance& ci, + llvm::StringRef file) override + { + std::unique_ptr generator = + clang::EmitLLVMOnlyAction::CreateASTConsumer(ci, file); + if (!generator) + return nullptr; + std::vector> consumers; + consumers.push_back(std::move(generator)); + consumers.push_back(std::make_unique(*this, targets_, out_)); + if (std::any_of(targets_.begin(), targets_.end(), + [](const Target& target) { return target.plan; })) + { + ci.getPreprocessor().addPPCallbacks(const_suggestion::recordMacroDefinitions( + ci.getSourceManager(), out_.macros)); + } + return std::make_unique(std::move(consumers)); + } + + private: + std::vector& targets_; + Compilation& out_; + }; + + // Preprocesses a unit and records every identifier, after macro expansion. + class ScanAction : public clang::PreprocessorFrontendAction + { + public: + explicit ScanAction(std::unordered_set& identifiers) + : identifiers_(identifiers) + { + } + + protected: + void ExecuteAction() override + { + clang::Preprocessor& preprocessor = getCompilerInstance().getPreprocessor(); + preprocessor.EnterMainSourceFile(); + clang::Token token; + do + { + preprocessor.Lex(token); + if (token.is(clang::tok::identifier)) + identifiers_.insert( + llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); + } while (token.isNot(clang::tok::eof)); + } + + private: + std::unordered_set& identifiers_; + }; + } // namespace + + Compilation const_suggestion::compile(const UnitCommand& command, + const std::map& rewritten, + std::vector& targets, std::string_view step, + bool timing) + { + const analyzer::ScopedHotspot hotspot(timing, step); + static std::once_flag targetsReady; + std::call_once(targetsReady, + [] + { + llvm::InitializeAllTargetInfos(); + llvm::InitializeAllTargets(); + llvm::InitializeAllTargetMCs(); + }); + Compilation out; + out.timing = timing; + std::string error; + const bool ran = runSerializedCompile( + command.workingDir, + [&] + { + llvm::IntrusiveRefCntPtr files = + llvm::vfs::getRealFileSystem(); + DiagnosticCollector collector(out); + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &collector, false); + options.VFS = files; + options.ProbePrecompiled = false; + std::unique_ptr invocation = + clang::createInvocation(argv, options); + if (!invocation) + { + out.failure = "the compile command is not understood: " + out.firstDiagnostic; + return; + } + // Nothing is written: no dependency file, no output. + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + // The inspection reads the AST after CodeGen: keep it for the backend. + invocation->getCodeGenOpts().ClearASTBeforeBackend = false; + // Values and blocks numbered, not named: a block that inlining names after + // the inlined function carries the rewritten function's symbol. + invocation->getCodeGenOpts().DiscardValueNames = true; + // The comparison removes debug information: do not generate it. + invocation->getCodeGenOpts().setDebugInfo(llvm::codegenoptions::NoDebugInfo); + clang::DiagnosticOptions& diagnostics = invocation->getDiagnosticOpts(); + // No "N warnings generated" on the analyzer's output. + diagnostics.ShowCarets = false; + diagnostics.IgnoreWarnings = false; + for (const char* warning : kCheckedWarnings) + diagnostics.Warnings.push_back(warning); + out.diagnostics.clear(); + out.reported.clear(); + out.firstDiagnostic.clear(); + + clang::CompilerInstance instance; + instance.setInvocation(std::move(invocation)); + instance.createDiagnostics(*files, &collector, false); + instance.createFileManager(files); + for (const auto& [path, text] : rewritten) + { + instance.getPreprocessorOpts().addRemappedFile( + path, llvm::MemoryBuffer::getMemBufferCopy(text, path).release()); + } + instance.getPreprocessorOpts().RetainRemappedFileBuffers = false; + instance.getFrontendOpts().DisableFree = false; + instance.getCodeGenOpts().DisableFree = false; + out.context = std::make_unique(); + InspectAction action(out.context.get(), targets, out); + instance.ExecuteAction(action); + out.module = action.takeModule(); + }, + error); + if (!ran) + out.failure = error; + return out; + } + + // The files a unit reads and its identifiers, from its preprocessing. Empty when the + // unit cannot be preprocessed without error: what it reads is then unknown. + UnitScan const_suggestion::scan(const UnitCommand& command, bool timing) + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.preprocess"); + UnitScan out; + std::string error; + runSerializedCompile( + command.workingDir, + [&] + { + llvm::IntrusiveRefCntPtr files = + llvm::vfs::getRealFileSystem(); + clang::IgnoringDiagConsumer ignore; + std::vector argv; + for (const std::string& arg : command.args) + argv.push_back(arg.c_str()); + clang::CreateInvocationOptions options; + options.Diags = clang::CompilerInstance::createDiagnostics( + *files, new clang::DiagnosticOptions(), &ignore, false); + options.VFS = files; + options.ProbePrecompiled = false; + std::unique_ptr invocation = + clang::createInvocation(argv, options); + if (!invocation) + return; + invocation->getDependencyOutputOpts() = clang::DependencyOutputOptions(); + invocation->getDiagnosticOpts().ShowCarets = false; + clang::CompilerInstance instance; + instance.setInvocation(std::move(invocation)); + instance.createDiagnostics(*files, &ignore, false); + instance.createFileManager(files); + ScanAction action(out.identifiers); + instance.ExecuteAction(action); + if (!instance.hasSourceManager() || instance.getDiagnostics().hasErrorOccurred()) + return; + const clang::SourceManager& sm = instance.getSourceManager(); + for (auto it = sm.fileinfo_begin(); it != sm.fileinfo_end(); ++it) + out.files.insert(realPath(it->first.getName())); + }, + error); + return out; + } +} // namespace ctrace::stack::analysis diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 1b58a31..00a43ab 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -7,8 +7,10 @@ #include #include #include +#include #include +#include #include #include "analysis/ConstSuggestionCheck.hpp" @@ -16,7 +18,6 @@ namespace clang { class ASTContext; - class FileID; class FunctionDecl; class PPCallbacks; class QualType; @@ -25,6 +26,7 @@ namespace clang namespace llvm { + class LLVMContext; class Module; } // namespace llvm @@ -127,4 +129,64 @@ namespace ctrace::stack::analysis::const_suggestion const PrintedSignature* signature, std::vector* accepted, std::string* renamedTo); + + // What one compilation found about the function a suggestion names. + struct FunctionFacts + { + // Before the rewrite, in the unit that defines it. + SuggestionPlan plan; + // In every compilation. + std::string printedSignature; + std::string problem; + std::multiset overrides; + }; + + // What to look for in one compilation. + struct Target + { + std::string symbol; // the function's symbol before the rewrite + std::string renamed; // and after, once known + std::string name; // its identifier + std::string expectedType; + std::set rewrittenFiles; + const std::set* symbolsBefore = nullptr; // to find the renamed function + // The parameter's position among those the source declares (P0), none for one it + // does not declare. + std::optional sourceIndex; + unsigned paramIndex = 0; + bool plan = false; // before the rewrite, in the defining unit + std::uint8_t reservedPadding[3] = {}; + }; + + // An error or a checked warning, in the order Clang reports it. + struct Diagnostic + { + std::string key; // place and kind, as `diagnostics` holds it + std::string text; + bool error = false; + std::uint8_t reservedPadding[7] = {}; + }; + + struct Compilation + { + bool timing = false; + std::uint8_t reservedPadding[7] = {}; + std::string failure; // the compilation could not run + std::multiset diagnostics; + std::vector reported; + std::string firstDiagnostic; + std::unique_ptr context; + std::unique_ptr module; + std::vector facts; + const_suggestion::MacroDefinitions macros; // read only when the compilation plans + }; + + // Compiles a unit, the files of `rewritten` remapped to their texts, and inspects it for + // `targets`. + Compilation compile(const UnitCommand& command, + const std::map& rewritten, + std::vector& targets, std::string_view step, bool timing); + + // The files a unit reads and its identifiers, from its preprocessing. + UnitScan scan(const UnitCommand& command, bool timing); } // namespace ctrace::stack::analysis::const_suggestion From 2de657fcb2523e76fb95b042f7c1f154a4dc2f40 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 23:04:55 +0200 Subject: [PATCH 068/152] fix(const-param): find the function by its declarations, not by its name --- src/analysis/ConstSuggestionCheck.cpp | 87 +++-- src/analysis/ConstSuggestionFacts.cpp | 394 ++++++++++++++++------- src/analysis/ConstSuggestionInternal.hpp | 120 +++++-- src/analysis/ConstSuggestionPlan.cpp | 35 +- 4 files changed, 436 insertions(+), 200 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 48087ae..74e9e9d 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -33,16 +33,16 @@ namespace ctrace::stack::analysis { namespace { - using const_suggestion::Compilation; - using const_suggestion::compile; using const_suggestion::Diagnostic; using const_suggestion::Edit; - using const_suggestion::FunctionFacts; + using const_suggestion::inspect; using const_suggestion::Judgement; using const_suggestion::realPath; using const_suggestion::scan; + using const_suggestion::SourceLinkage; using const_suggestion::SuggestionPlan; using const_suggestion::Target; + using const_suggestion::UnitFacts; using const_suggestion::Verdict; // What the check covers: the analyzed files, or every file of the compilation database. @@ -142,11 +142,11 @@ namespace ctrace::stack::analysis } // The overrides that each target's inspection recorded, indexed like the targets. - std::vector> overridesOf(const Compilation& compilation) + std::vector> overridesOf(const UnitFacts& unit) { std::vector> out; - for (const FunctionFacts& facts : compilation.facts) - out.push_back(facts.overrides); + for (const UnitFacts::Entity& entity : unit.entities) + out.push_back(entity.overrides); return out; } @@ -163,14 +163,8 @@ namespace ctrace::stack::analysis cache[key + '\0' + const_suggestion::fingerprintOf(command)]; if (out) return out; - std::vector targets = planned; - for (Target& target : targets) - { - target.plan = false; - target.expectedType.clear(); - } - Compilation before = - compile(command, {}, targets, "const_suggestion.compile.other_before", timing); + UnitFacts before = inspect(command, nullptr, {}, planned, + "const_suggestion.compile.other_before", timing); if (!before.failure.empty() || !before.module) return out; const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); @@ -195,22 +189,13 @@ namespace ctrace::stack::analysis Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, - const std::set& symbolsBefore, const SuggestionPlan& plan, - PrintedSignature& signature, std::vector& accepted, - std::string& renamed, bool timing) + const SuggestionPlan& plan, PrintedSignature& signature, + std::vector& accepted, std::string& renamed, bool timing) { if (!before) return indeterminate(kDoesNotCompileAsItIs); - std::vector targets{planned}; - targets[0].plan = false; - targets[0].renamed = renamed; - targets[0].expectedType = plan.expectedType; - targets[0].paramIndex = plan.sourceIndex; - targets[0].symbolsBefore = &symbolsBefore; - for (const auto& [path, edits] : plan.edits) - targets[0].rewrittenFiles.insert(path); - Compilation after = - compile(command, rewritten, targets, "const_suggestion.compile.after", timing); + UnitFacts after = inspect(command, &plan, rewritten, {planned}, + "const_suggestion.compile.after", timing); if (!after.failure.empty()) return indeterminate(after.failure); // The first diagnostic the rewrite adds, not one the unit had before it. @@ -226,9 +211,14 @@ namespace ctrace::stack::analysis } if (!after.module) return refuted("does not compile: " + after.firstDiagnostic); - if (renamed.empty()) - renamed = targets[0].renamed; - const FunctionFacts& facts = after.facts.empty() ? FunctionFacts() : after.facts[0]; + const UnitFacts::Entity& facts = + after.entities.empty() ? UnitFacts::Entity() : after.entities[0]; + // The suggestion's variant after the rewrite. + const auto variant = + std::find(plan.entity.symbols.begin(), plan.entity.symbols.end(), planned.symbol); + const auto k = static_cast(variant - plan.entity.symbols.begin()); + if (renamed.empty() && k < facts.symbols.size()) + renamed = facts.symbols[k]; if (facts.problem.starts_with("rewritten:")) return indeterminate("the rewritten declaration does not get the suggested type"); if (!facts.problem.empty()) @@ -359,18 +349,17 @@ namespace ctrace::stack::analysis targets[i].symbol = issues[i].funcName; if (!issues[i].binding.isArtificial) targets[i].sourceIndex = issues[i].binding.sourceIndex; - targets[i].plan = true; } - Compilation before = - compile(command, {}, targets, "const_suggestion.compile.defining_before", timing); - if (!before.failure.empty() || !before.module || before.facts.size() != issues.size()) + UnitFacts before = inspect(command, nullptr, {}, targets, + "const_suggestion.compile.defining_before", timing); + if (!before.failure.empty() || !before.module || before.entities.size() != issues.size()) { notChecked("the file does not compile with Clang as it is"); return; } - std::set symbolsBefore; - for (const llvm::Function& function : *before.module) - symbolsBefore.insert(function.getName().str()); + // The suggestions as planned: what the other compilations look for. + for (std::size_t i = 0; i < issues.size(); ++i) + targets[i].plan = &before.entities[i].plan; std::shared_ptr definingCode; { const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); @@ -408,9 +397,9 @@ namespace ctrace::stack::analysis for (std::size_t i = 0; i < issues.size(); ++i) { ConstParamIssue& issue = issues[i]; - const FunctionFacts& found = before.facts[i]; + const UnitFacts::Entity& found = before.entities[i]; const SuggestionPlan& plan = found.plan; - if (found.printedSignature.empty() && !plan.indeterminate) + if (!found.found && !plan.indeterminate) { issue.checkLines.push_back(kNotChecked + std::string("the function is not found in the source")); @@ -431,14 +420,13 @@ namespace ctrace::stack::analysis rewrittenFiles.insert(path); // The defining unit first: it gives the function's symbol after the rewrite. - Target planned = targets[i]; - planned.plan = false; + const Target& planned = targets[i]; PrintedSignature signature{found.printedSignature, ""}; std::vector accepted; std::string renamed; std::vector judgements{checkUnit(command, definingBefore, i, planned, - rewritten, symbolsBefore, plan, signature, - accepted, renamed, timing)}; + rewritten, plan, signature, accepted, + renamed, timing)}; std::vector judged{label(canonicalPath(self->filename, config), &command)}; // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or @@ -457,7 +445,10 @@ namespace ctrace::stack::analysis { break; } - const bool referencing = unit.module && references(*unit.module, issue.funcName); + // Another file references the function only through the linker: one with + // internal linkage is another file's homonym (§5.2). + const bool referencing = plan.entity.linkage == SourceLinkage::External && + unit.module && references(*unit.module, issue.funcName); if (unit.language != LanguageType::C && unit.language != LanguageType::CXX) { if (referencing) @@ -520,13 +511,13 @@ namespace ctrace::stack::analysis std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), [&](const std::string& path) { return seen.files.count(path); }) || (plan.declarableElsewhere && - seen.identifiers.count(llvm::xxh3_64bits(planned.name)) != 0); + seen.identifiers.count(llvm::xxh3_64bits(plan.entity.name)) != 0); if (!sees) continue; std::vector otherAccepted; - Judgement judgement = checkUnit( - other, originalOf(other, key, targets, originals, timing), i, planned, - rewritten, symbolsBefore, plan, signature, otherAccepted, renamed, timing); + Judgement judgement = + checkUnit(other, originalOf(other, key, targets, originals, timing), i, planned, + rewritten, plan, signature, otherAccepted, renamed, timing); if (judgement.verdict == Verdict::Indeterminate && judgement.reason == kDoesNotCompileAsItIs) { diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 2ae92d5..3fb2951 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -48,12 +49,16 @@ namespace ctrace::stack::analysis { namespace { - using const_suggestion::Compilation; - using const_suggestion::FunctionFacts; + using const_suggestion::DeclarationSite; + using const_suggestion::Edit; + using const_suggestion::EntityIdentity; + using const_suggestion::linkageOf; using const_suggestion::realPath; + using const_suggestion::SourceLinkage; + using const_suggestion::SourcePosition; + using const_suggestion::SuggestionPlan; using const_suggestion::Target; - using const_suggestion::variantOf; - using const_suggestion::withoutConst; + using const_suggestion::UnitFacts; using const_suggestion::writtenType; // Warnings that the C and C++ standards make constraint violations, or that a rewrite @@ -67,7 +72,7 @@ namespace ctrace::stack::analysis class DiagnosticCollector : public clang::DiagnosticConsumer { public: - explicit DiagnosticCollector(Compilation& out) : out_(out) {} + explicit DiagnosticCollector(UnitFacts& out) : out_(out) {} void HandleDiagnostic(clang::DiagnosticsEngine::Level level, const clang::Diagnostic& info) override @@ -106,16 +111,61 @@ namespace ctrace::stack::analysis } private: - Compilation& out_; + UnitFacts& out_; }; - // Declarations of the function, explicit casts of its address, and what each method of - // its name overrides. + // An offset of the rewritten text, in the original one. An offset inside inserted text + // maps to the edit's position. + std::size_t originalOffset(const std::vector& sortedEdits, std::size_t offset) + { + std::size_t inserted = 0; // text the edits before `offset` add + std::size_t removed = 0; // and remove + for (const Edit& edit : sortedEdits) + { + const std::size_t at = edit.offset + inserted - removed; + if (offset < at) + break; + if (offset < at + edit.text.size()) + return edit.offset; + inserted += edit.text.size(); + removed += edit.length; + } + return offset - inserted + removed; + } + + // The symbol CodeGen gives each variant of a function: a constructor's complete and base + // ones, a destructor's deleting, complete and base ones, otherwise the function's. It + // numbers unnamed types, such as a lambda ($_0, $_1), in the order it mangles them, as the + // IR names them. + std::vector variantSymbols(clang::CodeGenerator& generator, + const clang::FunctionDecl& function) + { + if (function.isDependentContext()) + return {}; + const auto symbol = [&](clang::GlobalDecl variant) + { return generator.GetMangledName(variant).str(); }; + if (const auto* constructor = llvm::dyn_cast(&function)) + { + return {symbol({constructor, clang::Ctor_Complete}), + symbol({constructor, clang::Ctor_Base})}; + } + if (const auto* destructor = llvm::dyn_cast(&function)) + { + return {symbol({destructor, clang::Dtor_Deleting}), + symbol({destructor, clang::Dtor_Complete}), + symbol({destructor, clang::Dtor_Base})}; + } + return {symbol(clang::GlobalDecl(&function))}; + } + + // Each target's function, found by its declarations (§3, §5.5), the explicit casts of its + // address, and what each method of its name overrides. class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor { public: - Inspector(clang::CodeGenAction& action, std::vector& targets, Compilation& out) - : action_(action), targets_(targets), out_(out) + Inspector(clang::CodeGenAction& action, const std::vector& targets, + const SuggestionPlan* rewrittenBy, UnitFacts& out) + : action_(action), targets_(targets), rewrittenBy_(rewrittenBy), out_(out) { } @@ -127,59 +177,59 @@ namespace ctrace::stack::analysis void HandleTranslationUnit(clang::ASTContext& context) override { context_ = &context; - out_.facts.resize(targets_.size()); + generator_ = action_.getCodeGenerator(); + sites_.emplace(context.getSourceManager(), rewrittenBy_); + out_.entities.resize(targets_.size()); located_.assign(targets_.size(), nullptr); - clang::CodeGenerator* generator = action_.getCodeGenerator(); + atSites_.assign(targets_.size(), {}); + linked_.assign(targets_.size(), {}); for (std::size_t i = 0; i < targets_.size(); ++i) - locate(i, generator); + { + if (!targets_[i].plan) + planAt(i); + } + // The files of the planned declarations: no other file has their sites. + for (std::size_t i = 0; i < targets_.size(); ++i) + { + for (const DeclarationSite& site : identity(i).redeclarations) + declarationFiles_.insert(site.expansion.file); + } const analyzer::ScopedHotspot hotspot(out_.timing, "const_suggestion.inspect"); TraverseDecl(context.getTranslationUnitDecl()); + for (std::size_t i = 0; i < targets_.size(); ++i) + resolve(i); } bool VisitFunctionDecl(clang::FunctionDecl* function) { + const clang::SourceManager& sm = context_->getSourceManager(); + const clang::SourceLocation location = function->getLocation(); + std::optional site; + if (location.isValid() && declarationFiles_.count(sites_->fileOf( + sm.getFileID(sm.getExpansionLoc(location))))) + { + site = (*sites_)(location); + } + const std::string& name = nameOf(function->getDeclName()); for (std::size_t i = 0; i < targets_.size(); ++i) { - if (!isTarget(i, *function)) - continue; - const Target& target = targets_[i]; - FunctionFacts& facts = out_.facts[i]; - if (!target.expectedType.empty() && facts.problem.empty() && - !function->isDependentContext() && - target.paramIndex < function->getNumParams()) + const EntityIdentity& entity = identity(i); + if (targets_[i].plan) { - const std::string type = writtenType( - *context_, function->getParamDecl(target.paramIndex)->getType()); - if (type != target.expectedType) + if (site && std::binary_search(entity.redeclarations.begin(), + entity.redeclarations.end(), *site)) { - const clang::SourceManager& sm = context_->getSourceManager(); - const clang::SourceLocation at = - sm.getExpansionLoc(function->getLocation()); - const clang::OptionalFileEntryRef entry = - sm.getFileEntryRefForID(sm.getFileID(at)); - const std::string file = entry ? realPath(entry->getName()) : ""; - facts.problem = - target.rewrittenFiles.count(file) - ? "rewritten:" + type - : "declared in " + llvm::sys::path::filename(file).str() + ":" + - std::to_string(sm.getExpansionLineNumber(at)) + - " with type " + type; + atSites_[i].push_back(function); } - } - if (const auto* method = llvm::dyn_cast(function)) - recordOverrides(facts, *method); - } - if (const auto* method = llvm::dyn_cast(function)) - { - for (std::size_t i = 0; i < targets_.size(); ++i) - { - if (method->getIdentifier() && - method->getIdentifier()->getName() == targets_[i].name && - !isTarget(i, *function)) + else if (entity.linkage == SourceLinkage::External && name == entity.name && + linkageOf(*function) == SourceLinkage::External) { - recordOverrides(out_.facts[i], *method); + linked_[i].push_back(function); } } + const auto* method = llvm::dyn_cast(function); + if (method && name == entity.name) + recordOverrides(out_.entities[i], *method); } return true; } @@ -198,18 +248,13 @@ namespace ctrace::stack::analysis const auto* reference = llvm::dyn_cast(operand); const auto* function = reference ? llvm::dyn_cast(reference->getDecl()) : nullptr; - if (!function) - return true; - for (std::size_t i = 0; i < targets_.size(); ++i) - { - if (out_.facts[i].problem.empty() && isTarget(i, *function)) - out_.facts[i].problem = "its address is converted by an explicit cast"; - } + if (function) + casts_.push_back(function->getCanonicalDecl()); return true; } private: - void recordOverrides(FunctionFacts& facts, const clang::CXXMethodDecl& method) + void recordOverrides(UnitFacts::Entity& facts, const clang::CXXMethodDecl& method) { std::vector overridden; for (const clang::CXXMethodDecl* base : method.overridden_methods()) @@ -219,89 +264,151 @@ namespace ctrace::stack::analysis llvm::join(overridden, ", ")); } - // The symbol CodeGen gives the function: it numbers unnamed types, such as a lambda - // ($_0, $_1), in the order it mangles them, as the IR names them. - std::string symbolOf(const clang::FunctionDecl& function) + const std::string& nameOf(clang::DeclarationName name) { - clang::CodeGenerator* generator = action_.getCodeGenerator(); - if (!generator || function.isDependentContext() || - llvm::isa(function) || - llvm::isa(function)) - { - return ""; - } - return generator->GetMangledName(clang::GlobalDecl(&function)).str(); + auto [it, added] = names_.try_emplace(name.getAsOpaquePtr()); + if (added) + it->second = name.getAsString(); + return it->second; } - // Whether `function` is a declaration of the function target i names, in this - // compilation: the one CodeGen emitted under its symbol, a redeclaration of it, or a - // declaration with its symbol before or after the rewrite. - bool isTarget(std::size_t i, const clang::FunctionDecl& function) + const EntityIdentity& identity(std::size_t i) const { - const clang::FunctionDecl* found = located_[i]; - if (found && function.getCanonicalDecl() == found->getCanonicalDecl()) - return true; - const Target& target = targets_[i]; - if (!function.getIdentifier() || function.getIdentifier()->getName() != target.name) - return false; - const std::string symbol = symbolOf(function); - return !symbol.empty() && (symbol == target.symbol || symbol == target.renamed); + return targets_[i].plan ? targets_[i].plan->entity : out_.entities[i].plan.entity; } - void locate(std::size_t i, clang::CodeGenerator* generator) + // In the defining unit's initial compilation, the function is the one CodeGen emits + // under the suggestion's symbol. + void planAt(std::size_t i) { - Target& target = targets_[i]; - FunctionFacts& facts = out_.facts[i]; - if (!generator) + if (!generator_) return; - std::string symbol = target.symbol; - if (!target.plan && target.symbolsBefore && generator->GetModule()) + const auto* function = llvm::dyn_cast_or_null( + generator_->GetDeclForMangledName(targets_[i].symbol)); + located_[i] = function; + if (!function) + return; + UnitFacts::Entity& facts = out_.entities[i]; + facts.plan = const_suggestion::plan(*context_, out_.macros, *function, + targets_[i].sourceIndex); + facts.plan.entity.symbols = variantSymbols(*generator_, *function); + } + + // The target's function in this compilation, and after the rewrite T v1's checks of + // its declarations. + void resolve(std::size_t i) + { + if (!generator_) + return; + const Target& target = targets_[i]; + const EntityIdentity& entity = identity(i); + UnitFacts::Entity& facts = out_.entities[i]; + std::vector found; // canonical declarations + const auto add = [&](const clang::FunctionDecl* function) + { + const clang::FunctionDecl* canonical = function->getCanonicalDecl(); + if (std::find(found.begin(), found.end(), canonical) == found.end()) + found.push_back(canonical); + }; + const auto named = [&](const clang::FunctionDecl* function) + { + const std::vector symbols = variantSymbols(*generator_, *function); + return std::find_first_of(symbols.begin(), symbols.end(), + entity.symbols.begin(), + entity.symbols.end()) != symbols.end(); + }; + if (!target.plan) { - // After the rewrite, the function may carry a new symbol: the one this - // compilation defines that it did not before, with the same name once const - // is left out. - const std::string key = withoutConst(target.symbol); - const std::string variant = variantOf(target.symbol); - for (const llvm::Function& emitted : *generator->GetModule()) + if (located_[i]) + add(located_[i]); + } + else if (!atSites_[i].empty()) + { + // Its declarations have its sites. A specialization has its pattern's, as the + // other specializations of its template: it is the one with its symbol. + for (const clang::FunctionDecl* function : atSites_[i]) { - const std::string name = emitted.getName().str(); - if (!emitted.isDeclaration() && !target.symbolsBefore->count(name) && - withoutConst(name) == key && variantOf(name) == variant) + if (!function->isDependentContext() && + (!entity.templateOrigin || named(function))) { - symbol = name; + add(function); } } - if (target.renamed.empty()) - target.renamed = symbol; } - const auto* function = llvm::dyn_cast_or_null( - generator->GetDeclForMangledName(symbol)); - located_[i] = function; - if (!function) + else + { + // A unit that shares none of its sites declares it only through the linker. + for (const clang::FunctionDecl* function : linked_[i]) + { + if (!function->isDependentContext() && named(function)) + add(function); + } + } + facts.found = !found.empty(); + if (!facts.found) return; + facts.symbols = variantSymbols(*generator_, *found.front()); facts.printedSignature = clang::PredefinedExpr::ComputeName( - clang::PredefinedIdentKind::PrettyFunction, function); - if (target.name.empty() && function->getIdentifier()) - target.name = function->getIdentifier()->getName().str(); - if (target.plan) - facts.plan = const_suggestion::plan(*context_, out_.macros, *function, - target.sourceIndex); + clang::PredefinedIdentKind::PrettyFunction, found.front()); + if (!target.plan || target.plan != rewrittenBy_) + return; + const clang::SourceManager& sm = context_->getSourceManager(); + for (const clang::FunctionDecl* function : found) + { + for (const clang::FunctionDecl* declaration : function->redecls()) + { + if (!facts.problem.empty() || declaration->isDependentContext() || + target.plan->sourceIndex >= declaration->getNumParams()) + { + continue; + } + const std::string type = writtenType( + *context_, + declaration->getParamDecl(target.plan->sourceIndex)->getType()); + if (type == target.plan->expectedType) + continue; + const clang::SourceLocation at = + sm.getExpansionLoc(declaration->getLocation()); + const std::string& file = sites_->fileOf(sm.getFileID(at)); + facts.problem = target.plan->edits.count(file) + ? "rewritten:" + type + : "declared in " + + llvm::sys::path::filename(file).str() + ":" + + std::to_string(sm.getExpansionLineNumber(at)) + + " with type " + type; + } + } + if (facts.problem.empty() && + std::find_first_of(casts_.begin(), casts_.end(), found.begin(), found.end()) != + casts_.end()) + { + facts.problem = "its address is converted by an explicit cast"; + } } std::uint8_t reservedPadding_[7] = {}; clang::CodeGenAction& action_; - std::vector& targets_; - Compilation& out_; + const std::vector& targets_; + const SuggestionPlan* rewrittenBy_; + UnitFacts& out_; clang::ASTContext* context_ = nullptr; + clang::CodeGenerator* generator_ = nullptr; + std::optional sites_; + std::set declarationFiles_; + std::map names_; std::vector located_; + std::vector> atSites_; // with one of its sites + std::vector> linked_; // and the linker's + std::vector casts_; // explicitly cast }; class InspectAction : public clang::EmitLLVMOnlyAction { public: - InspectAction(llvm::LLVMContext* context, std::vector& targets, - Compilation& out) - : clang::EmitLLVMOnlyAction(context), targets_(targets), out_(out) + InspectAction(llvm::LLVMContext* context, const std::vector& targets, + const SuggestionPlan* rewrittenBy, UnitFacts& out) + : clang::EmitLLVMOnlyAction(context), targets_(targets), rewrittenBy_(rewrittenBy), + out_(out) { } @@ -315,9 +422,10 @@ namespace ctrace::stack::analysis return nullptr; std::vector> consumers; consumers.push_back(std::move(generator)); - consumers.push_back(std::make_unique(*this, targets_, out_)); + consumers.push_back( + std::make_unique(*this, targets_, rewrittenBy_, out_)); if (std::any_of(targets_.begin(), targets_.end(), - [](const Target& target) { return target.plan; })) + [](const Target& target) { return !target.plan; })) { ci.getPreprocessor().addPPCallbacks(const_suggestion::recordMacroDefinitions( ci.getSourceManager(), out_.macros)); @@ -326,8 +434,9 @@ namespace ctrace::stack::analysis } private: - std::vector& targets_; - Compilation& out_; + const std::vector& targets_; + const SuggestionPlan* rewrittenBy_; + UnitFacts& out_; }; // Preprocesses a unit and records every identifier, after macro expansion. @@ -359,10 +468,11 @@ namespace ctrace::stack::analysis }; } // namespace - Compilation const_suggestion::compile(const UnitCommand& command, - const std::map& rewritten, - std::vector& targets, std::string_view step, - bool timing) + UnitFacts const_suggestion::inspect(const UnitCommand& command, + const SuggestionPlan* rewrittenBy, + const std::map& rewritten, + const std::vector& targets, std::string_view step, + bool timing) { const analyzer::ScopedHotspot hotspot(timing, step); static std::once_flag targetsReady; @@ -373,7 +483,7 @@ namespace ctrace::stack::analysis llvm::InitializeAllTargets(); llvm::InitializeAllTargetMCs(); }); - Compilation out; + UnitFacts out; out.timing = timing; std::string error; const bool ran = runSerializedCompile( @@ -430,7 +540,7 @@ namespace ctrace::stack::analysis instance.getFrontendOpts().DisableFree = false; instance.getCodeGenOpts().DisableFree = false; out.context = std::make_unique(); - InspectAction action(out.context.get(), targets, out); + InspectAction action(out.context.get(), targets, rewrittenBy, out); instance.ExecuteAction(action); out.module = action.takeModule(); }, @@ -483,4 +593,46 @@ namespace ctrace::stack::analysis error); return out; } + + const_suggestion::SiteReader::SiteReader(const clang::SourceManager& sm, + const SuggestionPlan* rewrittenBy) + : sm_(sm) + { + if (!rewrittenBy) + return; + edits_ = rewrittenBy->edits; + for (auto& [path, edits] : edits_) + { + std::sort(edits.begin(), edits.end(), + [](const Edit& a, const Edit& b) { return a.offset < b.offset; }); + } + } + + DeclarationSite const_suggestion::SiteReader::operator()(clang::SourceLocation location) + { + if (location.isInvalid()) + return {}; + return {position(sm_.getExpansionLoc(location)), position(sm_.getSpellingLoc(location))}; + } + + const std::string& const_suggestion::SiteReader::fileOf(clang::FileID file) + { + const auto found = files_.find(file); + if (found != files_.end()) + return found->second; + const clang::OptionalFileEntryRef entry = sm_.getFileEntryRefForID(file); + std::string path = entry ? realPath(entry->getName()) + : sm_.getBufferName(sm_.getLocForStartOfFile(file)).str(); + return files_.emplace(file, std::move(path)).first->second; + } + + SourcePosition const_suggestion::SiteReader::position(clang::SourceLocation location) + { + const auto [file, offset] = sm_.getDecomposedLoc(location); + SourcePosition out{fileOf(file), offset}; + const auto edits = edits_.find(out.file); + if (edits != edits_.end()) + out.offset = originalOffset(edits->second, out.offset); + return out; + } } // namespace ctrace::stack::analysis diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 00a43ab..a2386f2 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -71,9 +72,54 @@ namespace ctrace::stack::analysis::const_suggestion std::string text; }; + // A position in a file's original text: after the rewrite, a position in a rewritten file is + // mapped back through the plan's edits (§5.4). + struct SourcePosition + { + std::string file; // real path + std::size_t offset = 0; + bool operator==(const SourcePosition&) const = default; + bool operator<(const SourcePosition& other) const + { + return std::tie(file, offset) < std::tie(other.file, other.offset); + } + }; + + // Where a name is expanded and where it is written: one macro invocation can expand several + // names at the same place (ruling 14). + struct DeclarationSite + { + SourcePosition expansion; + SourcePosition spelling; + bool operator==(const DeclarationSite&) const = default; + bool operator<(const DeclarationSite& other) const + { + return std::tie(expansion, spelling) < std::tie(other.expansion, other.spelling); + } + }; + + enum class SourceLinkage : std::uint8_t + { + None, + Internal, + External + }; + + // The function a suggestion names, as the defining unit's initial compilation shows it (§3). + struct EntityIdentity + { + std::vector redeclarations; // the declarations the plan rewrites, sorted + std::string name; // as Clang prints it: dot, operator==, ~A + std::optional templateOrigin; // a specialization's pattern, first declared + std::vector symbols; // every variant's, before the rewrite (CodeGen) + SourceLinkage linkage = SourceLinkage::None; + std::uint8_t reservedPadding[7] = {}; + }; + // The rewrite of one suggestion, planned on the defining unit's initial compilation (§5.5). struct SuggestionPlan { + EntityIdentity entity; std::map> edits; // by real path std::string expectedType; // the parameter's type once rewritten, written canonically std::optional indeterminate; // why the plan is not executed @@ -85,6 +131,28 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[3] = {}; }; + // The sites of one compilation's locations. After the rewrite, a position in a file that + // `rewrittenBy` rewrites is mapped back to the original text through its edits. + class SiteReader + { + public: + SiteReader(const clang::SourceManager& sm, const SuggestionPlan* rewrittenBy); + + DeclarationSite operator()(clang::SourceLocation location); + // The real path of a file, or its buffer's name when it is none. + const std::string& fileOf(clang::FileID file); + + private: + SourcePosition position(clang::SourceLocation location); + + const clang::SourceManager& sm_; + std::map> edits_; // by real path, sorted by offset + std::map files_; + }; + + // External for external or module linkage, internal for internal or unique-external. + SourceLinkage linkageOf(const clang::FunctionDecl& function); + // A type written canonically, an anonymous type without its location. std::string writtenType(const clang::ASTContext& context, clang::QualType type); @@ -130,32 +198,15 @@ namespace ctrace::stack::analysis::const_suggestion std::vector* accepted, std::string* renamedTo); - // What one compilation found about the function a suggestion names. - struct FunctionFacts - { - // Before the rewrite, in the unit that defines it. - SuggestionPlan plan; - // In every compilation. - std::string printedSignature; - std::string problem; - std::multiset overrides; - }; - - // What to look for in one compilation. + // What to look for in one compilation: the function a suggestion names. struct Target { - std::string symbol; // the function's symbol before the rewrite - std::string renamed; // and after, once known - std::string name; // its identifier - std::string expectedType; - std::set rewrittenFiles; - const std::set* symbolsBefore = nullptr; // to find the renamed function + std::string symbol; // the suggestion's symbol, before the rewrite + // Its plan; null in the defining unit's initial compilation, which plans it. + const SuggestionPlan* plan = nullptr; // The parameter's position among those the source declares (P0), none for one it // does not declare. std::optional sourceIndex; - unsigned paramIndex = 0; - bool plan = false; // before the rewrite, in the defining unit - std::uint8_t reservedPadding[3] = {}; }; // An error or a checked warning, in the order Clang reports it. @@ -167,8 +218,21 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[7] = {}; }; - struct Compilation + // What one compilation shows (§5.3, §5.6). + struct UnitFacts { + // About the function of one target. + struct Entity + { + SuggestionPlan plan; // in the compilation that plans + std::vector symbols; // every variant's, in this compilation + std::string printedSignature; // for __PRETTY_FUNCTION__ + std::string problem; + std::multiset overrides; + bool found = false; // the compilation declares it + std::uint8_t reservedPadding[7] = {}; + }; + bool timing = false; std::uint8_t reservedPadding[7] = {}; std::string failure; // the compilation could not run @@ -177,15 +241,15 @@ namespace ctrace::stack::analysis::const_suggestion std::string firstDiagnostic; std::unique_ptr context; std::unique_ptr module; - std::vector facts; + std::vector entities; // indexed like the targets const_suggestion::MacroDefinitions macros; // read only when the compilation plans }; - // Compiles a unit, the files of `rewritten` remapped to their texts, and inspects it for - // `targets`. - Compilation compile(const UnitCommand& command, - const std::map& rewritten, - std::vector& targets, std::string_view step, bool timing); + // Compiles a unit and inspects it for `targets`. After the rewrite, `rewritten` remaps the + // files that the plan `rewrittenBy` rewrites to their texts. + UnitFacts inspect(const UnitCommand& command, const SuggestionPlan* rewrittenBy, + const std::map& rewritten, + const std::vector& targets, std::string_view step, bool timing); // The files a unit reads and its identifiers, from its preprocessing. UnitScan scan(const UnitCommand& command, bool timing); diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index b41ae28..41d79f7 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -25,6 +25,8 @@ namespace ctrace::stack::analysis using const_suggestion::Edit; using const_suggestion::MacroDefinitions; using const_suggestion::realPath; + using const_suggestion::SiteReader; + using const_suggestion::SourceLinkage; using const_suggestion::SuggestionPlan; using const_suggestion::writtenType; @@ -309,6 +311,21 @@ namespace ctrace::stack::analysis return type.getCanonicalType().getAsString(policy); } + SourceLinkage const_suggestion::linkageOf(const clang::FunctionDecl& function) + { + switch (function.getFormalLinkage()) + { + case clang::Linkage::External: + case clang::Linkage::Module: + return SourceLinkage::External; + case clang::Linkage::Internal: + case clang::Linkage::UniqueExternal: + return SourceLinkage::Internal; + default: + return SourceLinkage::None; + } + } + std::unique_ptr const_suggestion::recordMacroDefinitions(const clang::SourceManager& sm, MacroDefinitions& out) { @@ -323,6 +340,21 @@ namespace ctrace::stack::analysis SuggestionPlan out; const auto* method = llvm::dyn_cast(&function); out.declarableElsewhere = method == nullptr && function.isExternallyVisible(); + const clang::FunctionDecl* instantiated = function.getTemplateInstantiationPattern(); + const clang::FunctionDecl* pattern = instantiated ? instantiated : &function; + // The source identity (§3): the declarations the plan rewrites, by both positions of + // their names, and a specialization's pattern, by its first declaration. + SiteReader sites(context.getSourceManager(), nullptr); + for (const clang::FunctionDecl* declaration : pattern->redecls()) + { + if (!declaration->isImplicit()) + out.entity.redeclarations.push_back(sites(declaration->getLocation())); + } + std::sort(out.entity.redeclarations.begin(), out.entity.redeclarations.end()); + out.entity.name = function.getDeclName().getAsString(); + out.entity.linkage = linkageOf(function); + if (instantiated) + out.entity.templateOrigin = sites(instantiated->getFirstDecl()->getLocation()); if (!sourceIndex) { out.indeterminate = "the parameter is not found in the source"; @@ -340,9 +372,6 @@ namespace ctrace::stack::analysis out.indeterminate = "the parameter is no pointer or reference"; return out; } - const clang::FunctionDecl* pattern = function.getTemplateInstantiationPattern(); - if (!pattern) - pattern = &function; Planner planner(context, macros, out); for (const clang::FunctionDecl* declaration : pattern->redecls()) { From 7fb25d8d81f42ab7ba36e232f7770b1740167225 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 23:11:13 +0200 Subject: [PATCH 069/152] feat(const-param): refute a suggestion that changes a call, override or constant --- src/analysis/ConstSuggestionCheck.cpp | 143 ++++++++++++++-- src/analysis/ConstSuggestionFacts.cpp | 207 +++++++++++++++++++++-- src/analysis/ConstSuggestionInternal.hpp | 42 ++++- 3 files changed, 368 insertions(+), 24 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 74e9e9d..4be9a52 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -35,6 +35,9 @@ namespace ctrace::stack::analysis { using const_suggestion::Diagnostic; using const_suggestion::Edit; + using const_suggestion::Fact; + using const_suggestion::FactKey; + using const_suggestion::FactKind; using const_suggestion::inspect; using const_suggestion::Judgement; using const_suggestion::realPath; @@ -60,12 +63,13 @@ namespace ctrace::stack::analysis constexpr const char* kDoesNotCompileAsItIs = "a file does not compile with Clang as it is"; // A unit before the rewrite, prepared once for every suggestion of the module: its - // diagnostics, what the methods of each function's name override (indexed like the + // diagnostics, its facts R and K, the facts O of each target (indexed like the // targets), and its generated code ready to compare. struct Original { std::multiset diagnostics; - std::vector> overrides; + std::vector facts; + std::vector> overrides; std::shared_ptr code; }; @@ -141,10 +145,10 @@ namespace ctrace::stack::analysis return out; } - // The overrides that each target's inspection recorded, indexed like the targets. - std::vector> overridesOf(const UnitFacts& unit) + // The facts O that each target's inspection recorded, indexed like the targets. + std::vector> overridesOf(const UnitFacts& unit) { - std::vector> out; + std::vector> out; for (const UnitFacts::Entity& entity : unit.entities) out.push_back(entity.overrides); return out; @@ -168,12 +172,128 @@ namespace ctrace::stack::analysis if (!before.failure.empty() || !before.module) return out; const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - out = Original{before.diagnostics, overridesOf(before), + out = Original{before.diagnostics, std::move(before.facts), overridesOf(before), std::make_shared( const_suggestion::prepare(*before.module))}; return out; } + // The reason a fact gives once the rewrite changes it (Texts). + std::string changeOf(const Fact& before, const Fact& after) + { + const std::string place = + before.key.site.expansion.file + ":" + std::to_string(before.line); + if (before.kind == FactKind::Reference) + return place + " refers to '" + after.shown + "' instead of '" + before.shown + "'"; + if (before.kind == FactKind::Override) + { + return "'" + before.subject + "' overrides " + after.shown + " instead of " + + before.shown; + } + if (before.subject == "_Generic") + return "the _Generic selection at " + place + " chooses another association"; + return "the condition at " + place + " becomes " + after.value.front(); + } + + // The facts of one compilation by key; a key it shows twice is left out. + std::map byKey(const std::vector& facts) + { + std::map out; + std::set twice; + for (const Fact& fact : facts) + { + if (!out.emplace(fact.key, &fact).second) + twice.insert(fact.key); + } + for (const FactKey& key : twice) + out.erase(key); + return out; + } + + // Step 6 (§5.8): the first fact R or K, then O, that the rewrite changes. Facts are + // compared through the entity's own symbol pairs: a fact in an instantiation they do not + // map to the compilation after the rewrite is left out, and so is a reference whose + // targets are specializations or implicit functions on both sides; Task 1.5 pairs them. + std::optional changedFact(const Original& before, std::size_t index, + const UnitFacts& after, const SuggestionPlan& plan) + { + std::map renamed; // the entity's symbols, before to after + std::map back; + const UnitFacts::Entity* entity = after.entities.empty() ? nullptr : &after.entities[0]; + if (entity && entity->found && entity->symbols.size() == plan.entity.symbols.size()) + { + for (std::size_t k = 0; k < entity->symbols.size(); ++k) + { + renamed[plan.entity.symbols[k]] = entity->symbols[k]; + back[entity->symbols[k]] = plan.entity.symbols[k]; + } + } + const auto mapped = [&](FactKey key) -> std::optional + { + for (std::string& context : key.contexts) + { + const auto pair = renamed.find(context); + if (pair != renamed.end()) + context = pair->second; + else if (!after.instantiations.count(context)) + return std::nullopt; + } + return key; + }; + const auto mappedBack = [&](std::vector value) + { + for (std::string& item : value) + { + const auto pair = + item.starts_with("symbol:") ? back.find(item.substr(7)) : back.end(); + if (pair != back.end()) + item = "symbol:" + pair->second; + } + std::sort(value.begin(), value.end()); + return value; + }; + const auto sites = [](const std::vector& value) + { + return std::all_of(value.begin(), value.end(), [](const std::string& item) + { return item.starts_with("site:"); }); + }; + const auto first = [&](const std::vector& was, + const std::vector& now) -> std::optional + { + const std::map wasByKey = byKey(was); + const std::map nowByKey = byKey(now); + for (const Fact& fact : was) + { + const std::optional key = mapped(fact.key); + const auto found = key ? nowByKey.find(*key) : nowByKey.end(); + if (!wasByKey.count(fact.key) || found == nowByKey.end()) + continue; + const Fact& other = *found->second; + if (fact.kind == FactKind::Reference) + { + // Unchanged when its targets after the rewrite keep every declaration + // they had (ruling 4). + if (sites(fact.value) && sites(other.value) + ? std::includes(other.value.begin(), other.value.end(), + fact.value.begin(), fact.value.end()) + : !sites(fact.value) && !sites(other.value)) + { + continue; + } + } + else if (mappedBack(other.value) == fact.value) + { + continue; + } + return changeOf(fact, other); + } + return std::nullopt; + }; + if (std::optional changed = first(before.facts, after.facts)) + return changed; + return first(before.overrides[index], entity ? entity->overrides : std::vector()); + } + Judgement refuted(std::string why) { return {std::move(why), Verdict::Refuted}; @@ -223,8 +343,11 @@ namespace ctrace::stack::analysis return indeterminate("the rewritten declaration does not get the suggested type"); if (!facts.problem.empty()) return refuted(facts.problem); - if (before->overrides[index] != facts.overrides) - return refuted("the methods it overrides change"); + // Step 6 (§5.8): a reference, an override or a constant condition that changes. + if (const std::optional changed = changedFact(*before, index, after, plan)) + { + return refuted(*changed); + } if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; std::string renamedTo; @@ -366,8 +489,8 @@ namespace ctrace::stack::analysis definingCode = std::make_shared( const_suggestion::prepare(*before.module)); } - const std::optional definingBefore = - Original{before.diagnostics, overridesOf(before), definingCode}; + const std::optional definingBefore = Original{ + before.diagnostics, std::move(before.facts), overridesOf(before), definingCode}; OriginalCache originals; // The other units a rewrite may reach: the analyzed ones and, with a compilation diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 3fb2951..4b671d8 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -22,8 +22,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -37,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +55,8 @@ namespace ctrace::stack::analysis using const_suggestion::DeclarationSite; using const_suggestion::Edit; using const_suggestion::EntityIdentity; + using const_suggestion::Fact; + using const_suggestion::FactKind; using const_suggestion::linkageOf; using const_suggestion::realPath; using const_suggestion::SourceLinkage; @@ -158,8 +163,15 @@ namespace ctrace::stack::analysis return {symbol(clang::GlobalDecl(&function))}; } + // A site, as an R or O value holds it. + std::string textOf(const DeclarationSite& site) + { + return site.expansion.file + ":" + std::to_string(site.expansion.offset) + "|" + + site.spelling.file + ":" + std::to_string(site.spelling.offset); + } + // Each target's function, found by its declarations (§3, §5.5), the explicit casts of its - // address, and what each method of its name overrides. + // address, and the facts R, O and K (§5.4). class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor { public: @@ -174,10 +186,31 @@ namespace ctrace::stack::analysis return true; } + // The instantiations around a fact, by their symbols. + bool TraverseDecl(clang::Decl* declaration) + { + const auto* function = llvm::dyn_cast_or_null(declaration); + const bool instantiation = collectFacts_ && function && + function->isTemplateInstantiation() && + !symbolsOf(*function).empty(); + if (instantiation) + { + contexts_.push_back(symbolsOf(*function).front()); + out_.instantiations.insert(contexts_.back()); + } + const bool traversed = + clang::RecursiveASTVisitor::TraverseDecl(declaration); + if (instantiation) + contexts_.pop_back(); + return traversed; + } + void HandleTranslationUnit(clang::ASTContext& context) override { context_ = &context; generator_ = action_.getCodeGenerator(); + // After an error, the rewrite is refuted by it (§5.8, step 4): no fact is needed. + collectFacts_ = generator_ && !context.getDiagnostics().hasErrorOccurred(); sites_.emplace(context.getSourceManager(), rewrittenBy_); out_.entities.resize(targets_.size()); located_.assign(targets_.size(), nullptr); @@ -228,8 +261,8 @@ namespace ctrace::stack::analysis } } const auto* method = llvm::dyn_cast(function); - if (method && name == entity.name) - recordOverrides(out_.entities[i], *method); + if (collectFacts_ && method && method->isFirstDecl() && name == entity.name) + out_.entities[i].overrides.push_back(overridesOf(*method)); } return true; } @@ -253,15 +286,160 @@ namespace ctrace::stack::analysis return true; } + // R: what a call or a reference targets, unevaluated operands and instantiations + // included. + bool VisitDeclRefExpr(clang::DeclRefExpr* expression) + { + reference(expression->getDecl(), expression->getLocation()); + return true; + } + + bool VisitMemberExpr(clang::MemberExpr* expression) + { + reference(expression->getMemberDecl(), expression->getMemberLoc()); + return true; + } + + bool VisitCXXConstructExpr(clang::CXXConstructExpr* expression) + { + reference(expression->getConstructor(), expression->getLocation()); + return true; + } + + bool VisitCXXNewExpr(clang::CXXNewExpr* expression) + { + reference(expression->getOperatorNew(), expression->getBeginLoc()); + return true; + } + + bool VisitCXXDeleteExpr(clang::CXXDeleteExpr* expression) + { + reference(expression->getOperatorDelete(), expression->getBeginLoc()); + return true; + } + + // K: each if constexpr condition, and the association each _Generic selects. + bool VisitIfStmt(clang::IfStmt* statement) + { + const clang::Expr* condition = statement->getCond(); + bool value = false; + if (collectFacts_ && statement->isConstexpr() && condition && + !condition->isValueDependent() && + condition->EvaluateAsBooleanCondition(value, *context_)) + { + constant(statement->getIfLoc(), value ? "true" : "false", ""); + } + return true; + } + + bool VisitGenericSelectionExpr(clang::GenericSelectionExpr* selection) + { + if (collectFacts_ && !selection->isResultDependent()) + { + constant(selection->getGenericLoc(), + std::to_string(selection->getResultIndex()), "_Generic"); + } + return true; + } + private: - void recordOverrides(UnitFacts::Entity& facts, const clang::CXXMethodDecl& method) + // A symbol, cached by declaration. + const std::vector& symbolsOf(const clang::FunctionDecl& function) + { + const clang::FunctionDecl* canonical = function.getCanonicalDecl(); + auto [it, added] = symbols_.try_emplace(canonical); + if (added) + it->second = variantSymbols(*generator_, *canonical); + return it->second; + } + + // A function, as a reason names it. + const std::string& shownOf(const clang::FunctionDecl& function) { - std::vector overridden; + const clang::FunctionDecl* canonical = function.getCanonicalDecl(); + auto [it, added] = shown_.try_emplace(canonical); + if (added) + { + const std::vector& symbols = symbolsOf(*canonical); + it->second = symbols.empty() ? canonical->getQualifiedNameAsString() + : llvm::demangle(symbols.front()); + } + return it->second; + } + + // An R value (ruling 4): the target's declaration sites; a specialization or an + // implicit function, which has none of its own, by its symbol. + const std::vector& valueOf(const clang::FunctionDecl& target) + { + const clang::FunctionDecl* canonical = target.getCanonicalDecl(); + auto [it, added] = values_.try_emplace(canonical); + std::vector& value = it->second; + if (!added) + return value; + if (canonical->isImplicit() || canonical->isTemplateInstantiation()) + { + const std::vector& symbols = symbolsOf(*canonical); + if (!symbols.empty()) + value.push_back("symbol:" + symbols.front()); + return value; + } + for (const clang::FunctionDecl* declaration : canonical->redecls()) + value.push_back("site:" + textOf((*sites_)(declaration->getLocation()))); + std::sort(value.begin(), value.end()); + value.erase(std::unique(value.begin(), value.end()), value.end()); + return value; + } + + unsigned lineOf(clang::SourceLocation at) const + { + return context_->getSourceManager().getExpansionLineNumber(at); + } + + void reference(const clang::ValueDecl* declaration, clang::SourceLocation at) + { + const auto* target = llvm::dyn_cast_or_null(declaration); + if (!collectFacts_ || !target || at.isInvalid()) + return; + const std::vector& value = valueOf(*target); + if (value.empty()) + return; + out_.facts.push_back({{contexts_, (*sites_)(at)}, + value, + "", + shownOf(*target), + lineOf(at), + FactKind::Reference}); + } + + void constant(clang::SourceLocation at, std::string value, std::string subject) + { + out_.facts.push_back({{contexts_, (*sites_)(at)}, + {std::move(value)}, + std::move(subject), + "", + lineOf(at), + FactKind::Constant}); + } + + // O: the methods that a method of the entity's name overrides. + Fact overridesOf(const clang::CXXMethodDecl& method) + { + Fact out{{contexts_, (*sites_)(method.getLocation())}, + {}, + shownOf(method), + "", + lineOf(method.getLocation()), + FactKind::Override}; + std::vector shown; for (const clang::CXXMethodDecl* base : method.overridden_methods()) - overridden.push_back(base->getQualifiedNameAsString()); - std::sort(overridden.begin(), overridden.end()); - facts.overrides.insert(method.getQualifiedNameAsString() + " -> " + - llvm::join(overridden, ", ")); + { + out.value.push_back(llvm::join(valueOf(*base), ";")); + shown.push_back("'" + shownOf(*base) + "'"); + } + std::sort(out.value.begin(), out.value.end()); + std::sort(shown.begin(), shown.end()); + out.shown = shown.empty() ? "nothing" : llvm::join(shown, ", "); + return out; } const std::string& nameOf(clang::DeclarationName name) @@ -312,7 +490,7 @@ namespace ctrace::stack::analysis }; const auto named = [&](const clang::FunctionDecl* function) { - const std::vector symbols = variantSymbols(*generator_, *function); + const std::vector& symbols = symbolsOf(*function); return std::find_first_of(symbols.begin(), symbols.end(), entity.symbols.begin(), entity.symbols.end()) != symbols.end(); @@ -347,7 +525,7 @@ namespace ctrace::stack::analysis facts.found = !found.empty(); if (!facts.found) return; - facts.symbols = variantSymbols(*generator_, *found.front()); + facts.symbols = symbolsOf(*found.front()); facts.printedSignature = clang::PredefinedExpr::ComputeName( clang::PredefinedIdentKind::PrettyFunction, found.front()); if (!target.plan || target.plan != rewrittenBy_) @@ -386,7 +564,8 @@ namespace ctrace::stack::analysis } } - std::uint8_t reservedPadding_[7] = {}; + bool collectFacts_ = false; + std::uint8_t reservedPadding_[6] = {}; clang::CodeGenAction& action_; const std::vector& targets_; const SuggestionPlan* rewrittenBy_; @@ -400,6 +579,10 @@ namespace ctrace::stack::analysis std::vector> atSites_; // with one of its sites std::vector> linked_; // and the linker's std::vector casts_; // explicitly cast + std::vector contexts_; // the instantiations around, outermost first + std::map> symbols_; + std::map> values_; + std::map shown_; }; class InspectAction : public clang::EmitLLVMOnlyAction diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index a2386f2..67186bf 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -116,6 +116,42 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[7] = {}; }; + // R, O and K (§5.4). + enum class FactKind : std::uint8_t + { + Reference, + Override, + Constant + }; + + // Where a fact is: the instantiations around it, by their symbols, outermost first, and its + // site. + struct FactKey + { + std::vector contexts; + DeclarationSite site; + bool operator==(const FactKey&) const = default; + bool operator<(const FactKey& other) const + { + return std::tie(contexts, site) < std::tie(other.contexts, other.site); + } + }; + + // What one compilation shows at a key, and what a reason names when it changes (Texts). + struct Fact + { + FactKey key; + // Sorted. R: the target's declaration sites, or the symbol of a specialization or an + // implicit function (ruling 4); O: each overridden method, the same way; K: true, false + // or the selected association. + std::vector value; + std::string subject; // O: the method; K: _Generic for a selection + std::string shown; // the value, as a reason names it + unsigned line = 0; // the site's, where Clang expands it + FactKind kind = FactKind::Reference; + std::uint8_t reservedPadding[3] = {}; + }; + // The rewrite of one suggestion, planned on the defining unit's initial compilation (§5.5). struct SuggestionPlan { @@ -228,8 +264,8 @@ namespace ctrace::stack::analysis::const_suggestion std::vector symbols; // every variant's, in this compilation std::string printedSignature; // for __PRETTY_FUNCTION__ std::string problem; - std::multiset overrides; - bool found = false; // the compilation declares it + std::vector overrides; // O, for the methods named like it + bool found = false; // the compilation declares it std::uint8_t reservedPadding[7] = {}; }; @@ -241,6 +277,8 @@ namespace ctrace::stack::analysis::const_suggestion std::string firstDiagnostic; std::unique_ptr context; std::unique_ptr module; + std::vector facts; // R and K, in the order of the traversal + std::set instantiations; // the symbols of those around a fact std::vector entities; // indexed like the targets const_suggestion::MacroDefinitions macros; // read only when the compilation plans }; From ba93b73a019f9cbcd8c8a7f2573d1b799ebd3a57 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Oct 2026 23:16:27 +0200 Subject: [PATCH 070/152] feat(const-param): leave a rewrite that changes another declaration unchecked --- src/analysis/ConstSuggestionCheck.cpp | 47 +++++++++--- src/analysis/ConstSuggestionFacts.cpp | 91 ++++++++++++++++++++++-- src/analysis/ConstSuggestionInternal.hpp | 16 ++++- src/analysis/ConstSuggestionPlan.cpp | 3 + 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 4be9a52..4750ceb 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -33,6 +33,7 @@ namespace ctrace::stack::analysis { namespace { + using const_suggestion::DeclarationSite; using const_suggestion::Diagnostic; using const_suggestion::Edit; using const_suggestion::Fact; @@ -64,12 +65,14 @@ namespace ctrace::stack::analysis // A unit before the rewrite, prepared once for every suggestion of the module: its // diagnostics, its facts R and K, the facts O of each target (indexed like the - // targets), and its generated code ready to compare. + // targets), the declarations of the rewritten files, and its generated code ready to + // compare. struct Original { std::multiset diagnostics; std::vector facts; std::vector> overrides; + std::map writtenTypes; std::shared_ptr code; }; @@ -173,6 +176,7 @@ namespace ctrace::stack::analysis return out; const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); out = Original{before.diagnostics, std::move(before.facts), overridesOf(before), + std::move(before.writtenTypes), std::make_shared( const_suggestion::prepare(*before.module))}; return out; @@ -305,20 +309,48 @@ namespace ctrace::stack::analysis } // Checks one suggestion, the target at `index`, in one unit, against the unit before the - // rewrite. + // rewrite: the judgement of §5.8, steps 1 to 6, then T v1's comparison of the code. Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, const SuggestionPlan& plan, PrintedSignature& signature, std::vector& accepted, std::string& renamed, bool timing) { + // Step 1: a unit that cannot be checked. if (!before) return indeterminate(kDoesNotCompileAsItIs); UnitFacts after = inspect(command, &plan, rewritten, {planned}, "const_suggestion.compile.after", timing); if (!after.failure.empty()) return indeterminate(after.failure); - // The first diagnostic the rewrite adds, not one the unit had before it. + // Step 2: after a fatal error, the rest of the unit is not inspected. + if (!after.fatal.empty()) + { + return indeterminate("the rewritten code cannot be inspected entirely, a fatal " + "error stops its compilation: " + + after.fatal); + } + const UnitFacts::Entity& facts = + after.entities.empty() ? UnitFacts::Entity() : after.entities[0]; + // Step 3, the plan's scope (§5.6): its parameters have the candidate, and every other + // declaration that a rewritten file writes keeps its type. + if (facts.withoutCandidate) + return indeterminate("the rewritten declaration does not get the suggested type"); + for (const auto& [site, written] : after.writtenTypes) + { + const auto was = before->writtenTypes.find(site); + if (was == before->writtenTypes.end() || was->second.type == written.type || + std::binary_search(plan.entity.redeclarations.begin(), + plan.entity.redeclarations.end(), site) || + std::binary_search(plan.parameters.begin(), plan.parameters.end(), site)) + { + continue; + } + return indeterminate("the rewrite also changes the type of " + written.name + " (" + + site.expansion.file + ":" + std::to_string(written.line) + + ")"); + } + // Steps 4 and 5: the first error or checked warning that the rewrite adds. for (const Diagnostic& diagnostic : after.reported) { if (after.diagnostics.count(diagnostic.key) > @@ -331,16 +363,12 @@ namespace ctrace::stack::analysis } if (!after.module) return refuted("does not compile: " + after.firstDiagnostic); - const UnitFacts::Entity& facts = - after.entities.empty() ? UnitFacts::Entity() : after.entities[0]; // The suggestion's variant after the rewrite. const auto variant = std::find(plan.entity.symbols.begin(), plan.entity.symbols.end(), planned.symbol); const auto k = static_cast(variant - plan.entity.symbols.begin()); if (renamed.empty() && k < facts.symbols.size()) renamed = facts.symbols[k]; - if (facts.problem.starts_with("rewritten:")) - return indeterminate("the rewritten declaration does not get the suggested type"); if (!facts.problem.empty()) return refuted(facts.problem); // Step 6 (§5.8): a reference, an override or a constant condition that changes. @@ -489,8 +517,9 @@ namespace ctrace::stack::analysis definingCode = std::make_shared( const_suggestion::prepare(*before.module)); } - const std::optional definingBefore = Original{ - before.diagnostics, std::move(before.facts), overridesOf(before), definingCode}; + const std::optional definingBefore = + Original{before.diagnostics, std::move(before.facts), overridesOf(before), + std::move(before.writtenTypes), definingCode}; OriginalCache originals; // The other units a rewrite may reach: the analyzed ones and, with a compilation diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 4b671d8..d369a52 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -113,6 +113,8 @@ namespace ctrace::stack::analysis level >= clang::DiagnosticsEngine::Error}); if (out_.firstDiagnostic.empty()) out_.firstDiagnostic = out_.reported.back().text; + if (level == clang::DiagnosticsEngine::Fatal && out_.fatal.empty()) + out_.fatal = out_.reported.back().text; } private: @@ -163,6 +165,29 @@ namespace ctrace::stack::analysis return {symbol(clang::GlobalDecl(&function))}; } + // Whether a declaration comes from a template instantiation: a specialization the + // compilation instantiates, or a declaration inside one. + bool instantiated(const clang::Decl* declaration) + { + while (declaration) + { + const auto* function = llvm::dyn_cast(declaration); + const auto* variable = llvm::dyn_cast(declaration); + const auto* record = llvm::dyn_cast(declaration); + if ((function && function->isTemplateInstantiation()) || + (variable && + clang::isTemplateInstantiation(variable->getTemplateSpecializationKind())) || + (record && + clang::isTemplateInstantiation(record->getTemplateSpecializationKind()))) + { + return true; + } + const clang::DeclContext* context = declaration->getDeclContext(); + declaration = context ? clang::Decl::castFromDeclContext(context) : nullptr; + } + return false; + } + // A site, as an R or O value holds it. std::string textOf(const DeclarationSite& site) { @@ -227,6 +252,16 @@ namespace ctrace::stack::analysis for (const DeclarationSite& site : identity(i).redeclarations) declarationFiles_.insert(site.expansion.file); } + // The files that a plan rewrites: after the rewrite, those of its plan. + for (std::size_t i = 0; i < targets_.size(); ++i) + { + const SuggestionPlan& plan = + targets_[i].plan ? *targets_[i].plan : out_.entities[i].plan; + if (rewrittenBy_ && &plan != rewrittenBy_) + continue; + for (const auto& [path, edits] : plan.edits) + rewrittenFiles_.insert(path); + } const analyzer::ScopedHotspot hotspot(out_.timing, "const_suggestion.inspect"); TraverseDecl(context.getTranslationUnitDecl()); for (std::size_t i = 0; i < targets_.size(); ++i) @@ -286,6 +321,19 @@ namespace ctrace::stack::analysis return true; } + // §5.6: the type of each declaration that a rewritten file writes, by its site. + bool VisitDeclaratorDecl(clang::DeclaratorDecl* declaration) + { + written(*declaration, declaration->getType()); + return true; + } + + bool VisitTypedefNameDecl(clang::TypedefNameDecl* declaration) + { + written(*declaration, declaration->getUnderlyingType()); + return true; + } + // R: what a call or a reference targets, unevaluated operands and instantiations // included. bool VisitDeclRefExpr(clang::DeclRefExpr* expression) @@ -390,6 +438,27 @@ namespace ctrace::stack::analysis return value; } + // Neither implicit nor instantiated; an invalid declaration is left to the error + // that makes it so (§5.8, step 4). + void written(const clang::NamedDecl& declaration, clang::QualType type) + { + const clang::SourceLocation at = declaration.getLocation(); + if (rewrittenFiles_.empty() || at.isInvalid() || declaration.isImplicit() || + declaration.isInvalidDecl() || instantiated(&declaration)) + { + return; + } + const clang::SourceManager& sm = context_->getSourceManager(); + if (!rewrittenFiles_.count(sites_->fileOf(sm.getFileID(sm.getExpansionLoc(at))))) + return; + std::string name = declaration.getNameAsString(); + out_.writtenTypes.emplace( + (*sites_)(at), + UnitFacts::Written{writtenType(*context_, type), + name.empty() ? "an unnamed declaration" : std::move(name), + lineOf(at)}); + } + unsigned lineOf(clang::SourceLocation at) const { return context_->getSourceManager().getExpansionLineNumber(at); @@ -535,7 +604,7 @@ namespace ctrace::stack::analysis { for (const clang::FunctionDecl* declaration : function->redecls()) { - if (!facts.problem.empty() || declaration->isDependentContext() || + if (declaration->isDependentContext() || target.plan->sourceIndex >= declaration->getNumParams()) { continue; @@ -548,12 +617,16 @@ namespace ctrace::stack::analysis const clang::SourceLocation at = sm.getExpansionLoc(declaration->getLocation()); const std::string& file = sites_->fileOf(sm.getFileID(at)); - facts.problem = target.plan->edits.count(file) - ? "rewritten:" + type - : "declared in " + - llvm::sys::path::filename(file).str() + ":" + - std::to_string(sm.getExpansionLineNumber(at)) + - " with type " + type; + // A planned parameter must have the candidate (§5.6, step 3); one that + // another file declares stays with its type (T v1, Task 1.6). + if (target.plan->edits.count(file)) + facts.withoutCandidate = true; + else if (facts.problem.empty()) + { + facts.problem = "declared in " + llvm::sys::path::filename(file).str() + + ":" + std::to_string(sm.getExpansionLineNumber(at)) + + " with type " + type; + } } } if (facts.problem.empty() && @@ -574,6 +647,7 @@ namespace ctrace::stack::analysis clang::CodeGenerator* generator_ = nullptr; std::optional sites_; std::set declarationFiles_; + std::set rewrittenFiles_; std::map names_; std::vector located_; std::vector> atSites_; // with one of its sites @@ -704,6 +778,9 @@ namespace ctrace::stack::analysis // No "N warnings generated" on the analyzer's output. diagnostics.ShowCarets = false; diagnostics.IgnoreWarnings = false; + // No error limit: "too many errors" is fatal, and would leave unchecked a rewrite + // that its errors refute (ruling 12). + diagnostics.ErrorLimit = 0; for (const char* warning : kCheckedWarnings) diagnostics.Warnings.push_back(warning); out.diagnostics.clear(); diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 67186bf..64f2d22 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -156,6 +156,7 @@ namespace ctrace::stack::analysis::const_suggestion struct SuggestionPlan { EntityIdentity entity; + std::vector parameters; // the parameters it rewrites, sorted std::map> edits; // by real path std::string expectedType; // the parameter's type once rewritten, written canonically std::optional indeterminate; // why the plan is not executed @@ -266,7 +267,18 @@ namespace ctrace::stack::analysis::const_suggestion std::string problem; std::vector overrides; // O, for the methods named like it bool found = false; // the compilation declares it - std::uint8_t reservedPadding[7] = {}; + // After the rewrite, a declaration in a rewritten file without the candidate. + bool withoutCandidate = false; + std::uint8_t reservedPadding[6] = {}; + }; + + // A declaration that a rewritten file writes (§5.6). + struct Written + { + std::string type; // written canonically + std::string name; + unsigned line = 0; // where Clang expands it + std::uint8_t reservedPadding[4] = {}; }; bool timing = false; @@ -275,6 +287,8 @@ namespace ctrace::stack::analysis::const_suggestion std::multiset diagnostics; std::vector reported; std::string firstDiagnostic; + std::string fatal; // the first fatal error: the AST lacks the rest of the unit + std::map writtenTypes; // in the rewritten files, by site std::unique_ptr context; std::unique_ptr module; std::vector facts; // R and K, in the order of the traversal diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 41d79f7..1dcd104 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -384,7 +384,10 @@ namespace ctrace::stack::analysis } if (!planner.plan(*declaration, *declaration->getParamDecl(out.sourceIndex))) return out; + out.parameters.push_back( + sites(declaration->getParamDecl(out.sourceIndex)->getLocation())); } + std::sort(out.parameters.begin(), out.parameters.end()); // The plan's scope (§5.5): an edit written in a macro argument serves only the parameter // planned. if (!planner.inArguments().empty()) From 2861bf8fd02cbeead14d366ad3f2d25abf888151 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 00:46:15 +0200 Subject: [PATCH 071/152] test(const-param): compare each reference that a macro expands twice at one site --- .../const-suggestion-repeated-reference.cpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-repeated-reference.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-repeated-reference.cpp b/test/pointer_reference-const_correctness/const-suggestion-repeated-reference.cpp new file mode 100644 index 0000000..327a049 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-repeated-reference.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a macro that expands its argument twice writes two references at one site, both where +// Clang expands them and where they are written. Each one is compared with its counterpart: +// sizeof names counted(bool) before the rewrite and counted(const int*) after it, twice, and the +// size, so the generated code, stays the same. The suggestion is refuted. + +// not contains: parameter 'p' in function 'counted(int*)' + +int counted(int* p) +{ + return *p; +} + +int counted(bool flag); + +#define BOTH(e) (sizeof(e) + sizeof(e)) + +int size_of_both(const int* q) +{ + return static_cast(BOTH(counted(q))); +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From cbee390dc34bc0e4cc887da337085f74f544dd1a Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 00:46:15 +0200 Subject: [PATCH 072/152] test(const-param): keep a rewrite that makes another declaration invalid, unchecked --- .../const-suggestion-invalid-declaration.c | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-invalid-declaration.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-invalid-declaration.c b/test/pointer_reference-const_correctness/const-suggestion-invalid-declaration.c new file mode 100644 index 0000000..e48c93e --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-invalid-declaration.c @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: one macro invocation declares the function and a variable whose type its argument also +// writes. The rewrite changes the variable's type, which then contradicts the variable's other +// declaration: the variable becomes invalid, and the file no longer compiles. That error comes +// from a declaration outside the plan: it does not refute the suggestion, which is kept, not +// checked. The expectation stops before the file and the line. + +// strict-expectation-details: true + +extern int* read_value_slot; + +#define PAIR(name, type) \ + int name(type p); \ + type name##_slot; + +PAIR(read_value, int*) + +// at line 23, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ not checked with Clang: the rewrite also changes the type of read_value_slot ( +int read_value(int* p) +{ + return *p; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 9fadb09d1e00f43049e3a3c883d7272b88c1303c Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 00:48:29 +0200 Subject: [PATCH 073/152] fix(const-param): compare one to one the facts that one site repeats --- src/analysis/ConstSuggestionCheck.cpp | 145 +++++++++++++++++--------- 1 file changed, 93 insertions(+), 52 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 4750ceb..ae43d25 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -71,6 +71,7 @@ namespace ctrace::stack::analysis { std::multiset diagnostics; std::vector facts; + std::set instantiations; // the symbols of those around a fact std::vector> overrides; std::map writtenTypes; std::shared_ptr code; @@ -175,7 +176,10 @@ namespace ctrace::stack::analysis if (!before.failure.empty() || !before.module) return out; const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - out = Original{before.diagnostics, std::move(before.facts), overridesOf(before), + out = Original{before.diagnostics, + std::move(before.facts), + std::move(before.instantiations), + overridesOf(before), std::move(before.writtenTypes), std::make_shared( const_suggestion::prepare(*before.module))}; @@ -199,27 +203,23 @@ namespace ctrace::stack::analysis return "the condition at " + place + " becomes " + after.value.front(); } - // The facts of one compilation by key; a key it shows twice is left out. - std::map byKey(const std::vector& facts) + // What step 6 finds: the first fact that the rewrite changes, and else, when a site's + // facts cannot be compared one to one, why the comparison is incomplete. + struct FactComparison { - std::map out; - std::set twice; - for (const Fact& fact : facts) - { - if (!out.emplace(fact.key, &fact).second) - twice.insert(fact.key); - } - for (const FactKey& key : twice) - out.erase(key); - return out; - } + std::optional changed; + std::optional incomplete; + }; - // Step 6 (§5.8): the first fact R or K, then O, that the rewrite changes. Facts are - // compared through the entity's own symbol pairs: a fact in an instantiation they do not - // map to the compilation after the rewrite is left out, and so is a reference whose - // targets are specializations or implicit functions on both sides; Task 1.5 pairs them. - std::optional changedFact(const Original& before, std::size_t index, - const UnitFacts& after, const SuggestionPlan& plan) + // Step 6 (§5.8): the facts R and K, then O, compared key by key, one to one in the order + // of the traversal: one key can hold several facts, such as a macro argument expanded + // twice. A key with as many facts on both sides is compared completely; one with a + // different number, a fact on one side only included, leaves the comparison incomplete. + // Keys go through the entity's own symbol pairs: a fact in an instantiation they do not + // map is left out, and so is a reference whose targets are specializations or implicit + // functions on both sides; Task 1.5 pairs them. + FactComparison compareFacts(const Original& before, std::size_t index, + const UnitFacts& after, const SuggestionPlan& plan) { std::map renamed; // the entity's symbols, before to after std::map back; @@ -232,7 +232,8 @@ namespace ctrace::stack::analysis back[entity->symbols[k]] = plan.entity.symbols[k]; } } - const auto mapped = [&](FactKey key) -> std::optional + // A key before the rewrite, as the compilation after it writes it. + const auto forward = [&](FactKey key) -> std::optional { for (std::string& context : key.contexts) { @@ -244,6 +245,13 @@ namespace ctrace::stack::analysis } return key; }; + // Whether a key after the rewrite can have a counterpart before it. + const auto known = [&](const FactKey& key) + { + return std::all_of( + key.contexts.begin(), key.contexts.end(), [&](const std::string& context) + { return back.count(context) || before.instantiations.count(context); }); + }; const auto mappedBack = [&](std::vector value) { for (std::string& item : value) @@ -261,41 +269,72 @@ namespace ctrace::stack::analysis return std::all_of(value.begin(), value.end(), [](const std::string& item) { return item.starts_with("site:"); }); }; - const auto first = [&](const std::vector& was, - const std::vector& now) -> std::optional + const auto changed = [&](const Fact& was, const Fact& now) + { + if (was.kind != FactKind::Reference) + return mappedBack(now.value) != was.value; + // Unchanged when its targets after the rewrite keep every declaration they had + // (ruling 4). + if (sites(was.value) && sites(now.value)) + { + return !std::includes(now.value.begin(), now.value.end(), was.value.begin(), + was.value.end()); + } + return sites(was.value) != sites(now.value); + }; + FactComparison out; + const auto compare = [&](const std::vector& was, const std::vector& now) { - const std::map wasByKey = byKey(was); - const std::map nowByKey = byKey(now); + std::vector order; // as the traversal first meets each key + std::map, std::vector>> at; + const auto add = [&](const FactKey& key, const Fact& fact, bool rewritten) + { + auto [it, added] = at.try_emplace(key); + if (added) + order.push_back(key); + (rewritten ? it->second.second : it->second.first).push_back(&fact); + }; for (const Fact& fact : was) { - const std::optional key = mapped(fact.key); - const auto found = key ? nowByKey.find(*key) : nowByKey.end(); - if (!wasByKey.count(fact.key) || found == nowByKey.end()) - continue; - const Fact& other = *found->second; - if (fact.kind == FactKind::Reference) + if (const std::optional key = forward(fact.key)) + add(*key, fact, false); + } + for (const Fact& fact : now) + { + if (known(fact.key)) + add(fact.key, fact, true); + } + for (const FactKey& key : order) + { + const auto& [wasAt, nowAt] = at.at(key); + if (wasAt.size() != nowAt.size()) { - // Unchanged when its targets after the rewrite keep every declaration - // they had (ruling 4). - if (sites(fact.value) && sites(other.value) - ? std::includes(other.value.begin(), other.value.end(), - fact.value.begin(), fact.value.end()) - : !sites(fact.value) && !sites(other.value)) + const Fact& one = wasAt.empty() ? *nowAt.front() : *wasAt.front(); + if (!out.incomplete) { - continue; + out.incomplete = + "the facts at " + one.key.site.expansion.file + ":" + + std::to_string(one.line) + + " cannot be compared one to one: " + std::to_string(wasAt.size()) + + " before the rewrite, " + std::to_string(nowAt.size()) + + " after it"; } + continue; } - else if (mappedBack(other.value) == fact.value) + for (std::size_t k = 0; k < wasAt.size(); ++k) { - continue; + if (changed(*wasAt[k], *nowAt[k])) + { + out.changed = changeOf(*wasAt[k], *nowAt[k]); + return; + } } - return changeOf(fact, other); } - return std::nullopt; }; - if (std::optional changed = first(before.facts, after.facts)) - return changed; - return first(before.overrides[index], entity ? entity->overrides : std::vector()); + compare(before.facts, after.facts); + if (!out.changed) + compare(before.overrides[index], entity ? entity->overrides : std::vector()); + return out; } Judgement refuted(std::string why) @@ -372,10 +411,9 @@ namespace ctrace::stack::analysis if (!facts.problem.empty()) return refuted(facts.problem); // Step 6 (§5.8): a reference, an override or a constant condition that changes. - if (const std::optional changed = changedFact(*before, index, after, plan)) - { - return refuted(*changed); - } + const FactComparison compared = compareFacts(*before, index, after, plan); + if (compared.changed) + return refuted(*compared.changed); if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; std::string renamedTo; @@ -388,6 +426,9 @@ namespace ctrace::stack::analysis } if (difference) return refuted("the generated code changes: " + *difference); + // Facts that could not be compared one to one leave the unit unverified. + if (compared.incomplete) + return indeterminate(*compared.incomplete); if (renamed.empty() || renamed == planned.symbol) renamed = renamedTo; return {}; @@ -517,9 +558,9 @@ namespace ctrace::stack::analysis definingCode = std::make_shared( const_suggestion::prepare(*before.module)); } - const std::optional definingBefore = - Original{before.diagnostics, std::move(before.facts), overridesOf(before), - std::move(before.writtenTypes), definingCode}; + const std::optional definingBefore = Original{ + before.diagnostics, std::move(before.facts), std::move(before.instantiations), + overridesOf(before), std::move(before.writtenTypes), definingCode}; OriginalCache originals; // The other units a rewrite may reach: the analyzed ones and, with a compilation From 79ac3ef9c802cfffdc6465e7eb0bae2cd8bcae07 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 00:49:57 +0200 Subject: [PATCH 074/152] fix(const-param): compare the type of a declaration the rewrite makes invalid --- src/analysis/ConstSuggestionCheck.cpp | 80 +++++++++++++++++++----- src/analysis/ConstSuggestionFacts.cpp | 14 ++--- src/analysis/ConstSuggestionInternal.hpp | 4 +- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index ae43d25..7d487c7 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -73,7 +73,7 @@ namespace ctrace::stack::analysis std::vector facts; std::set instantiations; // the symbols of those around a fact std::vector> overrides; - std::map writtenTypes; + std::map> writtenTypes; std::shared_ptr code; }; @@ -337,6 +337,68 @@ namespace ctrace::stack::analysis return out; } + // Step 3 (§5.6): a declaration that a rewritten file writes outside the plan and whose + // type the rewrite changes, valid or not after it; and else, when a site's declarations + // cannot be compared one to one, why the comparison is incomplete. + std::optional outsideThePlan(const Original& before, const UnitFacts& after, + const SuggestionPlan& plan) + { + using Declarations = std::vector; + const auto planned = [&](const DeclarationSite& site) + { + return !plan.edits.count(site.expansion.file) || + std::binary_search(plan.entity.redeclarations.begin(), + plan.entity.redeclarations.end(), site) || + std::binary_search(plan.parameters.begin(), plan.parameters.end(), site); + }; + std::optional incomplete; + const Declarations none; + const auto compare = [&](const DeclarationSite& site, const Declarations& was, + const Declarations& now) -> std::optional + { + if (was.size() != now.size()) + { + const UnitFacts::Written& one = was.empty() ? now.front() : was.front(); + if (!incomplete) + { + incomplete = "the declarations at " + site.expansion.file + ":" + + std::to_string(one.line) + " cannot be compared one to one: " + + std::to_string(was.size()) + " before the rewrite, " + + std::to_string(now.size()) + " after it"; + } + return std::nullopt; + } + for (std::size_t k = 0; k < was.size(); ++k) + { + if (was[k].type != now[k].type) + { + return "the rewrite also changes the type of " + now[k].name + " (" + + site.expansion.file + ":" + std::to_string(now[k].line) + ")"; + } + } + return std::nullopt; + }; + for (const auto& [site, now] : after.writtenTypes) + { + if (planned(site)) + continue; + const auto was = before.writtenTypes.find(site); + if (std::optional changed = + compare(site, was == before.writtenTypes.end() ? none : was->second, now)) + { + return changed; + } + } + for (const auto& [site, was] : before.writtenTypes) + { + if (planned(site) || after.writtenTypes.count(site)) + continue; + if (std::optional changed = compare(site, was, none)) + return changed; + } + return incomplete; + } + Judgement refuted(std::string why) { return {std::move(why), Verdict::Refuted}; @@ -375,20 +437,8 @@ namespace ctrace::stack::analysis // declaration that a rewritten file writes keeps its type. if (facts.withoutCandidate) return indeterminate("the rewritten declaration does not get the suggested type"); - for (const auto& [site, written] : after.writtenTypes) - { - const auto was = before->writtenTypes.find(site); - if (was == before->writtenTypes.end() || was->second.type == written.type || - std::binary_search(plan.entity.redeclarations.begin(), - plan.entity.redeclarations.end(), site) || - std::binary_search(plan.parameters.begin(), plan.parameters.end(), site)) - { - continue; - } - return indeterminate("the rewrite also changes the type of " + written.name + " (" + - site.expansion.file + ":" + std::to_string(written.line) + - ")"); - } + if (const std::optional outside = outsideThePlan(*before, after, plan)) + return indeterminate(*outside); // Steps 4 and 5: the first error or checked warning that the rewrite adds. for (const Diagnostic& diagnostic : after.reported) { diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index d369a52..3cefd0c 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -438,13 +438,13 @@ namespace ctrace::stack::analysis return value; } - // Neither implicit nor instantiated; an invalid declaration is left to the error - // that makes it so (§5.8, step 4). + // Neither implicit nor instantiated, valid or not: a declaration that the rewrite + // makes invalid outside the plan is still one that it changes. void written(const clang::NamedDecl& declaration, clang::QualType type) { const clang::SourceLocation at = declaration.getLocation(); if (rewrittenFiles_.empty() || at.isInvalid() || declaration.isImplicit() || - declaration.isInvalidDecl() || instantiated(&declaration)) + instantiated(&declaration)) { return; } @@ -452,11 +452,9 @@ namespace ctrace::stack::analysis if (!rewrittenFiles_.count(sites_->fileOf(sm.getFileID(sm.getExpansionLoc(at))))) return; std::string name = declaration.getNameAsString(); - out_.writtenTypes.emplace( - (*sites_)(at), - UnitFacts::Written{writtenType(*context_, type), - name.empty() ? "an unnamed declaration" : std::move(name), - lineOf(at)}); + out_.writtenTypes[(*sites_)(at)].push_back(UnitFacts::Written{ + writtenType(*context_, type), + name.empty() ? "an unnamed declaration" : std::move(name), lineOf(at)}); } unsigned lineOf(clang::SourceLocation at) const diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 64f2d22..58474df 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -288,7 +288,9 @@ namespace ctrace::stack::analysis::const_suggestion std::vector reported; std::string firstDiagnostic; std::string fatal; // the first fatal error: the AST lacks the rest of the unit - std::map writtenTypes; // in the rewritten files, by site + // In the rewritten files, by site; one site can hold several, in the order of the + // traversal, such as a macro argument expanded twice. + std::map> writtenTypes; std::unique_ptr context; std::unique_ptr module; std::vector facts; // R and K, in the order of the traversal From a5f28cb0eeb4de0bffce0cd84b40b4bd8b5c450f Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 04:18:32 +0200 Subject: [PATCH 075/152] test(const-param): keep unchecked a fact that the rewrite leaves without counterpart --- .../const-suggestion-unpaired-fact.cpp | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-unpaired-fact.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-unpaired-fact.cpp b/test/pointer_reference-const_correctness/const-suggestion-unpaired-fact.cpp new file mode 100644 index 0000000..40ec5b1 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-unpaired-fact.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a fact that the rewrite leaves without a counterpart cannot be compared. Here p + Offset{} +// calls operator+(int*, Offset) before the rewrite; once p points to const, that operator no +// longer applies, and the built-in addition takes Offset through its conversion to int. The +// reference to operator+ has no counterpart after the rewrite, while the size, so the generated +// code, stays the same: the comparison is incomplete, and the suggestion is kept, not checked. +// The expectation stops before the file and the line. + +// strict-expectation-details: true + +struct Offset +{ + operator int() const + { + return 0; + } +}; + +int* operator+(int*, Offset); + +// at line 26, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ not checked with Clang: the facts at +int read(int* p) +{ + return *p ? static_cast(sizeof(p + Offset{})) : 0; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 1cabf7e0816e41abd53e53c9b94ad3306bc77ed3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 04:18:50 +0200 Subject: [PATCH 076/152] docs(plan): add the two reasons of an incomplete comparison to the texts --- docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 149558c..97f935b 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -890,6 +890,8 @@ Reasons of an indeterminate verdict, after `not checked with Clang: ` (shown in | `the parameter's type is written in a macro argument that writes other parameters too` | 1.3 | D5: the text an edit rewrites writes another parameter | | `the rewritten code cannot be inspected entirely, a fatal error stops its compilation: :: ` | 1.4 | step 2 | | `the rewrite also changes the type of (:)` | 1.4 | step 3 | +| `the declarations at : cannot be compared one to one: before the rewrite, after it` | 1.4 | step 3: a site of a rewritten file, outside the plan, whose declarations differ in number before and after the rewrite, a site on one side only included | +| `the facts at : cannot be compared one to one: before the rewrite, after it` | 1.4 | step 6: a key whose facts differ in number before and after the rewrite, a fact on one side only included; in Task 1.4, once T v1's comparison of the code, which refutes first, finds no difference | | `the generated code changes, and no reference, override or constant condition explains it: ` | 1.5 | step 7 (D16); `` is the comparator's text | | `'' and '' both become '': their correspondence is not one to one` | 1.5 | §5.7, D14 | | `'' becomes both '' and '': their correspondence is not one to one` | 1.5 | §5.7 | From 335ff47542cb9e94c61d47f5c715bca7f8b2cab6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:35:05 +0200 Subject: [PATCH 077/152] test(const-param): check a suggestion through the constructors that inherit it --- ...onst-suggestion-inheriting-constructor.cpp | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp b/test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp new file mode 100644 index 0000000..561b06e --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-inheriting-constructor.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217: a class that inherits a constructor (`using Base::Base`) gets constructors of its own, +// whose symbols name the inherited constructor's parameter types: the rewrite renames them too. +// The check pairs them before and after the rewrite through the constructor they inherit, so +// each suggestion is checked: a plain class, a class template as the base, a class template as +// the derived class, and a constructor template, inherited twice, for int* and for double*: the +// derived class inherits two constructors, one from each specialization. + +// strict-expectation-details: true + +// at line 19, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'Base::Base(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct Base +{ + int v; + Base(int* p) : v(*p) {} +}; + +struct Derived : Base +{ + using Base::Base; + int w = 0; +}; + +int make_derived(int x) +{ + Derived d(&x); + return d.v; +} + +// at line 42, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'TemplateBase::TemplateBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +template struct TemplateBase +{ + T v; + TemplateBase(T* p) : v(*p) {} +}; + +struct FromTemplate : TemplateBase +{ + using TemplateBase::TemplateBase; + int w = 0; +}; + +int make_from_template(int x) +{ + FromTemplate d(&x); + return d.v; +} + +// at line 65, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'PlainBase::PlainBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct PlainBase +{ + int v; + PlainBase(int* p) : v(*p) {} +}; + +template struct TemplateDerived : PlainBase +{ + using PlainBase::PlainBase; + T w = 0; +}; + +int make_template_derived(int x) +{ + TemplateDerived d(&x); + return d.v; +} + +// at line 94, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'GenericBase::GenericBase(int*)' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 94, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'GenericBase::GenericBase(double*)' is never used to modify the pointed object +// ↳ current type: double *p +// ↳ suggested type: const double *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +struct GenericBase +{ + int v; + template GenericBase(T* p) : v(*p) {} +}; + +struct FromGeneric : GenericBase +{ + using GenericBase::GenericBase; + int w = 0; +}; + +int make_from_generic(int x) +{ + FromGeneric d(&x); + return d.v; +} + +int make_from_generic_double(double x) +{ + FromGeneric d(&x); + return d.v; +} + +// The Clang check of const suggestions is experimental: it runs only with this option. +// analyzer-arg: --experimental-const-suggestion-check From 6863d6091af44c1b898c8e4519ef52a843b34872 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:35:06 +0200 Subject: [PATCH 078/152] test(const-param): keep the suggestions whose specializations merge, unchecked --- .../const-suggestion-deduction-refused.cpp | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp index 6c273ab..af78a74 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-refused.cpp @@ -1,15 +1,30 @@ // SPDX-License-Identifier: Apache-2.0 // #217: specializations of the rewritten template whose deduced type changes are paired only when -// the correspondence is unique, and their code is still compared. +// the correspondence is one to one, and what they select is still compared. // - call: g passes G& and h passes const G&; after the rewrite both deduce G, so two -// specializations become one. No unique correspondence: both suggestions are removed. The -// lambdas are noexcept, so that all three specializations get the same attributes: only the -// correspondence tells them apart. -// - pick: the lvalue's specialization is paired, but its code changes with what F deduces. +// specializations become one. Without a one-to-one correspondence, nothing shows that the +// rewrite keeps or changes what they do: both suggestions are kept, not checked, and the reason +// names the specializations. The lambdas are noexcept, so that all three specializations get +// the same attributes: only the correspondence tells them apart. +// - pick: the lvalue's specialization is paired, and the condition of its `if constexpr` changes +// with what F deduces: the suggestion is removed. -// not contains: in function 'int call< +// strict-expectation-details: true // not contains: in function 'int pick< +// at line 28, column 0 +// [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int call(from_temporary()::$_0&&)' is an rvalue reference and is only read, or passed where a reference to const is expected +// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ current type: &&f +// ↳ the callee's body is not checked: it may still modify the referred object through a cast +// ↳ not checked with Clang: 'int call(from_lvalues()::$_0&)' and 'int call(from_lvalues()::$_0 const&)' both become 'int call(from_lvalues()::$_0 const&)': their correspondence is not one to one + +// at line 28, column 0 +// [ !Info! ] ConstParameterNotModified.Reference: parameter 'f' in function 'int call(from_lvalues()::$_0&)' is only read, or passed where a reference to const is expected +// ↳ current type: &f +// ↳ suggested type: const &f +// ↳ the callee's body is not checked: it may still modify the referred object through a cast +// ↳ not checked with Clang: 'int call(from_lvalues()::$_0&)' and 'int call(from_lvalues()::$_0 const&)' both become 'int call(from_lvalues()::$_0 const&)': their correspondence is not one to one template int call(F&& f) { return f(1); From 50a98f02cfb0ab10e62b71d23d3b41ca6e41d5f6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:35:07 +0200 Subject: [PATCH 079/152] test(const-param): keep a suggestion whose code change nothing explains, unchecked --- .../const-suggestion-printed-signature.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c index cd7b616..f037fc6 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c +++ b/test/pointer_reference-const_correctness/const-suggestion-printed-signature.c @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // #217: __PRETTY_FUNCTION__ prints the function's signature, which the suggested type changes. The // suggestion is kept, and the message says what the program prints differently. Any other use of -// that text, such as its size, is a change of the generated code. +// that text, such as its size, changes the generated code in a way that no reference, override or +// constant condition explains: that suggestion is kept, not checked. // strict-expectation-details: true -// not contains: parameter 'p' in function 'signature_size' // not contains: ↳ ABI: #include @@ -20,6 +20,11 @@ int print_and_read(int* p) return *p; } +// at line 28, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'signature_size' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ not checked with Clang: the generated code changes, and no reference, override or constant condition explains it: code changed in signature_size: int signature_size(int* p) { return *p ? (int)sizeof(__PRETTY_FUNCTION__) : 0; From 105487a1c76a81a5bf5e03026d45d6d20172b020 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:45:46 +0200 Subject: [PATCH 080/152] feat(const-param): record the symbols a rewrite may rename, with their declarations --- src/analysis/ConstSuggestionFacts.cpp | 267 ++++++++++++++++++++++- src/analysis/ConstSuggestionInternal.hpp | 18 +- 2 files changed, 279 insertions(+), 6 deletions(-) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 3cefd0c..e0e715b 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -21,11 +21,14 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -46,6 +49,7 @@ #include #include #include +#include #include namespace ctrace::stack::analysis @@ -165,6 +169,57 @@ namespace ctrace::stack::analysis return {symbol(clang::GlobalDecl(&function))}; } + // The variant each of variantSymbols names, in the same order. + std::vector variantKeys(const clang::FunctionDecl& function) + { + if (function.isDependentContext()) + return {}; + if (llvm::isa(function)) + return {"C1", "C2"}; + if (llvm::isa(function)) + return {"D0", "D1", "D2"}; + return {""}; + } + + // A specialization's template arguments, its class's then its own, each type written + // canonically (ruling 7); "" for a function that is no specialization. + std::string argumentsOf(const clang::ASTContext& context, + const clang::FunctionDecl& function) + { + clang::PrintingPolicy policy(context.getLangOpts()); + policy.AnonymousTagLocations = false; + std::string out; + const auto add = [&](llvm::ArrayRef arguments) + { + out += "<"; + for (const clang::TemplateArgument& argument : arguments) + { + if (argument.getKind() == clang::TemplateArgument::Type) + { + out += writtenType(context, argument.getAsType()); + } + else + { + llvm::raw_string_ostream text(out); + argument.print(policy, text, /*IncludeType=*/true); + } + out += ","; + } + out += ">"; + }; + if (const auto* record = llvm::dyn_cast( + function.getDeclContext())) + { + add(record->getTemplateArgs().asArray()); + } + if (const clang::TemplateArgumentList* arguments = + function.getTemplateSpecializationArgs()) + { + add(arguments->asArray()); + } + return out; + } + // Whether a declaration comes from a template instantiation: a specialization the // compilation instantiates, or a declaration inside one. bool instantiated(const clang::Decl* declaration) @@ -234,6 +289,7 @@ namespace ctrace::stack::analysis { context_ = &context; generator_ = action_.getCodeGenerator(); + mangle_.reset(context.createMangleContext()); // After an error, the rewrite is refuted by it (§5.8, step 4): no fact is needed. collectFacts_ = generator_ && !context.getDiagnostics().hasErrorOccurred(); sites_.emplace(context.getSourceManager(), rewrittenBy_); @@ -321,6 +377,85 @@ namespace ctrace::stack::analysis return true; } + // Nested symbols (§5.7), which the rewrite renames with the function around them: a + // lambda's call operator, a local class's methods and type information, a static + // local variable and its guard. + bool VisitLambdaExpr(clang::LambdaExpr* lambda) + { + const clang::CXXMethodDecl* call = lambda->getCallOperator(); + if (!collectFacts_ || !call || call->isDependentContext()) + return true; + const std::vector& symbols = symbolsOf(*call); + if (!symbols.empty()) + { + nested(lambda->getLambdaClass()->getDeclContext(), lambda->getBeginLoc(), + {{symbols.front(), "lambda"}}); + } + return true; + } + + bool VisitCXXRecordDecl(clang::CXXRecordDecl* record) + { + if (!collectFacts_ || record->isDependentType() || + !record->isThisDeclarationADefinition()) + { + return true; + } + inheritingIn(*record); + if (!record->isLocalClass() || record->isLambda()) + return true; + const clang::QualType type = context_->getRecordType(record); + std::string information; + std::string name; + { + llvm::raw_string_ostream out(information); + mangle_->mangleCXXRTTI(type, out); + } + { + llvm::raw_string_ostream out(name); + mangle_->mangleCXXRTTIName(type, out); + } + std::vector> symbols = { + {information, "type information"}, {name, "type name"}}; + auto* itanium = llvm::dyn_cast(mangle_.get()); + if (itanium && record->isDynamicClass()) + { + std::string table; + llvm::raw_string_ostream out(table); + itanium->mangleCXXVTable(record, out); + symbols.push_back({table, "virtual table"}); + } + nested(record->getDeclContext(), record->getLocation(), symbols); + for (const clang::CXXMethodDecl* method : record->methods()) + { + const std::vector& variants = symbolsOf(*method); + const std::vector keys = variantKeys(*method); + std::vector> methods; + for (std::size_t k = 0; k < variants.size() && k < keys.size(); ++k) + methods.push_back({variants[k], "method " + keys[k]}); + nested(record->getDeclContext(), method->getLocation(), methods); + } + return true; + } + + bool VisitVarDecl(clang::VarDecl* variable) + { + if (!collectFacts_ || !variable->isStaticLocal() || + variable->getDeclContext()->isDependentContext()) + { + return true; + } + std::string guard; + { + llvm::raw_string_ostream out(guard); + mangle_->mangleStaticGuardVariable(variable, out); + } + nested(variable->getDeclContext(), variable->getLocation(), + {{generator_->GetMangledName(clang::GlobalDecl(variable)).str(), "static"}, + {guard, "guard"}}); + return true; + } + // §5.6: the type of each declaration that a rewritten file writes, by its site. bool VisitDeclaratorDecl(clang::DeclaratorDecl* declaration) { @@ -427,8 +562,17 @@ namespace ctrace::stack::analysis if (canonical->isImplicit() || canonical->isTemplateInstantiation()) { const std::vector& symbols = symbolsOf(*canonical); - if (!symbols.empty()) - value.push_back("symbol:" + symbols.front()); + if (symbols.empty()) + return value; + value.push_back("symbol:" + symbols.front()); + // A specialization also names its template: two specializations of one + // template may correspond (§5.7). + if (const clang::FunctionDecl* pattern = + canonical->getTemplateInstantiationPattern(/*ForDefinition=*/false)) + { + value.push_back("template:" + + textOf((*sites_)(pattern->getFirstDecl()->getLocation()))); + } return value; } for (const clang::FunctionDecl* declaration : canonical->redecls()) @@ -517,6 +661,101 @@ namespace ctrace::stack::analysis return it->second; } + // Whether a function has one of the sites of the entity of target i: it is one of + // its declarations, or a specialization of its template. + bool atSites(std::size_t i, const clang::FunctionDecl& function) + { + const clang::SourceLocation location = function.getLocation(); + if (location.isInvalid()) + return false; + const clang::SourceManager& sm = context_->getSourceManager(); + if (!declarationFiles_.count( + sites_->fileOf(sm.getFileID(sm.getExpansionLoc(location))))) + { + return false; + } + const EntityIdentity& entity = identity(i); + return std::binary_search(entity.redeclarations.begin(), + entity.redeclarations.end(), (*sites_)(location)); + } + + // The symbol of the function around a nested declaration, when its outermost + // function, through lambdas and local classes, is the entity of target i or a + // specialization of its template; "" otherwise. + std::string enclosingSymbol(std::size_t i, const clang::DeclContext* context) + { + const clang::FunctionDecl* immediate = nullptr; + for (const clang::DeclContext* at = context; at; at = at->getParent()) + { + const auto* function = llvm::dyn_cast(at); + if (!function) + continue; + if (!immediate) + immediate = function; + const auto* method = llvm::dyn_cast(function); + if (method && + (method->getParent()->isLambda() || method->getParent()->isLocalClass())) + { + continue; + } + if (function->isDependentContext() || !atSites(i, *function)) + return ""; + const std::vector& symbols = symbolsOf(*immediate); + return symbols.empty() ? "" : symbols.front(); + } + return ""; + } + + // Records nested symbols, each with its kind, for the targets whose entity they lie in. + void nested(const clang::DeclContext* context, clang::SourceLocation at, + const std::vector>& symbols) + { + if (at.isInvalid()) + return; + for (std::size_t i = 0; i < targets_.size(); ++i) + { + const std::string enclosing = enclosingSymbol(i, context); + if (enclosing.empty()) + continue; + const std::string site = textOf((*sites_)(at)); + for (const auto& [symbol, kind] : symbols) + out_.entities[i].nested.push_back({symbol, kind + "|" + site, enclosing}); + } + } + + // The constructors that inherit the entity of a target, or a specialization of its + // template (ruling 7): by derived class, inherited specialization and variant. + void inheritingIn(const clang::CXXRecordDecl& record) + { + for (const clang::CXXConstructorDecl* constructor : record.ctors()) + { + if (!constructor->isInheritingConstructor()) + continue; + const clang::CXXConstructorDecl* inherited = + constructor->getInheritedConstructor().getConstructor(); + if (!inherited) + continue; + std::string derived; + { + llvm::raw_string_ostream out(derived); + mangle_->mangleCanonicalTypeName(context_->getRecordType(&record), out); + } + const std::string arguments = argumentsOf(*context_, *inherited); + const std::vector& symbols = symbolsOf(*constructor); + const std::vector keys = variantKeys(*constructor); + for (std::size_t i = 0; i < targets_.size(); ++i) + { + if (!atSites(i, *inherited)) + continue; + for (std::size_t k = 0; k < symbols.size() && k < keys.size(); ++k) + { + out_.entities[i].inheriting.push_back( + {symbols[k], derived + "|" + arguments + "|" + keys[k], ""}); + } + } + } + } + const EntityIdentity& identity(std::size_t i) const { return targets_[i].plan ? targets_[i].plan->entity : out_.entities[i].plan.entity; @@ -537,6 +776,7 @@ namespace ctrace::stack::analysis facts.plan = const_suggestion::plan(*context_, out_.macros, *function, targets_[i].sourceIndex); facts.plan.entity.symbols = variantSymbols(*generator_, *function); + facts.plan.entity.templateArguments = argumentsOf(*context_, *function); } // The target's function in this compilation, and after the rewrite T v1's checks of @@ -570,15 +810,28 @@ namespace ctrace::stack::analysis else if (!atSites_[i].empty()) { // Its declarations have its sites. A specialization has its pattern's, as the - // other specializations of its template: it is the one with its symbol. + // other specializations of its template: it is the one with its symbol, or + // once the rewrite renames it, the only one with its template arguments + // (ruling 7). + std::vector sameArguments; for (const clang::FunctionDecl* function : atSites_[i]) { - if (!function->isDependentContext() && - (!entity.templateOrigin || named(function))) + if (function->isDependentContext()) + continue; + if (!entity.templateOrigin || named(function)) { add(function); } + else if (!entity.templateArguments.empty() && + argumentsOf(*context_, *function) == entity.templateArguments && + std::find(sameArguments.begin(), sameArguments.end(), + function->getCanonicalDecl()) == sameArguments.end()) + { + sameArguments.push_back(function->getCanonicalDecl()); + } } + if (found.empty() && sameArguments.size() == 1) + add(sameArguments.front()); } else { @@ -593,6 +846,9 @@ namespace ctrace::stack::analysis if (!facts.found) return; facts.symbols = symbolsOf(*found.front()); + const std::vector keys = variantKeys(*found.front()); + for (std::size_t k = 0; k < facts.symbols.size() && k < keys.size(); ++k) + facts.variants.push_back({facts.symbols[k], keys[k], ""}); facts.printedSignature = clang::PredefinedExpr::ComputeName( clang::PredefinedIdentKind::PrettyFunction, found.front()); if (!target.plan || target.plan != rewrittenBy_) @@ -644,6 +900,7 @@ namespace ctrace::stack::analysis clang::ASTContext* context_ = nullptr; clang::CodeGenerator* generator_ = nullptr; std::optional sites_; + std::unique_ptr mangle_; // for what CodeGen does not name std::set declarationFiles_; std::set rewrittenFiles_; std::map names_; diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 58474df..d483f18 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -112,6 +112,7 @@ namespace ctrace::stack::analysis::const_suggestion std::string name; // as Clang prints it: dot, operator==, ~A std::optional templateOrigin; // a specialization's pattern, first declared std::vector symbols; // every variant's, before the rewrite (CodeGen) + std::string templateArguments; // a specialization's, written canonically (ruling 7) SourceLinkage linkage = SourceLinkage::None; std::uint8_t reservedPadding[7] = {}; }; @@ -255,6 +256,14 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[7] = {}; }; + // A symbol that the rewrite may rename with the entity, and what pairs it (§5.7). + struct Named + { + std::string symbol; + std::string key; // its kind or variant, and what it belongs to + std::string enclosing; // a nested symbol's: the symbol of the function around it + }; + // What one compilation shows (§5.3, §5.6). struct UnitFacts { @@ -266,7 +275,14 @@ namespace ctrace::stack::analysis::const_suggestion std::string printedSignature; // for __PRETTY_FUNCTION__ std::string problem; std::vector overrides; // O, for the methods named like it - bool found = false; // the compilation declares it + std::vector variants; // the entity's: C1, C2, D0... or "" for a function + // The constructors inheriting it, or a specialization of its template: by derived + // class, inherited specialization and variant (ruling 7). + std::vector inheriting; + // Lambdas, local classes (methods, _ZTI, _ZTS, _ZTV), static locals and their guards, + // in the entity or in a specialization of its template: by kind and site. + std::vector nested; + bool found = false; // the compilation declares it // After the rewrite, a declaration in a rewritten file without the candidate. bool withoutCandidate = false; std::uint8_t reservedPadding[6] = {}; From 990b6a90b225eb2d27deb93418348ab6674cbb0b Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:47:37 +0200 Subject: [PATCH 081/152] feat(const-param): establish the correspondences of a rewrite from its facts --- CMakeLists.txt | 1 + include/analysis/ConstSuggestionCheck.hpp | 8 + src/analysis/ConstSuggestionInternal.hpp | 6 + src/analysis/ConstSuggestionMatch.cpp | 216 ++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/analysis/ConstSuggestionMatch.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ef13dfa..db2aa56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,7 @@ set(STACK_ANALYZER_SOURCES src/analysis/ConstSuggestionCheck.cpp src/analysis/ConstSuggestionCode.cpp src/analysis/ConstSuggestionFacts.cpp + src/analysis/ConstSuggestionMatch.cpp src/analysis/ConstSuggestionPlan.cpp src/analysis/CommandInjectionAnalysis.cpp src/analysis/DuplicateIfCondition.cpp diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index e292ec5..be231da 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -61,6 +61,14 @@ namespace ctrace::stack::analysis std::shared_ptr analyzedUnitOf(const std::string& filename, const llvm::Module& module); + // A symbol of a unit's compilation before a rewrite, and its counterpart after it, as the + // compilation facts establish them (#217, §5.7). + struct SymbolPair + { + std::string before; + std::string after; + }; + // What __PRETTY_FUNCTION__ prints for the rewritten function, before and after the rewrite. struct PrintedSignature { diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index d483f18..bba7bb8 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -315,6 +315,12 @@ namespace ctrace::stack::analysis::const_suggestion const_suggestion::MacroDefinitions macros; // read only when the compilation plans }; + // The pairs established between a unit's initial and modified compilations for the entity + // at `entity` of `before` and the one of `after` (§5.7). `missing` receives the first reason + // a correspondence could not be established. + std::vector correspond(const UnitFacts& before, std::size_t entity, + const UnitFacts& after, std::string& missing); + // Compiles a unit and inspects it for `targets`. After the rewrite, `rewritten` remaps the // files that the plan `rewrittenBy` rewrites to their texts. UnitFacts inspect(const UnitCommand& command, const SuggestionPlan* rewrittenBy, diff --git a/src/analysis/ConstSuggestionMatch.cpp b/src/analysis/ConstSuggestionMatch.cpp new file mode 100644 index 0000000..c855d3b --- /dev/null +++ b/src/analysis/ConstSuggestionMatch.cpp @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +// The correspondences of a rewrite (#217, §5.7): which symbols of a unit's compilation after the +// rewrite are those of its compilation before it, established from compilation facts only. +#include "ConstSuggestionInternal.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ctrace::stack::analysis +{ + namespace + { + using const_suggestion::Fact; + using const_suggestion::FactKey; + using const_suggestion::FactKind; + using const_suggestion::Named; + using const_suggestion::UnitFacts; + + // The item of an R value that starts with `prefix`, without it; "" when it has none. + std::string itemOf(const std::vector& value, std::string_view prefix) + { + for (const std::string& item : value) + { + if (item.starts_with(prefix)) + return item.substr(prefix.size()); + } + return ""; + } + + // Appends `item` once, in the order items come. + void addOnce(std::vector& items, const std::string& item) + { + if (std::find(items.begin(), items.end(), item) == items.end()) + items.push_back(item); + } + } // namespace + + std::vector const_suggestion::correspond(const UnitFacts& before, + std::size_t entity, const UnitFacts& after, + std::string& missing) + { + std::map pairs; // before the rewrite, to after it + std::set taken; // the symbols after it, once paired + const auto pair = [&](const std::string& was, const std::string& now) + { + if (was.empty() || now.empty() || pairs.count(was) || taken.count(now)) + return false; + pairs[was] = now; + taken.insert(now); + return true; + }; + const UnitFacts::Entity* was = + entity < before.entities.size() ? &before.entities[entity] : nullptr; + const UnitFacts::Entity* now = after.entities.empty() ? nullptr : &after.entities[0]; + + // 1 and 2: the entity's variants, by variant; the constructors inheriting it, by derived + // class, inherited specialization and variant. + const auto byKey = + [&](const std::vector& wasNamed, const std::vector& nowNamed) + { + for (const Named& one : wasNamed) + { + const auto other = + std::find_if(nowNamed.begin(), nowNamed.end(), + [&](const Named& named) { return named.key == one.key; }); + if (other != nowNamed.end()) + pair(one.symbol, other->symbol); + } + }; + if (was && now) + { + byKey(was->variants, now->variants); + byKey(was->inheriting, now->inheriting); + } + + // 3: the instantiation contexts. A reference whose instantiations already map, and whose + // target becomes another specialization of the same template, proposes that pair; the + // proposals of every reference are checked together, and each new pair maps more + // instantiations, until none is added. + for (bool added = true; added;) + { + added = false; + const auto forward = [&](FactKey key) -> std::optional + { + for (std::string& context : key.contexts) + { + const auto found = pairs.find(context); + if (found != pairs.end()) + context = found->second; + else if (!after.instantiations.count(context)) + return std::nullopt; + } + return key; + }; + std::vector order; + std::map> wasAt; + std::map> nowAt; + for (const Fact& fact : before.facts) + { + if (fact.kind != FactKind::Reference) + continue; + if (const std::optional key = forward(fact.key)) + { + if (!wasAt.count(*key)) + order.push_back(*key); + wasAt[*key].push_back(&fact); + } + } + for (const Fact& fact : after.facts) + { + if (fact.kind == FactKind::Reference) + nowAt[fact.key].push_back(&fact); + } + std::vector proposing; // in the order of the facts + std::map> to; // before to after + std::map> from; // after to before + std::map shown; + for (const FactKey& key : order) + { + const std::vector& wasFacts = wasAt[key]; + const auto nowFacts = nowAt.find(key); + if (nowFacts == nowAt.end() || nowFacts->second.size() != wasFacts.size()) + continue; + for (std::size_t k = 0; k < wasFacts.size(); ++k) + { + const Fact& one = *wasFacts[k]; + const Fact& other = *nowFacts->second[k]; + const std::string wasTarget = itemOf(one.value, "symbol:"); + const std::string nowTarget = itemOf(other.value, "symbol:"); + const std::string pattern = itemOf(one.value, "template:"); + if (wasTarget.empty() || nowTarget.empty() || pattern.empty() || + wasTarget == nowTarget || pattern != itemOf(other.value, "template:") || + pairs.count(wasTarget)) + { + continue; + } + addOnce(proposing, wasTarget); + addOnce(to[wasTarget], nowTarget); + addOnce(from[nowTarget], wasTarget); + shown[wasTarget] = one.shown; + shown[nowTarget] = other.shown; + } + } + for (const std::string& one : proposing) + { + const std::vector& targets = to[one]; + if (targets.size() > 1) + { + if (missing.empty()) + { + missing = "'" + shown[one] + "' becomes both '" + shown[targets[0]] + + "' and '" + shown[targets[1]] + + "': their correspondence is not one to one"; + } + continue; + } + // Two before the rewrite that become one, or one that becomes a symbol already + // paired. + std::vector sources = from[targets[0]]; + for (const auto& [wasSymbol, nowSymbol] : pairs) + { + if (nowSymbol == targets[0]) + sources.insert(sources.begin(), wasSymbol); + } + if (sources.size() > 1) + { + if (missing.empty()) + { + const auto name = [&](const std::string& symbol) + { return shown.count(symbol) ? shown[symbol] : llvm::demangle(symbol); }; + missing = "'" + name(sources[0]) + "' and '" + name(sources[1]) + + "' both become '" + name(targets[0]) + + "': their correspondence is not one to one"; + } + continue; + } + added = pair(one, targets[0]) || added; + } + } + + // 4: the nested symbols, by kind and site, once the symbols around them are paired. + for (bool added = was && now; added;) + { + added = false; + for (const Named& one : was->nested) + { + if (pairs.count(one.symbol)) + continue; + const auto around = pairs.find(one.enclosing); + const std::string enclosing = + around == pairs.end() ? one.enclosing : around->second; + const auto other = + std::find_if(now->nested.begin(), now->nested.end(), [&](const Named& named) + { return named.key == one.key && named.enclosing == enclosing; }); + if (other != now->nested.end()) + added = pair(one.symbol, other->symbol) || added; + } + } + + std::vector out; + for (const auto& [wasSymbol, nowSymbol] : pairs) + { + if (wasSymbol != nowSymbol) + out.push_back({wasSymbol, nowSymbol}); + } + return out; + } +} // namespace ctrace::stack::analysis From 2deae595fdd1c761479755720d5d3431ce2bc9b1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:53:56 +0200 Subject: [PATCH 082/152] fix(const-param): compare the code through established pairs, never refuting --- include/analysis/ConstSuggestionCheck.hpp | 21 +- src/analysis/ConstSuggestionCheck.cpp | 147 ++++++------- src/analysis/ConstSuggestionCode.cpp | 248 ++-------------------- src/analysis/ConstSuggestionFacts.cpp | 6 +- src/analysis/ConstSuggestionInternal.hpp | 20 +- src/analysis/ConstSuggestionMatch.cpp | 23 +- test/unit/analyzer_module_unit_tests.cpp | 33 ++- 7 files changed, 135 insertions(+), 363 deletions(-) diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index be231da..c1b07b6 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -76,25 +76,22 @@ namespace ctrace::stack::analysis std::string after; }; - // Compares the code generated for one unit before and after the declarations of the function - // `rewritten` (its symbol before the rewrite) are rewritten. Returns the first difference, or - // nullopt when the code matches once normalized: + // Compares the code generated for one unit before and after a rewrite. Returns the first + // difference, or nullopt when the code matches once normalized: // - debug information is left out; // - metadata are compared by the order in which each function or global uses them, not by // their numbers or contents; // - attribute groups are compared by contents, not by numbers; - // - the symbols the rewrite renames (the function, and what is named or instantiated inside - // it) are paired. A symbol the unit only declares is paired only if it is the function. + // - the symbols the rewrite renames are paired by `established`, the pairs that the rewrite's + // correspondences establish, and by them only: a symbol that only one side has, and that no + // pair covers, is a difference. // Two differences that a run can observe are accepted, each only when verified, and described // in `accepted`: the name of a type renamed with the function (_ZTS holds the mangled name // of T), and a string constant whose only change is the function's printed signature, from - // `signature` (null: none is accepted). `renamedTo` receives the function's symbol after the - // rewrite. - std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, - llvm::StringRef rewritten, - const PrintedSignature* signature = nullptr, - std::vector* accepted = nullptr, - std::string* renamedTo = nullptr); + // `signature` (null: none is accepted). + std::optional rewrittenCodeDifference( + llvm::Module& before, llvm::Module& after, const std::vector& established, + const PrintedSignature* signature = nullptr, std::vector* accepted = nullptr); // Checks the suggestions made for `mod` with Clang (#217): rewrites every declaration of the // parameter with the suggested type, recompiles every analyzed unit that sees a rewritten file diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 7d487c7..1e040fe 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -63,20 +63,24 @@ namespace ctrace::stack::analysis constexpr const char* kNotChecked = "not checked with Clang: "; constexpr const char* kDoesNotCompileAsItIs = "a file does not compile with Clang as it is"; - // A unit before the rewrite, prepared once for every suggestion of the module: its - // diagnostics, its facts R and K, the facts O of each target (indexed like the - // targets), the declarations of the rewritten files, and its generated code ready to - // compare. + // A unit before the rewrite, prepared once for every suggestion of the module: what its + // compilation shows, its module released, and its generated code ready to compare. struct Original { - std::multiset diagnostics; - std::vector facts; - std::set instantiations; // the symbols of those around a fact - std::vector> overrides; - std::map> writtenTypes; + UnitFacts unit; std::shared_ptr code; }; + Original originalFrom(UnitFacts unit, bool timing) + { + const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); + auto code = std::make_shared( + const_suggestion::prepare(*unit.module)); + unit.module.reset(); + unit.context.reset(); + return {std::move(unit), std::move(code)}; + } + bool commandFor(const AnalyzedUnit& unit, const AnalysisConfig& config, UnitCommand& out, std::string& error) { @@ -149,15 +153,6 @@ namespace ctrace::stack::analysis return out; } - // The facts O that each target's inspection recorded, indexed like the targets. - std::vector> overridesOf(const UnitFacts& unit) - { - std::vector> out; - for (const UnitFacts::Entity& entity : unit.entities) - out.push_back(entity.overrides); - return out; - } - // A unit before the rewrite, by its file and its configuration: compiled once for every // suggestion of the module (#217, §5, step 3). using OriginalCache = std::map>; @@ -175,14 +170,7 @@ namespace ctrace::stack::analysis "const_suggestion.compile.other_before", timing); if (!before.failure.empty() || !before.module) return out; - const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - out = Original{before.diagnostics, - std::move(before.facts), - std::move(before.instantiations), - overridesOf(before), - std::move(before.writtenTypes), - std::make_shared( - const_suggestion::prepare(*before.module))}; + out = originalFrom(std::move(before), timing); return out; } @@ -218,20 +206,17 @@ namespace ctrace::stack::analysis // Keys go through the entity's own symbol pairs: a fact in an instantiation they do not // map is left out, and so is a reference whose targets are specializations or implicit // functions on both sides; Task 1.5 pairs them. - FactComparison compareFacts(const Original& before, std::size_t index, - const UnitFacts& after, const SuggestionPlan& plan) + FactComparison compareFacts(const UnitFacts& before, std::size_t index, + const UnitFacts& after, const std::vector& pairs) { - std::map renamed; // the entity's symbols, before to after + std::map renamed; // the established pairs, before to after std::map back; - const UnitFacts::Entity* entity = after.entities.empty() ? nullptr : &after.entities[0]; - if (entity && entity->found && entity->symbols.size() == plan.entity.symbols.size()) + for (const SymbolPair& pair : pairs) { - for (std::size_t k = 0; k < entity->symbols.size(); ++k) - { - renamed[plan.entity.symbols[k]] = entity->symbols[k]; - back[entity->symbols[k]] = plan.entity.symbols[k]; - } + renamed[pair.before] = pair.after; + back[pair.after] = pair.before; } + const UnitFacts::Entity* entity = after.entities.empty() ? nullptr : &after.entities[0]; // A key before the rewrite, as the compilation after it writes it. const auto forward = [&](FactKey key) -> std::optional { @@ -280,7 +265,16 @@ namespace ctrace::stack::analysis return !std::includes(now.value.begin(), now.value.end(), was.value.begin(), was.value.end()); } - return sites(was.value) != sites(now.value); + if (sites(was.value) != sites(now.value)) + return true; + // Two symbols: the same target through the pairs; else a change only between two + // templates, or between a specialization and a function of no template. Two + // specializations of one template are the correspondences' to pair (§5.7). + if (mappedBack(now.value) == was.value) + return false; + const std::string wasTemplate = const_suggestion::itemOf(was.value, "template:"); + const std::string nowTemplate = const_suggestion::itemOf(now.value, "template:"); + return wasTemplate.empty() != nowTemplate.empty() || wasTemplate != nowTemplate; }; FactComparison out; const auto compare = [&](const std::vector& was, const std::vector& now) @@ -332,15 +326,18 @@ namespace ctrace::stack::analysis } }; compare(before.facts, after.facts); - if (!out.changed) - compare(before.overrides[index], entity ? entity->overrides : std::vector()); + if (!out.changed && index < before.entities.size()) + { + compare(before.entities[index].overrides, + entity ? entity->overrides : std::vector()); + } return out; } // Step 3 (§5.6): a declaration that a rewritten file writes outside the plan and whose // type the rewrite changes, valid or not after it; and else, when a site's declarations // cannot be compared one to one, why the comparison is incomplete. - std::optional outsideThePlan(const Original& before, const UnitFacts& after, + std::optional outsideThePlan(const UnitFacts& before, const UnitFacts& after, const SuggestionPlan& plan) { using Declarations = std::vector; @@ -410,7 +407,7 @@ namespace ctrace::stack::analysis } // Checks one suggestion, the target at `index`, in one unit, against the unit before the - // rewrite: the judgement of §5.8, steps 1 to 6, then T v1's comparison of the code. + // rewrite: the judgement of §5.8. Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, @@ -437,13 +434,14 @@ namespace ctrace::stack::analysis // declaration that a rewritten file writes keeps its type. if (facts.withoutCandidate) return indeterminate("the rewritten declaration does not get the suggested type"); - if (const std::optional outside = outsideThePlan(*before, after, plan)) + if (const std::optional outside = + outsideThePlan(before->unit, after, plan)) return indeterminate(*outside); // Steps 4 and 5: the first error or checked warning that the rewrite adds. for (const Diagnostic& diagnostic : after.reported) { if (after.diagnostics.count(diagnostic.key) > - before->diagnostics.count(diagnostic.key)) + before->unit.diagnostics.count(diagnostic.key)) { return refuted( (diagnostic.error ? "does not compile: " : "gains a checked warning: ") + @@ -452,35 +450,46 @@ namespace ctrace::stack::analysis } if (!after.module) return refuted("does not compile: " + after.firstDiagnostic); - // The suggestion's variant after the rewrite. - const auto variant = - std::find(plan.entity.symbols.begin(), plan.entity.symbols.end(), planned.symbol); - const auto k = static_cast(variant - plan.entity.symbols.begin()); - if (renamed.empty() && k < facts.symbols.size()) - renamed = facts.symbols[k]; + // The correspondences of the rewrite (§5.7), from the facts of both compilations; the + // ABI line names the suggestion's own symbol after it. + std::string missing; + const std::vector pairs = + const_suggestion::correspond(before->unit, index, after, missing); + for (const SymbolPair& pair : pairs) + { + if (renamed.empty() && pair.before == planned.symbol) + renamed = pair.after; + } if (!facts.problem.empty()) return refuted(facts.problem); - // Step 6 (§5.8): a reference, an override or a constant condition that changes. - const FactComparison compared = compareFacts(*before, index, after, plan); + // Step 6: a reference, an override or a constant condition that changes, compared + // through the pairs; facts that cannot be compared one to one leave the unit + // unverified, and so does a correspondence that is not one to one. + const FactComparison compared = compareFacts(before->unit, index, after, pairs); if (compared.changed) return refuted(*compared.changed); + if (compared.incomplete) + return indeterminate(*compared.incomplete); + if (!missing.empty()) + return indeterminate(missing); if (signature.after.empty() && !facts.printedSignature.empty()) signature.after = facts.printedSignature; - std::string renamedTo; + // Step 7: the generated code, compared through the pairs. A difference that no + // reference, override or constant condition explains leaves the unit unverified: it + // never refutes. std::optional difference; { const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.compare"); const const_suggestion::ModuleText now = const_suggestion::prepare(*after.module); - difference = const_suggestion::codeDifference(*before->code, now, planned.symbol, - &signature, &accepted, &renamedTo); + difference = const_suggestion::codeDifference(*before->code, now, pairs, &signature, + &accepted); } if (difference) - return refuted("the generated code changes: " + *difference); - // Facts that could not be compared one to one leave the unit unverified. - if (compared.incomplete) - return indeterminate(*compared.incomplete); - if (renamed.empty() || renamed == planned.symbol) - renamed = renamedTo; + { + return indeterminate("the generated code changes, and no reference, override or " + "constant condition explains it: " + + *difference); + } return {}; } } // namespace @@ -592,25 +601,19 @@ namespace ctrace::stack::analysis if (!issues[i].binding.isArtificial) targets[i].sourceIndex = issues[i].binding.sourceIndex; } - UnitFacts before = inspect(command, nullptr, {}, targets, - "const_suggestion.compile.defining_before", timing); - if (!before.failure.empty() || !before.module || before.entities.size() != issues.size()) + UnitFacts inspected = inspect(command, nullptr, {}, targets, + "const_suggestion.compile.defining_before", timing); + if (!inspected.failure.empty() || !inspected.module || + inspected.entities.size() != issues.size()) { notChecked("the file does not compile with Clang as it is"); return; } + const std::optional definingBefore = originalFrom(std::move(inspected), timing); + const UnitFacts& before = definingBefore->unit; // The suggestions as planned: what the other compilations look for. for (std::size_t i = 0; i < issues.size(); ++i) targets[i].plan = &before.entities[i].plan; - std::shared_ptr definingCode; - { - const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.prepare"); - definingCode = std::make_shared( - const_suggestion::prepare(*before.module)); - } - const std::optional definingBefore = Original{ - before.diagnostics, std::move(before.facts), std::move(before.instantiations), - overridesOf(before), std::move(before.writtenTypes), definingCode}; OriginalCache originals; // The other units a rewrite may reach: the analyzed ones and, with a compilation diff --git a/src/analysis/ConstSuggestionCode.cpp b/src/analysis/ConstSuggestionCode.cpp index 4e3e255..66c3327 100644 --- a/src/analysis/ConstSuggestionCode.cpp +++ b/src/analysis/ConstSuggestionCode.cpp @@ -375,120 +375,26 @@ namespace ctrace::stack::analysis const std::string now = i < b.size() ? b[i].trim().str() : "(end)"; return "'" + was.substr(0, 160) + "' became '" + now.substr(0, 160) + "'"; } - - // The function template `symbol` is a specialization of, as demangled names write it - // ("ns::f<"), or "" for a symbol that is no function template specialization. - std::string templateOf(const std::string& symbol) - { - llvm::ItaniumPartialDemangler demangler; - if (demangler.partialDemangle(symbol.c_str()) || !demangler.isFunction()) - return ""; - const auto text = [](char* buffer) - { - std::string out = buffer ? buffer : ""; - std::free(buffer); - return out; - }; - std::size_t size = 0; - const std::string base = text(demangler.getFunctionBaseName(nullptr, &size)); - const std::string scope = text(demangler.getFunctionDeclContextName(nullptr, &size)); - const std::string name = text(demangler.getFunctionName(nullptr, &size)); - const std::string qualified = scope.empty() ? base : scope + "::" + base; - if (base.empty() || !llvm::StringRef(name).starts_with(qualified + "<")) - return ""; - return qualified + "<"; - } - - // Whether the demangled `symbol` names `templateName` ("ns::f<") as a whole name. - bool namesTemplate(const std::string& symbol, const std::string& templateName) - { - const std::string text = llvm::demangle(symbol); - for (std::size_t at = text.find(templateName); at != std::string::npos; - at = text.find(templateName, at + 1)) - { - if (at == 0 || (!std::isalnum(static_cast(text[at - 1])) && - text[at - 1] != '_')) - { - return true; - } - } - return false; - } } // namespace - std::string const_suggestion::withoutConst(const std::string& symbol) - { - const std::string text = llvm::demangle(symbol); - std::string out; - // An identifier is made of letters, digits and underscores: const_iterator is no - // qualifier. - const auto inWord = [&](std::size_t i) - { return std::isalnum(static_cast(text[i])) || text[i] == '_'; }; - for (std::size_t i = 0; i < text.size();) - { - const bool wordStart = i == 0 || !inWord(i - 1); - if (wordStart && text.compare(i, 5, "const") == 0 && - (i + 5 == text.size() || !inWord(i + 5))) - { - i += 5; - continue; - } - if (text[i] != ' ') - out += text[i]; - ++i; - } - std::size_t pos = 0; - while ((pos = out.find("&&", pos)) != std::string::npos) - out.replace(pos, 2, "&"); - return out; - } - - std::string const_suggestion::variantOf(const std::string& symbol) - { - std::string variant; - for (std::size_t i = 1; i + 1 < symbol.size(); ++i) - { - const char kind = symbol[i]; - const char digit = symbol[i + 1]; - const bool afterName = - std::isalnum(static_cast(symbol[i - 1])) || symbol[i - 1] == '_'; - const bool next = - i + 2 == symbol.size() || std::isupper(static_cast(symbol[i + 2])); - if (afterName && next && - ((kind == 'C' && digit >= '1' && digit <= '5') || - (kind == 'D' && digit >= '0' && digit <= '5'))) - { - variant += symbol.substr(i, 2); - } - } - const std::string demangled = llvm::demangle(symbol); - if (llvm::StringRef(demangled).rtrim().ends_with(" const")) - variant += "|const"; - return variant; - } - const_suggestion::ModuleText const_suggestion::prepare(llvm::Module& module) { return textOf(module); } std::optional rewrittenCodeDifference(llvm::Module& before, llvm::Module& after, - llvm::StringRef rewritten, + const std::vector& established, const PrintedSignature* signature, - std::vector* accepted, - std::string* renamedTo) + std::vector* accepted) { - return const_suggestion::codeDifference(textOf(before), textOf(after), rewritten, signature, - accepted, renamedTo); + return const_suggestion::codeDifference(textOf(before), textOf(after), established, + signature, accepted); } - std::optional - const_suggestion::codeDifference(const ModuleText& was, const ModuleText& now, - llvm::StringRef rewritten, const PrintedSignature* signature, - std::vector* accepted, std::string* renamedTo) + std::optional const_suggestion::codeDifference( + const ModuleText& was, const ModuleText& now, const std::vector& established, + const PrintedSignature* signature, std::vector* accepted) { - const std::string function = rewritten.str(); - std::vector removed; std::vector added; for (const auto& [name, text] : was.globals) @@ -502,140 +408,25 @@ namespace ctrace::stack::analysis added.push_back(name); } - // The rewritten function and what is named inside it change their mangled names: pair - // them by their names with the function's encoding masked, then by their demangled names - // without const. - const std::string functionKey = withoutConst(function); - std::vector encodings; - if (rewritten.starts_with("_Z")) - encodings.push_back(function.substr(2)); - for (const std::string& name : added) - { - if (name.starts_with("_Z") && withoutConst(name) == functionKey) - encodings.push_back(name.substr(2)); - } - std::sort(encodings.begin(), encodings.end(), - [](const std::string& a, const std::string& b) { return a.size() > b.size(); }); - const auto masked = [&](std::string name) + // The symbols the rewrite renames, by the pairs its correspondences established and by + // them only (§5.9): a symbol that only one side has and no pair covers is a difference. + std::map rename; + std::set taken; + for (const SymbolPair& pair : established) { - for (const std::string& encoding : encodings) - { - for (std::size_t at = name.find(encoding); at != std::string::npos; - at = name.find(encoding, at + 3)) - { - name.replace(at, encoding.size(), "@F@"); - } - } - // Substitution indices (S_, S0_, ...) depend on what precedes them. - std::string out; - for (std::size_t i = 0; i < name.size(); ++i) + if (std::binary_search(removed.begin(), removed.end(), pair.before) && + std::binary_search(added.begin(), added.end(), pair.after)) { - if (name[i] == 'S') - { - std::size_t end = i + 1; - while (end < name.size() && - (std::isdigit(static_cast(name[end])) || - std::isupper(static_cast(name[end])))) - ++end; - if (end < name.size() && name[end] == '_') - { - out += "S_"; - i = end; - continue; - } - } - out += name[i]; + rename[pair.before] = pair.after; + taken.insert(pair.after); } - return out; - }; - - std::map> byMasked; - std::map> byDemangled; - for (const std::string& name : added) - { - byMasked[masked(name)].insert(name); - byDemangled[withoutConst(name)].insert(name); } - std::map rename; - std::set taken; std::vector unpaired; for (const std::string& name : removed) { - bool paired = false; - for (const auto* table : {&byMasked, &byDemangled}) - { - const auto found = - table->find(table == &byMasked ? masked(name) : withoutConst(name)); - if (found == table->end()) - continue; - std::vector candidates; - for (const std::string& candidate : found->second) - { - if (!taken.count(candidate)) - candidates.push_back(candidate); - } - if (candidates.size() > 1) - { - std::erase_if(candidates, [&](const std::string& candidate) - { return variantOf(candidate) != variantOf(name); }); - } - if (candidates.size() != 1) - continue; - // A symbol the unit only declares has no code to compare: it may be paired only - // if it is the rewritten function itself. - const bool itself = name == function || withoutConst(name) == functionKey || - masked(name) == "_Z@F@"; - if (!itself && (!was.defined.count(name) || !now.defined.count(candidates[0]))) - break; - rename[name] = candidates[0]; - taken.insert(candidates[0]); - paired = true; - break; - } - if (!paired) + if (!rename.count(name)) unpaired.push_back(name); } - - // Rewritten as const T&, a forwarding reference T&& deduces G instead of G& or const G& - // for an lvalue argument: the specializations of the rewritten template that change this - // way, and what is named inside them, change their names beyond const. One is paired - // when its demangled name without const and references is carried by exactly one symbol - // before the rewrite and one after, both with code to compare. - const std::string templateName = templateOf(function); - if (!templateName.empty()) - { - const auto loose = [](const std::string& name) - { - std::string key = withoutConst(name); - std::erase(key, '&'); - return key + "|" + variantOf(name); - }; - std::map> before; - std::map> after; - for (const std::string& name : unpaired) - { - if (namesTemplate(name, templateName)) - before[loose(name)].push_back(name); - } - for (const std::string& name : added) - { - if (!taken.count(name) && namesTemplate(name, templateName)) - after[loose(name)].push_back(name); - } - for (const auto& [key, names] : before) - { - const auto other = after.find(key); - if (names.size() != 1 || other == after.end() || other->second.size() != 1 || - !was.defined.count(names[0]) || !now.defined.count(other->second[0])) - { - continue; - } - rename[names[0]] = other->second[0]; - taken.insert(other->second[0]); - std::erase(unpaired, names[0]); - } - } - std::vector unexpected; for (const std::string& name : added) { @@ -649,11 +440,6 @@ namespace ctrace::stack::analysis return "symbols no longer used: " + list(unpaired) + "; symbols used only after the rewrite: " + list(unexpected); } - if (renamedTo) - { - const auto found = rename.find(function); - *renamedTo = found == rename.end() ? function : found->second; - } if (was.types != now.types) { diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index e0e715b..a5b0ce9 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -845,10 +845,10 @@ namespace ctrace::stack::analysis facts.found = !found.empty(); if (!facts.found) return; - facts.symbols = symbolsOf(*found.front()); + const std::vector& symbols = symbolsOf(*found.front()); const std::vector keys = variantKeys(*found.front()); - for (std::size_t k = 0; k < facts.symbols.size() && k < keys.size(); ++k) - facts.variants.push_back({facts.symbols[k], keys[k], ""}); + for (std::size_t k = 0; k < symbols.size() && k < keys.size(); ++k) + facts.variants.push_back({symbols[k], keys[k], ""}); facts.printedSignature = clang::PredefinedExpr::ComputeName( clang::PredefinedIdentKind::PrettyFunction, found.front()); if (!target.plan || target.plan != rewrittenBy_) diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index bba7bb8..2c091d7 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -207,13 +207,6 @@ namespace ctrace::stack::analysis::const_suggestion SuggestionPlan plan(clang::ASTContext& context, const MacroDefinitions& macros, const clang::FunctionDecl& function, std::optional sourceIndex); - // The demangled form of a symbol without const and spaces, with && written &: the rewritten - // function, and what is named inside it, keep this form across the rewrite. - std::string withoutConst(const std::string& symbol); - - // The constructor or destructor variant a symbol names, and whether it names a const method. - std::string variantOf(const std::string& symbol); - // The generated code of one module, ready to compare: what carries no meaning removed, and // each piece keyed by what it defines. struct ModuleText @@ -231,10 +224,9 @@ namespace ctrace::stack::analysis::const_suggestion // rewrittenCodeDifference, with the code before the rewrite already prepared. std::optional codeDifference(const ModuleText& was, const ModuleText& now, - llvm::StringRef rewritten, + const std::vector& established, const PrintedSignature* signature, - std::vector* accepted, - std::string* renamedTo); + std::vector* accepted); // What to look for in one compilation: the function a suggestion names. struct Target @@ -270,9 +262,8 @@ namespace ctrace::stack::analysis::const_suggestion // About the function of one target. struct Entity { - SuggestionPlan plan; // in the compilation that plans - std::vector symbols; // every variant's, in this compilation - std::string printedSignature; // for __PRETTY_FUNCTION__ + SuggestionPlan plan; // in the compilation that plans + std::string printedSignature; // for __PRETTY_FUNCTION__ std::string problem; std::vector overrides; // O, for the methods named like it std::vector variants; // the entity's: C1, C2, D0... or "" for a function @@ -315,6 +306,9 @@ namespace ctrace::stack::analysis::const_suggestion const_suggestion::MacroDefinitions macros; // read only when the compilation plans }; + // The item of an R value that starts with `prefix`, without it; "" when it has none. + std::string itemOf(const std::vector& value, std::string_view prefix); + // The pairs established between a unit's initial and modified compilations for the entity // at `entity` of `before` and the one of `after` (§5.7). `missing` receives the first reason // a correspondence could not be established. diff --git a/src/analysis/ConstSuggestionMatch.cpp b/src/analysis/ConstSuggestionMatch.cpp index c855d3b..f1aaa6d 100644 --- a/src/analysis/ConstSuggestionMatch.cpp +++ b/src/analysis/ConstSuggestionMatch.cpp @@ -21,20 +21,10 @@ namespace ctrace::stack::analysis using const_suggestion::Fact; using const_suggestion::FactKey; using const_suggestion::FactKind; + using const_suggestion::itemOf; using const_suggestion::Named; using const_suggestion::UnitFacts; - // The item of an R value that starts with `prefix`, without it; "" when it has none. - std::string itemOf(const std::vector& value, std::string_view prefix) - { - for (const std::string& item : value) - { - if (item.starts_with(prefix)) - return item.substr(prefix.size()); - } - return ""; - } - // Appends `item` once, in the order items come. void addOnce(std::vector& items, const std::string& item) { @@ -43,6 +33,17 @@ namespace ctrace::stack::analysis } } // namespace + std::string const_suggestion::itemOf(const std::vector& value, + std::string_view prefix) + { + for (const std::string& item : value) + { + if (item.starts_with(prefix)) + return item.substr(prefix.size()); + } + return ""; + } + std::vector const_suggestion::correspond(const UnitFacts& before, std::size_t entity, const UnitFacts& after, std::string& missing) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index a3dcc97..e5e03b8 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -750,26 +750,14 @@ namespace return report.failures == 0; } - /// #217: a symbol's demangled name without const keeps the identifiers that contain "const": - /// get_const(int*) and get_(int*) are two functions. - bool testConstSuggestionWithoutConstKeepsIdentifiers(TestReport& report) - { - using ctrace::stack::analysis::const_suggestion::withoutConst; - report.expect(withoutConst("_Z9get_constPi") != withoutConst("_Z4get_Pi"), - "ConstSuggestionCode: get_const(int*) and get_(int*) stay distinct"); - report.expect(withoutConst("_Z14const_iteratorPKi") == "const_iterator(int*)", - "ConstSuggestionCode: const_iterator keeps its name, the qualifier goes"); - report.expect(withoutConst("_Z1fPKi") == withoutConst("_Z1fPi"), - "ConstSuggestionCode: f(const int*) and f(int*) have the same key"); - return report.failures == 0; - } - /// #217: the code generated before and after a const rewrite is compared once the symbols - /// the rewrite renames are paired. A symbol the unit only declares has no code to compare: it - /// is paired only if it is the rewritten function itself. + /// the rewrite renames are paired, by the pairs its correspondences established and by them + /// only: a symbol renamed without a pair, even one whose name differs only by const, is a + /// difference. bool testConstSuggestionCodeComparison(TestReport& report) { using ctrace::stack::analysis::rewrittenCodeDifference; + using ctrace::stack::analysis::SymbolPair; llvm::LLVMContext context; llvm::SMDiagnostic error; const auto parse = @@ -815,11 +803,15 @@ namespace report.expect(false, "ConstSuggestionCode setup: the IR does not parse"); return false; } - report.expect(!rewrittenCodeDifference(*calledBefore, *calledAfter, "_Z1fPi"), + const std::vector function = {{"_Z1fPi", "_Z1fPKi"}}; + report.expect(!rewrittenCodeDifference(*calledBefore, *calledAfter, function), "ConstSuggestionCode: the rewritten function and its caller are renamed " "together"); - report.expect(rewrittenCodeDifference(*thrownBefore, *thrownAfter, "_Z1fPi").has_value(), - "ConstSuggestionCode: typeinfo for int* and for int const*, only declared, " + report.expect(rewrittenCodeDifference(*calledBefore, *calledAfter, {}).has_value(), + "ConstSuggestionCode: a function renamed without an established pair is a " + "difference"); + report.expect(rewrittenCodeDifference(*thrownBefore, *thrownAfter, function).has_value(), + "ConstSuggestionCode: typeinfo for int* and for int const*, without a pair, " "are not paired"); return report.failures == 0; } @@ -840,7 +832,7 @@ namespace llvm::parseAssemblyString(after, error, context); if (!was || !now) return std::optional(); - return std::optional(rewrittenCodeDifference(*was, *now, "f").has_value()); + return std::optional(rewrittenCodeDifference(*was, *now, {}).has_value()); }; const auto replaced = [](std::string text, const std::string& from, const std::string& to) { @@ -2893,7 +2885,6 @@ int main(int argc, char** argv) (void)testSarifSecuritySeverity(report); (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); (void)testConstSuggestionCodeComparison(report); - (void)testConstSuggestionWithoutConstKeepsIdentifiers(report); (void)testConstSuggestionNormalizations(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); From 7741c2568452d9c887f789216a7e1f73943d8df2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 05:54:24 +0200 Subject: [PATCH 083/152] fix(const-param): pair the specialization that an inheriting constructor calls --- src/analysis/ConstSuggestionFacts.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index a5b0ce9..31fe67a 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -743,6 +743,10 @@ namespace ctrace::stack::analysis const std::string arguments = argumentsOf(*context_, *inherited); const std::vector& symbols = symbolsOf(*constructor); const std::vector keys = variantKeys(*constructor); + // The specialization it inherits too: only its implicit code calls it, so no + // reference pairs it. + const std::vector& inheritedSymbols = symbolsOf(*inherited); + const std::vector inheritedKeys = variantKeys(*inherited); for (std::size_t i = 0; i < targets_.size(); ++i) { if (!atSites(i, *inherited)) @@ -752,6 +756,13 @@ namespace ctrace::stack::analysis out_.entities[i].inheriting.push_back( {symbols[k], derived + "|" + arguments + "|" + keys[k], ""}); } + for (std::size_t k = 0; + k < inheritedSymbols.size() && k < inheritedKeys.size(); ++k) + { + out_.entities[i].inheriting.push_back( + {inheritedSymbols[k], + "inherited|" + arguments + "|" + inheritedKeys[k], ""}); + } } } } From d711321b851e018084ab1325d7ccadf2bf5b7ce6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 09:43:59 +0200 Subject: [PATCH 084/152] test(const-param): an empty parameter may take an IR argument, as on Linux arm64 --- test/unit/analyzer_module_unit_tests.cpp | 33 +++++++++++++++++++++--- test/unit/parameter_binding_input.cpp | 3 ++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index f8f7f5d..ef2a54e 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -2521,6 +2521,9 @@ namespace } return parameters; }; + // The arguments' bindings, in order. An expected entry written "?name: binding" is the + // parameter of an argument that only some ABIs pass: absent, or at that place with that + // binding, its IR name the parameter's or derived from it (tag.coerce). const auto check = [&](const LoadedModule& loaded, const char* symbol, const std::vector& expected) { @@ -2529,7 +2532,28 @@ namespace std::string text; for (const std::string& parameter : actual) text += " [" + parameter + "]"; - report.expect(actual == expected, + const auto passes = [](const std::string& parameter, const std::string& optional) + { + const std::size_t colon = optional.find(':'); + const std::string name = optional.substr(1, colon - 1); + return parameter == optional.substr(1) || + (parameter.starts_with(name + ".") && + parameter.substr(parameter.find(':')) == optional.substr(colon)); + }; + std::size_t next = 0; + bool matches = true; + for (const std::string& entry : expected) + { + if (entry.starts_with("?")) + { + if (next < actual.size() && passes(actual[next], entry)) + ++next; + continue; + } + matches = matches && next < actual.size() && actual[next] == entry; + ++next; + } + report.expect(matches && next == actual.size(), std::string("ParameterDebugBinding: ") + symbol + text); }; const auto load = @@ -2574,7 +2598,8 @@ namespace {".block_descriptor: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); // A variadic function's debug type ends with a null entry for `...`, which no parameter - // takes; an empty parameter takes no IR argument, but keeps its debug declaration. + // takes; an empty parameter keeps its debug declaration, and takes an IR argument only on + // some ABIs (an i8 under AAPCS64 on Linux, none on x86-64 or Apple arm64). check(itanium, "_ZN7VarargsC2EPiPdz", {"this: artificial, source none", "vtt: artificial, source none", "x: int *, source 0", "y: double *, source 1"}); @@ -2583,7 +2608,7 @@ namespace "x: int *, source 0", "y: double *, source 1"}); check(itanium, "_ZN6TaggedC2EPi3Tag", {"this: artificial, source none", "vtt: artificial, source none", - "x: int *, source 0"}); + "x: int *, source 0", "?tag: Tag, source 1"}); check(itanium, "_Z8make_bigPiz", {"agg.result: artificial, source none", "p: int *, source 0"}); @@ -2598,7 +2623,7 @@ namespace // The empty parameter comes first: x is the second parameter the source declares. check(itanium, "_ZN8TagFirstC2E3TagPi", {"this: artificial, source none", "vtt: artificial, source none", - "x: int *, source 1"}); + "?tag: Tag, source 0", "x: int *, source 1"}); // Optimized code spills nothing: each argument is bound through its own debug records. check(optimized, "_ZN8VariadicC2EPiPdz", diff --git a/test/unit/parameter_binding_input.cpp b/test/unit/parameter_binding_input.cpp index 3551631..f2b828e 100644 --- a/test/unit/parameter_binding_input.cpp +++ b/test/unit/parameter_binding_input.cpp @@ -56,7 +56,8 @@ Varargs::Varargs(int* x, double* y, ...) seed = *x + static_cast(*y); } -// An empty parameter takes no IR argument, but keeps its debug declaration and its number. +// An empty parameter keeps its debug declaration and its number; whether it takes an IR argument +// depends on the ABI: none on x86-64 or Apple arm64, an i8 under AAPCS64 on Linux. struct Tag { }; From 6be6113578c317695a85d747e53a74d717dc6a95 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 09:47:40 +0200 Subject: [PATCH 085/152] test(const-param): keep a rewrite that takes another file's symbol, unchecked --- run_test.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/run_test.py b/run_test.py index fd9f8d8..5dfc205 100755 --- a/run_test.py +++ b/run_test.py @@ -3589,6 +3589,63 @@ def check_const_suggestion_header_read_differently() -> bool: return good +def check_const_suggestion_overload_defined_elsewhere() -> bool: + """ + #217: rewritten, f(int*) takes the symbol of the overload f(const int*), which another file + defines. The two definitions collide only if the files are linked together, which the check + does not know: the suggestion is kept, not checked, and the reason states that assumption, + whether the other file is analyzed or only listed in the compilation database. + """ + print("=== Testing a const suggestion that takes the symbol of another file's overload ===") + sources = { + "api.hpp": "int f(int* p);\nint f(const int* p);\n", + "a.cpp": '#include "api.hpp"\nint f(int* p)\n{\n return *p;\n}\n', + "b.cpp": '#include "api.hpp"\nint f(const int* p)\n{\n return *p + 1;\n}\n', + } + claims = ("checked in the analyzed files only", "checked in every file of the compilation") + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_overload_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + database = root / "compile_commands.json" + database.write_text( + json.dumps( + [ + { + "directory": str(root), + "file": str(root / f), + "arguments": ["clang++", "-std=c++17", "-c", str(root / f)], + } + for f in ("a.cpp", "b.cpp") + ] + ) + ) + expected = ( + f"not checked with Clang: _Z1fPKi is also defined in {root / 'b.cpp'}; if " + f"{root / 'a.cpp'} and {root / 'b.cpp'} are linked together, the two definitions " + "collide" + ) + for label, args in ( + ("both files analyzed", [str(root / "a.cpp"), str(root / "b.cpp")]), + ( + "the other file only in the compilation database", + [str(root / "a.cpp"), f"--compile-commands={database}"], + ), + ): + run = _const_suggestion_run(args) + texts = run[0].get("_Z1fPi", []) if run else [] + good = ( + len(texts) == 1 + and expected in texts[0] + and not any(claim in texts[0] for claim in claims) + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6549,6 +6606,7 @@ def record_ok(ok: bool): check_const_suggestion_operator_used_elsewhere, check_const_suggestion_homonyms_across_files, check_const_suggestion_header_read_differently, + check_const_suggestion_overload_defined_elsewhere, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 2bc1d29396a38207a02e6c7d9b868df0cbbafef9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 09:47:47 +0200 Subject: [PATCH 086/152] test(const-param): a function a shared header defines does not collide with itself --- run_test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/run_test.py b/run_test.py index 5dfc205..21bdc2b 100755 --- a/run_test.py +++ b/run_test.py @@ -3646,6 +3646,45 @@ def check_const_suggestion_overload_defined_elsewhere() -> bool: return ok +def check_const_suggestion_inline_in_a_header() -> bool: + """ + #217: a function defined in a header that two analyzed files include is defined in both: a C + `static inline` function, one function in each file, and a C++ `inline` function, one function + that both files define. Neither definition collides with the other: each suggestion is checked. + """ + print("=== Testing const suggestions for functions defined in a shared header ===") + sources = { + "peek.h": "static inline int peek_c(int* p)\n{\n return *p;\n}\n", + "one.c": '#include "peek.h"\n' + "int use_one(void)\n{\n int v = 1;\n return peek_c(&v);\n}\n", + "two.c": '#include "peek.h"\n' + "int use_two(void)\n{\n int v = 2;\n return peek_c(&v);\n}\n", + "peek.hpp": "inline int peek_cpp(int* p)\n{\n return *p;\n}\n", + "three.cpp": '#include "peek.hpp"\n' + "int use_three()\n{\n int v = 3;\n return peek_cpp(&v);\n}\n", + "four.cpp": '#include "peek.hpp"\n' + "int use_four()\n{\n int v = 4;\n return peek_cpp(&v);\n}\n", + } + checked = "checked in the analyzed files only" + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_inline_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, files, function in ( + ("C, static inline", ["one.c", "two.c"], "peek_c"), + ("C++, inline", ["three.cpp", "four.cpp"], "_Z8peek_cppPi"), + ): + run = _const_suggestion_run([str(root / f) for f in files]) + texts = run[0].get(function, []) if run else [] + # One suggestion for the definition in each file. + good = len(texts) == 2 and all(checked in text for text in texts) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6607,6 +6646,7 @@ def record_ok(ok: bool): check_const_suggestion_homonyms_across_files, check_const_suggestion_header_read_differently, check_const_suggestion_overload_defined_elsewhere, + check_const_suggestion_inline_in_a_header, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 291d280cda51e947d0572613352ce3ac642a77e6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 09:48:06 +0200 Subject: [PATCH 087/152] test(const-param): keep a suggestion that another file redeclares, unchecked --- run_test.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/run_test.py b/run_test.py index 21bdc2b..6808d45 100755 --- a/run_test.py +++ b/run_test.py @@ -2875,10 +2875,11 @@ def check_const_suggestion_cross_tu() -> bool: """ #217: a suggested const type is reported only if every analyzed file still compiles with it, once every declaration the defining file sees is rewritten. Another analyzed file that stores - the function where a non-const parameter is expected, declares it itself, or converts its - address with an explicit cast, removes the suggestion; a homonym with internal linkage in - another file does not. A file that is not analyzed is not checked. The callback and cast files - sort before the definition, the redeclaration after it. + the function where a non-const parameter is expected, or converts its address with an explicit + cast, removes the suggestion. One that declares it itself, without the header, keeps it, not + checked: whether that matters depends on how the files are linked. A homonym with internal + linkage in another file does not change it. A file that is not analyzed is not checked. The + callback and cast files sort before the definition, the redeclaration after it. """ return _check_cross_tu_rule( "suggested const types checked in every analyzed file", @@ -2889,7 +2890,7 @@ def check_const_suggestion_cross_tu() -> bool: ("def alone", ["def.c"], ["read_value"]), ("def and a caller", ["def.c", "caller.c"], ["read_value"]), ("def and a callback of the old type", ["def.c", "callback.c"], []), - ("def and a redeclaration", ["def.c", "redecl.c"], []), + ("def and a redeclaration", ["def.c", "redecl.c"], ["read_value"]), ("def and an explicit cast", ["def.c", "cast.c"], []), # Another file's homonym with internal linkage is another function. ( @@ -2966,13 +2967,14 @@ def check_const_suggestion_compilation_database() -> bool: """ #217: with a compilation database, a suggested const type is checked in every file the database lists that sees the change, analyzed or not, with its own compile command. A file - that is not analyzed and stores the function where a non-const parameter is expected, declares - it itself without the header, or converts its address with an explicit cast, removes the - suggestion, even listed after a file that cannot be checked. So does a file that takes the - address of a method with the old type: it sees the change through the header alone. A file - that sees the change but does not compile as it is, or cannot be preprocessed, keeps the - suggestion as not checked, without the claim of the check. A file that does not see the change - is not compiled. + that is not analyzed and stores the function where a non-const parameter is expected, or + converts its address with an explicit cast, removes the suggestion, even listed after a file + that cannot be checked. So does a file that takes the address of a method with the old type: it + sees the change through the header alone. A file that declares the function itself, without + the header, keeps the suggestion as not checked, and the reason names it and the link it + assumes. A file that sees the change but does not compile as it is, or cannot be preprocessed, + keeps the suggestion as not checked, without the claim of the check. A file that does not see + the change is not compiled. """ print("=== Testing const suggestions checked in the compilation database ===") # Absolute: a compilation database names absolute directories. @@ -3050,7 +3052,16 @@ def message(label: str, listed: list[str], function: str) -> tuple[bool, Optiona None, ), # Without the header: only the name of the function shows that it sees the change. - ("a redeclaration, not analyzed", ["def.c", "redecl.c"], "read_value", None), + ( + "a redeclaration, not analyzed", + ["def.c", "redecl.c"], + "read_value", + "not checked with Clang: " + str(fixtures / f"{prefix}redecl.c") + + " declares read_value with its current type and does not see the rewrite: if " + + str(fixtures / f"{prefix}def.c") + " and " + str(fixtures / f"{prefix}redecl.c") + + " are linked together, that declaration no longer matches the rewritten " + "definition", + ), ("an explicit cast, not analyzed", ["def.c", "cast.c"], "read_value", None), ("a method alone", ["method.cpp"], method, checked), ( From 2b4b968f0bad292e82e589ff8cf4c07b792d70e8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 09:59:59 +0200 Subject: [PATCH 088/152] feat(const-param): keep a rewrite that may collide with another file, unchecked --- src/analysis/ConstSuggestionCheck.cpp | 65 +++++++++++++++++---- src/analysis/ConstSuggestionFacts.cpp | 65 ++++++++++++++++++--- src/analysis/ConstSuggestionInternal.hpp | 29 ++++++++++ src/analysis/ConstSuggestionMatch.cpp | 74 ++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 18 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 1e040fe..5926a7b 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -5,6 +5,7 @@ #include "analysis/ConstSuggestionCheck.hpp" #include "ConstSuggestionInternal.hpp" +#include "analysis/AnalyzerUtils.hpp" #include "analysis/CompileCommands.hpp" #include "analysis/InputPipeline.hpp" #include "analyzer/HotspotProfiler.hpp" @@ -407,18 +408,20 @@ namespace ctrace::stack::analysis } // Checks one suggestion, the target at `index`, in one unit, against the unit before the - // rewrite: the judgement of §5.8. + // rewrite: the judgement of §5.8. `after` receives the unit's compilation after the + // rewrite, with the collisions it finds (§5.10). Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, const SuggestionPlan& plan, PrintedSignature& signature, - std::vector& accepted, std::string& renamed, bool timing) + std::vector& accepted, std::string& renamed, + UnitFacts& after, bool timing) { // Step 1: a unit that cannot be checked. if (!before) return indeterminate(kDoesNotCompileAsItIs); - UnitFacts after = inspect(command, &plan, rewritten, {planned}, - "const_suggestion.compile.after", timing); + after = inspect(command, &plan, rewritten, {planned}, "const_suggestion.compile.after", + timing); if (!after.failure.empty()) return indeterminate(after.failure); // Step 2: after a fatal error, the rest of the unit is not inspected. @@ -450,6 +453,21 @@ namespace ctrace::stack::analysis } if (!after.module) return refuted("does not compile: " + after.firstDiagnostic); + // Another entity's declaration that the rewrite merges into the entity: a declaration + // it has after the rewrite only (ruling 3). + if (!after.entities.empty() && index < before->unit.entities.size()) + { + UnitFacts::Entity& entity = after.entities[0]; + for (const auto& [site, line] : entity.declarations) + { + if (!before->unit.entities[index].declarations.count(site)) + { + entity.collisions.push_back( + {entity.variants.empty() ? planned.symbol : entity.variants[0].symbol, + site, std::nullopt, line}); + } + } + } // The correspondences of the rewrite (§5.7), from the facts of both compilations; the // ABI line names the suggestion's own symbol after it. std::string missing; @@ -670,10 +688,28 @@ namespace ctrace::stack::analysis PrintedSignature signature{found.printedSignature, ""}; std::vector accepted; std::string renamed; + // Each unit judged, as its compilation after the rewrite shows it: its collisions + // are judged once none of the units refutes (§5.10). Its module is not kept. + std::vector inspected(1); + const auto release = [](UnitFacts& unit) + { + unit.module.reset(); + unit.context.reset(); + }; + const std::string definingFile = canonicalPath(self->filename, config); std::vector judgements{checkUnit(command, definingBefore, i, planned, rewritten, plan, signature, accepted, - renamed, timing)}; - std::vector judged{label(canonicalPath(self->filename, config), &command)}; + renamed, inspected.back(), timing)}; + release(inspected.back()); + std::vector judged{label(definingFile, &command)}; + // The other units look for the symbols the entity takes after the rewrite, as the + // defining unit names them. + Target others = planned; + if (!inspected.back().entities.empty()) + { + for (const const_suggestion::Named& variant : inspected.back().entities[0].variants) + others.takes.push_back(variant.symbol); + } // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or // not. One that cannot be checked leaves the suggestion unchecked, unless another @@ -761,9 +797,10 @@ namespace ctrace::stack::analysis if (!sees) continue; std::vector otherAccepted; - Judgement judgement = - checkUnit(other, originalOf(other, key, targets, originals, timing), i, planned, - rewritten, plan, signature, otherAccepted, renamed, timing); + Judgement judgement = checkUnit( + other, originalOf(other, key, targets, originals, timing), i, others, rewritten, + plan, signature, otherAccepted, renamed, inspected.emplace_back(), timing); + release(inspected.back()); if (judgement.verdict == Verdict::Indeterminate && judgement.reason == kDoesNotCompileAsItIs) { @@ -777,7 +814,15 @@ namespace ctrace::stack::analysis accepted.push_back(line); } } - const Judgement verdict = const_suggestion::aggregate(judgements); + Judgement verdict = const_suggestion::aggregate(judgements); + if (verdict.verdict == Verdict::Verified) + { + std::vector units; + for (const UnitFacts& unit : inspected) + units.push_back(&unit); + verdict = const_suggestion::judgeCollisions( + units, 0, definingFile, formatFunctionNameForMessage(issue.funcName)); + } if (verdict.verdict == Verdict::Refuted) { trace(issue, "removed, " + verdict.reason); diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 31fe67a..6744a2a 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -293,6 +293,7 @@ namespace ctrace::stack::analysis // After an error, the rewrite is refuted by it (§5.8, step 4): no fact is needed. collectFacts_ = generator_ && !context.getDiagnostics().hasErrorOccurred(); sites_.emplace(context.getSourceManager(), rewrittenBy_); + out_.file = sites_->fileOf(context.getSourceManager().getMainFileID()); out_.entities.resize(targets_.size()); located_.assign(targets_.size(), nullptr); atSites_.assign(targets_.size(), {}); @@ -772,6 +773,41 @@ namespace ctrace::stack::analysis return targets_[i].plan ? targets_[i].plan->entity : out_.entities[i].plan.entity; } + // §5.10, after the rewrite: each symbol the entity of target i takes that this + // compilation defines. Another entity's definition is a collision, with its IR + // linkage; the entity's own, such as the inline function of a rewritten header that + // several files define, is none. + void definitions(std::size_t i) + { + UnitFacts::Entity& facts = out_.entities[i]; + const llvm::Module* module = generator_->GetModule(); + std::vector takes = targets_[i].takes; + for (const const_suggestion::Named& variant : facts.variants) + { + if (std::find(takes.begin(), takes.end(), variant.symbol) == takes.end()) + takes.push_back(variant.symbol); + } + for (const std::string& symbol : takes) + { + const llvm::GlobalValue* value = + module ? module->getNamedValue(symbol) : nullptr; + if (!value || value->isDeclaration()) + continue; + if (!facts.linkage) + facts.linkage = value->getLinkage(); + const auto* function = llvm::dyn_cast_or_null( + generator_->GetDeclForMangledName(symbol)); + const clang::FunctionDecl* definition = + function ? function->getDefinition() : nullptr; + if (definition && atSites(i, *definition)) + continue; + const clang::SourceLocation at = + definition ? definition->getLocation() : clang::SourceLocation(); + facts.collisions.push_back({symbol, (*sites_)(at), value->getLinkage(), + at.isValid() ? lineOf(at) : 0}); + } + } + // In the defining unit's initial compilation, the function is the one CodeGen emits // under the suggestion's symbol. void planAt(std::size_t i) @@ -854,16 +890,29 @@ namespace ctrace::stack::analysis } } facts.found = !found.empty(); - if (!facts.found) - return; - const std::vector& symbols = symbolsOf(*found.front()); - const std::vector keys = variantKeys(*found.front()); - for (std::size_t k = 0; k < symbols.size() && k < keys.size(); ++k) - facts.variants.push_back({symbols[k], keys[k], ""}); - facts.printedSignature = clang::PredefinedExpr::ComputeName( - clang::PredefinedIdentKind::PrettyFunction, found.front()); + if (facts.found) + { + const std::vector& symbols = symbolsOf(*found.front()); + const std::vector keys = variantKeys(*found.front()); + for (std::size_t k = 0; k < symbols.size() && k < keys.size(); ++k) + facts.variants.push_back({symbols[k], keys[k], ""}); + facts.printedSignature = clang::PredefinedExpr::ComputeName( + clang::PredefinedIdentKind::PrettyFunction, found.front()); + for (const clang::FunctionDecl* function : found) + { + for (const clang::FunctionDecl* declaration : function->redecls()) + { + const clang::SourceLocation at = declaration->getLocation(); + if (at.isValid()) + facts.declarations.emplace((*sites_)(at), lineOf(at)); + } + } + } if (!target.plan || target.plan != rewrittenBy_) return; + definitions(i); + if (!facts.found) + return; const clang::SourceManager& sm = context_->getSourceManager(); for (const clang::FunctionDecl* function : found) { diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 2c091d7..86b8ec4 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -13,6 +13,7 @@ #include #include +#include #include "analysis/ConstSuggestionCheck.hpp" @@ -237,6 +238,9 @@ namespace ctrace::stack::analysis::const_suggestion // The parameter's position among those the source declares (P0), none for one it // does not declare. std::optional sourceIndex; + // After the rewrite, the symbols the entity takes, as the defining unit's compilation + // names them: another unit can define one without declaring the entity (§5.10). + std::vector takes; }; // An error or a checked warning, in the order Clang reports it. @@ -262,9 +266,27 @@ namespace ctrace::stack::analysis::const_suggestion // About the function of one target. struct Entity { + // After the rewrite: a symbol the entity takes that another entity defines here, or + // another entity's declaration merged into it (ruling 3). + struct Collision + { + std::string symbol; + DeclarationSite site; // the other entity's definition, or declaration + std::optional linkage; // none for a declaration + unsigned line = 0; // the site's, where Clang expands it + std::uint8_t reservedPadding[4] = {}; + }; + SuggestionPlan plan; // in the compilation that plans std::string printedSignature; // for __PRETTY_FUNCTION__ std::string problem; + std::vector collisions; + // After the rewrite, the IR linkage of the first symbol it takes that this + // compilation defines: in the defining unit, the entity's own (§5.10). + std::optional linkage; + // Its declarations' sites, each with the line where Clang expands it: those that the + // rewrite adds are other entities' declarations that merge into it (ruling 3). + std::map declarations; std::vector overrides; // O, for the methods named like it std::vector variants; // the entity's: C1, C2, D0... or "" for a function // The constructors inheriting it, or a specialization of its template: by derived @@ -291,6 +313,7 @@ namespace ctrace::stack::analysis::const_suggestion bool timing = false; std::uint8_t reservedPadding[7] = {}; std::string failure; // the compilation could not run + std::string file; // its main file's real path std::multiset diagnostics; std::vector reported; std::string firstDiagnostic; @@ -315,6 +338,12 @@ namespace ctrace::stack::analysis::const_suggestion std::vector correspond(const UnitFacts& before, std::size_t entity, const UnitFacts& after, std::string& missing); + // §5.10: the verdict of the collisions that the units' compilations after the rewrite found, + // once none of the units refutes: `definingFile` names the defining unit, `function` the + // entity as the diagnostic shows it. + Judgement judgeCollisions(const std::vector& units, std::size_t entity, + const std::string& definingFile, const std::string& function); + // Compiles a unit and inspects it for `targets`. After the rewrite, `rewritten` remaps the // files that the plan `rewrittenBy` rewrites to their texts. UnitFacts inspect(const UnitCommand& command, const SuggestionPlan* rewrittenBy, diff --git a/src/analysis/ConstSuggestionMatch.cpp b/src/analysis/ConstSuggestionMatch.cpp index f1aaa6d..1594804 100644 --- a/src/analysis/ConstSuggestionMatch.cpp +++ b/src/analysis/ConstSuggestionMatch.cpp @@ -22,8 +22,10 @@ namespace ctrace::stack::analysis using const_suggestion::FactKey; using const_suggestion::FactKind; using const_suggestion::itemOf; + using const_suggestion::Judgement; using const_suggestion::Named; using const_suggestion::UnitFacts; + using const_suggestion::Verdict; // Appends `item` once, in the order items come. void addOnce(std::vector& items, const std::string& item) @@ -214,4 +216,76 @@ namespace ctrace::stack::analysis } return out; } + + Judgement const_suggestion::judgeCollisions(const std::vector& units, + std::size_t entity, const std::string& definingFile, + const std::string& function) + { + using llvm::GlobalValue; + const auto indeterminate = [](std::string why) + { return Judgement{std::move(why), Verdict::Indeterminate}; }; + // Strong: external; weak: weak or linkonce, ODR or not. Any other linkage, local above + // all, has no scope across files. + const auto strong = [](GlobalValue::LinkageTypes linkage) + { return GlobalValue::isExternalLinkage(linkage); }; + const auto weak = [](GlobalValue::LinkageTypes linkage) + { return GlobalValue::isWeakLinkage(linkage) || GlobalValue::isLinkOnceLinkage(linkage); }; + // The entity's own linkage, as the defining unit defines it; strong if it shows none. + GlobalValue::LinkageTypes own = GlobalValue::ExternalLinkage; + for (const UnitFacts* unit : units) + { + if (unit->file == definingFile && entity < unit->entities.size() && + unit->entities[entity].linkage) + { + own = *unit->entities[entity].linkage; + } + } + if (!strong(own) && !weak(own)) + return {}; + // A definition first, then a declaration (ruling 1), in the order the units are judged. + for (const UnitFacts* unit : units) + { + if (entity >= unit->entities.size()) + continue; + for (const UnitFacts::Entity::Collision& collision : unit->entities[entity].collisions) + { + if (!collision.linkage || + (!strong(*collision.linkage) && !weak(*collision.linkage))) + continue; + const std::string assumption = collision.symbol + " is also defined in " + + unit->file + "; if " + definingFile + " and " + + unit->file + " are linked together, "; + if (strong(own) && strong(*collision.linkage)) + return indeterminate(assumption + "the two definitions collide"); + if (weak(own) && weak(*collision.linkage)) + { + return indeterminate( + assumption + "the linker keeps one of the two weak definitions, and the " + "check establishes neither which one nor that they are " + "equivalent"); + } + const bool ownIsStrong = strong(own); + return indeterminate(assumption + "the strong definition, in " + + (ownIsStrong ? definingFile : unit->file) + + ", is used and the weak one, in " + + (ownIsStrong ? unit->file : definingFile) + ", is ignored"); + } + } + for (const UnitFacts* unit : units) + { + if (entity >= unit->entities.size()) + continue; + for (const UnitFacts::Entity::Collision& collision : unit->entities[entity].collisions) + { + if (collision.linkage) + continue; + return indeterminate("the rewrite gives " + function + + " the symbol of another declaration (" + + collision.site.expansion.file + ":" + + std::to_string(collision.line) + "); if a file linked with " + + definingFile + " defines it, the two definitions collide"); + } + } + return {}; + } } // namespace ctrace::stack::analysis From 79dad0ff6f0911f241132829b1af28d70ec6655e Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 10:03:19 +0200 Subject: [PATCH 089/152] fix(const-param): keep a suggestion that another file redeclares, unchecked --- src/analysis/ConstSuggestionCheck.cpp | 32 ++++++++++++++++-------- src/analysis/ConstSuggestionFacts.cpp | 15 +++++------ src/analysis/ConstSuggestionInternal.hpp | 5 +++- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 5926a7b..f63e428 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -409,13 +409,13 @@ namespace ctrace::stack::analysis // Checks one suggestion, the target at `index`, in one unit, against the unit before the // rewrite: the judgement of §5.8. `after` receives the unit's compilation after the - // rewrite, with the collisions it finds (§5.10). + // rewrite, with the collisions it finds (§5.10); `definingFile` names the defining unit. Judgement checkUnit(const UnitCommand& command, const std::optional& before, std::size_t index, const Target& planned, const std::map& rewritten, - const SuggestionPlan& plan, PrintedSignature& signature, - std::vector& accepted, std::string& renamed, - UnitFacts& after, bool timing) + const SuggestionPlan& plan, const std::string& definingFile, + PrintedSignature& signature, std::vector& accepted, + std::string& renamed, UnitFacts& after, bool timing) { // Step 1: a unit that cannot be checked. if (!before) @@ -488,6 +488,17 @@ namespace ctrace::stack::analysis return refuted(*compared.changed); if (compared.incomplete) return indeterminate(*compared.incomplete); + // A unit that declares the entity for the linker only, with its current type: whether + // that declaration matters depends on how the files are linked (D17). + if (facts.stale) + { + return indeterminate(after.file + " declares " + + formatFunctionNameForMessage(planned.symbol) + + " with its current type and does not see the rewrite: if " + + definingFile + " and " + after.file + + " are linked together, that declaration no longer matches " + "the rewritten definition"); + } if (!missing.empty()) return indeterminate(missing); if (signature.after.empty() && !facts.printedSignature.empty()) @@ -697,9 +708,9 @@ namespace ctrace::stack::analysis unit.context.reset(); }; const std::string definingFile = canonicalPath(self->filename, config); - std::vector judgements{checkUnit(command, definingBefore, i, planned, - rewritten, plan, signature, accepted, - renamed, inspected.back(), timing)}; + std::vector judgements{ + checkUnit(command, definingBefore, i, planned, rewritten, plan, definingFile, + signature, accepted, renamed, inspected.back(), timing)}; release(inspected.back()); std::vector judged{label(definingFile, &command)}; // The other units look for the symbols the entity takes after the rewrite, as the @@ -797,9 +808,10 @@ namespace ctrace::stack::analysis if (!sees) continue; std::vector otherAccepted; - Judgement judgement = checkUnit( - other, originalOf(other, key, targets, originals, timing), i, others, rewritten, - plan, signature, otherAccepted, renamed, inspected.emplace_back(), timing); + Judgement judgement = + checkUnit(other, originalOf(other, key, targets, originals, timing), i, others, + rewritten, plan, definingFile, signature, otherAccepted, renamed, + inspected.emplace_back(), timing); release(inspected.back()); if (judgement.verdict == Verdict::Indeterminate && judgement.reason == kDoesNotCompileAsItIs) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 6744a2a..0abb33a 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -46,7 +46,6 @@ #include #include #include -#include #include #include #include @@ -931,16 +930,14 @@ namespace ctrace::stack::analysis const clang::SourceLocation at = sm.getExpansionLoc(declaration->getLocation()); const std::string& file = sites_->fileOf(sm.getFileID(at)); - // A planned parameter must have the candidate (§5.6, step 3); one that - // another file declares stays with its type (T v1, Task 1.6). + // A planned parameter must have the candidate (§5.6, step 3). A declaration + // in a file that the plan does not rewrite, through which the unit declares + // the entity for the linker only, keeps its type: the unit does not see the + // rewrite (D17). if (target.plan->edits.count(file)) facts.withoutCandidate = true; - else if (facts.problem.empty()) - { - facts.problem = "declared in " + llvm::sys::path::filename(file).str() + - ":" + std::to_string(sm.getExpansionLineNumber(at)) + - " with type " + type; - } + else + facts.stale = true; } } if (facts.problem.empty() && diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 86b8ec4..1f59fee 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -298,7 +298,10 @@ namespace ctrace::stack::analysis::const_suggestion bool found = false; // the compilation declares it // After the rewrite, a declaration in a rewritten file without the candidate. bool withoutCandidate = false; - std::uint8_t reservedPadding[6] = {}; + // After the rewrite, a declaration in a file it does not rewrite, without the + // candidate: the unit declares the entity only for the linker (D17). + bool stale = false; + std::uint8_t reservedPadding[5] = {}; }; // A declaration that a rewritten file writes (§5.6). From 216b68b8115a23f752ce7ad69680bfe0e71bfea0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 10:03:50 +0200 Subject: [PATCH 090/152] docs(readme): a static function and its homonym elsewhere are two functions --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f9367cc..bcd0ffc 100644 --- a/README.md +++ b/README.md @@ -670,8 +670,9 @@ The check runs these steps: The files considered are the analyzed ones and, with `--compile-commands`, every C or C++ file of the compilation database, analyzed or not. A file sees the change when it reads a rewritten file, when its analyzed code references the function, or, for a function outside any class with - external linkage, when it names the function after preprocessing. A homonym with internal - linkage (`static`, anonymous namespace) in another file is another function. + external linkage, when it names the function after preprocessing. A function with internal + linkage (`static`, anonymous namespace) and a function of the same name in another file are two + functions, whichever of them has internal linkage. 3. The suggestion is removed when one of these files: - no longer compiles; - gains one of these warnings: `incompatible-pointer-types-discards-qualifiers`, From 01342a44ad69aa42200f8556f806ffec1fcae202 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 17:18:56 +0200 Subject: [PATCH 091/152] test(const-param): keep a rewrite that merges another declaration, unchecked --- run_test.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/run_test.py b/run_test.py index 6808d45..f22f3e9 100755 --- a/run_test.py +++ b/run_test.py @@ -3696,6 +3696,39 @@ def check_const_suggestion_inline_in_a_header() -> bool: return ok +def check_const_suggestion_merged_declaration() -> bool: + """ + #217: rewritten, f(int*) takes the symbol of the overload f(const int*) that the header also + declares: the two declarations become one function, which a file linked with this one may + define. The check does not know how the files are linked: the suggestion is kept, not checked, + and the reason names the declaration and that assumption (ruling 3). + """ + print("=== Testing a const suggestion whose rewrite merges another declaration ===") + sources = { + "api.hpp": "int f(int* p);\nint f(const int* p);\n", + "a.cpp": '#include "api.hpp"\nint f(int* p)\n{\n return *p;\n}\n', + } + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_merged_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + run = _const_suggestion_run([str(root / "a.cpp")]) + expected = ( + "not checked with Clang: the rewrite gives f(int*) the symbol of another declaration " + f"({root / 'api.hpp'}:2); if a file linked with {root / 'a.cpp'} defines it, the two " + "definitions collide" + ) + texts = run[0].get("_Z1fPi", []) if run else [] + good = ( + len(texts) == 1 + and expected in texts[0] + and "checked in the analyzed files only" not in texts[0] + ) + print(f" {'✅' if good else '❌'} a.cpp alone" + ("" if good else f": {texts}")) + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6658,6 +6691,7 @@ def record_ok(ok: bool): check_const_suggestion_header_read_differently, check_const_suggestion_overload_defined_elsewhere, check_const_suggestion_inline_in_a_header, + check_const_suggestion_merged_declaration, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 6b7df644f8b8ff0c168896b99a5eb9f4c3f38153 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 17:19:03 +0200 Subject: [PATCH 092/152] test(const-param): name the definition that a link keeps, strong against weak --- run_test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/run_test.py b/run_test.py index f22f3e9..b2df4ad 100755 --- a/run_test.py +++ b/run_test.py @@ -3729,6 +3729,45 @@ def check_const_suggestion_merged_declaration() -> bool: return good +def check_const_suggestion_strong_and_weak_definitions() -> bool: + """ + #217: rewritten, f(int*) takes the symbol of the overload f(const int*) that another file + defines, and one of the two definitions is weak. Linked together, the files keep the strong + definition and ignore the weak one, without an error: the suggestion is kept, not checked, and + the reason names the definition kept and the one ignored, whether the weak one is the other + file's or the suggested function's. The other file reads no rewritten file and does not + declare f(int*): only the symbol that the rewrite gives f(int*) leads to its definition. + """ + print("=== Testing a const suggestion whose symbol a strong and a weak definition share ===") + weak = "__attribute__((weak)) " + ok = True + for label, entity, other, kept, ignored in ( + ("the other file's definition is weak", "", weak, "a.cpp", "b.cpp"), + ("the suggested function's definition is weak", weak, "", "b.cpp", "a.cpp"), + ): + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_weak_") as tmp: + root = Path(tmp).resolve() + (root / "a.cpp").write_text(entity + "int f(int* p)\n{\n return *p;\n}\n") + (root / "b.cpp").write_text(other + "int f(const int* p)\n{\n return *p + 1;\n}\n") + run = _const_suggestion_run([str(root / "a.cpp"), str(root / "b.cpp")]) + expected = ( + f"not checked with Clang: _Z1fPKi is also defined in {root / 'b.cpp'}; if " + f"{root / 'a.cpp'} and {root / 'b.cpp'} are linked together, the strong " + f"definition, in {root / kept}, is used and the weak one, in {root / ignored}, " + "is ignored" + ) + texts = run[0].get("_Z1fPi", []) if run else [] + good = ( + len(texts) == 1 + and expected in texts[0] + and "checked in the analyzed files only" not in texts[0] + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6692,6 +6731,7 @@ def record_ok(ok: bool): check_const_suggestion_overload_defined_elsewhere, check_const_suggestion_inline_in_a_header, check_const_suggestion_merged_declaration, + check_const_suggestion_strong_and_weak_definitions, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From b3504ea188bc12371b3f39783af59ffdaf19456a Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 17:19:45 +0200 Subject: [PATCH 093/152] test(const-param): keep a suggestion whose address another file casts, unchecked --- run_test.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/run_test.py b/run_test.py index b2df4ad..d364572 100755 --- a/run_test.py +++ b/run_test.py @@ -2875,11 +2875,12 @@ def check_const_suggestion_cross_tu() -> bool: """ #217: a suggested const type is reported only if every analyzed file still compiles with it, once every declaration the defining file sees is rewritten. Another analyzed file that stores - the function where a non-const parameter is expected, or converts its address with an explicit - cast, removes the suggestion. One that declares it itself, without the header, keeps it, not - checked: whether that matters depends on how the files are linked. A homonym with internal - linkage in another file does not change it. A file that is not analyzed is not checked. The - callback and cast files sort before the definition, the redeclaration after it. + the function where a non-const parameter is expected removes the suggestion. One that declares + it itself, without the header, or converts its address with an explicit cast, keeps it, not + checked: whether that matters depends on how the files are linked, or on what the cast hides. + A homonym with internal linkage in another file does not change it. A file that is not + analyzed is not checked. The callback and cast files sort before the definition, the + redeclaration after it. """ return _check_cross_tu_rule( "suggested const types checked in every analyzed file", @@ -2891,7 +2892,7 @@ def check_const_suggestion_cross_tu() -> bool: ("def and a caller", ["def.c", "caller.c"], ["read_value"]), ("def and a callback of the old type", ["def.c", "callback.c"], []), ("def and a redeclaration", ["def.c", "redecl.c"], ["read_value"]), - ("def and an explicit cast", ["def.c", "cast.c"], []), + ("def and an explicit cast", ["def.c", "cast.c"], ["read_value"]), # Another file's homonym with internal linkage is another function. ( "a static function and a static homonym", @@ -2967,14 +2968,14 @@ def check_const_suggestion_compilation_database() -> bool: """ #217: with a compilation database, a suggested const type is checked in every file the database lists that sees the change, analyzed or not, with its own compile command. A file - that is not analyzed and stores the function where a non-const parameter is expected, or - converts its address with an explicit cast, removes the suggestion, even listed after a file - that cannot be checked. So does a file that takes the address of a method with the old type: it - sees the change through the header alone. A file that declares the function itself, without - the header, keeps the suggestion as not checked, and the reason names it and the link it - assumes. A file that sees the change but does not compile as it is, or cannot be preprocessed, - keeps the suggestion as not checked, without the claim of the check. A file that does not see - the change is not compiled. + that is not analyzed and stores the function where a non-const parameter is expected removes + the suggestion, even listed after a file that cannot be checked. So does a file that takes the + address of a method with the old type: it sees the change through the header alone. A file + that declares the function itself, without the header, keeps the suggestion as not checked, + and the reason names it and the link it assumes; so does a file that converts the function's + address with an explicit cast, and the reason names the cast. A file that sees the change but + does not compile as it is, or cannot be preprocessed, keeps the suggestion as not checked, + without the claim of the check. A file that does not see the change is not compiled. """ print("=== Testing const suggestions checked in the compilation database ===") # Absolute: a compilation database names absolute directories. @@ -3062,7 +3063,13 @@ def message(label: str, listed: list[str], function: str) -> tuple[bool, Optiona + " are linked together, that declaration no longer matches the rewritten " "definition", ), - ("an explicit cast, not analyzed", ["def.c", "cast.c"], "read_value", None), + ( + "an explicit cast, not analyzed", + ["def.c", "cast.c"], + "read_value", + "not checked with Clang: " + str(fixtures / f"{prefix}cast.c") + + ":10 converts the address of read_value with an explicit cast", + ), ("a method alone", ["method.cpp"], method, checked), ( "a method's address of the old type, not analyzed", From af997249ffcf953f391c9e4f549d49b3fb9e73b5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 20 Oct 2026 17:24:26 +0200 Subject: [PATCH 094/152] fix(const-param): keep a suggestion whose address is cast, unchecked --- src/analysis/ConstSuggestionCheck.cpp | 13 +++++++++++-- src/analysis/ConstSuggestionFacts.cpp | 21 ++++++++++++++------- src/analysis/ConstSuggestionInternal.hpp | 10 +++++++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index f63e428..aad225d 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -478,8 +478,6 @@ namespace ctrace::stack::analysis if (renamed.empty() && pair.before == planned.symbol) renamed = pair.after; } - if (!facts.problem.empty()) - return refuted(facts.problem); // Step 6: a reference, an override or a constant condition that changes, compared // through the pairs; facts that cannot be compared one to one leave the unit // unverified, and so does a correspondence that is not one to one. @@ -499,6 +497,17 @@ namespace ctrace::stack::analysis " are linked together, that declaration no longer matches " "the rewritten definition"); } + // An explicit cast of its address, other than to void: what it hides may change with + // the rewrite, and neither the errors nor the facts tell (D15). Alone, it leaves the + // unit unverified; the steps above still refute. + if (!facts.casts.empty()) + { + const UnitFacts::Entity::Cast& cast = facts.casts.front(); + return indeterminate(cast.site.expansion.file + ":" + std::to_string(cast.line) + + " converts the address of " + + formatFunctionNameForMessage(planned.symbol) + + " with an explicit cast"); + } if (!missing.empty()) return indeterminate(missing); if (signature.after.empty() && !facts.printedSignature.empty()) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 0abb33a..a820feb 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -372,8 +372,8 @@ namespace ctrace::stack::analysis const auto* reference = llvm::dyn_cast(operand); const auto* function = reference ? llvm::dyn_cast(reference->getDecl()) : nullptr; - if (function) - casts_.push_back(function->getCanonicalDecl()); + if (function && cast->getBeginLoc().isValid()) + casts_.push_back({function->getCanonicalDecl(), cast->getBeginLoc()}); return true; } @@ -940,12 +940,18 @@ namespace ctrace::stack::analysis facts.stale = true; } } - if (facts.problem.empty() && - std::find_first_of(casts_.begin(), casts_.end(), found.begin(), found.end()) != - casts_.end()) + // The explicit casts of its address, in source order (D15). + std::vector places; + for (const auto& [function, at] : casts_) { - facts.problem = "its address is converted by an explicit cast"; + if (std::find(found.begin(), found.end(), function) != found.end()) + places.push_back(sm.getExpansionLoc(at)); } + std::stable_sort(places.begin(), places.end(), + [&](clang::SourceLocation a, clang::SourceLocation b) + { return sm.isBeforeInTranslationUnit(a, b); }); + for (const clang::SourceLocation at : places) + facts.casts.push_back({(*sites_)(at), lineOf(at)}); } bool collectFacts_ = false; @@ -964,7 +970,8 @@ namespace ctrace::stack::analysis std::vector located_; std::vector> atSites_; // with one of its sites std::vector> linked_; // and the linker's - std::vector casts_; // explicitly cast + // The functions whose address is cast explicitly, other than to void, and where. + std::vector> casts_; std::vector contexts_; // the instantiations around, outermost first std::map> symbols_; std::map> values_; diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 1f59fee..a8d7ecc 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -277,9 +277,17 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[4] = {}; }; + // After the rewrite, an explicit cast of its address, other than to void (D15). + struct Cast + { + DeclarationSite site; + unsigned line = 0; // the site's, where Clang expands it + std::uint8_t reservedPadding[4] = {}; + }; + SuggestionPlan plan; // in the compilation that plans std::string printedSignature; // for __PRETTY_FUNCTION__ - std::string problem; + std::vector casts; // in source order std::vector collisions; // After the rewrite, the IR linkage of the first symbol it takes that this // compilation defines: in the defining unit, the entity's own (§5.10). From 75344727a45337e287aca32c4234766e1a822187 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 01:58:51 +0200 Subject: [PATCH 095/152] test(const-param): a cast in the defining file hides no other file's error --- run_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/run_test.py b/run_test.py index d364572..d08853b 100755 --- a/run_test.py +++ b/run_test.py @@ -3775,6 +3775,37 @@ def check_const_suggestion_strong_and_weak_definitions() -> bool: return ok +def check_const_suggestion_cast_in_the_defining_file() -> bool: + """ + #217: the defining file converts the function's address with an explicit cast, which alone + leaves the suggestion unchecked, and another analyzed file stores the function where a + non-const parameter is expected, which the rewrite breaks. That file is still checked, and its + error removes the suggestion: a cast never hides an error, a checked warning or a changed fact. + """ + print("=== Testing a const suggestion cast in its own file and broken in another ===") + fixtures = (RUN_CONFIG.test_dir / "pointer_reference-const_correctness").resolve() + header = fixtures / "cross-tu-const-suggestion-api.h" + callback = fixtures / "cross-tu-const-suggestion-callback.c" + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_cast_") as tmp: + definition = Path(tmp).resolve() / "def-with-cast.c" + definition.write_text( + f'#include "{header}"\n\ntypedef int (*erased_reader)(void*);\n\n' + "int read_value(int* p)\n{\n return *p;\n}\n\n" + "erased_reader erase_reader(void)\n{\n return (erased_reader)read_value;\n}\n" + ) + run = _const_suggestion_run([str(definition), str(callback)]) + removed = f"read_value parameter p: removed, does not compile: {callback}:" + good = ( + run is not None + and "read_value" not in run[0] + and any(line.startswith(removed) for line in run[1]) + ) + detail = run[1] if run else "the analysis did not run" + print(f" {'✅' if good else '❌'} removed by the callback" + ("" if good else f": {detail}")) + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6739,6 +6770,7 @@ def record_ok(ok: bool): check_const_suggestion_inline_in_a_header, check_const_suggestion_merged_declaration, check_const_suggestion_strong_and_weak_definitions, + check_const_suggestion_cast_in_the_defining_file, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From a60a1b43fc34992fbac45aebdaaf97f25c736ac5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 01:59:34 +0200 Subject: [PATCH 096/152] fix(const-param): stop judging the units at a refutation only --- src/analysis/ConstSuggestionCheck.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index aad225d..8485da7 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -732,8 +732,9 @@ namespace ctrace::stack::analysis } // Then every other unit that sees a rewritten file or references the function: the // analyzed ones and, with a compilation database, every file it lists, analyzed or - // not. One that cannot be checked leaves the suggestion unchecked, unless another - // one refutes it. + // not. One that is not verified, the defining one included, leaves the suggestion + // unchecked, unless another one refutes it: only a refutation stops the judgement + // (§5.10). const auto cannotCheck = [&](const std::string& unit, const UnitCommand* known, const std::string& why) { @@ -742,11 +743,8 @@ namespace ctrace::stack::analysis }; for (const AnalyzedUnit& unit : candidates) { - if (judgements.front().verdict != Verdict::Verified || - judgements.back().verdict == Verdict::Refuted) - { + if (judgements.back().verdict == Verdict::Refuted) break; - } // Another file references the function only through the linker: one with // internal linkage is another file's homonym (§5.2). const bool referencing = plan.entity.linkage == SourceLinkage::External && From 1f8143831a1badb796cbf8b0bf8c3239ce02694a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 01:59:49 +0200 Subject: [PATCH 097/152] test(const-param): an explicit cast keeps a suggestion, not checked --- .../const-suggestion-void-cast.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-void-cast.c b/test/pointer_reference-const_correctness/const-suggestion-void-cast.c index 97e0cbb..64560c7 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-void-cast.c +++ b/test/pointer_reference-const_correctness/const-suggestion-void-cast.c @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // #217: `(void)read_value` converts the function to no other function type: the suggestion is -// checked and kept. An explicit cast to another function type removes it +// checked and kept. An explicit cast to another function type keeps it, not checked // (cross-tu-const-suggestion-cast.c). // strict-expectation-details: true From da1f16e2b44eb759fe61cf811f2abe0656a3600a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 02:00:28 +0200 Subject: [PATCH 098/152] docs(plan): only a refutation stops the judgement of the units --- .../plans/2026-10-16-const-suggestion-check-v2.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 97f935b..2a9820f 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -946,6 +946,7 @@ The spec leaves these open. The user confirmed them on 2026-10-17, ruling 2 prov 14. **A declaration is known by where it is expanded and where it is written.** One macro invocation can declare several functions, whose names it expands at the same place: a `DeclarationSite` holds both positions, for the identity of the entity and for the scope after the rewrite. `const-suggestion-macro-two-functions.c` pins it: it passes at `64ed37e`, where T v1 finds a function by its symbol, and its failure is shown on a mutant of Task 1.4 that keeps only the expansion position. 15. **The comparator's unit tests are adapted in Task 1.5**, as changed tests submitted with the task: `testConstSuggestionCodeComparison` and `testConstSuggestionNormalizations` pass the established pairs, and keep their guarantees; `testConstSuggestionWithoutConstKeepsIdentifiers` goes with `withoutConst`, its guarantee (two functions whose names differ by `const` are not paired) now held by a case where a renamed symbol without a pair is a difference. 16. **At planning, the scope of D5 covers the parameters only.** Another declaration whose type a macro argument shares with the parameter, such as a variable that the same invocation declares, is caught after the rewrite by the scope of Task 1.4 (step 3, `the rewrite also changes the type of (:)`), which `check_const_suggestion_header_read_differently` pins; at planning, no test would reach it. Cost if wrong: that case gets the step-3 reason. +17. **Only a refutation stops the judgement of the units** (§5.10: refuted if one unit refutes; otherwise indeterminate if one unit or one collision is). T v1 also stopped once the defining unit was not verified; after Task 1.7, a cast in the defining file made it indeterminate, and the error of another file that the rewrite breaks went unseen (`check_const_suggestion_cast_in_the_defining_file`). The user confirmed on 2026-10-21 that every unit that sees the change is judged until one refutes, whatever the defining unit's verdict (`a60a1b4`). Cost: when the defining unit is not verified, the other units are compiled too; a verdict can only move from indeterminate to refuted. ### Shared types @@ -1192,7 +1193,7 @@ check_const_suggestion_compiles_each_file_once: FAIL return {}; } ``` - `checkUnit` returns a `Judgement` instead of its `bool` and its `removed` and `notChecked` strings: T v1's `removed` text is a refutation's reason, its `notChecked` text an indeterminate one's, and a unit that passes is verified. The loop over the units keeps T v1's order and stops at the first refutation, as T v1 does. The outcome maps onto T v1's report: refuted removes the suggestion (`removed, `), indeterminate adds `kNotChecked + reason` (`not checked, `), verified adds the "checked" line and the ABI line, unchanged. + `checkUnit` returns a `Judgement` instead of its `bool` and its `removed` and `notChecked` strings: T v1's `removed` text is a refutation's reason, its `notChecked` text an indeterminate one's, and a unit that passes is verified. The loop over the units keeps T v1's order and stops at the first refutation only (ruling 17); T v1 also stopped once the defining unit was not verified. The outcome maps onto T v1's report: refuted removes the suggestion (`removed, `), indeterminate adds `kNotChecked + reason` (`not checked, `), verified adds the "checked" line and the ABI line, unchanged. Commit: `refactor(const-param): judge each unit and aggregate three verdicts`. - [ ] **Step 4: Units and their configuration.** A unit is a `UnitConfiguration`: the file's real path (`canonicalPath`), its `UnitCommand` (`commandFor`, cached as T v1 caches it in `AnalyzedUnits::commands`), and `fingerprintOf(command)`: ```cpp @@ -2529,6 +2530,8 @@ check_const_suggestion_compilation_database: FAIL **Validation:** both checks pass with their new expectations. +**Follow-up (ruling 17, confirmed by the user on 2026-10-21).** A cast in the defining file left that unit indeterminate, which stopped the judgement of the other units: the error of a file that the rewrite breaks went unseen. `check_const_suggestion_cast_in_the_defining_file` (`7534472`) analyzes a defining file that casts the function's address with the callback of the old type (`cross-tu-const-suggestion-callback.c`), and expects the suggestion removed, `removed, does not compile: :`. Failure at `af99724`: `not checked, :12 converts the address of read_value with an explicit cast`. The loop then stops at a refutation only (`a60a1b4`). The comment of `const-suggestion-void-cast.c` now says that a cast to another function type keeps the suggestion, not checked (`1f81438`); its expectations are unchanged. + ### Task 1.8: trace and README **Files:** `src/analysis/ConstSuggestionCheck.cpp`, `src/analysis/ConstSuggestionPlan.cpp`, `src/analysis/ConstSuggestionInternal.hpp`, `README.md`, `run_test.py`. From 6f931f6ad740cb823f69b5bd089dccd247c58a52 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 03:37:00 +0200 Subject: [PATCH 099/152] test(const-param): give each verdict's grounds in the trace --- run_test.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/run_test.py b/run_test.py index d08853b..0b4c0e0 100755 --- a/run_test.py +++ b/run_test.py @@ -3806,6 +3806,73 @@ def check_const_suggestion_cast_in_the_defining_file() -> bool: return good +def check_const_suggestion_trace() -> bool: + """ + #217: with --verbose, the trace gives each verdict's grounds after the line of its outcome: the + suggestion (rule, function, the parameter's index among the source's parameters), the + transformation (current type, candidate, alternative) and where it is written, each unit + checked with its configuration and its verdict, then the verdict and its reason. Here one + suggestion verified, one refuted and one not checked. + """ + print("=== Testing the trace of const suggestion verdicts ===") + source = ( + "int read_value(int* p)\n{\n return *p;\n}\n" + "static int read_slot(int* slot)\n{\n return *slot;\n}\n" + "int (*const stored)(int*) = read_slot;\n" + "#define DECLARE(name) int name(int* q)\n" + "DECLARE(read_macro)\n{\n return *q;\n}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_trace_") as tmp: + path = Path(tmp).resolve() / "verdicts.c" + path.write_text(source) + run = _const_suggestion_run([str(path)]) + if run is None: + return fail_check("the analysis did not run") + _, trace, _ = run + file = re.escape(str(path)) + unit = rf"unit {file} \[[0-9a-f]+\]" + rule = r"ConstParameterNotModified\.Pointer" + expected = { + "verified": [ + r"read_value parameter p: checked in 1 file\(s\)", + rf"read_value parameter p, suggestion: {rule} on read_value, source index 0", + r"read_value parameter p, transformation: int \*p becomes const int \*p " + r"\(pointer to const\)", + rf"read_value parameter p, rewritten at: {file}:1", + rf"read_value parameter p, {unit}: verified", + r"read_value parameter p, verdict: verified", + ], + "refuted": [ + rf"read_slot parameter slot: removed, does not compile: {file}:9:", + rf"read_slot parameter slot, suggestion: {rule} on read_slot, source index 0", + r"read_slot parameter slot, transformation: int \*slot becomes const int \*slot " + r"\(pointer to const\)", + rf"read_slot parameter slot, rewritten at: {file}:5", + rf"read_slot parameter slot, {unit}: refuted: does not compile: {file}:9:", + rf"read_slot parameter slot, verdict: refuted: does not compile: {file}:9:", + ], + "not checked": [ + r"read_macro parameter q: not checked, the declaration is written by a macro " + r"definition", + rf"read_macro parameter q, suggestion: {rule} on read_macro, source index 0", + r"read_macro parameter q, transformation: int \*q becomes const int \*q " + r"\(pointer to const\)", + r"read_macro parameter q, verdict: indeterminate: the declaration is written by a " + r"macro definition", + ], + } + ok = True + for label, patterns in expected.items(): + missing = [p for p in patterns if not any(re.match(p, line) for line in trace)] + good = not missing + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": missing {missing}")) + ok = ok and good + if not ok: + print(" trace:\n" + "\n".join(f" {line}" for line in trace)) + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6771,6 +6838,7 @@ def record_ok(ok: bool): check_const_suggestion_merged_declaration, check_const_suggestion_strong_and_weak_definitions, check_const_suggestion_cast_in_the_defining_file, + check_const_suggestion_trace, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From f91dd314782fe2c1cabec32750aceccc7c5007c9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 03:44:15 +0200 Subject: [PATCH 100/152] feat(const-param): trace the suggestion, its rewrite, its units and its verdict --- src/analysis/ConstSuggestionCheck.cpp | 66 +++++++++++++++++++----- src/analysis/ConstSuggestionInternal.hpp | 16 +++++- src/analysis/ConstSuggestionPlan.cpp | 56 ++++++++++++++------ 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 8485da7..98bd733 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -407,6 +407,17 @@ namespace ctrace::stack::analysis return {std::move(why), Verdict::Indeterminate}; } + // The rule of a suggestion, as its diagnostic names it. + std::string ruleOf(const ConstParamIssue& issue) + { + const char* rule = "Pointer"; + if (issue.pointerConstOnly) + rule = "PointerConstOnly"; + else if (issue.isReference) + rule = issue.isRvalueRef ? "ReferenceRvaluePreferValue" : "Reference"; + return std::string("ConstParameterNotModified.") + rule; + } + // Checks one suggestion, the target at `index`, in one unit, against the unit before the // rewrite: the judgement of §5.8. `after` receives the unit's compilation after the // rewrite, with the collisions it finds (§5.10); `definingFile` names the defining unit. @@ -589,21 +600,44 @@ namespace ctrace::stack::analysis coretrace::log(coretrace::Level::Debug, "[const-suggestion] {} parameter {}: {}\n", issue.funcName, issue.binding.name, outcome); }; - // Each unit judged, by its file and its configuration, after T v1's line. - const auto traceUnits = [](const ConstParamIssue& issue, - const std::vector& judged, - const std::vector& judgements) + // The verdict's grounds, after T v1's line (§4): the suggestion, the type it tries, where + // the rewrite writes it, each unit judged with its configuration, then the verdict. A + // suggestion whose plan is not executed has no rewrite and no unit. + const auto grounds = [](const ConstParamIssue& issue, const SuggestionPlan* plan, + const std::vector& judged, + const std::vector& judgements, const Judgement& verdict) { static constexpr const char* kVerdicts[] = {"verified", "refuted", "indeterminate"}; - for (std::size_t k = 0; k < judgements.size(); ++k) + static const std::map> kNoEdits; + const auto line = [&](const std::string& text) + { + coretrace::log(coretrace::Level::Debug, "[const-suggestion] {} parameter {}, {}\n", + issue.funcName, issue.binding.name, text); + }; + const auto outcome = [](const Judgement& judgement) + { + return kVerdicts[static_cast(judgement.verdict)] + + (judgement.reason.empty() ? std::string() : ": " + judgement.reason); + }; + line("suggestion: " + ruleOf(issue) + " on " + + formatFunctionNameForMessage(issue.funcName) + ", source index " + + (issue.binding.sourceIndex ? std::to_string(*issue.binding.sourceIndex) : "none")); + if (plan && !plan->candidate.type.empty()) { - const Judgement& judgement = judgements[k]; - coretrace::log(coretrace::Level::Debug, - "[const-suggestion] {} parameter {}, unit {}: {}{}\n", - issue.funcName, issue.binding.name, judged[k], - kVerdicts[static_cast(judgement.verdict)], - judgement.reason.empty() ? "" : ": " + judgement.reason); + line("transformation: " + plan->candidate.current + " becomes " + + plan->candidate.type + " (" + plan->candidate.alternative + ")"); } + for (const auto& [path, edits] : plan && !plan->indeterminate ? plan->edits : kNoEdits) + { + std::vector sorted = edits; + std::sort(sorted.begin(), sorted.end(), + [](const Edit& a, const Edit& b) { return a.offset < b.offset; }); + for (const Edit& edit : sorted) + line("rewritten at: " + path + ":" + std::to_string(edit.line)); + } + for (std::size_t k = 0; k < judgements.size(); ++k) + line("unit " + judged[k] + ": " + outcome(judgements[k])); + line("verdict: " + outcome(verdict)); }; const auto label = [](const std::string& file, const UnitCommand* command) { @@ -616,6 +650,7 @@ namespace ctrace::stack::analysis { issue.checkLines.push_back(kNotChecked + why); trace(issue, "not checked, " + why); + grounds(issue, nullptr, {}, {}, indeterminate(why)); } }; if (!self || (self->language != LanguageType::C && self->language != LanguageType::CXX)) @@ -688,6 +723,8 @@ namespace ctrace::stack::analysis issue.checkLines.push_back(kNotChecked + std::string("the function is not found in the source")); trace(issue, "not checked, the function is not found in the source"); + grounds(issue, &plan, {}, {}, + indeterminate("the function is not found in the source")); kept.push_back(std::move(issue)); continue; } @@ -695,6 +732,7 @@ namespace ctrace::stack::analysis { issue.checkLines.push_back(kNotChecked + *plan.indeterminate); trace(issue, "not checked, " + *plan.indeterminate); + grounds(issue, &plan, {}, {}, indeterminate(*plan.indeterminate)); kept.push_back(std::move(issue)); continue; } @@ -845,19 +883,19 @@ namespace ctrace::stack::analysis if (verdict.verdict == Verdict::Refuted) { trace(issue, "removed, " + verdict.reason); - traceUnits(issue, judged, judgements); + grounds(issue, &plan, judged, judgements, verdict); continue; } if (verdict.verdict == Verdict::Indeterminate) { issue.checkLines.push_back(kNotChecked + verdict.reason); trace(issue, "not checked, " + verdict.reason); - traceUnits(issue, judged, judgements); + grounds(issue, &plan, judged, judgements, verdict); kept.push_back(std::move(issue)); continue; } trace(issue, "checked in " + std::to_string(judgements.size()) + " file(s)"); - traceUnits(issue, judged, judgements); + grounds(issue, &plan, judged, judgements, verdict); const std::string checked = std::string(config.compilationDatabase ? kCheckedDatabase : kCheckedAnalyzed) + kCheckedResult; diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index a8d7ecc..4b1ea4c 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -71,6 +71,17 @@ namespace ctrace::stack::analysis::const_suggestion std::size_t offset = 0; std::size_t length = 0; std::string text; + unsigned line = 0; // the offset's, in the original text + std::uint8_t reservedPadding[4] = {}; + }; + + // The type a suggestion tries (§3), as the trace shows it: the parameter's current type and + // the candidate, each written with the parameter's name, and the alternative it stands for. + struct Candidate + { + std::string current; + std::string type; + std::string alternative; }; // A position in a file's original text: after the rewrite, a position in a rewritten file is @@ -160,8 +171,9 @@ namespace ctrace::stack::analysis::const_suggestion EntityIdentity entity; std::vector parameters; // the parameters it rewrites, sorted std::map> edits; // by real path - std::string expectedType; // the parameter's type once rewritten, written canonically - std::optional indeterminate; // why the plan is not executed + Candidate candidate; // the type the rewrite gives the parameter + std::string expectedType; // the candidate, written canonically + std::optional indeterminate; // why the plan is not executed unsigned sourceIndex = 0; // A free function with external linkage can be declared by a file that reads none of the // rewritten files; a method only where its class is defined. One with internal linkage diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 1dcd104..85f8825 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -17,6 +17,7 @@ #include #include #include +#include namespace ctrace::stack::analysis { @@ -208,7 +209,7 @@ namespace ctrace::stack::analysis const bool seen = std::any_of(edits.begin(), edits.end(), [&](const Edit& edit) { return edit.offset == offset; }); if (!seen) - edits.push_back({offset, length, text}); + edits.push_back({offset, length, text, sm_.getLineNumber(file, offset)}); if (inArgument) inArguments_.push_back(at); return true; @@ -278,25 +279,43 @@ namespace ctrace::stack::analysis const std::set& planned_; }; - // The type the parameter must have once rewritten, written canonically. - std::string expectedType(clang::ASTContext& context, clang::QualType type) + // The type the parameter has once rewritten, which the edits write (§3): a pointer to + // const, its top-level qualifiers kept; a reference to const, for an rvalue reference + // too. Null for another type. + clang::QualType candidateOf(clang::ASTContext& context, clang::QualType type) { - clang::QualType out; if (const auto* pointer = type->getAs()) { - out = context.getQualifiedType( + return context.getQualifiedType( context.getPointerType(pointer->getPointeeType().withConst()), type.getQualifiers()); } - else if (type->isReferenceType()) - { - out = context.getLValueReferenceType(type.getNonReferenceType().withConst()); - } - else - { - return ""; - } - return writtenType(context, out); + if (type->isReferenceType()) + return context.getLValueReferenceType(type.getNonReferenceType().withConst()); + return {}; + } + + // A type as a declaration of `name` writes it. + std::string declaring(const clang::ASTContext& context, clang::QualType type, + llvm::StringRef name) + { + clang::PrintingPolicy policy(context.getLangOpts()); + policy.AnonymousTagLocations = false; + std::string out; + llvm::raw_string_ostream text(out); + type.print(text, policy, name); + return out; + } + + // What the candidate stands for, as the trace names it (Texts). + const char* alternativeOf(clang::QualType type) + { + if (type->isRValueReferenceType()) + return "reference to const; passing by value is not checked"; + if (type->isReferenceType()) + return "reference to const"; + return type.isConstQualified() ? "pointer to const, top-level const kept" + : "pointer to const"; } } // namespace @@ -366,12 +385,17 @@ namespace ctrace::stack::analysis out.indeterminate = "the parameter is not found in the source"; return out; } - out.expectedType = expectedType(context, function.getParamDecl(out.sourceIndex)->getType()); - if (out.expectedType.empty()) + const clang::ParmVarDecl& param = *function.getParamDecl(out.sourceIndex); + const clang::QualType candidate = candidateOf(context, param.getType()); + if (candidate.isNull()) { out.indeterminate = "the parameter is no pointer or reference"; return out; } + out.expectedType = writtenType(context, candidate); + out.candidate = {declaring(context, param.getType(), param.getName()), + declaring(context, candidate, param.getName()), + alternativeOf(param.getType())}; Planner planner(context, macros, out); for (const clang::FunctionDecl* declaration : pattern->redecls()) { From 00a21b7e6e783d95ac9ea35e623da7e73549aca5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 03:48:40 +0200 Subject: [PATCH 101/152] docs(readme): describe the three verdicts of the const suggestion check --- README.md | 140 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 96 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index bcd0ffc..fbec8cd 100644 --- a/README.md +++ b/README.md @@ -648,43 +648,71 @@ those whose suggested type does not compile, drops a qualifier, or changes the g With `--experimental-const-suggestion-check` (or `experimental-const-suggestion-check=true` in a config file), each suggestion is checked with Clang before it is reported. The check is experimental: -- it compares the IR that Clang prints before and after the rewrite. A difference its - normalizations do not cover removes a suggestion that is valid; several such cases were found - and fixed, others may remain; -- it compiles the files that see the change again, once per suggestion: on this repository's - self-analysis, the analysis takes about 19% longer. - -The option is a trade-off, not a fix: with it, a valid suggestion can still be removed; without -it, suggestions whose type does not compile, drops a qualifier, or changes the generated code are -reported. +- it judges the rewrite from what Clang compiles before and after it: its errors and warnings, + what calls and references target, what methods override, constant conditions, and the generated + code. A difference that the facts do not explain keeps the suggestion as not checked. A change + that they show removes it, even when the program would behave the same; +- it compiles each file that sees the change once as it is, for all the suggestions of an analyzed + file, and once more for each suggestion. The cost of this version is not measured yet; the first + version made this repository's self-analysis about 19% longer. + +The option is a trade-off, not a fix: with it, a valid suggestion can still be removed or kept as +not checked; without it, suggestions whose type does not compile, drops a qualifier, or changes the +generated code are reported. + +Each suggestion gets one of three verdicts: +- verified: the suggestion is kept, with a `checked ...` line. In every file that sees the change, + the rewrite compiles, adds none of the tracked warnings, changes none of the facts below, and the + generated code matches; +- refuted: the suggestion is removed. In one of these files, the rewrite adds an error or a tracked + warning, or changes what a call or a reference targets, what a method of that name overrides, an + `if constexpr` condition, or the association that a `_Generic` selection chooses; +- indeterminate: the suggestion is kept, with `not checked with Clang: `. It then rests on + the IR analysis alone: it is not shown to be valid. The check runs these steps: -1. Every declaration of the function that the defining file sees, in that file and its headers, - is rewritten in memory with the suggested type: +1. The plan. The function is found in the defining file's compilation by its symbol. Every + declaration of it that this file sees, in the file and its headers, is rewritten in memory with + the type the check tries: a pointer to const (`const T *p`; `T *const p` becomes + `const T *const p`), or a reference to const (`const T &r`, for an rvalue reference `T &&r` too): - a type written in a macro argument is rewritten where it is written; - - a type written by a macro that writes only the parameter gets `const` before the invocation. - - A macro definition is never rewritten. -2. Every file that sees the change is compiled again with its own compile command, at `-O0`. - The files considered are the analyzed ones and, with `--compile-commands`, every C or C++ file - of the compilation database, analyzed or not. A file sees the change when it reads a rewritten - file, when its analyzed code references the function, or, for a function outside any class with - external linkage, when it names the function after preprocessing. A function with internal - linkage (`static`, anonymous namespace) and a function of the same name in another file are two - functions, whichever of them has internal linkage. -3. The suggestion is removed when one of these files: - - no longer compiles; - - gains one of these warnings: `incompatible-pointer-types-discards-qualifiers`, - `incompatible-pointer-types`, `incompatible-function-pointer-types`, `int-conversion`, - `overloaded-virtual`; - - declares the function, outside the rewritten files, with another parameter type; - - converts the function's address with an explicit cast, other than a cast to `void`; - - changes what the methods of that name override; - - generates different code. - - A file that cannot be preprocessed, or does not compile as it is, cannot be checked: the - suggestion is then kept as not checked, unless another file refutes it. + - a type written by a macro that writes only the parameter gets `const` before the invocation; + - a typedef of a plain pointer is replaced by the type it names, made const. + + A macro definition is never rewritten, nor a macro argument that writes another parameter's type + too: the suggestion is then not checked. +2. The files. Every file that sees the change is compiled as it is and with the rewrite, with its + own compile command, at `-O0`. The files considered are the analyzed ones and, with + `--compile-commands`, every C or C++ file of the compilation database, analyzed or not. A file + sees the change when it reads a rewritten file, when its analyzed code references the function, + or, for a function outside any class with external linkage, when it names the function after + preprocessing. A function with internal linkage (`static`, anonymous namespace) and a function of + the same name in another file are two functions, whichever of them has internal linkage. +3. The judgement, in each file, in this order: + - a file that cannot be preprocessed, or does not compile as it is, cannot be checked, nor a file + whose compilation with the rewrite stops at a fatal error: not checked; + - the rewrite changes only what it plans: a rewritten declaration that does not get the type + tried, or another declaration of a rewritten file whose type changes, leaves it not checked; + - an error, or one of these warnings, that the rewrite adds: refuted. The tracked warnings are + `incompatible-pointer-types-discards-qualifiers`, `incompatible-pointer-types`, + `incompatible-function-pointer-types`, `int-conversion` and `overloaded-virtual`; + - a call or a reference that targets another function, a method of that name that overrides + other methods, an `if constexpr` condition or a `_Generic` selection that changes: refuted. + Facts that cannot be compared one to one leave the file not checked; + - a file that declares the function itself, outside the rewritten files, with its current type, + or that converts the function's address with an explicit cast, other than to `void`: not + checked; + - the symbols that the rewrite renames are paired from these facts. One left without a pair, or + two that would pair with the same symbol, leaves the file not checked; + - the generated code is compared through those pairs. It matches: verified. A difference that + none of these facts explains: not checked. + + Once every file is judged, a symbol that the rewrite gives the function and that another file + defines, or another declaration that the rewrite merges into the function, could collide if the + files are linked together: not checked, and the reason states that assumption. The suggestion is + refuted if one file refutes it, otherwise not checked if one file or one collision leaves it + so, otherwise verified. Only a refutation stops the check of the other files. Generated code is compared function by function and global by global, after these normalizations: @@ -697,12 +725,10 @@ normalizations: - every other metadata, module flags included, is compared by contents, not by number; - attribute groups are compared by contents, not by numbers; - values are numbered, not named, and comments of the printed IR are ignored; -- the symbols the rewrite renames are paired: the function, and what is named or instantiated - inside it. A symbol the file only declares is paired only if it is the function itself. -- rewritten as `const T&`, a forwarding reference `T&&` deduces `G` instead of `G&` or `const G&` - for an lvalue argument: a specialization of the rewritten template that changes this way, and - what is named inside it, is paired when its name without `const` and references is carried by - exactly one symbol before the rewrite and one after. Its code is then compared like any other. +- the symbols that the rewrite renames are paired, as step 3 establishes them: the function's own + symbols, the constructors that inherit it, what is named inside it (lambdas, local classes, + static locals), and the specializations that its calls instantiate. A renamed symbol without a + pair is a difference. Two differences that a program can observe are accepted, each only once verified, and the message lists them: @@ -712,12 +738,32 @@ lists them: The check covers only: - the suggestions it checks. A suggestion marked `not checked with Clang: ...` rests on the IR - analysis alone: a declaration written by a macro definition, an LLVM IR input, a file that sees - the change but cannot be preprocessed or does not compile as it is; + analysis alone, and its reason says why; - the files that see the change, with their compile commands, and the code those configurations compile. Other configurations, other targets, code that the preprocessor excludes, and files outside both the analysis and the compilation database are not checked. +Its limits: +- it does not know which files are linked together, and does not decide the one-definition rule: a + possible collision between files keeps the suggestion as not checked; +- it checks the one configuration that the analysis loads for each file: a file that the + compilation database lists twice is checked with one of its commands; +- a rewrite that changes the program's behavior in a way that calls, overrides and constant + conditions do not show is kept as not checked, not removed: `sizeof(__PRETTY_FUNCTION__)`, for + example, changes with the rewrite; +- the type shown can differ from the type checked. For a `T *const p` parameter, the message shows + `const T *p`, and the check tries `const T *const p`. For an rvalue reference `T &&r`, the message + suggests passing by value, and the check tries `const T &r` only. The trace names the type tried; +- a compile command with `-Werror` that disables a tracked warning group leaves a file that warns in + that group not checked: the check enables the group, and the file then does not compile as it is; +- the type tried is compared as each file's language writes it: a C function that a C++ file + declares through a shared header (`extern "C"`) is not checked when C++ writes the parameter's + type differently, `foo *` for C's `struct foo *`; +- a pointer typedef with a qualifier written before it (`const IntPtr p`) does not get the type + tried: the suggestion is not checked; +- a file that sees the changes of two analyzed files is compiled as it is once for each, and a hit + of the IR cache gives no syntax tree: the check always compiles from the sources. + A CI job that enables the option checks the projects and configurations it analyzes, and nothing else: an analysis run without the option, by a user or by another job, is not checked. @@ -736,9 +782,15 @@ The message says how the suggestion was checked: - `difference: ...`, for each accepted difference; - `ABI: ...`, for a C++ function with external linkage: its symbol changes, so code compiled separately must use the updated declaration and be recompiled; -- `not checked with Clang: ...`, when the check could not run, for example on an LLVM IR input - (`.ll`, `.bc`), which has no source, or for a file of the compilation database that cannot be - preprocessed. The suggestion then rests on the IR analysis alone. +- `not checked with Clang: ...`, when the check could not run or could not conclude: for example on + an LLVM IR input (`.ll`, `.bc`), which has no source, for a file of the compilation database that + cannot be preprocessed, or for a possible collision between files. The suggestion then rests on + the IR analysis alone. + +With `--verbose`, the trace gives each verdict's grounds, on `[const-suggestion]` lines: the +suggestion (its rule, its function, the parameter's position among those the source declares), the +type it tries, where the rewrite writes it, each file judged with its configuration and its +verdict, then the verdict and its reason. ### Example From b0d5cda797fa5b4469c0e7041a33f9d3acfb5419 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 04:30:09 +0200 Subject: [PATCH 102/152] docs(plan): open the draft pull requests after stage 1 --- .../plans/2026-10-16-const-suggestion-check-v2.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 2a9820f..33bb079 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2635,6 +2635,12 @@ check_const_suggestion_trace: FAIL **Validation of stage 1:** the catalogue tests D1 (T), D2, D3, D4, D5, D7, D11, D14, D15, D16, D17, Review Focus 2 to 4, the tests that rulings 6, 11, 12, 13 and 14 add, and the unit tests that ruling 15 adapts, pass; every T v1 test passes, except the changed expectations of D14, D15, D16 and D17; the suite without and with the option, the unit tests, on macOS and Linux; the review cases t1 to t15 give the verdicts of §6 for the stage-1 IDs; CI green and code scanning triaged. +**Pull requests opened after stage 1 (the user, 2026-10-21).** For CI and code scanning, the user had both branches published once stage 1 passed locally, ahead of the spec's calendar (§9, step 6, which opens the draft at the end): +- #238, draft, `237-parameter-binding-abi` at `d711321`, `Fixes #237`; +- #239, draft, `217-t-v2` at `00a21b7`, part of #217; it carries `d711321`. + +Both target `main`, and neither is merged; #221 stays the draft of T v1. From then on, each stage's validation also runs on #239: CI green, code scanning compared with `main`, the triage summarized in the description, which states what remains. Task F.3 keeps the description up to date instead of opening the pull request. + ## Stage 2 — D6, D8, D10, D12, D13 ### Task 2.1: warning policy (D6) @@ -2674,6 +2680,6 @@ check_const_suggestion_trace: FAIL **Work:** the protocol of §8. The criterion is +10 % on the self-analysis. ### Task F.3: delivery -**Work:** README; a draft pull request after the user's go; CI; code scanning triaged; final review on the updated base. No merge. +**Work:** README; the description of the draft #239, opened after stage 1, brought up to date; CI; code scanning triaged; final review on the updated base. No merge. **Validation of the final phase:** every classification explained; cost reported against the criterion; CI green; triage summarized. From 519500a734d7adc568bccb29b4fe2bf36629ff4a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 07:08:32 +0200 Subject: [PATCH 103/152] refactor(const-param): take the visitors' read-only parameters as const --- src/analysis/ConstSuggestionFacts.cpp | 14 +++++++------- src/analysis/ConstSuggestionPlan.cpp | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index a820feb..807801f 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -457,13 +457,13 @@ namespace ctrace::stack::analysis } // §5.6: the type of each declaration that a rewritten file writes, by its site. - bool VisitDeclaratorDecl(clang::DeclaratorDecl* declaration) + bool VisitDeclaratorDecl(const clang::DeclaratorDecl* declaration) { written(*declaration, declaration->getType()); return true; } - bool VisitTypedefNameDecl(clang::TypedefNameDecl* declaration) + bool VisitTypedefNameDecl(const clang::TypedefNameDecl* declaration) { written(*declaration, declaration->getUnderlyingType()); return true; @@ -477,25 +477,25 @@ namespace ctrace::stack::analysis return true; } - bool VisitMemberExpr(clang::MemberExpr* expression) + bool VisitMemberExpr(const clang::MemberExpr* expression) { reference(expression->getMemberDecl(), expression->getMemberLoc()); return true; } - bool VisitCXXConstructExpr(clang::CXXConstructExpr* expression) + bool VisitCXXConstructExpr(const clang::CXXConstructExpr* expression) { reference(expression->getConstructor(), expression->getLocation()); return true; } - bool VisitCXXNewExpr(clang::CXXNewExpr* expression) + bool VisitCXXNewExpr(const clang::CXXNewExpr* expression) { reference(expression->getOperatorNew(), expression->getBeginLoc()); return true; } - bool VisitCXXDeleteExpr(clang::CXXDeleteExpr* expression) + bool VisitCXXDeleteExpr(const clang::CXXDeleteExpr* expression) { reference(expression->getOperatorDelete(), expression->getBeginLoc()); return true; @@ -515,7 +515,7 @@ namespace ctrace::stack::analysis return true; } - bool VisitGenericSelectionExpr(clang::GenericSelectionExpr* selection) + bool VisitGenericSelectionExpr(const clang::GenericSelectionExpr* selection) { if (collectFacts_ && !selection->isResultDependent()) { diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 85f8825..02ba47a 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -244,7 +244,7 @@ namespace ctrace::stack::analysis { } - bool VisitParmVarDecl(clang::ParmVarDecl* parameter) + bool VisitParmVarDecl(const clang::ParmVarDecl* parameter) { if (!shared && !planned_.count(parameter)) shared = writtenAtAnEdit(parameter->getTypeSourceInfo()); From 6a91a7987b2a641b8926dc2afbce1a5fc3d27c12 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 22:45:23 +0200 Subject: [PATCH 104/152] test(analysis): demangle a C++23 explicit object member on any C++ runtime --- test/unit/analyzer_module_unit_tests.cpp | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index f8f7f5d..0832894 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -21,6 +21,7 @@ #include "analyzer/DiagnosticEmitter.hpp" #include "analyzer/LocationResolver.hpp" #include "analyzer/ModulePreparationService.hpp" +#include "mangle.hpp" #include #include @@ -2484,6 +2485,32 @@ namespace return report.failures == 0; } + /// A symbol is recognized as mangled and shown demangled when a demangler reads it, whatever + /// the C++ runtime: one that predates a mangling, such as a C++23 explicit object member's + /// (macOS 14, GCC 11), leaves it to LLVM's (#240). A name that neither reads stays as it is. + bool testSymbolDemangling(TestReport& report) + { + using ctrace::stack::analysis::formatFunctionNameForMessage; + const std::string explicitObject = "_ZNH6Reader4peekERKS_Pi"; + const std::string shown = "Reader::peek(this Reader const&, int*)"; + report.expect(ctrace_tools::isMangled(explicitObject), + "Demangling: an explicit object member's symbol is a mangled name"); + report.expect(ctrace_tools::demangle(explicitObject.c_str()) == shown, + "Demangling: an explicit object member's symbol is shown demangled"); + report.expect(formatFunctionNameForMessage(explicitObject) == shown, + "Demangling: a message names an explicit object member demangled"); + report.expect(ctrace_tools::isMangled("_ZN6Reader4peekEPi") && + ctrace_tools::demangle("_ZN6Reader4peekEPi") == "Reader::peek(int*)", + "Demangling: a member's symbol is recognized and shown demangled"); + report.expect(!ctrace_tools::isMangled("read_value") && + formatFunctionNameForMessage("read_value") == "read_value", + "Demangling: a C function's name is no mangled name"); + report.expect(!ctrace_tools::isMangled("_Z_not_a_symbol") && + ctrace_tools::demangle("_Z_not_a_symbol") == "_Z_not_a_symbol", + "Demangling: a name that no demangler reads stays as it is"); + return report.failures == 0; + } + /// resolveParameterDebugBinding: each parameter gets its own type, and its position among the /// parameters the source declares. The others are artificial: `this`, the vtt that the /// base-object constructor (C2) of a class with a virtual base takes, is_most_derived under the @@ -2688,6 +2715,7 @@ int main(int argc, char** argv) (void)testOwnershipCollectorExceptions(repoRoot, report); (void)testOwnershipSummaryIndex(repoRoot, report); (void)testCompileIRCacheWithoutCompilerRevision(report); + (void)testSymbolDemangling(report); if (report.failures == 0) { From 270955e79953d54f12683ef62dea3bb18af02224 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 21 Oct 2026 22:45:23 +0200 Subject: [PATCH 105/152] fix(analysis): fall back to LLVM's demangler when the C++ runtime's fails --- include/mangle.hpp | 28 ++++------------------------ src/mangle.cpp | 31 +++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/include/mangle.hpp b/include/mangle.hpp index bb17b2e..de229d1 100644 --- a/include/mangle.hpp +++ b/include/mangle.hpp @@ -8,13 +8,6 @@ namespace ctrace_tools { - /** - * @brief Concept to define types that can be converted to `std::string_view`. - * - * The `StringLike` concept ensures that any type passed to functions - * requiring it can be implicitly converted to `std::string_view`. - */ - template concept StringLike = std::convertible_to; // TODO: add mangling for windows /** @@ -23,32 +16,19 @@ namespace ctrace_tools * This function determines whether a given name follows the Itanium C++ ABI * mangling conventions (e.g., names starting with `_Z`). * - * @tparam T A type satisfying the `StringLike` concept. * @param name The name to check for mangling. * @return `true` if the name is mangled, `false` otherwise. * - * @note This function uses `abi::__cxa_demangle` to attempt demangling. - * If the demangling succeeds, the name is considered mangled. + * @note The name is mangled if a demangler reads it: the C++ runtime's + * (`abi::__cxa_demangle`), or LLVM's when the runtime's predates the + * name's mangling, as a C++23 explicit object member's on macOS 14. * @note This implementation is specific to platforms using the Itanium C++ ABI * (e.g., Linux, macOS). Windows mangling is not yet supported. * @note The function is marked `[[nodiscard]]`, meaning the return value * should not be ignored. It is also `noexcept`, indicating that it * does not throw exceptions. */ - [[nodiscard]] bool isMangled(StringLike auto name) noexcept - { - int status = 0; - std::string_view sv{name}; - - if (sv.length() < 2 || sv.substr(0, 2) != "_Z") - { - return false; - } - - std::unique_ptr demangled( - abi::__cxa_demangle(sv.data(), nullptr, nullptr, &status), std::free); - return status == 0; - } + [[nodiscard]] bool isMangled(std::string_view name) noexcept; /** * @brief Generates a mangled name for a function. diff --git a/src/mangle.cpp b/src/mangle.cpp index bed2557..459ae7c 100644 --- a/src/mangle.cpp +++ b/src/mangle.cpp @@ -1,8 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 #include "mangle.hpp" +#include + namespace ctrace_tools { + namespace + { + // A symbol, demangled by the C++ runtime's demangler, or by LLVM's when the runtime's + // predates the symbol's mangling (#240); empty when neither reads it. + std::string demangled(const std::string& name) + { + int status = 0; + std::unique_ptr out( + abi::__cxa_demangle(name.c_str(), nullptr, nullptr, &status), std::free); + if (status != 0 || !out) + out.reset(llvm::itaniumDemangle(name)); + return out ? out.get() : ""; + } + } // namespace + + bool isMangled(std::string_view name) noexcept + { + return name.starts_with("_Z") && !demangled(std::string(name)).empty(); + } std::string mangleFunction(const std::string& namespaceName, const std::string& functionName, const std::vector& paramTypes) @@ -71,14 +92,8 @@ namespace ctrace_tools std::string demangle(const char* name) { - int status = 0; - char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status); - - std::string result = (status == 0 && demangled) ? demangled : name; - - free(demangled); - - return result; + std::string result = demangled(name); + return result.empty() ? name : result; } std::string canonicalizeMangledName(std::string_view name) From 9f723c69fa8450fe31d7bcd6d99c8dfba4161c5f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:31:53 +0200 Subject: [PATCH 106/152] docs(spec): keep Clang's default errors in the tracked groups, add D18 to stage 2 --- ...-10-16-const-suggestion-check-v2-design.md | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index 7b53f55..bd75434 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -1,7 +1,7 @@ # Vérification des suggestions const par Clang, version 2 (#217) Statut : validée à `8b283a8`, puis complétée à la relecture du plan d'implémentation (D17, fixture -de D13), et au détail de l'étape 1 (mutualisation limitée au module, §5 et §10). N'autorise ni le code ni la modification des tests : chaque lot présentera ses +de D13), au détail de l'étape 1 (mutualisation limitée au module, §5 et §10), et à celui de l'étape 2 (erreurs natives de Clang conservées, §5.3 ; D18, §5.6 et §6). N'autorise ni le code ni la modification des tests : chaque lot présentera ses tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 (identité d'un global entre fichiers). @@ -44,7 +44,7 @@ configurations effectivement vérifiées. - identités explicites, plan séparé de son exécution, unité définie par un fichier et une configuration, faits R, O et K, correspondances établies, collisions, verdicts à trois états, trace ; -- étape 2 : D6, D8, D10, D12 et D13 (§6) ; +- étape 2 : D6, D8, D10, D12, D13 et D18 (§6) ; - étape 3 : les coûts évitables mesurés et une seule construction de l'invocation Clang. **Hors périmètre :** @@ -152,8 +152,14 @@ sert au plan. Elle relève : Les groupes d'avertissements suivis sont activés explicitement par `-W`, puis maintenus comme avertissements par `-Wno-error=`, avec la même politique dans les compilations initiale et -modifiée. Une commande de projet qui désactive un groupe (`-Wno-`) ou promeut les -avertissements en erreurs (`-Werror`) ne change donc ni leur relevé ni leur nature (D6). +modifiée. Les erreurs natives de Clang restent des erreurs. `-Wno-error=` rétrograderait +aussi les diagnostics que Clang traite comme des erreurs par défaut. Chaque groupe suivi dont tous +les diagnostics sont des erreurs par défaut, d'après les niveaux que Clang leur donne, est donc +ensuite remis en erreur par `-Werror=`. Avec Clang 20, ce sont, en C, +`incompatible-function-pointer-types` et `int-conversion`, et chaque erreur par défaut d'un groupe +suivi appartient à l'un d'eux. Une commande de projet qui désactive un groupe (`-Wno-`), ou +qui promeut en erreurs les avertissements (`-Werror`) ou un seul groupe (`-Werror=`), ne +change donc ni leur relevé ni leur nature (D6). ### 5.4 Faits R, O et K @@ -199,7 +205,8 @@ dans chaque unité : signatures instanciées (spécialisations de gabarits) qui changent avec la réécriture ne relèvent pas de cet invariant : elles sont examinées par les correspondances (5.7), comme dans `const-suggestion-deduction-kept.cpp` ; -- les redéclarations visées ont le type du candidat, comparé dans la langue de l'unité (D8). +- chaque redéclaration visée a le candidat de son propre type, qualificatifs de premier niveau + compris (D18), comparé dans la langue de son unité (D8). Si les déclarations nécessaires n'ont pas pu être inspectées (erreur fatale, unité incomplète), la portée est inconnue. @@ -302,6 +309,7 @@ suggestion valide. | D10 | `const` écrit devant un typedef de pointeur (t3/t.c, t3/t.cpp) | réécrit en `const const int *` ; « not checked » | vérifiée (`const int *const`) ; indéterminée si le qualificatif vient d'une macro | fixtures C et C++ | 2 | | D12 | alternative de `ReferenceRvaluePreferValue` | seul `const T &` est vérifié ; la ligne dit « the suggested type » | vérifiée ; le type affiché et la ligne nomment `const T &`, le passage par valeur n'est pas vérifié | attentes modifiées (`const-suggestion-deduction-kept.cpp` et autres) | 2 | | D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | nouvelle fixture qui active T, aucune fixture de T v1 ne couvrant `PointerConstOnly` ; le test existant sans l'option reste inchangé | 2 | +| D18 | redéclaration qui diffère de la définition par un qualificatif de premier niveau du paramètre (`int f(int *p);` puis `int f(int *const p) {…}`, et l'inverse), observée à `4fd0dae` (`stage2/observations/toplevel-qualifier/`) | « not checked: the rewritten declaration does not get the suggested type » | vérifiée (VP), dans les deux sens | contrôle run_test.py, deux sens | 2 | **Non retenus :** - le cache « avant » indexé par l'identifiant (point 9 de la revue) : non reproduit (t12) ; les @@ -343,7 +351,7 @@ acceptation d'un dépassement. 3. **Étape 1** : flux, identités, plan et portée, faits R, O et K, correspondances, paires établies pour le comparateur, collisions, trace ; D1 (partie T), D2, D3, D4, D5, D7, D11, D14, D15, D16 et D17. -4. **Étape 2** : D6, D8, D10, D12 et D13. +4. **Étape 2** : D6, D8, D10, D12, D13 et D18. 5. **Étape 3** : les coûts mesurés et l'invocation Clang construite en un seul endroit. 6. **Comparaison des corpus, mesure du coût et README** ; une PR en brouillon. La revue finale porte sur la base actualisée. From de957e75a937e17069fe0a8e2904efc975996b7f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:31:53 +0200 Subject: [PATCH 107/152] docs(plan): detail stage 2, its rulings and batch 2.1's tests --- .../2026-10-16-const-suggestion-check-v2.md | 248 ++++++++++++++++-- 1 file changed, 233 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 33bb079..ba38302 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -10,7 +10,7 @@ **Spec:** `docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md`, approved at `8b283a8`. -**Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. Stage 1's detail was written at its start, once Task I.1 was validated. +**Level of detail.** P0 is planned step by step with its code, validated in a throwaway prototype (evidence below). Stages 1 to 3 and the final phase are planned to the level of tasks, files, interfaces, tests and validation criteria. The step-level code of a stage is added to this plan when that stage starts: P0 and the updated `main` are integrated first, and they change the code these stages modify. A stage starts only once the previous one is validated. Stage 1's detail was written at its start, once Task I.1 was validated; stage 2's at its start, once stage 1 was validated. **Evidence:** `coretrace-stack-analyzer-evidence/217/p0/` (`repro/`, `harness/`, `fixture/`, `effect/`, `prototype-v1.diff`, `prototype-v2.diff`, `prototype-v3.diff`, `ir/`, `variadic/`, `review-v3/`). @@ -57,7 +57,7 @@ Commands (macOS): 2. **C++23 explicit object parameter.** Clang marks `self` an object pointer, not artificial, and the source declares it: in `pick(this const Picker& self, int* p)`, `p` has source index 1, and T rewrites `p`. Pinned in Task P0.1 (unit test) and Task 1.2 (`const-suggestion-explicit-object.cpp` unchanged). 3. **A function defined in a header that several analyzed files include** (`static inline` in C, `inline` in C++): one entity in each unit, never a collision with itself, verified when each unit verifies it. Pinned in Task 1.6 (new `run_test.py` check). 4. **A file that the compilation database lists twice, with different commands.** The loader keeps one; T checks only that configuration, never claims the other, and the trace names it. Pinned in Task 1.1 (new `run_test.py` check). -5. **`-Werror=` on a tracked group**, not plain `-Werror`: the group stays a warning in both compilations, and an introduced warning still refutes. Pinned in Task 2.1 (fixture variant). +5. **`-Werror=` on a tracked group**, not plain `-Werror`: the group stays a warning in both compilations, and an introduced warning still refutes. Pinned in Task 2.1 (a `run_test.py` check, stage 2, ruling 2). --- @@ -2641,25 +2641,243 @@ check_const_suggestion_trace: FAIL Both target `main`, and neither is merged; #221 stays the draft of T v1. From then on, each stage's validation also runs on #239: CI green, code scanning compared with `main`, the triage summarized in the description, which states what remains. Task F.3 keeps the description up to date instead of opening the pull request. -## Stage 2 — D6, D8, D10, D12, D13 +## Stage 2 — D6, D8, D10, D12, D13, D18 -### Task 2.1: warning policy (D6) -**Work:** `-W` then `-Wno-error=` for each tracked group, in both compilations. -**Tests:** D6, a new fixture (t5) with `// analyzer-arg: --compile-arg=-Werror` and a tracked group disabled, verified; Review Focus 5, a variant with `--compile-arg=-Werror=incompatible-pointer-types-discards-qualifiers` where an introduced warning still refutes. +Detailed at the stage's start, on `4fd0dae` (2026-10-22). Stage 1 is validated at that head on macOS, Linux arm64 and Linux x86_64: built without warning, unit and ownership tests passed, suite 2645/2645 on each. The CI of #239 is green there, macOS 14 included. Catalogue: D6, D8, D10, D12, D13, and D18, found while detailing the stage and added to Task 2.2 by the user (spec §6); Review Focus 5. Evidence: `coretrace-stack-analyzer-evidence/217/stage2/`. -### Task 2.2: the unit's language (D8) -**Work:** the targeted redeclarations compared with the candidate in the language of each unit. -**Tests:** D8, a new `run_test.py` check (t4: C definition, C++ user through an `extern "C"` header, verified; the C witness unchanged). +The stage runs in four batches, one per task, in this order: 2.1, 2.2, 2.4, 2.3 (ruling 4). Each batch's tests are written out with their failure at the batch's base, and are authorized before they are added. Batch 2.1's are written out below; the others' are written out when their batch starts. -### Task 2.3: typedef with a qualifier (D10) -**Work:** a pointer typedef receives the printed candidate, a separately written qualifier is folded in; one written by a macro gives indeterminate. -**Tests:** D10, new fixtures in C and C++ (t3/t.c, t3/t.cpp: verified with `const int *const`). +### Texts + +A new reason of an indeterminate verdict, after `not checked with Clang: `: + +| Reason | Task | When | +|---|---|---| +| `the qualifier of the parameter's typedef is written by a macro` | 2.3 | D10: a qualifier written apart from a pointer typedef, by a macro | + +The type displayed (Task 2.4, ruling 3). This applies to a `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion whose plan has a candidate, whatever its verdict. `` is the plan's candidate, written with the analyzer's labels: +- **`ReferenceRvaluePreferValue`, the advice.** `consider passing by value () or const reference ()` becomes `consider passing by const reference ()`. +- **`ReferenceRvaluePreferValue`, the "checked" line of a verified suggestion:** + ```text + checked in the analyzed files only, with their compile commands: they compile with the suggested const reference, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside; passing by value is not checked + ``` + The database variant keeps its own opening, `checked in every file of the compilation database that sees the change, with its compile command: `. +- **`PointerConstOnly`.** `consider '' for API const-correctness` and `suggested type: `, for example `const int *const p` where the rule writes `const int *p`. + +The other rules keep their lines. Without the option, nothing changes. + +### Rulings + +1. **The tracked groups as Clang reports them by default** (Task 2.1). Validated by the user on 2026-10-22; spec §5.3 updated. + - The plan and §5.3 wrote `-W` then `-Wno-error=`. But `-Wno-error=` also turns into warnings the diagnostics that Clang reports as errors by default. With Clang 20, in C, these are the diagnostics of `incompatible-function-pointer-types` and `int-conversion` (`stage2/batch-2.1/diagtool-groups-llvm20.txt`). + - Trial 1 (`batch-2.1/trial-fix.diff`) shows the effect. Three tests of T then read "gains a checked warning" instead of "does not compile", and 42 of the 45 pass: + - `check_const_suggestion_removal_reason`; + - the refuted case of `check_const_suggestion_trace`; + - `check_const_suggestion_cast_in_the_defining_file`. + - The policy therefore adds `-Werror=`, after those flags, for each tracked group whose diagnostics are all errors by default. Clang's `DiagnosticIDs` gives these levels (`getDiagnosticsInGroup`, `isDefaultMappingAsError`). With Clang 20, every default error of a tracked group belongs to such a group. + - Trial 2 (`batch-2.1/trial2-fix.diff`) passes the two new tests, and the 45 checks and fixtures of T. + - Cost if wrong: such a refutation would say "does not compile" where it should say "gains a checked warning". The verdict is the same. +2. **Review Focus 5 is a check, not a fixture variant** (Task 2.1). Validated by the user on 2026-10-22. Under `-Werror=`, the suggestion is removed both before and after the change. Only the reason shows that the group stayed a warning, and only the trace gives it. Cost if wrong: none. +3. **The type displayed is the plan's candidate** (Task 2.4). Revised as the user asked on 2026-10-22. + - For `ReferenceRvaluePreferValue` and `PointerConstOnly`, once a suggestion has a candidate, the type displayed is written from `SuggestionPlan::candidate`. It replaces the rule's proposal, so no other definition of the proposed type is displayed. + - Where Clang's labels differ from those of the analyzer's other lines, the planner writes the analyzer's: + - a type without a name is ``, which Clang writes `(lambda)`; + - a C tag type has no keyword: `foo`, which Clang writes `struct foo`. + + The trace keeps Clang's writing. + - Both rules take the same path. The D13 fixture, where the rule's proposal (`const int *p`) differs from the candidate, therefore pins that path for both. For `ReferenceRvaluePreferValue`, no fixture could: with these labels, the rule's const reference and the candidate write the same text in every case found (`stage2/observations/rvalue-spelling/`). + - Cost if wrong: a label that differs from Clang's and is not listed here is displayed as Clang writes it. +4. **Task 2.4 runs before Task 2.3.** Validated by the user on 2026-10-22. t3's suggestions are `PointerConstOnly` (`const IntPtr p` is `int *const p`), and Task 2.4 changes how that rule is displayed. In this order, the D10 fixtures are written once, with the candidate displayed. Cost if wrong: none. + +### Task 2.1: warning policy (D6, Review Focus 5) + +**Files:** `src/analysis/ConstSuggestionFacts.cpp` (`inspect`), `README.md`, `run_test.py`, `test/pointer_reference-const_correctness/const-suggestion-werror.c` (new). + +**Decision logic.** Both compilations, before and after the rewrite, go through `inspect`. For each tracked group, `-W` and `-Wno-error=`. Then `-Werror=` for each tracked group whose diagnostics are all errors by default (ruling 1). + +**Tests.** For D6 (t5), a new fixture: + +```c +// SPDX-License-Identifier: Apache-2.0 +// #217 (D6): the project turns its warnings into errors (-Werror) and disables a warning group +// that the check tracks, which this file triggers: strip returns a pointer to const as a plain +// pointer. The check keeps each tracked group a warning in both of its compilations: the file +// compiles as it is, and the suggestion for read_value is checked. + +// strict-expectation-details: true + +// at line 20, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// The project's compile command, which both the analysis and the check use. +// analyzer-arg: --compile-arg=-Werror +// analyzer-arg: --compile-arg=-Wno-incompatible-pointer-types-discards-qualifiers +// analyzer-arg: --experimental-const-suggestion-check + +int read_value(int* p) +{ + return *p; +} + +char* strip(const char* s) +{ + return s; +} +``` + +For Review Focus 5 (ruling 2), a new check, placed after `check_const_suggestion_trace` and registered after it: + +```python +def check_const_suggestion_werror_on_a_tracked_group() -> bool: + """ + #217 (Review Focus 5): a compile command that turns one tracked warning group into errors, + not every warning (-Werror=incompatible-pointer-types-discards-qualifiers), does not change + what the check sees: the group stays a warning in both compilations, and a rewrite that adds + such a warning is removed for that warning, not for an error. + """ + print("=== Testing a tracked warning group that the compile command turns into errors ===") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_werror_") as tmp: + path = Path(tmp).resolve() / "keep.c" + path.write_text("void keep(int* p)\n{\n int* q = p;\n (void)q;\n}\n") + run = _const_suggestion_run( + [str(path), "--compile-arg=-Werror=incompatible-pointer-types-discards-qualifiers"] + ) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + removed = ( + f"keep parameter p: removed, gains a checked warning: {path}:3: initializing 'int *' " + "with an expression of type 'const int *' discards qualifiers" + ) + good = "keep" not in messages and any(line.startswith(removed) for line in trace) + print(f" {'✅' if good else '❌'} removed for the warning" + ("" if good else f": {trace}")) + print() + return good +``` + +Failure at `4fd0dae`, on macOS (`batch-2.1/red-rerun-4fd0dae-macos.txt`): +- **The fixture.** In both passes, default and smt-z3, the expected block is missing. The suggestion reads `not checked with Clang: the file does not compile with Clang as it is`. Only the two strict diagnostic counts pass, 2 of the 4 checks. +- **The check.** It reads `keep parameter p: removed, does not compile: …/keep.c:3: initializing 'int *' with an expression of type 'const int *' discards qualifiers`. + +With trial 2 applied, both pass, the fixture 4/4 (`batch-2.1/green-trial2-rerun-macos.txt`). + +- [ ] **Step 1: The fixture.** Add `const-suggestion-werror.c` and run it. Expected: FAIL in both passes, as above. + Commit: `test(const-param): check a suggestion despite -Werror and a disabled tracked group`. +- [ ] **Step 2: The check.** Add `check_const_suggestion_werror_on_a_tracked_group` and run it. Expected: FAIL, as above. + Commit: `test(const-param): a tracked group made an error stays a warning for the check`. +- [ ] **Step 3: The policy (ruling 1)**, in `inspect`, where the tracked groups are enabled: + ```cpp + // D6: each tracked group as Clang reports it by default, whatever the command + // says: enabled, a warning under -Werror, and an error when its diagnostics are + // errors by default (incompatible-function-pointer-types, int-conversion in C). + const clang::DiagnosticIDs& ids = *options.Diags->getDiagnosticIDs(); + std::vector errors; + for (const char* warning : kCheckedWarnings) + { + diagnostics.Warnings.push_back(warning); + diagnostics.Warnings.push_back(std::string("no-error=") + warning); + llvm::SmallVector members; + if (!ids.getDiagnosticsInGroup(clang::diag::Flavor::WarningOrError, warning, + members) && + std::all_of(members.begin(), members.end(), [&](clang::diag::kind id) + { return ids.isDefaultMappingAsError(id); })) + { + errors.push_back(std::string("error=") + warning); + } + } + diagnostics.Warnings.insert(diagnostics.Warnings.end(), errors.begin(), + errors.end()); + ``` + README: + - the judgement's step on errors and tracked warnings says that the tracked groups keep the level Clang gives them by default; + - the limit of a `-Werror` command that disables a tracked group goes. + + Run: both tests, then the tests of T. Expected: + - both tests PASS; + - `check_const_suggestion_removal_reason`, `check_const_suggestion_trace` and `check_const_suggestion_cast_in_the_defining_file` pass unchanged. + + Commit: `fix(const-param): keep the tracked warning groups as Clang reports them by default`. +- [ ] **Step 4: Task tests.** As Task 1.1, Step 6. + +### Task 2.2: each declaration's candidate, in its unit's language (D8, D18) + +**Files:** `src/analysis/ConstSuggestionInternal.hpp`, `src/analysis/ConstSuggestionFacts.cpp`, `src/analysis/ConstSuggestionCheck.cpp`, `run_test.py`. + +**Problem.** After the rewrite, step 3 compares each rewritten declaration with a single type: the plan's `expectedType`. That is the candidate of the defining declaration's parameter, as the defining unit writes it. +- **D8 (t4).** A C function takes a `struct foo *`, and a C++ file declares it through an `extern "C"` header. + - The defining unit writes the candidate `const struct foo *`; the C++ unit writes the same type `const foo *`. + - The C++ unit's declaration therefore "does not get the suggested type", and the suggestion is not checked. + - The C witness (`user2.c`) is verified. +- **D18.** A parameter's top-level qualifier is not part of the function's type, so its declarations may differ in it: `int f(int *p);` then `int f(int *const p) {…}`, or the other way round. + - The rewrite gives each declaration the candidate of its own type: `const int *p`, `const int *const p`. + - Only the defining declaration matches `expectedType`, so in both directions the suggestion is not checked (`stage2/observations/toplevel-qualifier/`). + +**Interfaces.** +- `UnitFacts::Entity::expected`, recorded in each compilation before the rewrite. For each declaration of the entity, by site, it holds the candidate of that declaration's own parameter type, top-level qualifiers included. It is written canonically in that unit's language (`writtenType` of `candidateOf`). + - The defining unit records it once it has planned the entity. + - The other units record it with the plan that their targets carry. +- `Target::expected`: the unit's `expected`, passed to its compilation after the rewrite. + +**Decision logic.** Step 3 compares each rewritten declaration with the expected type of its own site. + +**Tests (batch 2.2).** Two new checks, written out with their failures when the batch starts: +- **D8, over t4.** The C++ user is verified. The C witness is verified, as at the batch's base. +- **D18, in both directions.** `f` is declared without the top-level `const` and defined with it; `g` is declared with it and defined without it. Both are verified. + +The checks read the verdicts in the trace, which Task 2.4 does not change. + +Expected failures at the batch's base: `not checked with Clang: the rewritten declaration does not get the suggested type`, for the C++ user of t4, for `f` and for `g`. ### Task 2.4: one candidate, displayed (D12, D13) -**Work:** the displayed type and the "checked" line name the candidate: `const T &` for `ReferenceRvaluePreferValue` (passing by value is not checked), `const int *const p` for `PointerConstOnly`. -**Tests:** D12, changed expectations of the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, and of the one D14 adds to `const-suggestion-deduction-refused.cpp` (stage 1, ruling 10); D13, a new fixture that enables T (t15: `int* const p` only read, displayed and verified as `const int *const p`), presented with its failure evidence; no T v1 fixture covers `PointerConstOnly`, and the existing test without the option stays unchanged. -**Validation of stage 2:** as stage 1, for D6, D8, D10, D12, D13 and Review Focus 5. +**Files:** +- `src/analysis/ConstSuggestionPlan.cpp`: the candidate written with the analyzer's labels; +- `src/analysis/ConstSuggestionCheck.cpp`: the type displayed, and the "checked" line; +- `src/analyzer/DiagnosticEmitter.cpp`: the advice that names the const reference alone; +- the fixtures `const-suggestion-deduction-kept.cpp` and `const-suggestion-deduction-refused.cpp` (changed), and one new fixture. + +**Decision logic.** Take each `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion that T keeps and whose plan has a candidate. Its displayed type becomes that candidate, as the planner writes it with the analyzer's labels (ruling 3); the rule's own proposal is no longer displayed. +- **`ReferenceRvaluePreferValue` (D12).** The advice names the const reference alone. A verified suggestion's "checked" line says that passing by value is not checked (Texts). +- **`PointerConstOnly` (D13).** The advice and the "suggested type" line name the candidate, `const int *const p`. + +A suggestion without a candidate keeps the rule's proposal (§4). Without the option, nothing changes. + +**Tests (batch 2.4).** +- **D12, changed expectations:** + - the two `ReferenceRvaluePreferValue` blocks of `const-suggestion-deduction-kept.cpp`, advice and "checked" line; + - the block that D14 adds to `const-suggestion-deduction-refused.cpp`, advice only (stage 1, ruling 10). +- **D13.** A new fixture that enables T (t15: `int *const p`, only read), displayed and verified as `const int *const p`. +- The existing test without the option, and every other expectation of T, stay unchanged. + +Expected failures at the batch's base: the two alternatives and the old "checked" line (D12), and the displayed `const int *p` (D13). + +### Task 2.3: typedef with a qualifier (D10) + +**Files:** `src/analysis/ConstSuggestionPlan.cpp` (`Planner::plan`), new fixtures in C and C++. + +**Problem (t3).** In `const IntPtr p`, a pointer typedef carries a `const` written before it. +- The planner replaces only `IntPtr`, with `const int *`, which gives `const const int * p`: the pointer loses its own `const`. +- The candidate, `const int *const p`, is therefore not met, and the suggestion is not checked. + +**Decision logic.** +- The edit replaces the whole written type with the printed candidate of that declaration's type, `const int *const`. The written type runs from its first qualifier to the typedef's name, or to the last qualifier when one follows the name. The qualifiers written apart are thus folded into the candidate. +- A qualifier written by a macro leaves the plan indeterminate, with the new reason (Texts). +- A typedef without a separate qualifier (`IntPtr p`) keeps T v1's edit, `const int *`. + +**Tests (batch 2.3).** +- New fixtures in C and C++ (t3: `t.c`, `t.cpp`), verified with `const int *const p`. +- The C fixture also has a qualifier written by a macro, indeterminate with the new reason. + +Expected failure at the batch's base: in all three cases, `not checked with Clang: the rewritten declaration does not get the suggested type`. + +**Validation of stage 2.** As for stage 1, for D6, D8, D10, D12, D13, D18 and Review Focus 5: +- their tests pass, and every other test of T passes unchanged; +- the suite passes on macOS, Linux arm64 and Linux x86_64; +- the review cases t3, t4, t5 and t15 give the verdicts of §6; +- the CI of #239 is green, and code scanning is triaged. ## Stage 3 — measured costs, one Clang invocation From 43759b0efed6d295831d909ffa184cab6419e10c Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:32:40 +0200 Subject: [PATCH 108/152] test(const-param): check a suggestion despite -Werror and a disabled tracked group --- .../const-suggestion-werror.c | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-werror.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-werror.c b/test/pointer_reference-const_correctness/const-suggestion-werror.c new file mode 100644 index 0000000..873b775 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-werror.c @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217 (D6): the project turns its warnings into errors (-Werror) and disables a warning group +// that the check tracks, which this file triggers: strip returns a pointer to const as a plain +// pointer. The check keeps each tracked group a warning in both of its compilations: the file +// compiles as it is, and the suggestion for read_value is checked. + +// strict-expectation-details: true + +// at line 20, column 0 +// [ !Info! ] ConstParameterNotModified.Pointer: parameter 'p' in function 'read_value' is never used to modify the pointed object +// ↳ current type: int *p +// ↳ suggested type: const int *p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// The project's compile command, which both the analysis and the check use. +// analyzer-arg: --compile-arg=-Werror +// analyzer-arg: --compile-arg=-Wno-incompatible-pointer-types-discards-qualifiers +// analyzer-arg: --experimental-const-suggestion-check + +int read_value(int* p) +{ + return *p; +} + +char* strip(const char* s) +{ + return s; +} From fc4d7be659d1a7f97e9f6275300a6e2e7c70aa59 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:32:53 +0200 Subject: [PATCH 109/152] test(const-param): a tracked group made an error stays a warning for the check --- run_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/run_test.py b/run_test.py index 0b4c0e0..c34622d 100755 --- a/run_test.py +++ b/run_test.py @@ -3873,6 +3873,33 @@ def check_const_suggestion_trace() -> bool: return ok +def check_const_suggestion_werror_on_a_tracked_group() -> bool: + """ + #217 (Review Focus 5): a compile command that turns one tracked warning group into errors, + not every warning (-Werror=incompatible-pointer-types-discards-qualifiers), does not change + what the check sees: the group stays a warning in both compilations, and a rewrite that adds + such a warning is removed for that warning, not for an error. + """ + print("=== Testing a tracked warning group that the compile command turns into errors ===") + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_werror_") as tmp: + path = Path(tmp).resolve() / "keep.c" + path.write_text("void keep(int* p)\n{\n int* q = p;\n (void)q;\n}\n") + run = _const_suggestion_run( + [str(path), "--compile-arg=-Werror=incompatible-pointer-types-discards-qualifiers"] + ) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + removed = ( + f"keep parameter p: removed, gains a checked warning: {path}:3: initializing 'int *' " + "with an expression of type 'const int *' discards qualifiers" + ) + good = "keep" not in messages and any(line.startswith(removed) for line in trace) + print(f" {'✅' if good else '❌'} removed for the warning" + ("" if good else f": {trace}")) + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6839,6 +6866,7 @@ def record_ok(ok: bool): check_const_suggestion_strong_and_weak_definitions, check_const_suggestion_cast_in_the_defining_file, check_const_suggestion_trace, + check_const_suggestion_werror_on_a_tracked_group, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 5b74b31e75446f830331c2c8fb31e5c63f544654 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 01:33:39 +0200 Subject: [PATCH 110/152] fix(const-param): keep the tracked warning groups as Clang reports them by default --- README.md | 6 +++--- src/analysis/ConstSuggestionFacts.cpp | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fbec8cd..3245d9f 100644 --- a/README.md +++ b/README.md @@ -696,7 +696,9 @@ The check runs these steps: tried, or another declaration of a rewritten file whose type changes, leaves it not checked; - an error, or one of these warnings, that the rewrite adds: refuted. The tracked warnings are `incompatible-pointer-types-discards-qualifiers`, `incompatible-pointer-types`, - `incompatible-function-pointer-types`, `int-conversion` and `overloaded-virtual`; + `incompatible-function-pointer-types`, `int-conversion` and `overloaded-virtual`. Whatever the + compile command says, the check enables them and keeps the level Clang gives them by default: + a warning stays a warning under `-Werror`, and an error stays an error; - a call or a reference that targets another function, a method of that name that overrides other methods, an `if constexpr` condition or a `_Generic` selection that changes: refuted. Facts that cannot be compared one to one leave the file not checked; @@ -754,8 +756,6 @@ Its limits: - the type shown can differ from the type checked. For a `T *const p` parameter, the message shows `const T *p`, and the check tries `const T *const p`. For an rvalue reference `T &&r`, the message suggests passing by value, and the check tries `const T &r` only. The trace names the type tried; -- a compile command with `-Werror` that disables a tracked warning group leaves a file that warns in - that group not checked: the check enables the group, and the file then does not compile as it is; - the type tried is compared as each file's language writes it: a C function that a C++ file declares through a shared header (`extern "C"`) is not checked when C++ writes the parameter's type differently, `foo *` for C's `struct foo *`; diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 807801f..6b999e0 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -1100,8 +1100,26 @@ namespace ctrace::stack::analysis // No error limit: "too many errors" is fatal, and would leave unchecked a rewrite // that its errors refute (ruling 12). diagnostics.ErrorLimit = 0; + // D6: each tracked group as Clang reports it by default, whatever the command + // says: enabled, a warning under -Werror, and an error when its diagnostics are + // errors by default (incompatible-function-pointer-types, int-conversion in C). + const clang::DiagnosticIDs& ids = *options.Diags->getDiagnosticIDs(); + std::vector errors; for (const char* warning : kCheckedWarnings) + { diagnostics.Warnings.push_back(warning); + diagnostics.Warnings.push_back(std::string("no-error=") + warning); + llvm::SmallVector members; + if (!ids.getDiagnosticsInGroup(clang::diag::Flavor::WarningOrError, warning, + members) && + std::all_of(members.begin(), members.end(), [&](clang::diag::kind id) + { return ids.isDefaultMappingAsError(id); })) + { + errors.push_back(std::string("error=") + warning); + } + } + diagnostics.Warnings.insert(diagnostics.Warnings.end(), errors.begin(), + errors.end()); out.diagnostics.clear(); out.reported.clear(); out.firstDiagnostic.clear(); From f849c3602c5369a53b85e07dffe8c960c30d74d2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:43:18 +0200 Subject: [PATCH 111/152] docs(plan): keep C's tag keywords in the displayed candidate, write out batch 2.2 --- .../2026-10-16-const-suggestion-check-v2.md | 117 +++++++++++++++--- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index ba38302..9987f00 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2655,7 +2655,7 @@ A new reason of an indeterminate verdict, after `not checked with Clang: `: |---|---|---| | `the qualifier of the parameter's typedef is written by a macro` | 2.3 | D10: a qualifier written apart from a pointer typedef, by a macro | -The type displayed (Task 2.4, ruling 3). This applies to a `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion whose plan has a candidate, whatever its verdict. `` is the plan's candidate, written with the analyzer's labels: +The type displayed (Task 2.4, ruling 3). This applies to a `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion whose plan has a candidate, whatever its verdict. `` is the plan's candidate, written as ruling 3 says: - **`ReferenceRvaluePreferValue`, the advice.** `consider passing by value () or const reference ()` becomes `consider passing by const reference ()`. - **`ReferenceRvaluePreferValue`, the "checked" line of a verified suggestion:** ```text @@ -2678,15 +2678,11 @@ The other rules keep their lines. Without the option, nothing changes. - Trial 2 (`batch-2.1/trial2-fix.diff`) passes the two new tests, and the 45 checks and fixtures of T. - Cost if wrong: such a refutation would say "does not compile" where it should say "gains a checked warning". The verdict is the same. 2. **Review Focus 5 is a check, not a fixture variant** (Task 2.1). Validated by the user on 2026-10-22. Under `-Werror=`, the suggestion is removed both before and after the change. Only the reason shows that the group stayed a warning, and only the trace gives it. Cost if wrong: none. -3. **The type displayed is the plan's candidate** (Task 2.4). Revised as the user asked on 2026-10-22. +3. **The type displayed is the plan's candidate** (Task 2.4). Revised as the user asked on 2026-10-22, then validated by the user with C's tag keywords kept. - For `ReferenceRvaluePreferValue` and `PointerConstOnly`, once a suggestion has a candidate, the type displayed is written from `SuggestionPlan::candidate`. It replaces the rule's proposal, so no other definition of the proposed type is displayed. - - Where Clang's labels differ from those of the analyzer's other lines, the planner writes the analyzer's: - - a type without a name is ``, which Clang writes `(lambda)`; - - a C tag type has no keyword: `foo`, which Clang writes `struct foo`. - - The trace keeps Clang's writing. - - Both rules take the same path. The D13 fixture, where the rule's proposal (`const int *p`) differs from the candidate, therefore pins that path for both. For `ReferenceRvaluePreferValue`, no fixture could: with these labels, the rule's const reference and the candidate write the same text in every case found (`stage2/observations/rvalue-spelling/`). - - Cost if wrong: a label that differs from Clang's and is not listed here is displayed as Clang writes it. + - The planner writes it as Clang does, with `struct`, `union` and `enum` where C needs them. Only a type without a name takes the analyzer's label, ``, which Clang writes `(lambda)`: it reads better beside the rule's other lines. The trace keeps Clang's writing. + - Both rules take the same path. The D13 fixture, where the rule's proposal (`const int *p`) differs from the candidate, therefore pins that path for both. For `ReferenceRvaluePreferValue`, no fixture could: with the anonymous label, the rule's const reference and the candidate write the same text in every case found (`stage2/observations/rvalue-spelling/`). + - Cost if wrong: beside the rule's `current type` line, the displayed candidate can write a type otherwise, `struct foo` where the rule writes `foo`. 4. **Task 2.4 runs before Task 2.3.** Validated by the user on 2026-10-22. t3's suggestions are `PointerConstOnly` (`const IntPtr p` is `int *const p`), and Task 2.4 changes how that rule is displayed. In this order, the D10 fixtures are written once, with the candidate displayed. Cost if wrong: none. ### Task 2.1: warning policy (D6, Review Focus 5) @@ -2821,25 +2817,112 @@ With trial 2 applied, both pass, the fixture 4/4 (`batch-2.1/green-trial2-rerun- - The other units record it with the plan that their targets carry. - `Target::expected`: the unit's `expected`, passed to its compilation after the rewrite. -**Decision logic.** Step 3 compares each rewritten declaration with the expected type of its own site. +**Decision logic.** Step 3 compares each rewritten declaration with the expected type of its own site, in its unit; a declaration without one does not get it. No comparison ignores the top-level qualifiers, as the user asked on 2026-10-22: the definition's own belong to its candidate, which D10 needs (`const IntPtr p` must become `const int *const p`). `SuggestionPlan::expectedType`, which only that comparison read, goes. -**Tests (batch 2.2).** Two new checks, written out with their failures when the batch starts: -- **D8, over t4.** The C++ user is verified. The C witness is verified, as at the batch's base. -- **D18, in both directions.** `f` is declared without the top-level `const` and defined with it; `g` is declared with it and defined without it. Both are verified. +**Tests.** Two new checks, placed after `check_const_suggestion_werror_on_a_tracked_group` and registered after it. They read the verdicts in the trace, which Task 2.4 does not change. -The checks read the verdicts in the trace, which Task 2.4 does not change. +```python +def check_const_suggestion_extern_c_user() -> bool: + """ + #217 (D8): a C function that a C++ file declares through a shared header (extern "C"). Each + file writes the parameter's type in its own language, struct foo * in C and foo * in C++, and + each rewritten declaration is compared with the candidate as its own file writes it: the + suggestion is checked with the C++ user, as with a C user. + """ + print('=== Testing a C function that a C++ file declares through an extern "C" header ===') + sources = { + "api.h": '#ifdef __cplusplus\nextern "C" {\n#endif\nstruct foo { int x; };\n' + "int rd(struct foo* f);\n#ifdef __cplusplus\n}\n#endif\n", + "def.c": '#include "api.h"\nint rd(struct foo* f) { return f->x; }\n', + "user.cpp": '#include "api.h"\nint user(foo* f) { return rd(f); }\n', + "user2.c": '#include "api.h"\nint user2(struct foo* f) { return rd(f); }\n', + } + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_extern_c_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, user in (("a C++ user", "user.cpp"), ("a C user", "user2.c")): + run = _const_suggestion_run([str(root / "def.c"), str(root / user)]) + messages, trace, _ = run if run else ({}, [], "") + verdicts = [line for line in trace if line.startswith("rd parameter f, verdict: ")] + good = "rd" in messages and verdicts == ["rd parameter f, verdict: verified"] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {verdicts}")) + ok = ok and good + print() + return ok -Expected failures at the batch's base: `not checked with Clang: the rewritten declaration does not get the suggested type`, for the C++ user of t4, for `f` and for `g`. + +def check_const_suggestion_top_level_qualifier() -> bool: + """ + #217 (D18): a parameter's top-level qualifier is not part of the function's type, so a + declaration and the definition can differ in it. The rewrite gives each declaration the + candidate of its own type, and each is compared with that candidate: in both directions, the + suggestion is checked. + """ + print("=== Testing declarations that differ from the definition in a top-level qualifier ===") + source = ( + "int f(int* p);\nint f(int* const p)\n{\n return *p;\n}\n" + "int g(int* const p);\nint g(int* p)\n{\n return *p;\n}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_top_level_") as tmp: + path = Path(tmp).resolve() / "decls.c" + path.write_text(source) + run = _const_suggestion_run([str(path)]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + ok = True + for label, function in ( + ("declared without the top-level const, defined with it", "f"), + ("declared with the top-level const, defined without it", "g"), + ): + verdicts = [line for line in trace if line.startswith(f"{function} parameter p, verdict: ")] + good = function in messages and verdicts == [f"{function} parameter p, verdict: verified"] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {verdicts}")) + ok = ok and good + print() + return ok +``` + +Failure at `5b74b31`, on macOS (`batch-2.2/red-5b74b31-macos.txt`): + +```text +##### check_const_suggestion_extern_c_user: FAIL + ❌ a C++ user: ['rd parameter f, verdict: indeterminate: the rewritten declaration does not get the suggested type'] + ✅ a C user +##### check_const_suggestion_top_level_qualifier: FAIL + ❌ declared without the top-level const, defined with it: ['f parameter p, verdict: indeterminate: the rewritten declaration does not get the suggested type'] + ❌ declared with the top-level const, defined without it: ['g parameter p, verdict: indeterminate: the rewritten declaration does not get the suggested type'] +``` + +- [ ] **Step 1: The D8 check.** Add `check_const_suggestion_extern_c_user` and run it. Expected: FAIL for the C++ user, as above; the C user passes. + Commit: `test(const-param): check a C function that a C++ file declares through extern "C"`. +- [ ] **Step 2: The D18 check.** Add `check_const_suggestion_top_level_qualifier` and run it. Expected: FAIL for `f` and `g`, as above. + Commit: `test(const-param): check declarations that differ in a top-level qualifier`. +- [ ] **Step 3: Each declaration's expected type.** + - `candidateOf` is shared through `ConstSuggestionInternal.hpp`. + - `resolve` records `UnitFacts::Entity::expected` in each compilation before the rewrite. + - `checkUnit` passes the unit's own to its compilation after the rewrite, through `Target::expected`, and step 3 compares with it. + - README: the limit of a C function that C++ declares through an `extern "C"` header goes. + + Run: both checks, then the tests of T. Expected: PASS. + Before the task closes, as the user asked on 2026-10-22: + - t3's C and C++ cases (the review cases `t3/t.c` and `t3/t.cpp`) stay `not checked with Clang: the rewritten declaration does not get the suggested type`, until Task 2.3 corrects their rewrite; + - a mutant whose comparison ignores the top-level qualifiers makes them verified, so this replay detects it. + + Commit: `fix(const-param): expect each declaration's own candidate, in its unit's language`. +- [ ] **Step 4: Task tests.** As Task 1.1, Step 6. ### Task 2.4: one candidate, displayed (D12, D13) **Files:** -- `src/analysis/ConstSuggestionPlan.cpp`: the candidate written with the analyzer's labels; +- `src/analysis/ConstSuggestionPlan.cpp`: the candidate written for display, an anonymous type as ``; - `src/analysis/ConstSuggestionCheck.cpp`: the type displayed, and the "checked" line; - `src/analyzer/DiagnosticEmitter.cpp`: the advice that names the const reference alone; - the fixtures `const-suggestion-deduction-kept.cpp` and `const-suggestion-deduction-refused.cpp` (changed), and one new fixture. -**Decision logic.** Take each `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion that T keeps and whose plan has a candidate. Its displayed type becomes that candidate, as the planner writes it with the analyzer's labels (ruling 3); the rule's own proposal is no longer displayed. +**Decision logic.** Take each `ReferenceRvaluePreferValue` or `PointerConstOnly` suggestion that T keeps and whose plan has a candidate. Its displayed type becomes that candidate, written as ruling 3 says; the rule's own proposal is no longer displayed. - **`ReferenceRvaluePreferValue` (D12).** The advice names the const reference alone. A verified suggestion's "checked" line says that passing by value is not checked (Texts). - **`PointerConstOnly` (D13).** The advice and the "suggested type" line name the candidate, `const int *const p`. From ada576ab46abd6e1655a38abff013881e7420765 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:43:25 +0200 Subject: [PATCH 112/152] test(const-param): check a C function that a C++ file declares through extern "C" --- run_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/run_test.py b/run_test.py index c34622d..9e6da4b 100755 --- a/run_test.py +++ b/run_test.py @@ -3900,6 +3900,37 @@ def check_const_suggestion_werror_on_a_tracked_group() -> bool: return good +def check_const_suggestion_extern_c_user() -> bool: + """ + #217 (D8): a C function that a C++ file declares through a shared header (extern "C"). Each + file writes the parameter's type in its own language, struct foo * in C and foo * in C++, and + each rewritten declaration is compared with the candidate as its own file writes it: the + suggestion is checked with the C++ user, as with a C user. + """ + print('=== Testing a C function that a C++ file declares through an extern "C" header ===') + sources = { + "api.h": '#ifdef __cplusplus\nextern "C" {\n#endif\nstruct foo { int x; };\n' + "int rd(struct foo* f);\n#ifdef __cplusplus\n}\n#endif\n", + "def.c": '#include "api.h"\nint rd(struct foo* f) { return f->x; }\n', + "user.cpp": '#include "api.h"\nint user(foo* f) { return rd(f); }\n', + "user2.c": '#include "api.h"\nint user2(struct foo* f) { return rd(f); }\n', + } + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_extern_c_") as tmp: + root = Path(tmp).resolve() + for name, text in sources.items(): + (root / name).write_text(text) + for label, user in (("a C++ user", "user.cpp"), ("a C user", "user2.c")): + run = _const_suggestion_run([str(root / "def.c"), str(root / user)]) + messages, trace, _ = run if run else ({}, [], "") + verdicts = [line for line in trace if line.startswith("rd parameter f, verdict: ")] + good = "rd" in messages and verdicts == ["rd parameter f, verdict: verified"] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {verdicts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6867,6 +6898,7 @@ def record_ok(ok: bool): check_const_suggestion_cast_in_the_defining_file, check_const_suggestion_trace, check_const_suggestion_werror_on_a_tracked_group, + check_const_suggestion_extern_c_user, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From ead913f312ae59d96a0b003a816faa97790feceb Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:44:02 +0200 Subject: [PATCH 113/152] test(const-param): check declarations that differ in a top-level qualifier --- run_test.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/run_test.py b/run_test.py index 9e6da4b..8f03f82 100755 --- a/run_test.py +++ b/run_test.py @@ -3931,6 +3931,38 @@ def check_const_suggestion_extern_c_user() -> bool: return ok +def check_const_suggestion_top_level_qualifier() -> bool: + """ + #217 (D18): a parameter's top-level qualifier is not part of the function's type, so a + declaration and the definition can differ in it. The rewrite gives each declaration the + candidate of its own type, and each is compared with that candidate: in both directions, the + suggestion is checked. + """ + print("=== Testing declarations that differ from the definition in a top-level qualifier ===") + source = ( + "int f(int* p);\nint f(int* const p)\n{\n return *p;\n}\n" + "int g(int* const p);\nint g(int* p)\n{\n return *p;\n}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_top_level_") as tmp: + path = Path(tmp).resolve() / "decls.c" + path.write_text(source) + run = _const_suggestion_run([str(path)]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + ok = True + for label, function in ( + ("declared without the top-level const, defined with it", "f"), + ("declared with the top-level const, defined without it", "g"), + ): + verdicts = [line for line in trace if line.startswith(f"{function} parameter p, verdict: ")] + good = function in messages and verdicts == [f"{function} parameter p, verdict: verified"] + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {verdicts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6899,6 +6931,7 @@ def record_ok(ok: bool): check_const_suggestion_trace, check_const_suggestion_werror_on_a_tracked_group, check_const_suggestion_extern_c_user, + check_const_suggestion_top_level_qualifier, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 095d23c2ac5cbe2059b19434c75b2796633f225e Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:48:56 +0200 Subject: [PATCH 114/152] fix(const-param): expect each declaration's own candidate, in its unit's language --- README.md | 3 -- src/analysis/ConstSuggestionCheck.cpp | 5 ++- src/analysis/ConstSuggestionFacts.cpp | 39 +++++++++++++++++++++--- src/analysis/ConstSuggestionInternal.hpp | 11 ++++++- src/analysis/ConstSuggestionPlan.cpp | 34 ++++++++++----------- 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3245d9f..528d9b2 100644 --- a/README.md +++ b/README.md @@ -756,9 +756,6 @@ Its limits: - the type shown can differ from the type checked. For a `T *const p` parameter, the message shows `const T *p`, and the check tries `const T *const p`. For an rvalue reference `T &&r`, the message suggests passing by value, and the check tries `const T &r` only. The trace names the type tried; -- the type tried is compared as each file's language writes it: a C function that a C++ file - declares through a shared header (`extern "C"`) is not checked when C++ writes the parameter's - type differently, `foo *` for C's `struct foo *`; - a pointer typedef with a qualifier written before it (`const IntPtr p`) does not get the type tried: the suggestion is not checked; - a file that sees the changes of two analyzed files is compiled as it is once for each, and a hit diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 98bd733..6f3199a 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -431,7 +431,10 @@ namespace ctrace::stack::analysis // Step 1: a unit that cannot be checked. if (!before) return indeterminate(kDoesNotCompileAsItIs); - after = inspect(command, &plan, rewritten, {planned}, "const_suggestion.compile.after", + // Each declaration's candidate, as the unit writes it before the rewrite (D8, D18). + Target target = planned; + target.expected = before->unit.entities[index].expected; + after = inspect(command, &plan, rewritten, {target}, "const_suggestion.compile.after", timing); if (!after.failure.empty()) return indeterminate(after.failure); diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 6b999e0..6c4c486 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -55,6 +55,7 @@ namespace ctrace::stack::analysis { namespace { + using const_suggestion::candidateOf; using const_suggestion::DeclarationSite; using const_suggestion::Edit; using const_suggestion::EntityIdentity; @@ -907,6 +908,30 @@ namespace ctrace::stack::analysis } } } + // Before the rewrite, the type each declaration must get: the candidate of its + // own parameter type, as this compilation's language writes it (D8, D18). + const SuggestionPlan& plan = target.plan ? *target.plan : facts.plan; + if (!rewrittenBy_ && !plan.indeterminate) + { + for (const clang::FunctionDecl* function : found) + { + for (const clang::FunctionDecl* declaration : function->redecls()) + { + if (declaration->isDependentContext() || + plan.sourceIndex >= declaration->getNumParams()) + { + continue; + } + const clang::QualType candidate = candidateOf( + *context_, declaration->getParamDecl(plan.sourceIndex)->getType()); + if (!candidate.isNull()) + { + facts.expected.emplace((*sites_)(declaration->getLocation()), + writtenType(*context_, candidate)); + } + } + } + } if (!target.plan || target.plan != rewrittenBy_) return; definitions(i); @@ -925,15 +950,19 @@ namespace ctrace::stack::analysis const std::string type = writtenType( *context_, declaration->getParamDecl(target.plan->sourceIndex)->getType()); - if (type == target.plan->expectedType) + // A declaration with no expected type was another entity's before the + // rewrite, which merges into it: the collisions judge it (ruling 3). + const auto expected = + target.expected.find((*sites_)(declaration->getLocation())); + if (expected == target.expected.end() || type == expected->second) continue; const clang::SourceLocation at = sm.getExpansionLoc(declaration->getLocation()); const std::string& file = sites_->fileOf(sm.getFileID(at)); - // A planned parameter must have the candidate (§5.6, step 3). A declaration - // in a file that the plan does not rewrite, through which the unit declares - // the entity for the linker only, keeps its type: the unit does not see the - // rewrite (D17). + // A planned parameter must have the candidate of its own type, as its + // unit writes it (§5.6, step 3; D8, D18). A declaration in a file that the + // plan does not rewrite, through which the unit declares the entity for the + // linker only, keeps its type: the unit does not see the rewrite (D17). if (target.plan->edits.count(file)) facts.withoutCandidate = true; else diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 4b1ea4c..31c4dc0 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -172,7 +172,6 @@ namespace ctrace::stack::analysis::const_suggestion std::vector parameters; // the parameters it rewrites, sorted std::map> edits; // by real path Candidate candidate; // the type the rewrite gives the parameter - std::string expectedType; // the candidate, written canonically std::optional indeterminate; // why the plan is not executed unsigned sourceIndex = 0; // A free function with external linkage can be declared by a file that reads none of the @@ -207,6 +206,9 @@ namespace ctrace::stack::analysis::const_suggestion // A type written canonically, an anonymous type without its location. std::string writtenType(const clang::ASTContext& context, clang::QualType type); + // The type a parameter of type `type` gets once rewritten (§3); null for another type. + clang::QualType candidateOf(clang::ASTContext& context, clang::QualType type); + // The macro definitions a compilation reads, by file: from each macro's name to its last // token, as offsets (D11). using MacroDefinitions = std::map>>; @@ -253,6 +255,9 @@ namespace ctrace::stack::analysis::const_suggestion // After the rewrite, the symbols the entity takes, as the defining unit's compilation // names them: another unit can define one without declaring the entity (§5.10). std::vector takes; + // After the rewrite, the type each declaration of the entity must have, by site: the + // unit's own, from its compilation before the rewrite (D8, D18). + std::map expected; }; // An error or a checked warning, in the order Clang reports it. @@ -307,6 +312,10 @@ namespace ctrace::stack::analysis::const_suggestion // Its declarations' sites, each with the line where Clang expands it: those that the // rewrite adds are other entities' declarations that merge into it (ruling 3). std::map declarations; + // Before the rewrite, by site, the type each of its declarations must get: the + // candidate of the declaration's own parameter type, its top-level qualifiers kept, + // written canonically in this compilation's language (D8, D18). + std::map expected; std::vector overrides; // O, for the methods named like it std::vector variants; // the entity's: C1, C2, D0... or "" for a function // The constructors inheriting it, or a specialization of its template: by derived diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 02ba47a..98afe34 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -29,7 +29,6 @@ namespace ctrace::stack::analysis using const_suggestion::SiteReader; using const_suggestion::SourceLinkage; using const_suggestion::SuggestionPlan; - using const_suggestion::writtenType; // Records where each macro definition lies, by file. class MacroRecorder : public clang::PPCallbacks @@ -279,22 +278,6 @@ namespace ctrace::stack::analysis const std::set& planned_; }; - // The type the parameter has once rewritten, which the edits write (§3): a pointer to - // const, its top-level qualifiers kept; a reference to const, for an rvalue reference - // too. Null for another type. - clang::QualType candidateOf(clang::ASTContext& context, clang::QualType type) - { - if (const auto* pointer = type->getAs()) - { - return context.getQualifiedType( - context.getPointerType(pointer->getPointeeType().withConst()), - type.getQualifiers()); - } - if (type->isReferenceType()) - return context.getLValueReferenceType(type.getNonReferenceType().withConst()); - return {}; - } - // A type as a declaration of `name` writes it. std::string declaring(const clang::ASTContext& context, clang::QualType type, llvm::StringRef name) @@ -319,6 +302,22 @@ namespace ctrace::stack::analysis } } // namespace + // The type the parameter has once rewritten, which the edits write (§3): a pointer to const, + // its top-level qualifiers kept; a reference to const, for an rvalue reference too. Null for + // another type. + clang::QualType const_suggestion::candidateOf(clang::ASTContext& context, clang::QualType type) + { + if (const auto* pointer = type->getAs()) + { + return context.getQualifiedType( + context.getPointerType(pointer->getPointeeType().withConst()), + type.getQualifiers()); + } + if (type->isReferenceType()) + return context.getLValueReferenceType(type.getNonReferenceType().withConst()); + return {}; + } + // A type written canonically. An anonymous type, such as a lambda's, is written without // its location: the path of that location depends on how the file was named, relative // before the rewrite and absolute once the rewritten file is remapped. @@ -392,7 +391,6 @@ namespace ctrace::stack::analysis out.indeterminate = "the parameter is no pointer or reference"; return out; } - out.expectedType = writtenType(context, candidate); out.candidate = {declaring(context, param.getType(), param.getName()), declaring(context, candidate, param.getName()), alternativeOf(param.getType())}; From 361cdcab5e72be5d619cac863911757470bd53b1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 03:50:06 +0200 Subject: [PATCH 115/152] docs(plan): leave merged declarations to the collisions in Task 2.2 --- .../superpowers/plans/2026-10-16-const-suggestion-check-v2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index 9987f00..f8df97a 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2817,7 +2817,7 @@ With trial 2 applied, both pass, the fixture 4/4 (`batch-2.1/green-trial2-rerun- - The other units record it with the plan that their targets carry. - `Target::expected`: the unit's `expected`, passed to its compilation after the rewrite. -**Decision logic.** Step 3 compares each rewritten declaration with the expected type of its own site, in its unit; a declaration without one does not get it. No comparison ignores the top-level qualifiers, as the user asked on 2026-10-22: the definition's own belong to its candidate, which D10 needs (`const IntPtr p` must become `const int *const p`). `SuggestionPlan::expectedType`, which only that comparison read, goes. +**Decision logic.** Step 3 compares each rewritten declaration with the expected type of its own site, in its unit. A declaration without one was another entity's before the rewrite, which merges into the entity: the collisions judge it (stage 1, ruling 3), as `check_const_suggestion_merged_declaration` and `check_const_suggestion_overload_defined_elsewhere` require. No comparison ignores the top-level qualifiers, as the user asked on 2026-10-22: the definition's own belong to its candidate, which D10 needs (`const IntPtr p` must become `const int *const p`). `SuggestionPlan::expectedType`, which only that comparison read, goes. **Tests.** Two new checks, placed after `check_const_suggestion_werror_on_a_tracked_group` and registered after it. They read the verdicts in the trace, which Task 2.4 does not change. @@ -2954,7 +2954,7 @@ Expected failures at the batch's base: the two alternatives and the old "checked - New fixtures in C and C++ (t3: `t.c`, `t.cpp`), verified with `const int *const p`. - The C fixture also has a qualifier written by a macro, indeterminate with the new reason. -Expected failure at the batch's base: in all three cases, `not checked with Clang: the rewritten declaration does not get the suggested type`. +Expected failure at the batch's base: in all three cases, `not checked with Clang: the rewritten declaration does not get the suggested type`. That failure also guards Task 2.2's comparison: one that ignored the top-level qualifiers would make t3 verified at the batch's base (`batch-2.2/mutant-ignore-top-level.diff`), and no other test of T detects it. **Validation of stage 2.** As for stage 1, for D6, D8, D10, D12, D13, D18 and Review Focus 5: - their tests pass, and every other test of T passes unchanged; From 0e640481b88ef6bfb0666265e6681407578bc4e9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 04:35:28 +0200 Subject: [PATCH 116/152] test(const-param): detect a rewrite that loses the pointer's own top-level const --- test/unit/analyzer_module_unit_tests.cpp | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index e56ab00..859f22c 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -970,6 +970,47 @@ namespace return report.failures == 0; } + /// #217 (D10, D18): step 3 detects a rewrite that loses the parameter's own top-level const. + /// The plan of read_only(int *const p) is executed with an edit that writes `const int *` + /// instead of `const int *const`: the declaration does not get the candidate of its type, + /// whatever the planner's later edits are. + bool testConstSuggestionTopLevelQualifierLost(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + const std::filesystem::path dir = + std::filesystem::temp_directory_path() / "ct_const_suggestion_top_level_lost"; + std::filesystem::create_directories(dir); + const std::filesystem::path file = dir / "k.c"; + const std::string text = "int read_only(int *const p) { return *p; }\n"; + std::ofstream(file) << text; + const ctrace::stack::analysis::UnitCommand command{{"clang", "-c", file.string()}, + dir.string()}; + std::vector targets(1); + targets[0].symbol = "read_only"; + targets[0].sourceIndex = 0; + const cs::UnitFacts before = cs::inspect(command, nullptr, {}, targets, "test", false); + if (before.entities.size() != 1 || before.entities[0].plan.edits.size() != 1) + { + report.expect(false, "ConstSuggestionTopLevel setup: read_only is not planned"); + return false; + } + cs::SuggestionPlan lost = before.entities[0].plan; + const std::string written = "int *const"; + const std::size_t at = text.find(written); + lost.edits.begin()->second = {{at, written.size(), "const int *", 1}}; + std::string rewritten = text; + rewritten.replace(at, written.size(), "const int *"); + cs::Target target = targets[0]; + target.plan = &lost; + target.expected = before.entities[0].expected; + const cs::UnitFacts after = cs::inspect( + command, &lost, {{lost.edits.begin()->first, rewritten}}, {target}, "test", false); + report.expect(after.entities.size() == 1 && after.entities[0].withoutCandidate, + "ConstSuggestionTopLevel: a rewrite that loses the pointer's own const does " + "not get the candidate"); + return report.failures == 0; + } + bool testUninitializedSummaryKeysAreLinkerSymbols(const std::filesystem::path& repoRoot, TestReport& report) { @@ -2938,6 +2979,7 @@ int main(int argc, char** argv) (void)testAnalysisIncompleteNotesHaveTheirOwnRules(report); (void)testConstSuggestionCodeComparison(report); (void)testConstSuggestionNormalizations(report); + (void)testConstSuggestionTopLevelQualifierLost(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From e998a149df79989cac83153d5a453560a16c7545 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 04:35:28 +0200 Subject: [PATCH 117/152] test(const-param): expect the const reference alone for an rvalue reference --- .../const-suggestion-deduction-kept.cpp | 8 ++++---- .../const-suggestion-deduction-refused.cpp | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp index bad7033..f1dbb8a 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-deduction-kept.cpp @@ -8,15 +8,15 @@ // at line 24, column 0 // [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_0&&)' is an rvalue reference and is never used to modify the referred object -// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ consider passing by const reference (const &f) // ↳ current type: &&f -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested const reference, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside; passing by value is not checked // at line 24, column 0 // [ !Info! ] ConstParameterNotModified.ReferenceRvaluePreferValue: parameter 'f' in function 'int apply_twice(from_temporaries()::$_1&&)' is an rvalue reference and is never used to modify the referred object -// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ consider passing by const reference (const &f) // ↳ current type: &&f -// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested const reference, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside; passing by value is not checked // The lvalue passes a const reference already: no suggestion for that specialization. // not contains: in function 'int apply_twice(from_temporary()::$_0&&)' is an rvalue reference and is only read, or passed where a reference to const is expected -// ↳ consider passing by value ( f) or const reference (const &f) +// ↳ consider passing by const reference (const &f) // ↳ current type: &&f // ↳ the callee's body is not checked: it may still modify the referred object through a cast // ↳ not checked with Clang: 'int call(from_lvalues()::$_0&)' and 'int call(from_lvalues()::$_0 const&)' both become 'int call(from_lvalues()::$_0 const&)': their correspondence is not one to one From a900e8f8f6c5733043834fa5299a9c65c93f020c Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 04:35:28 +0200 Subject: [PATCH 118/152] test(const-param): display the candidate of a const pointer to a read object --- .../const-suggestion-pointer-const-only.c | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-pointer-const-only.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-pointer-const-only.c b/test/pointer_reference-const_correctness/const-suggestion-pointer-const-only.c new file mode 100644 index 0000000..c7a0cbb --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-pointer-const-only.c @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217 (D13): a pointer that is const itself and whose pointed object is only read. The rule +// suggests const for the pointed object; the check tries the plan's candidate, which keeps the +// pointer's own const, and the suggestion displays that candidate. + +// strict-expectation-details: true + +// at line 18, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'read_only' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// analyzer-arg: --experimental-const-suggestion-check + +int read_only(int* const p) +{ + return *p; +} From 4c7233250d1fa88db4b867a0b3d87a5741bd5e1d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 04:37:48 +0200 Subject: [PATCH 119/152] fix(const-param): display the plan's candidate for rvalue and const pointer rules --- README.md | 3 --- src/analysis/ConstSuggestionCheck.cpp | 14 ++++++++++++- src/analysis/ConstSuggestionInternal.hpp | 3 +++ src/analysis/ConstSuggestionPlan.cpp | 26 +++++++++++++++++++++++- src/analyzer/DiagnosticEmitter.cpp | 9 ++++++-- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 528d9b2..b8b1da1 100644 --- a/README.md +++ b/README.md @@ -753,9 +753,6 @@ Its limits: - a rewrite that changes the program's behavior in a way that calls, overrides and constant conditions do not show is kept as not checked, not removed: `sizeof(__PRETTY_FUNCTION__)`, for example, changes with the rewrite; -- the type shown can differ from the type checked. For a `T *const p` parameter, the message shows - `const T *p`, and the check tries `const T *const p`. For an rvalue reference `T &&r`, the message - suggests passing by value, and the check tries `const T &r` only. The trace names the type tried; - a pointer typedef with a qualifier written before it (`const IntPtr p`) does not get the type tried: the suggestion is not checked; - a file that sees the changes of two analyzed files is compiled as it is once for each, and a hit diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 6f3199a..778bb90 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -61,6 +61,11 @@ namespace ctrace::stack::analysis "they compile with the suggested type, and their generated code matches, metadata " "included, once debug information, diagnostic metadata, descriptive names and " "renamed symbols are set aside"; + // For an rvalue reference, the const reference only (D12). + constexpr const char* kCheckedReference = + "they compile with the suggested const reference, and their generated code matches, " + "metadata included, once debug information, diagnostic metadata, descriptive names " + "and renamed symbols are set aside; passing by value is not checked"; constexpr const char* kNotChecked = "not checked with Clang: "; constexpr const char* kDoesNotCompileAsItIs = "a file does not compile with Clang as it is"; @@ -721,6 +726,13 @@ namespace ctrace::stack::analysis ConstParamIssue& issue = issues[i]; const UnitFacts::Entity& found = before.entities[i]; const SuggestionPlan& plan = found.plan; + // The type displayed is the plan's candidate, not the rule's proposal (stage 2, + // ruling 3): for an rvalue reference, the const reference alone. + if (!plan.candidate.shown.empty() && (issue.isRvalueRef || issue.pointerConstOnly)) + { + issue.suggestedType = plan.candidate.shown; + issue.suggestedTypeAlt.clear(); + } if (!found.found && !plan.indeterminate) { issue.checkLines.push_back(kNotChecked + @@ -901,7 +913,7 @@ namespace ctrace::stack::analysis grounds(issue, &plan, judged, judgements, verdict); const std::string checked = std::string(config.compilationDatabase ? kCheckedDatabase : kCheckedAnalyzed) + - kCheckedResult; + (issue.isRvalueRef ? kCheckedReference : kCheckedResult); issue.checkLines.push_back( accepted.empty() ? checked : checked + ", except for the differences below"); issue.checkLines.insert(issue.checkLines.end(), accepted.begin(), accepted.end()); diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 31c4dc0..2a319bd 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -82,6 +82,9 @@ namespace ctrace::stack::analysis::const_suggestion std::string current; std::string type; std::string alternative; + // The candidate as a diagnostic displays it: Clang's writing, a type without a name + // written (stage 2, ruling 3). + std::string shown; }; // A position in a file's original text: after the rewrite, a position in a rewritten file is diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 98afe34..c29b2e4 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -290,6 +290,29 @@ namespace ctrace::stack::analysis return out; } + // The candidate as a diagnostic displays it: as Clang writes it, struct, union and enum + // included where C needs them, except that a type without a name is , + // as the rule's own lines write it (stage 2, ruling 3). + std::string shownOf(const clang::ASTContext& context, clang::QualType type, + llvm::StringRef name) + { + std::string out = declaring(context, type, name); + const clang::Type* base = type.getTypePtr(); + while (!base->getPointeeType().isNull()) + base = base->getPointeeType().getTypePtr(); + const clang::TagDecl* tag = base->getAsTagDecl(); + if (tag && !tag->getIdentifier() && !tag->getTypedefNameForAnonDecl()) + { + clang::PrintingPolicy policy(context.getLangOpts()); + policy.AnonymousTagLocations = false; + const std::string spelled = + clang::QualType(base, 0).getUnqualifiedType().getAsString(policy); + if (const std::size_t at = out.find(spelled); at != std::string::npos) + out.replace(at, spelled.size(), ""); + } + return out; + } + // What the candidate stands for, as the trace names it (Texts). const char* alternativeOf(clang::QualType type) { @@ -393,7 +416,8 @@ namespace ctrace::stack::analysis } out.candidate = {declaring(context, param.getType(), param.getName()), declaring(context, candidate, param.getName()), - alternativeOf(param.getType())}; + alternativeOf(param.getType()), + shownOf(context, candidate, param.getName())}; Planner planner(context, macros, out); for (const clang::FunctionDecl* declaration : pattern->redecls()) { diff --git a/src/analyzer/DiagnosticEmitter.cpp b/src/analyzer/DiagnosticEmitter.cpp index 250b5ef..b9be9fa 100644 --- a/src/analyzer/DiagnosticEmitter.cpp +++ b/src/analyzer/DiagnosticEmitter.cpp @@ -1051,8 +1051,13 @@ namespace ctrace::stack::analyzer if (issue.isRvalueRef) { - body << kDiagIndentArrow << "consider passing by value (" << issue.suggestedType - << ") or const reference (" << issue.suggestedTypeAlt << ")\n"; + // Without an alternative, the check names the const reference it tried (D12). + if (issue.suggestedTypeAlt.empty()) + body << kDiagIndentArrow << "consider passing by const reference (" + << issue.suggestedType << ")\n"; + else + body << kDiagIndentArrow << "consider passing by value (" << issue.suggestedType + << ") or const reference (" << issue.suggestedTypeAlt << ")\n"; body << kDiagIndentArrow << "current type: " << issue.currentType << "\n"; } else if (issue.pointerConstOnly) From 0d66e2829ce6dda7eb2dc04638c0a150e5f199b2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 05:11:28 +0200 Subject: [PATCH 120/152] test(const-param): check a C pointer typedef with a qualifier written apart --- .../const-suggestion-typedef-qualifier.c | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c diff --git a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c new file mode 100644 index 0000000..e5089f5 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217 (D10): a pointer typedef with a qualifier written apart. The rewrite writes the candidate +// in place of the whole written type, so that the pointer keeps its own const: const IntPtr p +// becomes const int *const p. A qualifier after the name is already kept. A declaration and the +// definition that differ in the qualifier each keep their own. A qualifier written by a macro +// cannot be folded into the candidate: the suggestion is kept, not checked. + +// strict-expectation-details: true + +// at line 43, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'before' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 44, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'after' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 46, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'declared' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside + +// at line 47, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'by_macro' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ not checked with Clang: the qualifier of the parameter's typedef is written by a macro + +// analyzer-arg: --experimental-const-suggestion-check + +typedef int* IntPtr; +#define CONST const + +int before(const IntPtr p) { return *p; } +int after(IntPtr const p) { return *p; } +int declared(IntPtr p); +int declared(const IntPtr p) { return *p; } +int by_macro(CONST IntPtr p) { return *p; } From 4192a15246ce70e1ad18bacc79b2a236c583bdd3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 05:11:28 +0200 Subject: [PATCH 121/152] test(const-param): check a C++ pointer typedef with a qualifier written apart --- .../const-suggestion-typedef-qualifier.cpp | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp diff --git a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp new file mode 100644 index 0000000..0caadf6 --- /dev/null +++ b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// #217 (D10): in C++ too, const IntPtr p becomes const int *const p, the pointer keeping its own +// const; a qualifier after the name is already kept. + +// strict-expectation-details: true + +// at line 27, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'before(int*)' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_Z6beforePi -> _Z6beforePKi): code compiled separately must use the updated declaration and be recompiled + +// at line 28, column 0 +// [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'after(int*)' is declared 'int * const p' but the pointed object is never modified +// ↳ consider 'const int *const p' for API const-correctness +// ↳ current type: int * const p +// ↳ suggested type: const int *const p +// ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside +// ↳ ABI: the suggested type changes the symbol (_Z5afterPi -> _Z5afterPKi): code compiled separately must use the updated declaration and be recompiled + +// analyzer-arg: --experimental-const-suggestion-check + +typedef int* IntPtr; + +int before(const IntPtr p) { return *p; } +int after(IntPtr const p) { return *p; } From b17ff2a6bd59530e7efb2198a9491d27be0ce161 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 05:14:05 +0200 Subject: [PATCH 122/152] fix(const-param): fold a pointer typedef's separate qualifiers into the candidate --- README.md | 5 +- src/analysis/ConstSuggestionPlan.cpp | 72 ++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b8b1da1..ee7f6da 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,8 @@ The check runs these steps: `const T *const p`), or a reference to const (`const T &r`, for an rvalue reference `T &&r` too): - a type written in a macro argument is rewritten where it is written; - a type written by a macro that writes only the parameter gets `const` before the invocation; - - a typedef of a plain pointer is replaced by the type it names, made const. + - a typedef of a plain pointer is replaced by the type it names, made const, with the + qualifiers written beside it; one written by a macro leaves the suggestion not checked. A macro definition is never rewritten, nor a macro argument that writes another parameter's type too: the suggestion is then not checked. @@ -753,8 +754,6 @@ Its limits: - a rewrite that changes the program's behavior in a way that calls, overrides and constant conditions do not show is kept as not checked, not removed: `sizeof(__PRETTY_FUNCTION__)`, for example, changes with the rewrite; -- a pointer typedef with a qualifier written before it (`const IntPtr p`) does not get the type - tried: the suggestion is not checked; - a file that sees the changes of two analyzed files is compiled as it is once for each, and a hit of the IR cache gives no syntax tree: the check always compiles from the sources. diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index c29b2e4..9dd1e57 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -23,6 +23,7 @@ namespace ctrace::stack::analysis { namespace { + using const_suggestion::candidateOf; using const_suggestion::Edit; using const_suggestion::MacroDefinitions; using const_suggestion::realPath; @@ -123,9 +124,10 @@ namespace ctrace::stack::analysis { return fail("the parameter's type is a typedef of no plain pointer"); } + // The candidate of this declaration's own type, its qualifiers kept (D10). clang::PrintingPolicy policy(context_.getLangOpts()); const std::string text = - pointer->getPointeeType().withConst().getAsString(policy) + " *"; + candidateOf(context_, param.getType()).getAsString(policy); if (text.find("(unnamed") != std::string::npos || text.find("(anonymous") != std::string::npos || text.find("(lambda") != std::string::npos) @@ -136,10 +138,70 @@ namespace ctrace::stack::analysis const clang::SourceLocation end = loc.getEndLoc(); if (begin.isMacroID() || end.isMacroID()) return fail("the parameter's typedef is written by a macro"); - const unsigned length = sm_.getFileOffset(clang::Lexer::getLocForEndOfToken( - end, 0, sm_, context_.getLangOpts())) - - sm_.getFileOffset(begin); - return replace(function, begin, length, text); + // The qualifiers written apart, before or after the name, are folded into + // the candidate: the edit spans them (D10). + const clang::LangOptions& lang = context_.getLangOpts(); + const auto qualifier = [&](const clang::Token& token) + { + if (!token.is(clang::tok::raw_identifier)) + return std::string(); + const llvm::StringRef word = token.getRawIdentifier(); + return word == "const" || word == "volatile" || word == "restrict" + ? word.str() + : std::string(); + }; + std::set folded; + clang::SourceLocation first = begin; + for (clang::SourceLocation at = param.getBeginLoc(); + at.isValid() && !at.isMacroID() && + sm_.isBeforeInTranslationUnit(at, begin);) + { + clang::Token token; + if (clang::Lexer::getRawToken(at, token, sm_, lang, true)) + break; + if (qualifier(token).empty()) + { + folded.clear(); + first = begin; + } + else + { + if (folded.empty()) + first = at; + folded.insert(qualifier(token)); + } + const std::optional next = + clang::Lexer::findNextToken(at, sm_, lang); + at = next ? next->getLocation() : clang::SourceLocation(); + } + clang::SourceLocation last = end; + for (std::optional next = + clang::Lexer::findNextToken(end, sm_, lang); + next && !next->getLocation().isMacroID(); + next = clang::Lexer::findNextToken(next->getLocation(), sm_, lang)) + { + clang::Token token; + if (clang::Lexer::getRawToken(next->getLocation(), token, sm_, lang, + true) || + qualifier(token).empty()) + { + break; + } + folded.insert(qualifier(token)); + last = next->getLocation(); + } + const clang::Qualifiers written = param.getType().getLocalQualifiers(); + if ((written.hasConst() && !folded.count("const")) || + (written.hasVolatile() && !folded.count("volatile")) || + (written.hasRestrict() && !folded.count("restrict"))) + { + return fail( + "the qualifier of the parameter's typedef is written by a macro"); + } + const unsigned length = + sm_.getFileOffset(clang::Lexer::getLocForEndOfToken(last, 0, sm_, lang)) - + sm_.getFileOffset(first); + return replace(function, first, length, text); } return fail("the parameter's type has no form this check rewrites"); } From fe430673dcd56b9bdc881a69a56b5daad5d80aa6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 05:44:30 +0200 Subject: [PATCH 123/152] style(test): format the D10 typedef fixtures --- .../const-suggestion-typedef-qualifier.c | 26 ++++++++++++++----- .../const-suggestion-typedef-qualifier.cpp | 12 ++++++--- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c index e5089f5..abb554f 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c +++ b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.c @@ -14,21 +14,21 @@ // ↳ suggested type: const int *const p // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside -// at line 44, column 0 +// at line 47, column 0 // [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'after' is declared 'int * const p' but the pointed object is never modified // ↳ consider 'const int *const p' for API const-correctness // ↳ current type: int * const p // ↳ suggested type: const int *const p // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside -// at line 46, column 0 +// at line 52, column 0 // [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'declared' is declared 'int * const p' but the pointed object is never modified // ↳ consider 'const int *const p' for API const-correctness // ↳ current type: int * const p // ↳ suggested type: const int *const p // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside -// at line 47, column 0 +// at line 56, column 0 // [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'by_macro' is declared 'int * const p' but the pointed object is never modified // ↳ consider 'const int *const p' for API const-correctness // ↳ current type: int * const p @@ -40,8 +40,20 @@ typedef int* IntPtr; #define CONST const -int before(const IntPtr p) { return *p; } -int after(IntPtr const p) { return *p; } +int before(const IntPtr p) +{ + return *p; +} +int after(IntPtr const p) +{ + return *p; +} int declared(IntPtr p); -int declared(const IntPtr p) { return *p; } -int by_macro(CONST IntPtr p) { return *p; } +int declared(const IntPtr p) +{ + return *p; +} +int by_macro(CONST IntPtr p) +{ + return *p; +} diff --git a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp index 0caadf6..d0adf35 100644 --- a/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp +++ b/test/pointer_reference-const_correctness/const-suggestion-typedef-qualifier.cpp @@ -12,7 +12,7 @@ // ↳ checked in the analyzed files only, with their compile commands: they compile with the suggested type, and their generated code matches, metadata included, once debug information, diagnostic metadata, descriptive names and renamed symbols are set aside // ↳ ABI: the suggested type changes the symbol (_Z6beforePi -> _Z6beforePKi): code compiled separately must use the updated declaration and be recompiled -// at line 28, column 0 +// at line 31, column 0 // [ !Info! ] ConstParameterNotModified.PointerConstOnly: parameter 'p' in function 'after(int*)' is declared 'int * const p' but the pointed object is never modified // ↳ consider 'const int *const p' for API const-correctness // ↳ current type: int * const p @@ -24,5 +24,11 @@ typedef int* IntPtr; -int before(const IntPtr p) { return *p; } -int after(IntPtr const p) { return *p; } +int before(const IntPtr p) +{ + return *p; +} +int after(IntPtr const p) +{ + return *p; +} From a6a216031204b528d4a575709babb83b3d46a409 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 18:44:26 +0200 Subject: [PATCH 124/152] test(const-param): pin the multiplicity and order of correspondences --- test/unit/analyzer_module_unit_tests.cpp | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index 859f22c..3597bab 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -1011,6 +1011,73 @@ namespace return report.failures == 0; } + /// #217 (Task 3.2, O1): the correspondences of §5.7 keep the multiplicity and the order of + /// the references at each key, those with a template and those without alike, and map the + /// instantiation contexts through the pairs that earlier rounds establish. These cases pin + /// what an index built once must give. + bool testConstSuggestionCorrespondenceIndex(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + const auto site = [](std::size_t offset) + { + cs::DeclarationSite out; + out.expansion = {"use.cpp", offset}; + out.spelling = out.expansion; + return out; + }; + const auto reference = [](cs::FactKey key, std::vector value) + { + cs::Fact fact; + fact.key = std::move(key); + fact.value = std::move(value); + fact.shown = fact.value.front(); + return fact; + }; + const cs::FactKey at{{"ctx"}, site(10)}; + const cs::Fact withTemplate = reference(at, {"symbol:_Z1aIiEvv", "template:a"}); + const cs::Fact rewritten = reference(at, {"symbol:_Z1aIKiEvv", "template:a"}); + const cs::Fact plain = reference(at, {"site:use.cpp:3"}); + const auto pairsOf = [&](std::vector before, std::vector after, + std::set instantiations) + { + cs::UnitFacts was; + was.entities.resize(1); + was.facts = std::move(before); + cs::UnitFacts now; + now.entities.resize(1); + now.facts = std::move(after); + now.instantiations = std::move(instantiations); + std::string missing; + std::vector out; + for (const ctrace::stack::analysis::SymbolPair& pair : + cs::correspond(was, 0, now, missing)) + out.push_back(pair.before + "->" + pair.after); + return out; + }; + using Pairs = std::vector; + report.expect(pairsOf({withTemplate, plain}, {rewritten}, {"ctx"}).empty(), + "ConstSuggestionCorrespondence: two references before, one after, at one " + "key: no pair, though only one has a template"); + report.expect(pairsOf({withTemplate, plain}, {rewritten, plain}, {"ctx"}) == + Pairs{"_Z1aIiEvv->_Z1aIKiEvv"}, + "ConstSuggestionCorrespondence: as many on both sides, in the same order: " + "the template's references pair"); + report.expect(pairsOf({withTemplate, plain}, {plain, rewritten}, {"ctx"}).empty(), + "ConstSuggestionCorrespondence: as many on both sides, in another order: " + "no pair"); + const cs::Fact outer = reference({{}, site(20)}, {"symbol:_Z1xIiEvv", "template:x"}); + const cs::Fact outerAfter = reference({{}, site(20)}, {"symbol:_Z1xIKiEvv", "template:x"}); + const cs::Fact inner = + reference({{"_Z1xIiEvv"}, site(30)}, {"symbol:_Z1yIiEvv", "template:y"}); + const cs::Fact innerAfter = + reference({{"_Z1xIKiEvv"}, site(30)}, {"symbol:_Z1yIKiEvv", "template:y"}); + report.expect(pairsOf({outer, inner}, {outerAfter, innerAfter}, {}) == + Pairs{"_Z1xIiEvv->_Z1xIKiEvv", "_Z1yIiEvv->_Z1yIKiEvv"}, + "ConstSuggestionCorrespondence: a context maps through the pair an earlier " + "round establishes"); + return report.failures == 0; + } + bool testUninitializedSummaryKeysAreLinkerSymbols(const std::filesystem::path& repoRoot, TestReport& report) { @@ -2980,6 +3047,7 @@ int main(int argc, char** argv) (void)testConstSuggestionCodeComparison(report); (void)testConstSuggestionNormalizations(report); (void)testConstSuggestionTopLevelQualifierLost(report); + (void)testConstSuggestionCorrespondenceIndex(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From 48f3ec1632ed5b0decf26a0b2370c86a89f70de4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 22 Oct 2026 18:47:15 +0200 Subject: [PATCH 125/152] perf(const-param): index the correspondences' references once per call --- src/analysis/ConstSuggestionMatch.cpp | 73 +++++++++++++++++++++------ 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src/analysis/ConstSuggestionMatch.cpp b/src/analysis/ConstSuggestionMatch.cpp index 1594804..6706438 100644 --- a/src/analysis/ConstSuggestionMatch.cpp +++ b/src/analysis/ConstSuggestionMatch.cpp @@ -88,6 +88,41 @@ namespace ctrace::stack::analysis // target becomes another specialization of the same template, proposes that pair; the // proposals of every reference are checked together, and each new pair maps more // instantiations, until none is added. + // The references at each key, in the order of the traversal (Task 3.2, O1): those after + // the rewrite, and those before it at a key without instantiation context, which no pair + // changes, are grouped once; the others go through each round's pairs. + struct At + { + std::size_t first = 0; // the position of its first reference before the rewrite + FactKey key; + std::vector facts; + }; + std::map> nowAt; + for (const Fact& fact : after.facts) + { + if (fact.kind == FactKind::Reference) + nowAt[fact.key].push_back(&fact); + } + std::vector fixed; + std::vector> contextual; + { + std::map slot; + for (std::size_t i = 0; i < before.facts.size(); ++i) + { + const Fact& fact = before.facts[i]; + if (fact.kind != FactKind::Reference) + continue; + if (!fact.key.contexts.empty()) + { + contextual.emplace_back(i, &fact); + continue; + } + const auto [it, added] = slot.try_emplace(fact.key, fixed.size()); + if (added) + fixed.push_back({i, fact.key, {}}); + fixed[it->second].facts.push_back(&fact); + } + } for (bool added = true; added;) { added = false; @@ -103,33 +138,39 @@ namespace ctrace::stack::analysis } return key; }; - std::vector order; - std::map> wasAt; - std::map> nowAt; - for (const Fact& fact : before.facts) + std::vector moving; { - if (fact.kind != FactKind::Reference) - continue; - if (const std::optional key = forward(fact.key)) + std::map slot; + for (const auto& [i, fact] : contextual) { - if (!wasAt.count(*key)) - order.push_back(*key); - wasAt[*key].push_back(&fact); + if (const std::optional key = forward(fact->key)) + { + const auto [it, added] = slot.try_emplace(*key, moving.size()); + if (added) + moving.push_back({i, *key, {}}); + moving[it->second].facts.push_back(fact); + } } } - for (const Fact& fact : after.facts) + // Both, merged in the order of their first references: a key with contexts never + // equals one without. + std::vector order; + order.reserve(fixed.size() + moving.size()); + for (std::size_t a = 0, b = 0; a < fixed.size() || b < moving.size();) { - if (fact.kind == FactKind::Reference) - nowAt[fact.key].push_back(&fact); + if (b == moving.size() || (a < fixed.size() && fixed[a].first < moving[b].first)) + order.push_back(&fixed[a++]); + else + order.push_back(&moving[b++]); } std::vector proposing; // in the order of the facts std::map> to; // before to after std::map> from; // after to before std::map shown; - for (const FactKey& key : order) + for (const At* at : order) { - const std::vector& wasFacts = wasAt[key]; - const auto nowFacts = nowAt.find(key); + const std::vector& wasFacts = at->facts; + const auto nowFacts = nowAt.find(at->key); if (nowFacts == nowAt.end() || nowFacts->second.size() != wasFacts.size()) continue; for (std::size_t k = 0; k < wasFacts.size(); ++k) From 33e288c892242ff9b724e9aff8426ba2ee205f9d Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 23 Oct 2026 07:17:21 +0200 Subject: [PATCH 126/152] test(const-param): refute a rewrite that breaks a call a deduced declaration makes --- run_test.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/run_test.py b/run_test.py index 8f03f82..80ae63e 100755 --- a/run_test.py +++ b/run_test.py @@ -3963,6 +3963,50 @@ def check_const_suggestion_top_level_qualifier() -> bool: return ok +def check_const_suggestion_deduced_type_error() -> bool: + """ + #217: a rewrite that makes a call ill-formed changes the type that a declaration deduces from + that call, here a structured binding. The deduced part of a type is not what the rewrite + writes: the declaration stays within the plan (step 3), and the error refutes the suggestion + (step 4). + """ + print("=== Testing a declaration that deduces its type from a call the rewrite breaks ===") + source = ( + "#include \n" + "\n" + "// publish writes the map that publish_all receives: const does not compile.\n" + "void publish_all(std::map& ranges, int key)\n" + "{\n" + " const auto publish = [&ranges](int k)\n" + " {\n" + " const auto [it, inserted] = ranges.try_emplace(k, 0);\n" + " if (!inserted)\n" + " ++it->second;\n" + " };\n" + " publish(key);\n" + "}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_deduced_") as tmp: + path = Path(tmp).resolve() / "publish.cpp" + path.write_text(source) + run = _const_suggestion_run([str(path), "--compile-arg=-std=c++17"]) + if run is None: + return fail_check("the analysis did not run") + messages, trace, _ = run + removed = ( + f"ranges: removed, does not compile: {path}:8: no matching member function for call to " + "'try_emplace'" + ) + good = not any("publish_all" in name for name in messages) and any( + " parameter " in line and line.split(" parameter ", 1)[1].startswith(removed) + for line in trace + ) + detail = "" if good else f": {[line for line in trace if ' parameter ranges' in line]}" + print(f" {'✅' if good else '❌'} removed for the error{detail}") + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6932,6 +6976,7 @@ def record_ok(ok: bool): check_const_suggestion_werror_on_a_tracked_group, check_const_suggestion_extern_c_user, check_const_suggestion_top_level_qualifier, + check_const_suggestion_deduced_type_error, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 86f629f9381e34d78dd3d78cbb0a59a719dcec22 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 23 Oct 2026 07:17:21 +0200 Subject: [PATCH 127/152] test(const-param): detect written parts that the rewrite changes outside the plan --- run_test.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/run_test.py b/run_test.py index 80ae63e..926972c 100755 --- a/run_test.py +++ b/run_test.py @@ -4007,6 +4007,43 @@ def check_const_suggestion_deduced_type_error() -> bool: return good +def check_const_suggestion_written_part_outside_the_plan() -> bool: + """ + #217: a macro argument writes the parameter's type and, outside the plan, a part of another + type that the source writes explicitly: a template argument of a type whose other part is + deduced, or a cast in the initializer of a variable whose type is deduced. The rewrite changes + what is written outside the plan: the suggestion is kept, not checked (step 3), whatever the + deduced part becomes. + """ + print("=== Testing an explicitly written part changed outside the plan ===") + head = "#define PAIR(name, type) int name(type p); " + tail = "PAIR(read_value, int*)\n\nint read_value(int* p)\n{\n return *p;\n}\n" + cases = { + "a written template argument beside a deduced one": ( + "template struct Two\n{\n A a;\n B b;\n};\n" + + head + + "Two name##_two;\n" + ), + "a cast in a deduced declaration's initializer": ( + head + "auto name##_slot = static_cast(nullptr);\n" + ), + } + ok = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_written_part_") as tmp: + for k, (label, prefix) in enumerate(cases.items()): + path = Path(tmp).resolve() / f"pair{k}.cpp" + path.write_text(prefix + tail) + run = _const_suggestion_run([str(path)]) + texts = run[0].get("_Z10read_valuePi", []) if run else [] + good = len(texts) == 1 and ( + "not checked with Clang: the rewrite also changes the type" in texts[0] + ) + print(f" {'✅' if good else '❌'} {label}" + ("" if good else f": {texts}")) + ok = ok and good + print() + return ok + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -6977,6 +7014,7 @@ def record_ok(ok: bool): check_const_suggestion_extern_c_user, check_const_suggestion_top_level_qualifier, check_const_suggestion_deduced_type_error, + check_const_suggestion_written_part_outside_the_plan, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 5f0ccf7deb091497b6cd26da48ea0a380ebf7bbc Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 23 Oct 2026 07:17:48 +0200 Subject: [PATCH 128/152] docs(spec): separate a type's written form from its deduced part (D19) --- .../plans/2026-10-16-const-suggestion-check-v2.md | 11 +++++++++++ .../2026-10-16-const-suggestion-check-v2-design.md | 13 ++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index f8df97a..fe0f856 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2983,4 +2983,15 @@ Expected failure at the batch's base: in all three cases, `not checked with Clan ### Task F.3: delivery **Work:** README; the description of the draft #239, opened after stage 1, brought up to date; CI; code scanning triaged; final review on the updated base. No merge. +### Task F.4: the scope of step 3, written form and deduced component (D19) +Found by the comparison of the corpora at `48f3ec1`, authorized by the user on 2026-10-23. +**Work:** +- 1a: a declaration whose type has a deduced part (`auto`, `decltype(auto)`, `decltype`, `typeof`, a deduced class template's arguments) is recorded by its written form, the deduced part written as its keyword; any other declaration as before, canonically. +- 1b: in each compilation after the rewrite, a type location whose spelling an edit writes, and that does not belong to a planned parameter's type, is outside the plan: "the rewrite also changes the type written at :", reported after the declarations. A planned parameter's default argument is outside its type. +- The order of the judgement's steps is unchanged; the checks of D5, of the X-macro and of the invalid declarations stay as they are. + +**Tests:** `check_const_suggestion_deduced_type_error` (publish_all removed for its error; FAIL at `48f3ec1`), `check_const_suggestion_written_part_outside_the_plan` (a written template argument beside a deduced one, a cast in a deduced declaration's initializer; PASS at `48f3ec1`, FAIL with a fix that ignores declarations with a deduced part). A check of a planned parameter's default argument is presented with its proof before it is added. + +**Validation:** the three platforms; the comparison of the corpora, where only publishSingleStoreSlots may change (removed for its error); the cost added. + **Validation of the final phase:** every classification explained; cost reported against the criterion; CI green; triage summarized. diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index bd75434..9cae3c3 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -1,7 +1,7 @@ # Vérification des suggestions const par Clang, version 2 (#217) Statut : validée à `8b283a8`, puis complétée à la relecture du plan d'implémentation (D17, fixture -de D13), au détail de l'étape 1 (mutualisation limitée au module, §5 et §10), et à celui de l'étape 2 (erreurs natives de Clang conservées, §5.3 ; D18, §5.6 et §6). N'autorise ni le code ni la modification des tests : chaque lot présentera ses +de D13), au détail de l'étape 1 (mutualisation limitée au module, §5 et §10), à celui de l'étape 2 (erreurs natives de Clang conservées, §5.3 ; D18, §5.6 et §6), et après la comparaison des corpus (D19, §5.6 et §6). N'autorise ni le code ni la modification des tests : chaque lot présentera ses tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 (identité d'un global entre fichiers). @@ -201,10 +201,16 @@ Sur la compilation initiale du fichier de définition : Les fichiers réécrits sont remappés ; les mêmes faits qu'en 5.3 sont relevés. La portée est vérifiée dans chaque unité : -- aucune déclaration écrite dans les sources des fichiers réécrits ne change hors du plan. Les - signatures instanciées (spécialisations de gabarits) qui changent avec la réécriture ne relèvent +- aucune déclaration écrite dans les sources des fichiers réécrits ne change hors du plan dans sa + forme écrite : ses qualificatifs, ses déclarateurs, les types et les arguments de gabarit que le + source écrit. Sa composante déduite (`auto`, `decltype`, arguments déduits d'un gabarit de + classe) n'en fait pas partie : ce que la réécriture y change est une conséquence du plan, que + jugent les étapes 4 à 7 (D19). Les signatures instanciées (spécialisations de gabarits) qui changent avec la réécriture ne relèvent pas de cet invariant : elles sont examinées par les correspondances (5.7), comme dans `const-suggestion-deduction-kept.cpp` ; +- aucun type qu'une modification écrit ne sert hors du type des paramètres prévus, dans une + déclaration comme dans une expression (conversion, `sizeof`, argument de gabarit) ; l'argument + par défaut d'un paramètre prévu est hors du plan (D19) ; - chaque redéclaration visée a le candidat de son propre type, qualificatifs de premier niveau compris (D18), comparé dans la langue de son unité (D8). @@ -310,6 +316,7 @@ suggestion valide. | D12 | alternative de `ReferenceRvaluePreferValue` | seul `const T &` est vérifié ; la ligne dit « the suggested type » | vérifiée ; le type affiché et la ligne nomment `const T &`, le passage par valeur n'est pas vérifié | attentes modifiées (`const-suggestion-deduction-kept.cpp` et autres) | 2 | | D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | nouvelle fixture qui active T, aucune fixture de T v1 ne couvrant `PointerConstOnly` ; le test existant sans l'option reste inchangé | 2 | | D18 | redéclaration qui diffère de la définition par un qualificatif de premier niveau du paramètre (`int f(int *p);` puis `int f(int *const p) {…}`, et l'inverse), observée à `4fd0dae` (`stage2/observations/toplevel-qualifier/`) | « not checked: the rewritten declaration does not get the suggested type » | vérifiée (VP), dans les deux sens | contrôle run_test.py, deux sens | 2 | +| D19 | déclaration dont le type est déduit d'un appel que la réécriture rend invalide (`publishSingleStoreSlots`, `IntRanges.cpp:314`, relevée par la comparaison des corpus à `48f3ec1`) | gardée : « not checked: the rewrite also changes the type of an unnamed declaration » | réfutée pour l'erreur introduite ; une partie écrite que la réécriture change hors du plan, y compris dans une expression ou un argument par défaut, reste indéterminée | contrôles run_test.py | F | **Non retenus :** - le cache « avant » indexé par l'identifiant (point 9 de la revue) : non reproduit (t12) ; les From 7010ff64bbd51869c84365692b1d95f088e7c9b0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 23 Oct 2026 07:22:00 +0200 Subject: [PATCH 129/152] fix(const-param): judge a type's written form, and the types that the edits write --- src/analysis/ConstSuggestionCheck.cpp | 7 + src/analysis/ConstSuggestionFacts.cpp | 163 ++++++++++++++++++++++- src/analysis/ConstSuggestionInternal.hpp | 14 +- 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 778bb90..c2a2310 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -399,6 +399,13 @@ namespace ctrace::stack::analysis if (std::optional changed = compare(site, was, none)) return changed; } + // A type that an edit writes outside the planned parameters' types (D19). + if (after.writtenOutside) + { + return "the rewrite also changes the type written at " + + after.writtenOutside->site.expansion.file + ":" + + std::to_string(after.writtenOutside->line); + } return incomplete; } diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 6c4c486..195e169 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -252,6 +252,97 @@ namespace ctrace::stack::analysis // Each target's function, found by its declarations (§3, §5.5), the explicit casts of its // address, and the facts R, O and K (§5.4). + // §5.6 (D19): a type as the source writes it. A deduced part (auto, decltype(auto), + // decltype, typeof, a deduced class template's arguments) is written as its keyword, the + // rest canonically, so that what deduction gives it, a consequence of the plan, is left + // to steps 4 to 7. `deduced` tells whether the type has such a part. + std::string writtenForm(const clang::ASTContext& context, clang::QualType type, + bool& deduced) + { + const clang::PrintingPolicy policy(context.getLangOpts()); + const std::string qualifiers = type.getLocalQualifiers().getAsString(policy); + const auto qualified = [&](const std::string& text) + { return qualifiers.empty() ? text : qualifiers + " " + text; }; + const auto part = [&](clang::QualType inner) + { return writtenForm(context, inner, deduced); }; + const clang::Type* node = type.getTypePtr(); + if (const auto* deducedTemplate = + llvm::dyn_cast(node)) + { + deduced = true; + std::string name; + llvm::raw_string_ostream out(name); + deducedTemplate->getTemplateName().print(out, policy); + return qualified(name); + } + if (const auto* automatic = llvm::dyn_cast(node)) + { + deduced = true; + return qualified(automatic->isDecltypeAuto() ? "decltype(auto)" : "auto"); + } + if (llvm::isa(node)) + { + deduced = true; + return qualified("decltype"); + } + if (const auto* pointer = llvm::dyn_cast(node)) + return qualified(part(pointer->getPointeeType()) + " *"); + if (const auto* reference = llvm::dyn_cast(node)) + { + return qualified(part(reference->getPointeeTypeAsWritten()) + + (reference->isLValueReferenceType() ? " &" : " &&")); + } + if (const auto* member = llvm::dyn_cast(node)) + { + return qualified(part(member->getPointeeType()) + " " + + writtenType(context, clang::QualType(member->getClass(), 0)) + + "::*"); + } + if (const auto* array = llvm::dyn_cast(node)) + { + const auto* constant = llvm::dyn_cast(array); + return qualified(part(array->getElementType()) + " [" + + (constant ? llvm::toString(constant->getSize(), 10, false) : "") + + "]"); + } + if (const auto* function = llvm::dyn_cast(node)) + { + std::string out = part(function->getReturnType()) + " ("; + for (unsigned i = 0; i < function->getNumParams(); ++i) + out += (i ? ", " : "") + part(function->getParamType(i)); + out += function->isVariadic() ? ", ...)" : ")"; + const std::string method = function->getMethodQuals().getAsString(policy); + return qualified(method.empty() ? out : out + " " + method); + } + if (const auto* specialization = + llvm::dyn_cast(node); + specialization && !specialization->isTypeAlias()) + { + std::string out; + llvm::raw_string_ostream text(out); + specialization->getTemplateName().print(text, policy); + text << "<"; + bool first = true; + for (const clang::TemplateArgument& argument : specialization->template_arguments()) + { + text << (first ? "" : ", "); + first = false; + if (argument.getKind() == clang::TemplateArgument::Type) + text << part(argument.getAsType()); + else + argument.print(policy, text, /*IncludeType=*/true); + } + text << ">"; + return qualified(out); + } + if (llvm::isa(node)) + { + return qualified(part(node->getLocallyUnqualifiedSingleStepDesugaredType())); + } + return qualified(writtenType(context, clang::QualType(node, 0))); + } + class Inspector : public clang::ASTConsumer, public clang::RecursiveASTVisitor { public: @@ -439,6 +530,56 @@ namespace ctrace::stack::analysis return true; } + // §5.6 (D19), after the rewrite: the planned parameters' types are the plan's. A type + // that an edit writes anywhere else, in a declaration, an expression or a planned + // parameter's default argument, is outside it. + bool TraverseParmVarDecl(clang::ParmVarDecl* parameter) + { + const clang::TypeSourceInfo* outer = plannedType_; + if (rewrittenBy_ && parameter->getTypeSourceInfo() && + std::binary_search(rewrittenBy_->parameters.begin(), + rewrittenBy_->parameters.end(), + (*sites_)(parameter->getLocation()))) + { + plannedType_ = parameter->getTypeSourceInfo(); + } + const bool traversed = + clang::RecursiveASTVisitor::TraverseParmVarDecl(parameter); + plannedType_ = outer; + return traversed; + } + + bool TraverseTypeLoc(clang::TypeLoc loc) + { + const bool planned = plannedType_ && loc == plannedType_->getTypeLoc(); + inPlannedType_ += planned; + const bool traversed = clang::RecursiveASTVisitor::TraverseTypeLoc(loc); + inPlannedType_ -= planned; + return traversed; + } + + bool VisitTypeLoc(clang::TypeLoc loc) + { + if (!rewrittenBy_ || inPlannedType_ || out_.writtenOutside) + return true; + const clang::SourceLocation at = loc.getBeginLoc(); + if (at.isInvalid()) + return true; + const clang::SourceManager& sm = context_->getSourceManager(); + const clang::FileID file = sm.getFileID(sm.getSpellingLoc(at)); + const auto known = rewrittenSpelling_.find(file.getHashValue()); + const bool rewritten = known != rewrittenSpelling_.end() + ? known->second + : (rewrittenSpelling_[file.getHashValue()] = + rewrittenFiles_.count(sites_->fileOf(file)) != 0); + if (!rewritten) + return true; + const DeclarationSite site = (*sites_)(at); + if (sites_->edited(site.spelling)) + out_.writtenOutside = UnitFacts::Outside{site, lineOf(at)}; + return true; + } + bool VisitVarDecl(clang::VarDecl* variable) { if (!collectFacts_ || !variable->isStaticLocal() || @@ -597,8 +738,10 @@ namespace ctrace::stack::analysis if (!rewrittenFiles_.count(sites_->fileOf(sm.getFileID(sm.getExpansionLoc(at))))) return; std::string name = declaration.getNameAsString(); + bool deduced = false; + std::string form = writtenForm(*context_, type, deduced); out_.writtenTypes[(*sites_)(at)].push_back(UnitFacts::Written{ - writtenType(*context_, type), + deduced ? std::move(form) : writtenType(*context_, type), name.empty() ? "an unnamed declaration" : std::move(name), lineOf(at)}); } @@ -1005,6 +1148,10 @@ namespace ctrace::stack::analysis std::map> symbols_; std::map> values_; std::map shown_; + // After the rewrite, the planned parameter's type being traversed (D19). + const clang::TypeSourceInfo* plannedType_ = nullptr; + std::size_t inPlannedType_ = 0; + std::map rewrittenSpelling_; // by FileID: is it a rewritten file }; class InspectAction : public clang::EmitLLVMOnlyAction @@ -1252,6 +1399,20 @@ namespace ctrace::stack::analysis return files_.emplace(file, std::move(path)).first->second; } + bool const_suggestion::SiteReader::edited(const SourcePosition& at) const + { + const auto edits = edits_.find(at.file); + if (edits == edits_.end()) + return false; + return std::any_of(edits->second.begin(), edits->second.end(), + [&](const Edit& edit) + { + return edit.offset <= at.offset && + at.offset < + edit.offset + std::max(edit.length, 1); + }); + } + SourcePosition const_suggestion::SiteReader::position(clang::SourceLocation location) { const auto [file, offset] = sm_.getDecomposedLoc(location); diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 2a319bd..d9d0bb1 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -194,6 +194,9 @@ namespace ctrace::stack::analysis::const_suggestion DeclarationSite operator()(clang::SourceLocation location); // The real path of a file, or its buffer's name when it is none. const std::string& fileOf(clang::FileID file); + // Whether a position of the original text is written by one of the edits: inside the + // text an edit replaces, or where it inserts text (§5.6, D19). + bool edited(const SourcePosition& at) const; private: SourcePosition position(clang::SourceLocation location); @@ -339,7 +342,7 @@ namespace ctrace::stack::analysis::const_suggestion // A declaration that a rewritten file writes (§5.6). struct Written { - std::string type; // written canonically + std::string type; // canonically, or its written form when a part is deduced (D19) std::string name; unsigned line = 0; // where Clang expands it std::uint8_t reservedPadding[4] = {}; @@ -356,6 +359,15 @@ namespace ctrace::stack::analysis::const_suggestion // In the rewritten files, by site; one site can hold several, in the order of the // traversal, such as a macro argument expanded twice. std::map> writtenTypes; + // After the rewrite, the first type that an edit writes outside the planned parameters' + // types, in a declaration or an expression, a default argument included (§5.6, D19). + struct Outside + { + DeclarationSite site; + unsigned line = 0; // where Clang expands it + std::uint8_t reservedPadding[4] = {}; + }; + std::optional writtenOutside; std::unique_ptr context; std::unique_ptr module; std::vector facts; // R and K, in the order of the traversal From 51679987d8386a6d839aaa916f4d6bfa0198f8e0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 23 Oct 2026 20:11:01 +0200 Subject: [PATCH 130/152] test(const-param): keep a planned parameter's default argument outside the plan --- run_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/run_test.py b/run_test.py index 926972c..4f6c3e8 100755 --- a/run_test.py +++ b/run_test.py @@ -4044,6 +4044,37 @@ def check_const_suggestion_written_part_outside_the_plan() -> bool: return ok +def check_const_suggestion_default_argument_outside_the_plan() -> bool: + """ + #217 (D19): only the types that make up a planned parameter's type are the plan's. A macro + argument writes the parameter's type and the type of a cast in its default argument: the + rewrite changes, outside the plan, what the default argument writes, and the suggestion is + kept, not checked. + """ + print("=== Testing a planned parameter's default argument written by its macro argument ===") + source = ( + "#define PARAM(type) type p = static_cast(nullptr)\n" + "\n" + "int read_value(PARAM(int*))\n" + "{\n" + " return p ? *p : 0;\n" + "}\n" + ) + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_default_argument_") as tmp: + path = Path(tmp).resolve() / "default.cpp" + path.write_text(source) + run = _const_suggestion_run([str(path)]) + expected = ( + f"not checked with Clang: the rewrite also changes the type written at {path}:3" + ) + texts = run[0].get("_Z10read_valuePi", []) if run else [] + good = len(texts) == 1 and expected in texts[0] + detail = "" if good else f": {texts}" + print(f" {'✅' if good else '❌'} the default argument's cast{detail}") + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7015,6 +7046,7 @@ def record_ok(ok: bool): check_const_suggestion_top_level_qualifier, check_const_suggestion_deduced_type_error, check_const_suggestion_written_part_outside_the_plan, + check_const_suggestion_default_argument_outside_the_plan, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 4454947408c88e157a8e547626539a2915d7fb3f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 25 Oct 2026 23:08:17 +0100 Subject: [PATCH 131/152] test(const-param): check the correspondences of nested entities end to end --- run_test.py | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/run_test.py b/run_test.py index 4f6c3e8..9f39982 100755 --- a/run_test.py +++ b/run_test.py @@ -4075,6 +4075,324 @@ def check_const_suggestion_default_argument_outside_the_plan() -> bool: return good +# #217 (§5.7, step 5): the correspondences of nested entities, end to end. +P2_LAMBDAS_SOURCE = ( + "template void run_all(int n, F&& f)\n" + "{\n" + " for (int i = 0; i < n; ++i)\n" + " f(i);\n" + "}\n" + "\n" + "template int pass(int n, Each&& each)\n" + "{\n" + " int total = 0;\n" + " each(n, [&](int i) { total += i; });\n" + " return total;\n" + "}\n" + "\n" + "int drive(int n)\n" + "{\n" + " return pass(n, [&](int count, auto&& body) { run_all(count, [&](int i) { body(i); }); });\n" + "}\n" +) + +P2_LOCAL_CLASS_SOURCE = ( + "#include \n" + "\n" + "template int count_paths(int n, Pred&& pred)\n" + "{\n" + " struct State\n" + " {\n" + " int value;\n" + " };\n" + " std::deque work;\n" + " work.push_back({n});\n" + " int found = 0;\n" + " while (!work.empty())\n" + " {\n" + " const State state = work.front();\n" + " work.pop_front();\n" + " if (pred(state.value))\n" + " ++found;\n" + " if (state.value > 0)\n" + " work.push_back({state.value - 1});\n" + " }\n" + " return found;\n" + "}\n" + "\n" + "int use(int n)\n" + "{\n" + " return count_paths(n, [](int v) { return v % 2 == 0; });\n" + "}\n" +) + +P2_TWO_INSTANTIATIONS_SOURCE = P2_LAMBDAS_SOURCE.replace( + " each(n, [&](int i) { total += i; });\n", + " each(n, [&](int i) { total += i; });\n each(n, [&](int i) { total -= 2 * i; });\n", +) + +# Everything nested in the generic lambda's call operator, a specialization that step 3 pairs, so +# that P2, not step 4, pairs it: a local class with non-trivial implicit members, a lambda converted +# to a function pointer by apply_both (operator() and __invoke), a class template's hidden friend, +# and probe of three member classes, two of which differ only by a closure type. No lambda is kept in +# a local variable and Box holds a pointer: the __const globals of lambdas kept in locals, which step 5 +# does not pair, and Box's implicit members, which have their own check, are not what this +# check is about. The unit test +# testConstSuggestionNestedIdentities adds a polymorphic local class to this source, whose vtable P2 +# does not pair. +P2_KEYS_SOURCE = ( + "#include \n" + "\n" + "template struct Box\n" + "{\n" + " T* value;\n" + " friend bool operator==(const Box& a, const Box& b) { return a.value == b.value; }\n" + " struct Inner\n" + " {\n" + " int n = 0;\n" + " };\n" + "};\n" + "\n" + "template int probe(T) { return 1; }\n" + "\n" + "template int apply_both(L call)\n" + "{\n" + " int (*pointer)(int) = call;\n" + " return pointer(1) + call(1);\n" + "}\n" + "\n" + "template int pass(int n, Each&& each)\n" + "{\n" + " int total = 0;\n" + " each(n, [&](int i) { total += i; });\n" + " return total;\n" + "}\n" + "\n" + "int drive(int n)\n" + "{\n" + " return pass(n, [&](int count, auto&& body) {\n" + " struct State\n" + " {\n" + " std::vector v;\n" + " };\n" + " State a{{count}};\n" + " State b = a;\n" + " State c = static_cast(a);\n" + " b = c;\n" + " c = static_cast(b);\n" + " State d;\n" + " Box x{nullptr}, y{nullptr};\n" + " const int found = (x == y) + probe(typename Box::Inner{}) +\n" + " probe(typename Box::Inner{}) +\n" + " probe(typename Box::Inner{}) +\n" + " apply_both([](int v) { return 2 * v; });\n" + " body(found + static_cast(b.v.size() + c.v.size() + d.v.size()));\n" + " });\n" + "}\n" +) + +_P2_VERDICT = re.compile(r"^(\S+) parameter (\S+): (checked in \d+ file\(s\)|removed, .*|not checked, .*)$") + + +def _p2_verdicts(name: str, source: str) -> Optional[dict[str, list[str]]]: + """The trace's verdicts by parameter name, in the order of the trace; None if no run.""" + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_p2_") as tmp: + path = Path(tmp).resolve() / name + path.write_text(source) + run = _const_suggestion_run([str(path), "--compile-arg=-std=c++20"]) + if not run: + return None + verdicts: dict[str, list[str]] = {} + for line in run[1]: + match = _P2_VERDICT.match(line) + if match: + verdicts.setdefault(match.group(2), []).append(match.group(3)) + return verdicts + + +def _p2_expect(label: str, verdicts: Optional[dict[str, list[str]]], expected: dict[str, list[str]]) -> bool: + """Each expected parameter's verdicts, by their beginning.""" + good = verdicts is not None and all( + len(verdicts.get(name, [])) == len(starts) + and all(text.startswith(start) for text, start in zip(verdicts[name], starts)) + for name, starts in expected.items() + ) + print(f" {'✅' if good else '❌'} {label}{'' if good else f': {verdicts}'}") + return good + + +def check_const_suggestion_p2_lambdas_of_another_specialization() -> bool: + """ + #217 (§5.7, step 5): a lambda nested in the generic lambda's call operator, a specialization that the + rewrite of pass renames, is the argument of run_all: P2 pairs it through that specialization, + and every suggestion is checked. + """ + print("=== Testing the lambdas nested in another renamed specialization (P2) ===") + good = _p2_expect( + "each, body and f checked", + _p2_verdicts("lambdas.cpp", P2_LAMBDAS_SOURCE), + {"each": ["checked in"], "body": ["checked in"], "f": ["checked in"]}, + ) + print() + return good + + +def check_const_suggestion_p2_local_class_in_a_library_template() -> bool: + """ + #217 (§5.7, step 5): a local class of the rewritten specialization is the argument of + std::deque; the library's members of std::deque, its member classes and their implicit + members among them, pair through State. A member key without the member's type, or member + classes written as Clang prints them, leave pred not checked; so does a standard library whose + implicit members (libstdc++'s _Deque_impl::~_Deque_impl()) would go unidentified. + """ + print("=== Testing a local class as a library template's argument (P2) ===") + good = _p2_expect( + "pred checked", + _p2_verdicts("local_class.cpp", P2_LOCAL_CLASS_SOURCE), + {"pred": ["checked in"]}, + ) + print() + return good + + +def check_const_suggestion_p2_other_specialization_of_the_rewritten_template() -> bool: + """ + #217 (§5.7, step 5): run_all is instantiated twice. Rewriting f in one specialization + rewrites the template, so the other specialization's function type changes too; P2 keeps every + part of a key but the nested types, so that one stays unpaired and both f stay not checked. A + specialization key without the function type would pair it. + """ + print("=== Testing the other specialization of the rewritten template (P2) ===") + good = _p2_expect( + "each and both body checked, both f not checked", + _p2_verdicts("two_instantiations.cpp", P2_TWO_INSTANTIATIONS_SOURCE), + { + "each": ["checked in"], + "body": ["checked in", "checked in"], + "f": ["not checked, the generated code changes", "not checked, the generated code changes"], + }, + ) + print() + return good + + +def check_const_suggestion_p2_keys_keep_candidates_apart() -> bool: + """ + #217 (§5.7, step 5): the candidates nested in the generic lambda's call operator pair one to + one: the local class's implicit constructors and assignments (by the member's type), the + lambda's operator() and __invoke (by name, static flag and qualifiers, which this check protects + together, not each one), the hidden friend of Box (by its function type), probe of member + classes whose specializations differ only by a closure type (through their specialization). A + key without any of these parts merges two of them, and each stays not checked. + """ + print("=== Testing that the keys keep the nested candidates apart (P2) ===") + good = _p2_expect( + "each and body checked", + _p2_verdicts("keys.cpp", P2_KEYS_SOURCE), + {"each": ["checked in"], "body": ["checked in"]}, + ) + print() + return good + + +# The implicit members of classes whose arguments hold a type nested in the generic lambda's call +# operator: Box's default, copy and move constructors, assignments and destructor, the +# destructors of Holder and Holder. No polymorphic class: its vtable is a +# global that P2 does not pair. +P2_IMPLICIT_SOURCE = ( + "#include \n" + "\n" + "template struct Box\n" + "{\n" + " T value;\n" + "};\n" + "\n" + "template struct Holder\n" + "{\n" + " T value;\n" + " U other;\n" + "};\n" + "\n" + "template int pass(int n, Each&& each)\n" + "{\n" + " int total = 0;\n" + " each(n, [&](int i) { total += i; });\n" + " return total;\n" + "}\n" + "\n" + "int drive(int n)\n" + "{\n" + " return pass(n, [&](int count, auto&& body) {\n" + " struct State\n" + " {\n" + " std::vector v;\n" + " };\n" + " Box a{{{count}}};\n" + " Box b = a;\n" + " Box c = static_cast&&>(a);\n" + " b = c;\n" + " c = static_cast&&>(b);\n" + " Box d;\n" + " Holder h{};\n" + " Holder k{};\n" + " body(static_cast(b.value.v.size() + c.value.v.size() + d.value.v.size()) +\n" + " h.other + static_cast(k.other));\n" + " });\n" + "}\n" +) + +# The rewrite of p, only read, changes Tag's own argument, int * becoming const int *: Tag's implicit +# destructor is then another class's, and Tag holds no nested type. (p is not stored in Tag: a stored +# pointer escapes, and no const is suggested; the first version of this check had no suggestion.) +P2_OWNER_CHANGED_SOURCE = ( + "#include \n" + "\n" + "template struct Tag\n" + "{\n" + " std::vector items;\n" + "};\n" + "\n" + "int read_value(int* p)\n" + "{\n" + " Tag tag{};\n" + " return *p + static_cast(tag.items.size());\n" + "}\n" +) + + +def check_const_suggestion_p2_implicit_members_of_a_specialization() -> bool: + """ + #217 (§5.7, step 5): the implicit members of Box and of the two Holders pair through + State, by their class, kind and name, variant and whole type. Without the type, the variant or + the class's other arguments, two of them merge, and each stays not checked. + """ + print("=== Testing the implicit members of specializations (P2) ===") + good = _p2_expect( + "each and body checked", + _p2_verdicts("implicit.cpp", P2_IMPLICIT_SOURCE), + {"each": ["checked in"], "body": ["checked in"]}, + ) + print() + return good + + +def check_const_suggestion_p2_owner_changed_by_the_rewrite() -> bool: + """ + #217 (§5.7, step 5, negative): the rewrite of p changes Tag's own argument, so Tag's + implicit destructor has no counterpart; step 5, which keeps every argument and identifies only + the classes that hold a nested type, leaves it unpaired, and the suggestion stays not checked. A + key that wrote the class by its name alone, whatever it holds, would pair it. + """ + print("=== Testing an implicit member whose class the rewrite changes (P2) ===") + good = _p2_expect( + "p not checked", + _p2_verdicts("owner_changed.cpp", P2_OWNER_CHANGED_SOURCE), + {"p": ["not checked, the generated code changes"]}, + ) + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7047,6 +7365,12 @@ def record_ok(ok: bool): check_const_suggestion_deduced_type_error, check_const_suggestion_written_part_outside_the_plan, check_const_suggestion_default_argument_outside_the_plan, + check_const_suggestion_p2_lambdas_of_another_specialization, + check_const_suggestion_p2_local_class_in_a_library_template, + check_const_suggestion_p2_other_specialization_of_the_rewritten_template, + check_const_suggestion_p2_keys_keep_candidates_apart, + check_const_suggestion_p2_implicit_members_of_a_specialization, + check_const_suggestion_p2_owner_changed_by_the_rewrite, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 7511f500c338f886d639685eb6612fc28b7ca95f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 25 Oct 2026 23:08:39 +0100 Subject: [PATCH 132/152] =?UTF-8?q?feat(const-param):=20pair=20the=20entit?= =?UTF-8?q?ies=20nested=20in=20established=20pairs=20(=C2=A75.7,=20step=20?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/analysis/ConstSuggestionFacts.cpp | 220 ++++++++++++++++++ src/analysis/ConstSuggestionInternal.hpp | 12 + src/analysis/ConstSuggestionMatch.cpp | 282 +++++++++++++++-------- 3 files changed, 414 insertions(+), 100 deletions(-) diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 195e169..3a7e31b 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -414,6 +414,11 @@ namespace ctrace::stack::analysis TraverseDecl(context.getTranslationUnitDecl()); for (std::size_t i = 0; i < targets_.size(); ++i) resolve(i); + { + const analyzer::ScopedHotspot identifying(out_.timing, + "const_suggestion.identify"); + identify(); + } } bool VisitFunctionDecl(clang::FunctionDecl* function) @@ -850,6 +855,221 @@ namespace ctrace::stack::analysis return ""; } + // The symbol of the function around a nested type (§5.7, step 5); "" in a template. + std::string enclosingOf(const clang::CXXRecordDecl& record) + { + for (const clang::DeclContext* at = record.getDeclContext(); at; + at = at->getParent()) + { + if (const auto* function = llvm::dyn_cast(at)) + { + if (function->isDependentContext()) + return ""; + const std::vector& symbols = symbolsOf(*function); + return symbols.empty() ? "" : symbols.front(); + } + } + return ""; + } + + // A template argument's type, canonically (§5.7, step 5), each nested type written as + // its record's site and the symbol of the function around it, which the + // correspondences map; every other part, qualifiers included, kept as it is. + std::string typeKey(clang::QualType type, bool& nested) + { + type = type.getCanonicalType(); + const std::string qualifiers = type.getLocalQualifiers().getAsString(); + const auto with = [&](const std::string& text) + { return qualifiers.empty() ? text : qualifiers + " " + text; }; + const clang::Type* node = type.getTypePtr(); + if (const auto* pointer = llvm::dyn_cast(node)) + return with(typeKey(pointer->getPointeeType(), nested) + " *"); + if (const auto* reference = llvm::dyn_cast(node)) + { + return with(typeKey(reference->getPointeeType(), nested) + + (reference->isLValueReferenceType() ? " &" : " &&")); + } + if (const auto* array = llvm::dyn_cast(node)) + { + return with(typeKey(array->getElementType(), nested) + " [" + + llvm::toString(array->getSize(), 10, false) + "]"); + } + if (const auto* function = llvm::dyn_cast(node)) + { + std::string out = typeKey(function->getReturnType(), nested) + " ("; + for (unsigned i = 0; i < function->getNumParams(); ++i) + out += (i ? ", " : "") + typeKey(function->getParamType(i), nested); + return with(out + (function->isVariadic() ? ", ...)" : ")")); + } + if (const clang::CXXRecordDecl* record = node->getAsCXXRecordDecl()) + { + if (record->isLambda() || record->isLocalClass()) + { + nested = true; + return with("\x01" + textOf((*sites_)(record->getLocation())) + "\x02" + + enclosingOf(*record) + "\x03"); + } + // A member class is written through the class around it, whose arguments + // may hold a nested type: std::vector::__destroy_vector. + const auto* parent = + llvm::dyn_cast(record->getDeclContext()); + const std::string outer = + parent ? typeKey(context_->getRecordType(parent), nested) + "::" : ""; + if (const auto* specialization = + llvm::dyn_cast(record)) + { + return with( + (parent ? outer + specialization->getNameAsString() + : specialization->getSpecializedTemplate() + ->getQualifiedNameAsString()) + + "<" + + argumentsKey(specialization->getTemplateArgs().asArray(), nested) + + ">"); + } + if (parent) + return with(outer + record->getNameAsString()); + } + return with(writtenType(*context_, type)); + } + + std::string argumentsKey(llvm::ArrayRef arguments, + bool& nested) + { + std::string out; + for (const clang::TemplateArgument& argument : arguments) + { + if (!out.empty()) + out += ", "; + if (argument.getKind() == clang::TemplateArgument::Type) + out += typeKey(argument.getAsType(), nested); + else if (argument.getKind() == clang::TemplateArgument::Pack) + out += "{" + argumentsKey(argument.getPackAsArray(), nested) + "}"; + else + { + llvm::raw_string_ostream text(out); + argument.print(clang::PrintingPolicy(context_->getLangOpts()), text, true); + } + } + return out; + } + + // The identities of the functions the module emits (§5.7, step 5), read before the AST + // goes: a member of a nested type, a specialization whose arguments or type hold a + // nested type, an implicit member of a class whose key holds one. + void identify() + { + if (!generator_ || !collectFacts_ || !generator_->GetModule()) + return; + for (const llvm::Function& emitted : *generator_->GetModule()) + { + if (!emitted.hasName()) + continue; + const std::string symbol = emitted.getName().str(); + const auto* function = llvm::dyn_cast_or_null( + generator_->GetDeclForMangledName(symbol)); + if (!function) + continue; + const std::vector& symbols = symbolsOf(*function); + const std::vector keys = variantKeys(*function); + std::string variant; + for (std::size_t k = 0; k < symbols.size() && k < keys.size(); ++k) + { + if (symbols[k] == symbol) + variant = keys[k]; + } + UnitFacts::Identity identity{symbol, "", "", ""}; + if (const auto* method = llvm::dyn_cast(function)) + { + const clang::CXXRecordDecl* record = method->getParent(); + const std::string enclosing = record->isLambda() || record->isLocalClass() + ? enclosingOf(*record) + : ""; + if (!enclosing.empty()) + { + // The member's own type tells overloads apart, such as a local + // class's implicit default and move constructors, at one site. + bool member = false; + identity.nested = + std::string(record->isLambda() ? "lambda " : "method ") + variant + + "|" + textOf((*sites_)(record->getLocation())) + "|" + + textOf((*sites_)(method->getLocation())) + "|" + + method->getNameAsString() + + (method->isStatic() ? " static " : " ") + + method->getMethodQualifiers().getAsString() + "|" + + typeKey(method->getType(), member); + identity.enclosing = enclosing; + } + } + if (const clang::FunctionDecl* pattern = + function->getTemplateInstantiationPattern(/*ForDefinition=*/false)) + { + bool nested = false; + std::string arguments; + std::vector chain; + for (const clang::DeclContext* at = function->getDeclContext(); at; + at = at->getParent()) + chain.push_back(at); + for (auto at = chain.rbegin(); at != chain.rend(); ++at) + { + if (const auto* specialization = + llvm::dyn_cast(*at)) + { + arguments += + "<" + + argumentsKey(specialization->getTemplateArgs().asArray(), + nested) + + ">"; + } + } + if (const clang::TemplateArgumentList* own = + function->getTemplateSpecializationArgs()) + arguments += "(" + argumentsKey(own->asArray(), nested) + ")"; + // Its type too, so that a friend defined in a class template, which has + // neither arguments nor an enclosing specialization, is identified. + arguments += "[" + typeKey(function->getType(), nested) + "]"; + if (nested) + { + identity.specialization = variant + "|" + + textOf((*sites_)(pattern->getLocation())) + + "|" + arguments; + } + } + // An implicit member has no pattern and, outside a nested type, no nested + // key; when its class's key holds a nested type, it is identified by that + // class, its kind and name, its variant and its whole type, its qualifiers + // included. Nothing in it is specific to a standard library. + if (identity.nested.empty() && identity.specialization.empty()) + { + const auto* method = llvm::dyn_cast(function); + if (method && method->isImplicit()) + { + bool nested = false; + const std::string owner = + typeKey(context_->getRecordType(method->getParent()), nested); + if (nested) + { + const char* kind = + llvm::isa(method) ? "constructor" + : llvm::isa(method) ? "destructor" + : "method"; + const char* reference = + method->getRefQualifier() == clang::RQ_LValue ? " &" + : method->getRefQualifier() == clang::RQ_RValue ? " &&" + : ""; + bool inType = false; + identity.specialization = + "implicit " + variant + "|" + owner + "|" + kind + " " + + method->getNameAsString() + "|" + + method->getMethodQualifiers().getAsString() + reference + "|" + + typeKey(method->getType(), inType); + } + } + } + if (!identity.nested.empty() || !identity.specialization.empty()) + out_.identities.push_back(std::move(identity)); + } + } + // Records nested symbols, each with its kind, for the targets whose entity they lie in. void nested(const clang::DeclContext* context, clang::SourceLocation at, const std::vector>& symbols) diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index d9d0bb1..4f0740f 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -368,6 +368,18 @@ namespace ctrace::stack::analysis::const_suggestion std::uint8_t reservedPadding[4] = {}; }; std::optional writtenOutside; + // Each function the module emits that a nested type (a lambda or a local class) names, as + // Clang identifies it, read before the AST goes (§5.7, step 5). + struct Identity + { + std::string symbol; + std::string + nested; // member of a nested type: kind and variant, record and member sites + std::string enclosing; // the symbol of the function around that nested type + std::string + specialization; // variant, pattern site, template arguments with nested types + }; + std::vector identities; std::unique_ptr context; std::unique_ptr module; std::vector facts; // R and K, in the order of the traversal diff --git a/src/analysis/ConstSuggestionMatch.cpp b/src/analysis/ConstSuggestionMatch.cpp index 6706438..13f8103 100644 --- a/src/analysis/ConstSuggestionMatch.cpp +++ b/src/analysis/ConstSuggestionMatch.cpp @@ -123,132 +123,214 @@ namespace ctrace::stack::analysis fixed[it->second].facts.push_back(&fact); } } - for (bool added = true; added;) + // Steps 3, 4 and 5 run to one common fixpoint: a pair that one establishes may map what + // another needs. + for (std::size_t settled = static_cast(-1); settled != pairs.size();) { - added = false; - const auto forward = [&](FactKey key) -> std::optional + settled = pairs.size(); + for (bool added = true; added;) { - for (std::string& context : key.contexts) + added = false; + const auto forward = [&](FactKey key) -> std::optional { - const auto found = pairs.find(context); - if (found != pairs.end()) - context = found->second; - else if (!after.instantiations.count(context)) - return std::nullopt; + for (std::string& context : key.contexts) + { + const auto found = pairs.find(context); + if (found != pairs.end()) + context = found->second; + else if (!after.instantiations.count(context)) + return std::nullopt; + } + return key; + }; + std::vector moving; + { + std::map slot; + for (const auto& [i, fact] : contextual) + { + if (const std::optional key = forward(fact->key)) + { + const auto [it, added] = slot.try_emplace(*key, moving.size()); + if (added) + moving.push_back({i, *key, {}}); + moving[it->second].facts.push_back(fact); + } + } } - return key; - }; - std::vector moving; - { - std::map slot; - for (const auto& [i, fact] : contextual) + // Both, merged in the order of their first references: a key with contexts never + // equals one without. + std::vector order; + order.reserve(fixed.size() + moving.size()); + for (std::size_t a = 0, b = 0; a < fixed.size() || b < moving.size();) { - if (const std::optional key = forward(fact->key)) + if (b == moving.size() || + (a < fixed.size() && fixed[a].first < moving[b].first)) + order.push_back(&fixed[a++]); + else + order.push_back(&moving[b++]); + } + std::vector proposing; // in the order of the facts + std::map> to; // before to after + std::map> from; // after to before + std::map shown; + for (const At* at : order) + { + const std::vector& wasFacts = at->facts; + const auto nowFacts = nowAt.find(at->key); + if (nowFacts == nowAt.end() || nowFacts->second.size() != wasFacts.size()) + continue; + for (std::size_t k = 0; k < wasFacts.size(); ++k) { - const auto [it, added] = slot.try_emplace(*key, moving.size()); - if (added) - moving.push_back({i, *key, {}}); - moving[it->second].facts.push_back(fact); + const Fact& one = *wasFacts[k]; + const Fact& other = *nowFacts->second[k]; + const std::string wasTarget = itemOf(one.value, "symbol:"); + const std::string nowTarget = itemOf(other.value, "symbol:"); + const std::string pattern = itemOf(one.value, "template:"); + if (wasTarget.empty() || nowTarget.empty() || pattern.empty() || + wasTarget == nowTarget || pattern != itemOf(other.value, "template:") || + pairs.count(wasTarget)) + { + continue; + } + addOnce(proposing, wasTarget); + addOnce(to[wasTarget], nowTarget); + addOnce(from[nowTarget], wasTarget); + shown[wasTarget] = one.shown; + shown[nowTarget] = other.shown; } } - } - // Both, merged in the order of their first references: a key with contexts never - // equals one without. - std::vector order; - order.reserve(fixed.size() + moving.size()); - for (std::size_t a = 0, b = 0; a < fixed.size() || b < moving.size();) - { - if (b == moving.size() || (a < fixed.size() && fixed[a].first < moving[b].first)) - order.push_back(&fixed[a++]); - else - order.push_back(&moving[b++]); - } - std::vector proposing; // in the order of the facts - std::map> to; // before to after - std::map> from; // after to before - std::map shown; - for (const At* at : order) - { - const std::vector& wasFacts = at->facts; - const auto nowFacts = nowAt.find(at->key); - if (nowFacts == nowAt.end() || nowFacts->second.size() != wasFacts.size()) - continue; - for (std::size_t k = 0; k < wasFacts.size(); ++k) + for (const std::string& one : proposing) { - const Fact& one = *wasFacts[k]; - const Fact& other = *nowFacts->second[k]; - const std::string wasTarget = itemOf(one.value, "symbol:"); - const std::string nowTarget = itemOf(other.value, "symbol:"); - const std::string pattern = itemOf(one.value, "template:"); - if (wasTarget.empty() || nowTarget.empty() || pattern.empty() || - wasTarget == nowTarget || pattern != itemOf(other.value, "template:") || - pairs.count(wasTarget)) + const std::vector& targets = to[one]; + if (targets.size() > 1) + { + if (missing.empty()) + { + missing = "'" + shown[one] + "' becomes both '" + shown[targets[0]] + + "' and '" + shown[targets[1]] + + "': their correspondence is not one to one"; + } + continue; + } + // Two before the rewrite that become one, or one that becomes a symbol already + // paired. + std::vector sources = from[targets[0]]; + for (const auto& [wasSymbol, nowSymbol] : pairs) + { + if (nowSymbol == targets[0]) + sources.insert(sources.begin(), wasSymbol); + } + if (sources.size() > 1) { + if (missing.empty()) + { + const auto name = [&](const std::string& symbol) + { + return shown.count(symbol) ? shown[symbol] : llvm::demangle(symbol); + }; + missing = "'" + name(sources[0]) + "' and '" + name(sources[1]) + + "' both become '" + name(targets[0]) + + "': their correspondence is not one to one"; + } continue; } - addOnce(proposing, wasTarget); - addOnce(to[wasTarget], nowTarget); - addOnce(from[nowTarget], wasTarget); - shown[wasTarget] = one.shown; - shown[nowTarget] = other.shown; + added = pair(one, targets[0]) || added; + } + } + + // 4: the nested symbols, by kind and site, once the symbols around them are paired. + for (bool added = was && now; added;) + { + added = false; + for (const Named& one : was->nested) + { + if (pairs.count(one.symbol)) + continue; + const auto around = pairs.find(one.enclosing); + const std::string enclosing = + around == pairs.end() ? one.enclosing : around->second; + const auto other = std::find_if( + now->nested.begin(), now->nested.end(), [&](const Named& named) + { return named.key == one.key && named.enclosing == enclosing; }); + if (other != now->nested.end()) + added = pair(one.symbol, other->symbol) || added; } } - for (const std::string& one : proposing) + + // 5: the types nested in every established pair (lambdas, local classes) and what they + // reach: the members of those types, the specializations whose arguments or type hold + // them, and the implicit members of the classes that hold them, by the identities the + // facts give (UnitFacts::Identity). A key's nested types are mapped through the pairs; + // every other part of it is kept as it is. One to one: a key with more than one symbol + // on either side gives no pair, and a reason. { - const std::vector& targets = to[one]; - if (targets.size() > 1) + const auto substitute = [&](const std::string& key) { - if (missing.empty()) + std::string out; + for (std::size_t at = 0; at < key.size();) { - missing = "'" + shown[one] + "' becomes both '" + shown[targets[0]] + - "' and '" + shown[targets[1]] + - "': their correspondence is not one to one"; + const std::size_t open = key.find('\x02', at); + if (open == std::string::npos) + { + out += key.substr(at); + break; + } + const std::size_t close = key.find('\x03', open); + out += key.substr(at, open + 1 - at); + const std::string enclosing = key.substr(open + 1, close - open - 1); + const auto mapped = pairs.find(enclosing); + out += mapped == pairs.end() ? enclosing : mapped->second; + at = close; } - continue; - } - // Two before the rewrite that become one, or one that becomes a symbol already - // paired. - std::vector sources = from[targets[0]]; - for (const auto& [wasSymbol, nowSymbol] : pairs) + return out; + }; + // Only the symbols that the rewrite renames: one side has them, the other not. + std::set beforeNames, afterNames; + for (const UnitFacts::Identity& identity : before.identities) + beforeNames.insert(identity.symbol); + for (const UnitFacts::Identity& identity : after.identities) + afterNames.insert(identity.symbol); + std::map> nowAtKey; // key -> symbols after + for (const UnitFacts::Identity& identity : after.identities) { - if (nowSymbol == targets[0]) - sources.insert(sources.begin(), wasSymbol); + if (taken.count(identity.symbol) || beforeNames.count(identity.symbol)) + continue; + if (!identity.nested.empty()) + nowAtKey["n" + identity.nested + '\0' + identity.enclosing].push_back( + identity.symbol); + if (!identity.specialization.empty()) + nowAtKey["s" + identity.specialization].push_back(identity.symbol); } - if (sources.size() > 1) + std::map> + wasAtKey; // mapped key -> symbols before + for (const UnitFacts::Identity& identity : before.identities) { - if (missing.empty()) + if (pairs.count(identity.symbol) || afterNames.count(identity.symbol)) + continue; + if (!identity.nested.empty()) { - const auto name = [&](const std::string& symbol) - { return shown.count(symbol) ? shown[symbol] : llvm::demangle(symbol); }; - missing = "'" + name(sources[0]) + "' and '" + name(sources[1]) + - "' both become '" + name(targets[0]) + - "': their correspondence is not one to one"; + const auto around = pairs.find(identity.enclosing); + if (around != pairs.end()) + wasAtKey["n" + substitute(identity.nested) + '\0' + around->second] + .push_back(identity.symbol); } - continue; + else if (!identity.specialization.empty()) + wasAtKey["s" + substitute(identity.specialization)].push_back( + identity.symbol); + } + for (const auto& [key, sources] : wasAtKey) + { + const auto found = nowAtKey.find(key); + if (found == nowAtKey.end()) + continue; + if (sources.size() == 1 && found->second.size() == 1) + pair(sources.front(), found->second.front()); + else if (missing.empty()) + missing = "'" + llvm::demangle(sources.front()) + + "' has no one-to-one counterpart through its template arguments"; } - added = pair(one, targets[0]) || added; - } - } - - // 4: the nested symbols, by kind and site, once the symbols around them are paired. - for (bool added = was && now; added;) - { - added = false; - for (const Named& one : was->nested) - { - if (pairs.count(one.symbol)) - continue; - const auto around = pairs.find(one.enclosing); - const std::string enclosing = - around == pairs.end() ? one.enclosing : around->second; - const auto other = - std::find_if(now->nested.begin(), now->nested.end(), [&](const Named& named) - { return named.key == one.key && named.enclosing == enclosing; }); - if (other != now->nested.end()) - added = pair(one.symbol, other->symbol) || added; } } - std::vector out; for (const auto& [wasSymbol, nowSymbol] : pairs) { From c8d7883040f05296b7bae8cd6844145d564537d3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 25 Oct 2026 23:11:36 +0100 Subject: [PATCH 133/152] test(const-param): pin the keys of nested and implicit-member identities --- test/unit/analyzer_module_unit_tests.cpp | 412 +++++++++++++++++++++++ 1 file changed, 412 insertions(+) diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index 3597bab..2e9ae05 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -30,6 +30,8 @@ #include #include #include +#include +#include #include #include #include @@ -1011,6 +1013,413 @@ namespace return report.failures == 0; } + /// A compile command for a C++ source, as the Clang check builds one: compilerlib's driver + /// settings (Clang's resource directory, the SDK on macOS) before the arguments, so that the + /// standard library's headers are found on every platform. + ctrace::stack::analysis::UnitCommand cxxCommand(const std::filesystem::path& file) + { + const std::vector args{"-std=c++20", "-c", file.string()}; + ctrace::stack::analysis::UnitCommand out{{"clang++"}, file.parent_path().string()}; + compilerlib::DriverConfig driver; + std::string error; + if (compilerlib::resolveDriverConfig(args, driver, error)) + { + out.args = {driver.clang_path}; + if (driver.force_cxx_driver) + out.args.push_back("--driver-mode=g++"); + if (driver.add_resource_dir) + { + out.args.push_back("-resource-dir"); + out.args.push_back(driver.resource_dir); + } + if (driver.add_sysroot) + { + out.args.push_back("-isysroot"); + out.args.push_back(driver.sysroot); + } + } + out.args.insert(out.args.end(), args.begin(), args.end()); + return out; + } + + // run_test.py's P2_KEYS_SOURCE with a polymorphic local class, whose destructor has a deleting + // and a base variant. + constexpr const char* kNestedKeysSourceWithPoly = R"(#include + +template struct Box +{ + T* value; + friend bool operator==(const Box& a, const Box& b) { return a.value == b.value; } + struct Inner + { + int n = 0; + }; +}; + +template int probe(T) { return 1; } + +template int apply_both(L call) +{ + int (*pointer)(int) = call; + return pointer(1) + call(1); +} + +template int pass(int n, Each&& each) +{ + int total = 0; + each(n, [&](int i) { total += i; }); + return total; +} + +int drive(int n) +{ + return pass(n, [&](int count, auto&& body) { + struct State + { + std::vector v; + }; + State a{{count}}; + State b = a; + State c = static_cast(a); + b = c; + c = static_cast(b); + State d; + struct Poly + { + virtual ~Poly() {} + }; + delete new Poly; + Box x{nullptr}, y{nullptr}; + const int found = (x == y) + probe(typename Box::Inner{}) + + probe(typename Box::Inner{}) + + probe(typename Box::Inner{}) + + apply_both([](int v) { return 2 * v; }); + body(found + static_cast(b.v.size() + c.v.size() + d.v.size())); + }); +} +)"; + + /// #217 (§5.7, step 5): the correspondences of the types nested in established pairs and of the + /// specializations whose arguments hold them (§5.7): only the symbols that the rewrite renames, + /// one to one, every other part of a key kept. Synthetic facts: what Clang writes into a key is + /// pinned by testConstSuggestionNestedIdentities. + bool testConstSuggestionNestedCorrespondences(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + // The entity before and after the rewrite, paired by its variant (step 1). + const std::string was = "_Z1fIiEviOT_"; + const std::string now = "_Z1fIiEviRKT_"; + // A type nested in a function: its record's site and the symbol of the function around it. + const auto nested = [](const std::string& site, const std::string& around) + { return "\x01" + site + "\x02" + around + "\x03"; }; + // A specialization's key: variant, pattern site, its arguments, its function type. + const auto spec = + [&](const std::string& variant, const std::string& argument, const std::string& other) + { + return variant + "|use.cpp:3|(" + argument + ", " + other + ")[void (" + argument + + " &&)]"; + }; + const auto pairsOf = [&](std::vector before, + std::vector after, std::string& missing) + { + cs::UnitFacts wasUnit; + wasUnit.entities.resize(1); + wasUnit.entities[0].variants = {{was, "", ""}}; + wasUnit.identities = std::move(before); + cs::UnitFacts nowUnit; + nowUnit.entities.resize(1); + nowUnit.entities[0].variants = {{now, "", ""}}; + nowUnit.identities = std::move(after); + std::vector out; + for (const ctrace::stack::analysis::SymbolPair& pair : + cs::correspond(wasUnit, 0, nowUnit, missing)) + { + if (pair.before != was) + out.push_back(pair.before + "->" + pair.after); + } + return out; + }; + using Pairs = std::vector; + const std::string inWas = nested("use.cpp:40", was); + const std::string inNow = nested("use.cpp:40", now); + std::string missing; + + // A specialization whose argument is a type nested in the entity pairs through the + // entity's pair. + report.expect(pairsOf({{"_Z1aOld", "", "", spec("", inWas, "int")}}, + {{"_Z1aNew", "", "", spec("", inNow, "int")}}, + missing) == Pairs{"_Z1aOld->_Z1aNew"} && + missing.empty(), + "ConstSuggestionNested: a specialization pairs through its nested argument"); + + // Every other argument is kept: int against long, no pair (mutant: a key without the + // arguments that hold no nested type). + missing.clear(); + report.expect(pairsOf({{"_Z1aOld", "", "", spec("", inWas, "int")}}, + {{"_Z1aNew", "", "", spec("", inNow, "long")}}, missing) + .empty(), + "ConstSuggestionNested: another argument differs, no pair"); + + // Refinement 1, only renamed symbols: a symbol present on both sides at the same key + // stays out, and the renamed one pairs (without that filter: two candidates on each + // side, no pair, a reason). The nested type is written in a template, without an + // enclosing symbol, so its key does not change with the rewrite. + const std::string inTemplate = nested("use.cpp:50", ""); + missing.clear(); + report.expect(pairsOf({{"_Z1bOld", "", "", spec("", inTemplate, "int")}, + {"_Z1bSame", "", "", spec("", inTemplate, "int")}}, + {{"_Z1bNew", "", "", spec("", inTemplate, "int")}, + {"_Z1bSame", "", "", spec("", inTemplate, "int")}}, + missing) == Pairs{"_Z1bOld->_Z1bNew"} && + missing.empty(), + "ConstSuggestionNested: a symbol the rewrite does not rename takes no part"); + + // One to one: two before and two after at one key, no pair and a reason, not the + // first candidates paired. + missing.clear(); + report.expect(pairsOf({{"_Z1cOne", "", "", spec("", inWas, "int")}, + {"_Z1cTwo", "", "", spec("", inWas, "int")}}, + {{"_Z1cThree", "", "", spec("", inNow, "int")}, + {"_Z1cFour", "", "", spec("", inNow, "int")}}, + missing) + .empty() && + missing.find("has no one-to-one counterpart") != std::string::npos, + "ConstSuggestionNested: two candidates on each side, no pair, a reason"); + + // One before, two after: no pair, a reason. + missing.clear(); + report.expect(pairsOf({{"_Z1dOne", "", "", spec("", inWas, "int")}}, + {{"_Z1dTwo", "", "", spec("", inNow, "int")}, + {"_Z1dThree", "", "", spec("", inNow, "int")}}, + missing) + .empty() && + missing.find("has no one-to-one counterpart") != std::string::npos, + "ConstSuggestionNested: one candidate becomes two, no pair, a reason"); + + // The ABI variant is part of the key: C1 before, C2 after, no pair. + missing.clear(); + report.expect(pairsOf({{"_Z1eOld", "", "", spec("C1", inWas, "int")}}, + {{"_Z1eNew", "", "", spec("C2", inNow, "int")}}, missing) + .empty(), + "ConstSuggestionNested: two variants never pair"); + + // A member of a nested type pairs once the function around it is paired; around a + // function without a pair, it does not. + const std::string member = "lambda |use.cpp:40|use.cpp:40|operator() const|int (int)"; + missing.clear(); + report.expect(pairsOf({{"_ZOldL", member, was, ""}}, {{"_ZNewL", member, now, ""}}, + missing) == Pairs{"_ZOldL->_ZNewL"}, + "ConstSuggestionNested: a lambda's member pairs through its function"); + missing.clear(); + report.expect( + pairsOf({{"_ZOldL", member, "_Z1hv", ""}}, {{"_ZNewL", member, now, ""}}, missing) + .empty(), + "ConstSuggestionNested: around an unpaired function, no pair"); + return report.failures == 0; + } + + /// #217 (§5.7, step 5): what Clang writes into the keys of the nested entities, on one compilation of + /// real code. Every expected member and specialization has an identity, and two symbols + /// never share a key: a local class's implicit members, a lambda's call operator and static + /// invoker, a virtual destructor's variants, a class template's hidden friend, and member + /// classes of two specializations that differ only by a closure type. + bool testConstSuggestionNestedIdentities(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + const std::filesystem::path dir = + std::filesystem::temp_directory_path() / "ct_const_suggestion_nested_identities"; + std::filesystem::create_directories(dir); + const std::filesystem::path file = dir / "keys.cpp"; + // checks.py's P2_KEYS_SOURCE, with a polymorphic local class for the destructor's + // variants (its vtable is a global that P2 does not pair, so the end-to-end check + // leaves it out). + std::ofstream(file) << kNestedKeysSourceWithPoly; + const ctrace::stack::analysis::UnitCommand command = cxxCommand(file); + std::vector targets(1); + targets[0].symbol = "_Z4passIZ5driveiE3$_0EiiOT_"; + targets[0].sourceIndex = 0; + const cs::UnitFacts unit = cs::inspect(command, nullptr, {}, targets, "test", false); + // How many identities a mangled or demangled part selects, by kind of key. + const auto count = [&](const std::string& part, bool specialization) + { + return std::count_if(unit.identities.begin(), unit.identities.end(), + [&](const cs::UnitFacts::Identity& identity) + { + const std::string& key = + specialization ? identity.specialization : identity.nested; + return !key.empty() && + (identity.symbol.find(part) != std::string::npos || + llvm::demangle(identity.symbol).find(part) != + std::string::npos); + }); + }; + // Presence: each refinement's candidates are identified: the + // default, copy and move constructors (State d, b, c), the two assignments. + report.expect(count("::State::State(", false) >= 3 && + count("::State::operator=(", false) == 2, + "ConstSuggestionNestedKeys: the local class's implicit members are there"); + report.expect(count("__invoke", false) == 1, "ConstSuggestionNestedKeys: the static " + "invoker is there"); + report.expect(count("4PolyD0Ev", false) == 1 && count("4PolyD2Ev", false) == 1, + "ConstSuggestionNestedKeys: the destructor's deleting and base variants " + "are there"); + report.expect(count("operator==", true) == 1, + "ConstSuggestionNestedKeys: the hidden friend is identified (by its " + "function type)"); + report.expect(count("::Inner>(", true) == 3, + "ConstSuggestionNestedKeys: probe of the three member classes is identified " + "(through their specialization, which holds the nested type)"); + // Distinctness: no two symbols share a key (without the member's type, the implicit + // constructors and assignments merge; without the name, the static flag and the + // qualifiers together, which the test protects collectively, not each one, operator() and + // __invoke merge; without the variant, D0 and D2 merge; a member + // class written without its specialization's arguments: probe's two closures merge). + std::map owner; + bool apart = true; + for (const cs::UnitFacts::Identity& identity : unit.identities) + { + for (const std::string& key : + {identity.nested.empty() ? "" : "n" + identity.nested + '\0' + identity.enclosing, + identity.specialization.empty() ? "" : "s" + identity.specialization}) + { + if (key.empty()) + continue; + const auto [it, added] = owner.try_emplace(key, identity.symbol); + apart = apart && (added || it->second == identity.symbol); + } + } + report.expect(apart, "ConstSuggestionNestedKeys: two symbols never share a key"); + return report.failures == 0; + } + + // The implicit members of classes whose arguments hold a type nested in the generic lambda's + // call operator (P2, implicit-member extension): Box's six, Derived's virtual + // destructor (D0 and D2), Holder's and Holder's destructors; and a + // class whose arguments hold no nested type, Plain, whose members must not be identified. + constexpr const char* kImplicitMembersSource = R"(#include + +struct Base +{ + virtual ~Base() {} +}; + +template struct Box +{ + T value; +}; + +template struct Derived : Base +{ + T value; +}; + +template struct Holder +{ + T value; + U other; +}; + +template struct Plain +{ + std::vector items; +}; + +template int pass(int n, Each&& each) +{ + int total = 0; + each(n, [&](int i) { total += i; }); + return total; +} + +int drive(int n) +{ + return pass(n, [&](int count, auto&& body) { + struct State + { + std::vector v; + }; + Box a{{{count}}}; + Box b = a; + Box c = static_cast&&>(a); + b = c; + c = static_cast&&>(b); + Box d; + delete new Derived; + Holder h{}; + Holder k{}; + Plain plain; + body(static_cast(b.value.v.size() + c.value.v.size() + d.value.v.size() + + plain.items.size()) + + h.other + static_cast(k.other)); + }); +} +)"; + + /// #217 (§5.7, step 5): an implicit member of a class whose key holds a nested type is + /// identified by that class, its kind and name, its variant and its whole type. Every expected + /// one is there, a class without a nested type gets none, and no two symbols share a key: the + /// copy and move constructors (the type), D0 and D2 (the variant), Holder and + /// Holder (the class's other arguments). Limit: the kind and the name are not + /// shown to be needed. On every real case found, the variant (C against D) or the type already + /// separates two implicit members, so a key without them passes these tests too; they are kept, + /// and protected only together with the variant and the type. + bool testConstSuggestionImplicitMemberIdentities(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + const std::filesystem::path dir = + std::filesystem::temp_directory_path() / "ct_const_suggestion_implicit_members"; + std::filesystem::create_directories(dir); + const std::filesystem::path file = dir / "implicit.cpp"; + std::ofstream(file) << kImplicitMembersSource; + const ctrace::stack::analysis::UnitCommand command = cxxCommand(file); + std::vector targets(1); + targets[0].symbol = "_Z4passIZ5driveiE3$_0EiiOT_"; + targets[0].sourceIndex = 0; + const cs::UnitFacts unit = cs::inspect(command, nullptr, {}, targets, "test", false); + // The implicit-member keys whose symbol holds a mangled or demangled part. + const auto count = [&](const std::string& part) + { + return std::count_if(unit.identities.begin(), unit.identities.end(), + [&](const cs::UnitFacts::Identity& identity) + { + return identity.specialization.rfind("implicit ", 0) == 0 && + (identity.symbol.find(part) != std::string::npos || + llvm::demangle(identity.symbol).find(part) != + std::string::npos); + }); + }; + report.expect(count("Box<") >= 6, + "ConstSuggestionImplicitMembers: Box's default, copy and move " + "constructors, assignments and destructor are identified"); + report.expect(count("DerivedI") >= 2 && count("D0Ev") >= 1 && count("D2Ev") >= 1, + "ConstSuggestionImplicitMembers: Derived's deleting and base " + "destructors are identified"); + // Each Holder's destructor, by its mangled class (State then int, State then long): one variant + // where the base and complete destructors are aliases (ELF), both where they are not (Darwin). + report.expect(count("5StateiED") >= 1 && count("5StatelED") >= 1, + "ConstSuggestionImplicitMembers: both Holder destructors are identified"); + report.expect(count("Plain<") == 0, + "ConstSuggestionImplicitMembers: a class without a nested type gets none " + "(not identified whatever the class)"); + std::map owner; + bool apart = true; + for (const cs::UnitFacts::Identity& identity : unit.identities) + { + for (const std::string& key : + {identity.nested.empty() ? "" : "n" + identity.nested + '\0' + identity.enclosing, + identity.specialization.empty() ? "" : "s" + identity.specialization}) + { + if (key.empty()) + continue; + const auto [it, added] = owner.try_emplace(key, identity.symbol); + apart = apart && (added || it->second == identity.symbol); + } + } + report.expect(apart, "ConstSuggestionImplicitMembers: two symbols never share a key (the " + "type, the variant and the class's arguments keep them apart)"); + return report.failures == 0; + } + /// #217 (Task 3.2, O1): the correspondences of §5.7 keep the multiplicity and the order of /// the references at each key, those with a template and those without alike, and map the /// instantiation contexts through the pairs that earlier rounds establish. These cases pin @@ -3048,6 +3457,9 @@ int main(int argc, char** argv) (void)testConstSuggestionNormalizations(report); (void)testConstSuggestionTopLevelQualifierLost(report); (void)testConstSuggestionCorrespondenceIndex(report); + (void)testConstSuggestionNestedCorrespondences(report); + (void)testConstSuggestionNestedIdentities(report); + (void)testConstSuggestionImplicitMemberIdentities(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From d20c8555d9da6adb3fef04ef533143a50871f163 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 25 Oct 2026 23:12:16 +0100 Subject: [PATCH 134/152] =?UTF-8?q?docs(spec):=20pair=20the=20entities=20n?= =?UTF-8?q?ested=20in=20established=20pairs=20(=C2=A75.7,=20D20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-16-const-suggestion-check-v2.md | 6 ++++++ ...-10-16-const-suggestion-check-v2-design.md | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index fe0f856..eca5000 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2994,4 +2994,10 @@ Found by the comparison of the corpora at `48f3ec1`, authorized by the user on 2 **Validation:** the three platforms; the comparison of the corpora, where only publishSingleStoreSlots may change (removed for its error); the cost added. +### Task F.5: the correspondences of nested entities (§5.7, step 5; D20) +Found by the comparison of the corpora at `48f3ec1`; a throwaway prototype, then its implicit-member extension, measured before the integration, which the user authorized on 2026-10-25. +**Work:** identities read before the AST goes (members of nested types, specializations whose arguments or type hold one, implicit members of classes whose key holds one); step 5 maps their nested types through the pairs, keeps every other part of a key, pairs one to one and refuses ambiguities, in a fixpoint common with steps 3 and 4. +**Tests:** end-to-end checks in run_test.py first (five FAIL at `5167998`, the negative one passes there), then the unit tests of the keys and of `correspond`, which need the integration's declarations. Each was proven against mutants of the prototype; the kind and the name of an implicit member, like the name, static flag and qualifiers of a nested member, are protected only together (§5.7). +**Validation:** the three platforms with the full suite; the corpora, where only parallelFor x2, build x2 and isRecursiveCallee x2 may change (checked). + **Validation of the final phase:** every classification explained; cost reported against the criterion; CI green; triage summarized. diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index 9cae3c3..a812ce6 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -231,6 +231,26 @@ configurations : les demandent se correspondent (chacun avec son propre contexte), visent le même gabarit avant et après, désignent tous la même paire, et forment une bijection. Sinon, indéterminée, et la raison nomme le contexte. +- **Entités imbriquées dans une paire établie** (étape 5, P2) : les types imbriqués (lambdas, classes + locales) de toute fonction déjà appariée, et ce qu'ils atteignent, identifiés par les faits de + Clang lus avant la destruction de l'AST, jamais par des heuristiques de noms : + - un membre d'un type imbriqué, par sa nature, sa variante ABI, les positions du type et du + membre, son nom, `static`, ses qualificatifs et son type ; + - une spécialisation dont les arguments ou le type contiennent un type imbriqué, par sa variante, + la position de son patron, les arguments des spécialisations englobantes, les siens et son type + de fonction ; + - un membre implicite d'une classe dont la clé contient un type imbriqué, par cette classe, sa + nature et son nom, sa variante et son type complet, qualificatifs compris. + + Dans une clé, seuls les types imbriqués passent par les paires ; tout le reste (autres arguments, + qualifications, type) est comparé tel quel. Seuls les symboles que la réécriture renomme y + prennent part. Les étapes 3, 4 et 5 tournent jusqu'à un point fixe commun. Une clé portée par plus + d'un symbole d'un côté ne donne aucune paire, et une raison. Une spécialisation dont la + réécriture change elle-même le type ou un argument reste sans paire. Les globales (vtables, + typeinfo, constantes `__const`) ne sont pas couvertes. Limite des tests : la nature et le nom d'un + membre implicite ne sont pas montrés nécessaires (sur les cas réels, la variante ou le type + suffit à séparer), de même que chacun des nom, `static` et qualificatifs d'un membre de type + imbriqué ; ils sont gardés et protégés ensemble. ### 5.8 Jugement @@ -317,6 +337,7 @@ suggestion valide. | D13 | `PointerConstOnly` (t15) | affiche `const int *p`, vérifie `const int *const p` | vérifiée ; le type affiché et la ligne nomment `const int *const p` | nouvelle fixture qui active T, aucune fixture de T v1 ne couvrant `PointerConstOnly` ; le test existant sans l'option reste inchangé | 2 | | D18 | redéclaration qui diffère de la définition par un qualificatif de premier niveau du paramètre (`int f(int *p);` puis `int f(int *const p) {…}`, et l'inverse), observée à `4fd0dae` (`stage2/observations/toplevel-qualifier/`) | « not checked: the rewritten declaration does not get the suggested type » | vérifiée (VP), dans les deux sens | contrôle run_test.py, deux sens | 2 | | D19 | déclaration dont le type est déduit d'un appel que la réécriture rend invalide (`publishSingleStoreSlots`, `IntRanges.cpp:314`, relevée par la comparaison des corpus à `48f3ec1`) | gardée : « not checked: the rewrite also changes the type of an unnamed declaration » | réfutée pour l'erreur introduite ; une partie écrite que la réécriture change hors du plan, y compris dans une expression ou un argument par défaut, reste indéterminée | contrôles run_test.py | F | +| D20 | spécialisations et membres implicites qui dépendent d'entités imbriquées dans une autre paire (`parallelFor`, `build`, `isRecursiveCallee`, corpus à `48f3ec1` ; P2a, P2b, P2c) | gardées : « not checked: the generated code changes … symbols no longer used » | appariées par l'étape 5 de 5.7, vérifiées si l'IR concorde ; une clé ambiguë, ou dont la réécriture change une autre partie, reste sans paire et indéterminée | tests unitaires et contrôles run_test.py | F | **Non retenus :** - le cache « avant » indexé par l'identifiant (point 9 de la revue) : non reproduit (t12) ; les From bed265daeaf30c19c7c15096025bc6944b71c7db Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 26 Oct 2026 02:33:58 +0100 Subject: [PATCH 135/152] docs: bring the plan's status, the spec and the README up to date --- README.md | 11 +++++++---- .../plans/2026-10-16-const-suggestion-check-v2.md | 5 +++++ .../2026-10-16-const-suggestion-check-v2-design.md | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ee7f6da..352d3bd 100644 --- a/README.md +++ b/README.md @@ -653,8 +653,8 @@ experimental: code. A difference that the facts do not explain keeps the suggestion as not checked. A change that they show removes it, even when the program would behave the same; - it compiles each file that sees the change once as it is, for all the suggestions of an analyzed - file, and once more for each suggestion. The cost of this version is not measured yet; the first - version made this repository's self-analysis about 19% longer. + file, and once more for each suggestion. On this repository's self-analysis, on 4 CPUs, it adds + about 25 to 30% to the wall time. The option is a trade-off, not a fix: with it, a valid suggestion can still be removed or kept as not checked; without it, suggestions whose type does not compile, drops a qualifier, or changes the @@ -706,8 +706,11 @@ The check runs these steps: - a file that declares the function itself, outside the rewritten files, with its current type, or that converts the function's address with an explicit cast, other than to `void`: not checked; - - the symbols that the rewrite renames are paired from these facts. One left without a pair, or - two that would pair with the same symbol, leaves the file not checked; + - the symbols that the rewrite renames are paired from these facts, and from what Clang + identifies of the lambdas and local classes nested in paired functions: their members, the + specializations whose template arguments or type hold them, and the implicit members of the + classes that hold them. Every other part of such an identity must match. One left without a + pair, or two that would pair with the same symbol, leaves the file not checked; - the generated code is compared through those pairs. It matches: verified. A difference that none of these facts explains: not checked. diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index eca5000..bf07e14 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2967,21 +2967,26 @@ Expected failure at the batch's base: in all three cases, `not checked with Clan ### Task 3.1: measure **Work:** the `const_suggestion.*` hotspots of `--timing` on the self-analysis with the option, as CI runs it; the per-suggestion copy of each unit's scan, the recompilation of a failing initial compilation, the duplicated construction of the invocation, each measured. **Validation:** a measurement report in `evidence/217/v2-cost/`; only measurable costs go to Task 3.2. +**Status:** done (`report-fe43067.md`, `report-ct3-fe43067.md`). Measured on the self-analysis: the 38 compilations about 270 s (front-end about 166 s, inspection 87 s, CodeGen 17 s), the correspondences about 80 to 90 s, the facts' comparison 10 to 15 s, the scans 31 s; the copies of the scans 2.7 s; the preparation of the commands, the rewrite and the release of the modules under 1 s together. ### Task 3.2: remove the measured costs; build the invocation once **Work:** shared scans instead of copies; a failing initial compilation cached as failing; one function builds the Clang invocation for scanning and compiling, reusing the existing path normalization. **Validation:** outputs identical before and after the stage (suite, review cases, corpora with the option); the gain measured. +**Status:** partly done. The correspondences' index is built once per call (O1, `48f3ec1`): correspond 49.5 -> 23.0 s, same pairs and rounds. The shared scans and the single construction of the invocation are not done: their measured cost (2.7 s, and under 1 s) is below the noise, so by Task 3.1's rule they stay as they are. The failing initial compilation is not cached either: 4 such compilations on the self-analysis. The faster fact comparison (O2) was withdrawn by the user; the precompiled preamble (O3) was abandoned after its prototype (a net loss, a crash); the reduced scans (O4) and the stop after an introduced error (O5) were not engaged; sharing the pipeline's compilation (O6) is not recommended (`final/o6-feasibility.md`). The +10 % criterion is not met. ## Final phase ### Task F.1: corpora **Work:** on Linux, `src/`, zlib, tinyxml2 and PCRE2, three outputs: without the option, T v1, T v2. Each suggestion classified verified, kept indeterminate, or removed on an established refutation. Each disappearance against the output without the option is explained by its violation; each difference with T v1 is explained. +**Status:** done at `48f3ec1` (`final/report.md`); the seven differences with T v1 led to Tasks F.4 and F.5, each of which changed only the cases it targets (src/ now 10 verified, 0 not checked, 12 removed; zlib 16/0/10, tinyxml2 0/0/6, PCRE2 10/31/3 with the corpora configured in the container). ### Task F.2: cost **Work:** the protocol of §8. The criterion is +10 % on the self-analysis. +**Status:** measured, not met: +24 to +25 % in a 6 GiB container, +30 to +32 % in a 10 GiB one (4 CPUs, Linux x86_64); T v1 measured +14.6 % in the first conditions. No overrun is accepted. ### Task F.3: delivery **Work:** README; the description of the draft #239, opened after stage 1, brought up to date; CI; code scanning triaged; final review on the updated base. No merge. +**Status:** in progress with the publication of the integrated head on #239, still a draft. ### Task F.4: the scope of step 3, written form and deduced component (D19) Found by the comparison of the corpora at `48f3ec1`, authorized by the user on 2026-10-23. diff --git a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md index a812ce6..145825a 100644 --- a/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md +++ b/docs/superpowers/specs/2026-10-16-const-suggestion-check-v2-design.md @@ -1,7 +1,7 @@ # Vérification des suggestions const par Clang, version 2 (#217) Statut : validée à `8b283a8`, puis complétée à la relecture du plan d'implémentation (D17, fixture -de D13), au détail de l'étape 1 (mutualisation limitée au module, §5 et §10), à celui de l'étape 2 (erreurs natives de Clang conservées, §5.3 ; D18, §5.6 et §6), et après la comparaison des corpus (D19, §5.6 et §6). N'autorise ni le code ni la modification des tests : chaque lot présentera ses +de D13), au détail de l'étape 1 (mutualisation limitée au module, §5 et §10), à celui de l'étape 2 (erreurs natives de Clang conservées, §5.3 ; D18, §5.6 et §6), après la comparaison des corpus (D19, §5.6 et §6), puis à l'intégration de l'étape 5 de §5.7 (D20). N'autorise ni le code ni la modification des tests : chaque lot présentera ses tests, avec leurs preuves d'échec, avant de les ajouter. La fusion n'est pas autorisée. Issue : #217. Liens : #221 (T v1, référence conservée), #167 (faits et contrat const), #197 (identité d'un global entre fichiers). From bb068c8a3ed9816cd3145057ac5fd89d5066ebb7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 26 Oct 2026 06:28:07 +0100 Subject: [PATCH 136/152] docs(plan): record the #215 reserve, the deferred invocation and the due review --- .../plans/2026-10-16-const-suggestion-check-v2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md index bf07e14..ad4815e 100644 --- a/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md +++ b/docs/superpowers/plans/2026-10-16-const-suggestion-check-v2.md @@ -2972,13 +2972,13 @@ Expected failure at the batch's base: in all three cases, `not checked with Clan ### Task 3.2: remove the measured costs; build the invocation once **Work:** shared scans instead of copies; a failing initial compilation cached as failing; one function builds the Clang invocation for scanning and compiling, reusing the existing path normalization. **Validation:** outputs identical before and after the stage (suite, review cases, corpora with the option); the gain measured. -**Status:** partly done. The correspondences' index is built once per call (O1, `48f3ec1`): correspond 49.5 -> 23.0 s, same pairs and rounds. The shared scans and the single construction of the invocation are not done: their measured cost (2.7 s, and under 1 s) is below the noise, so by Task 3.1's rule they stay as they are. The failing initial compilation is not cached either: 4 such compilations on the self-analysis. The faster fact comparison (O2) was withdrawn by the user; the precompiled preamble (O3) was abandoned after its prototype (a net loss, a crash); the reduced scans (O4) and the stop after an introduced error (O5) were not engaged; sharing the pipeline's compilation (O6) is not recommended (`final/o6-feasibility.md`). The +10 % criterion is not met. +**Status:** partly done. The correspondences' index is built once per call (O1, `48f3ec1`): correspond 49.5 -> 23.0 s, same pairs and rounds. The shared scans and the single construction of the invocation are not done: their measured cost (2.7 s, and under 1 s) is below the noise. The user deferred the single construction of the Clang invocation to a separate follow-up (2026-10-26), the task staying partly done; that deferral does not accept the cost overrun. The failing initial compilation is not cached either: 4 such compilations on the self-analysis. The faster fact comparison (O2) was withdrawn by the user; the precompiled preamble (O3) was abandoned after its prototype (a net loss, a crash); the reduced scans (O4) and the stop after an introduced error (O5) were not engaged; sharing the pipeline's compilation (O6) is not recommended (`final/o6-feasibility.md`). The +10 % criterion is not met. ## Final phase ### Task F.1: corpora **Work:** on Linux, `src/`, zlib, tinyxml2 and PCRE2, three outputs: without the option, T v1, T v2. Each suggestion classified verified, kept indeterminate, or removed on an established refutation. Each disappearance against the output without the option is explained by its violation; each difference with T v1 is explained. -**Status:** done at `48f3ec1` (`final/report.md`); the seven differences with T v1 led to Tasks F.4 and F.5, each of which changed only the cases it targets (src/ now 10 verified, 0 not checked, 12 removed; zlib 16/0/10, tinyxml2 0/0/6, PCRE2 10/31/3 with the corpora configured in the container). +**Status:** done at `48f3ec1` (`final/report.md`); the seven differences with T v1 led to Tasks F.4 and F.5, each of which changed only the cases it targets (src/ now 10 verified, 0 not checked, 12 removed; zlib 16/0/10, tinyxml2 0/0/6, PCRE2 10/31/3 with the corpora configured in the container). **Reserve (#215):** every comparison leaves out the units on which the analysis does not finish, as T v1's measurements did: 5 of zlib's 37 (`crc32.c`, `deflate.c`, `infback.c`, `inffast.c`, `inflate.c`) and 14 of PCRE2's 35 (`pcre2_auto_possess.c`, `pcre2_compile.c`, `pcre2_compile_class.c`, `pcre2_convert.c`, `pcre2_dfa_match.c`, `pcre2_find_bracket.c`, `pcre2_match.c`, `pcre2_pattern_info.c`, `pcre2_script_run.c`, `pcre2_study.c`, `pcre2_valid_utf.c`, `pcre2_xclass.c`, `pcre2grep.c`, `pcre2test.c`). Analyzed alone without the option by the product binary, each runs past a 120 s limit (2026-10-26). The suggestions of these units are not classified; the conclusions on the corpora hold for the units analyzed only. ### Task F.2: cost **Work:** the protocol of §8. The criterion is +10 % on the self-analysis. @@ -2986,7 +2986,7 @@ Expected failure at the batch's base: in all three cases, `not checked with Clan ### Task F.3: delivery **Work:** README; the description of the draft #239, opened after stage 1, brought up to date; CI; code scanning triaged; final review on the updated base. No merge. -**Status:** in progress with the publication of the integrated head on #239, still a draft. +**Status:** in progress. The integrated head is published on #239, still a draft; README, description, CI and code scanning are done at `bed265d`. The final review on the updated base is still due: its findings, and their fixes, come before any decision to merge. ### Task F.4: the scope of step 3, written form and deduced component (D19) Found by the comparison of the corpora at `48f3ec1`, authorized by the user on 2026-10-23. From 60a982804b0ee09ab1847a028ceb03bd68b03e30 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:16:37 +0100 Subject: [PATCH 137/152] test(analysis): cover only-declared dependencies, operators and failed rewrites Cases from the final review of #239. A renamed dependency that the file only declares must keep the suggestion not checked, while one defined in its header stays checked. A free operator declared again in a file of the compilation database must keep it not checked, while a file with another operator only does not. A rewrite whose file cannot be read, or no longer has the planned text, must name the file without an exception. rewrite() is exposed with its behavior unchanged so that the unit test compiles; these cases fail until the next commit. --- run_test.py | 92 ++++++++++++++++++++++++ src/analysis/ConstSuggestionCheck.cpp | 44 +++++++----- src/analysis/ConstSuggestionInternal.hpp | 5 ++ test/unit/analyzer_module_unit_tests.cpp | 54 ++++++++++++++ 4 files changed, 177 insertions(+), 18 deletions(-) diff --git a/run_test.py b/run_test.py index 9f39982..ac1cbe4 100755 --- a/run_test.py +++ b/run_test.py @@ -4393,6 +4393,96 @@ def check_const_suggestion_p2_owner_changed_by_the_rewrite() -> bool: return good +def check_const_suggestion_external_dependency_only_declared() -> bool: + """ + #217 (final review of #239, C1): f's rewrite changes the specialization of h it calls, from + h to h. h is only declared in f.cpp; another file defines h by an + explicit instantiation and nothing defines h. Comparing the two declarations proves + nothing about that code, so the suggestion is kept, not checked, naming h. The witness: with h + defined in its header, f.cpp defines the specialization it calls, and the suggestion stays + checked. + """ + print("=== Testing a renamed dependency that the file only declares ===") + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_external_dependency_") as tmp: + root = Path(tmp).resolve() + for name, header, extra in ( + ("only declared", "template int h(T p);\n", + "template int h(T p) { return p ? 1 : 0; }\ntemplate int h(int*);\n"), + ("defined in its header", "template int h(T p) { return p ? 1 : 0; }\n", ""), + ): + case = root / name.replace(" ", "_") + case.mkdir() + (case / "h.hpp").write_text(header) + (case / "hdef.cpp").write_text('#include "h.hpp"\n' + extra + "int g() { return 0; }\n") + (case / "f.cpp").write_text( + '#include "h.hpp"\nint f(int* p)\n{\n return *p + h(decltype(p){});\n}\n' + ) + run = _const_suggestion_run([str(case / "f.cpp"), str(case / "hdef.cpp")]) + texts = run[0].get("_Z1fPi", []) if run else [] + if name == "only declared": + ok = ( + len(texts) == 1 + and "not checked with Clang:" in texts[0] + and "int h(int*)" in texts[0] + and "only declares" in texts[0] + ) + else: + ok = len(texts) == 1 and "checked in the analyzed files only" in texts[0] + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {texts}'}") + good = good and ok + print() + return good + + +def check_const_suggestion_operator_declared_elsewhere() -> bool: + """ + #217 (final review of #239, C2, D17): a free operator defined in a.cpp is declared again with + its current type in b.cpp, which only the compilation database lists. An operator's name is not + an identifier, so the units that write `operator` are checked for it: b.cpp is judged and the + suggestion is kept, not checked, for that declaration. The witness: c.cpp writes another + operator only; it is judged too, and does not keep the suggestion of a.cpp's own unit from + being checked. + """ + print("=== Testing a free operator declared again in a file of the database ===") + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_operator_elsewhere_") as tmp: + root = Path(tmp).resolve() + for name, other in ( + ("declared again", "struct A { int v; };\nbool operator==(A& x, A& y);\n" + "bool same(A& x, A& y) { return x == y; }\n"), + ("another operator only", "struct B { int v; };\n" + "bool operator<(B x, B y) { return x.v < y.v; }\n"), + ): + case = root / name.replace(" ", "_") + case.mkdir() + (case / "a.cpp").write_text( + "struct A { int v; };\nbool operator==(A& x, A& y) { return x.v == y.v; }\n" + ) + (case / "b.cpp").write_text(other) + (case / "compile_commands.json").write_text(json.dumps([ + {"directory": str(case), "command": f"clang++ -c {f} -o {f}.o", "file": f} + for f in ("a.cpp", "b.cpp") + ])) + run = _const_suggestion_run([ + str(case / "a.cpp"), f"--compile-commands={case / 'compile_commands.json'}" + ]) + texts = run[0].get("_ZeqR1AS0_", []) if run else [] + if name == "declared again": + ok = ( + len(texts) == 2 + and all("not checked with Clang:" in t for t in texts) + and all("b.cpp declares" in t for t in texts) + ) + else: + ok = len(texts) == 2 and all( + "checked in every file of the compilation database" in t for t in texts + ) + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {texts}'}") + good = good and ok + print() + return good + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7371,6 +7461,8 @@ def record_ok(ok: bool): check_const_suggestion_p2_keys_keep_candidates_apart, check_const_suggestion_p2_implicit_members_of_a_specialization, check_const_suggestion_p2_owner_changed_by_the_rewrite, + check_const_suggestion_external_dependency_only_declared, + check_const_suggestion_operator_declared_elsewhere, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index c2a2310..4ad821d 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -139,26 +139,32 @@ namespace ctrace::stack::analysis return value && !value->hasLocalLinkage(); } - std::map rewrite(const SuggestionPlan& plan) + } // namespace + + std::map const_suggestion::rewrite(const SuggestionPlan& plan, + std::string& failure) + { + (void)failure; + std::map out; + for (const auto& [path, edits] : plan.edits) { - std::map out; - for (const auto& [path, edits] : plan.edits) - { - llvm::ErrorOr> buffer = - llvm::MemoryBuffer::getFile(path); - if (!buffer) - continue; - std::string text = (*buffer)->getBuffer().str(); - std::vector sorted = edits; - std::sort(sorted.begin(), sorted.end(), - [](const Edit& a, const Edit& b) { return a.offset > b.offset; }); - for (const Edit& edit : sorted) - text.replace(edit.offset, edit.length, edit.text); - out[path] = std::move(text); - } - return out; + llvm::ErrorOr> buffer = + llvm::MemoryBuffer::getFile(path); + if (!buffer) + continue; + std::string text = (*buffer)->getBuffer().str(); + std::vector sorted = edits; + std::sort(sorted.begin(), sorted.end(), + [](const Edit& a, const Edit& b) { return a.offset > b.offset; }); + for (const Edit& edit : sorted) + text.replace(edit.offset, edit.length, edit.text); + out[path] = std::move(text); } + return out; + } + namespace + { // A unit before the rewrite, by its file and its configuration: compiled once for every // suggestion of the module (#217, §5, step 3). using OriginalCache = std::map>; @@ -758,7 +764,9 @@ namespace ctrace::stack::analysis kept.push_back(std::move(issue)); continue; } - const std::map rewritten = rewrite(plan); + std::string unreadable; + const std::map rewritten = + const_suggestion::rewrite(plan, unreadable); std::set rewrittenFiles; for (const auto& [path, text] : rewritten) rewrittenFiles.insert(path); diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 4f0740f..4a345d3 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -409,6 +409,11 @@ namespace ctrace::stack::analysis::const_suggestion const std::map& rewritten, const std::vector& targets, std::string_view step, bool timing); + // The text of each file that `plan` rewrites, its edits applied. `failure` names a file that + // cannot be read, or that no longer has the text the plan was made on; the result is then + // incomplete and must not be judged. + std::map rewrite(const SuggestionPlan& plan, std::string& failure); + // The files a unit reads and its identifiers, from its preprocessing. UnitScan scan(const UnitCommand& command, bool timing); } // namespace ctrace::stack::analysis::const_suggestion diff --git a/test/unit/analyzer_module_unit_tests.cpp b/test/unit/analyzer_module_unit_tests.cpp index 022693d..ebea357 100644 --- a/test/unit/analyzer_module_unit_tests.cpp +++ b/test/unit/analyzer_module_unit_tests.cpp @@ -1420,6 +1420,59 @@ int drive(int n) return report.failures == 0; } + /// #217 (final review of #239, M5): a plan's rewrite that cannot be carried out says so. A + /// file that can no longer be read, and one that no longer has the text the plan was made on + /// (an edit past its end), name the file and leave the result unjudged, without an exception; + /// a plan on an unchanged file rewrites it. + bool testConstSuggestionRewriteFailures(TestReport& report) + { + namespace cs = ctrace::stack::analysis::const_suggestion; + const std::filesystem::path dir = + std::filesystem::temp_directory_path() / + ("ct_const_suggestion_rewrite_" + std::to_string(::getpid())); + std::filesystem::create_directories(dir); + const std::filesystem::path file = dir / "r.c"; + std::ofstream(file) << "int read_value(int *p) { return *p; }\n"; + const auto attempt = [](const cs::SuggestionPlan& plan, std::string& failure) + { + try + { + return std::optional>( + cs::rewrite(plan, failure)); + } + catch (const std::exception& error) + { + failure = std::string("exception: ") + error.what(); + return std::optional>(); + } + }; + cs::SuggestionPlan plan; + plan.edits[file.string()] = {{15, 0, "const ", 1}}; + std::string failure; + const auto done = attempt(plan, failure); + report.expect(done && failure.empty() && + done->at(file.string()).find("const int *p") != std::string::npos, + "ConstSuggestionRewrite: an unchanged file is rewritten"); + cs::SuggestionPlan gone; + gone.edits[(dir / "missing.c").string()] = {{0, 0, "const ", 1}}; + failure.clear(); + (void)attempt(gone, failure); + report.expect(failure.find("missing.c") != std::string::npos && + failure.find("cannot be read") != std::string::npos, + "ConstSuggestionRewrite: a file that cannot be read is named"); + cs::SuggestionPlan shrunk; + shrunk.edits[file.string()] = {{4096, 3, "const ", 1}}; + failure.clear(); + (void)attempt(shrunk, failure); + report.expect( + failure.find("r.c") != std::string::npos && + failure.find("changed after the plan") != std::string::npos, + "ConstSuggestionRewrite: a file that no longer has the planned text is named, " + "without an exception"); + std::filesystem::remove_all(dir); + return report.failures == 0; + } + /// #217 (Task 3.2, O1): the correspondences of §5.7 keep the multiplicity and the order of /// the references at each key, those with a template and those without alike, and map the /// instantiation contexts through the pairs that earlier rounds establish. These cases pin @@ -3572,6 +3625,7 @@ int main(int argc, char** argv) (void)testConstSuggestionNestedCorrespondences(report); (void)testConstSuggestionNestedIdentities(report); (void)testConstSuggestionImplicitMemberIdentities(report); + (void)testConstSuggestionRewriteFailures(report); (void)testUnresolvedCallsMarkStackUnknown(repoRoot, report); (void)testAssumeExternalFrameReplacesUnknown(repoRoot, report); (void)testUninitializedFixpointBudgetIsExplicit(repoRoot, report); From 3266376704868769c2130c1e0961453f9ef00ca1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:17:49 +0100 Subject: [PATCH 138/152] fix(analysis): keep a suggestion not checked when a renamed callee is only declared A rewrite can change the specialization a function calls, h to h, when h is only declared in the file. Comparing the two declarations proves nothing about the code another file defines for h, and none may define the new one: the link failed after a suggestion reported as checked. A renamed symbol that the file only declares on both sides, other than the entity's own variants and inheriting constructors, now keeps the suggestion not checked and names it. --- src/analysis/ConstSuggestionCheck.cpp | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 4ad821d..fdf3617 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -551,6 +551,34 @@ namespace ctrace::stack::analysis { const analyzer::ScopedHotspot hotspot(timing, "const_suggestion.compare"); const const_suggestion::ModuleText now = const_suggestion::prepare(*after.module); + // A renamed symbol that this file only declares, on both sides, other than the + // entity's own variants and inheriting constructors (which the defining file + // compiles): comparing its two declarations proves nothing about the code that + // another file defines for it, if any does. + std::set own; + if (index < before->unit.entities.size()) + { + for (const const_suggestion::Named& named : + before->unit.entities[index].variants) + own.insert(named.symbol); + for (const const_suggestion::Named& named : + before->unit.entities[index].inheriting) + own.insert(named.symbol); + } + for (const SymbolPair& pair : pairs) + { + if (own.count(pair.before) || !before->code->globals.count(pair.before) || + !now.globals.count(pair.after) || + before->code->defined.count(pair.before) || now.defined.count(pair.after)) + { + continue; + } + return indeterminate(formatFunctionNameForMessage(pair.before) + " becomes " + + formatFunctionNameForMessage(pair.after) + ", which " + + after.file + + " only declares: the code another file defines for it " + "is not checked"); + } difference = const_suggestion::codeDifference(*before->code, now, pairs, &signature, &accepted); } From a01fbf5abd27e72302223c3237cd888520aa3198 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:17:54 +0100 Subject: [PATCH 139/152] fix(analysis): find a free operator declared again in a file of the database The scan of the compilation database records identifiers, and an operator's name is not one: a file that declared operator== again with its old type was not judged, and the suggestion was reported as checked in every file of the database. The scan now records `operator` for an operator, and the lookup uses the same token. --- src/analysis/ConstSuggestionCheck.cpp | 3 ++- src/analysis/ConstSuggestionFacts.cpp | 14 ++++++++++++++ src/analysis/ConstSuggestionInternal.hpp | 4 ++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index fdf3617..d212933 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -907,7 +907,8 @@ namespace ctrace::stack::analysis std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), [&](const std::string& path) { return seen.files.count(path); }) || (plan.declarableElsewhere && - seen.identifiers.count(llvm::xxh3_64bits(plan.entity.name)) != 0); + seen.identifiers.count( + llvm::xxh3_64bits(const_suggestion::scannedName(plan.entity.name))) != 0); if (!sees) continue; std::vector otherAccepted; diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 3a7e31b..165fa47 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -6,6 +6,7 @@ #include "analyzer/HotspotProfiler.hpp" #include +#include #include #include #include @@ -1432,6 +1433,9 @@ namespace ctrace::stack::analysis if (token.is(clang::tok::identifier)) identifiers_.insert( llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); + else if (token.is(clang::tok::kw_operator)) + identifiers_.insert( + llvm::xxh3_64bits(const_suggestion::scannedName("operator"))); } while (token.isNot(clang::tok::eof)); } @@ -1440,6 +1444,16 @@ namespace ctrace::stack::analysis }; } // namespace + std::string const_suggestion::scannedName(llvm::StringRef name) + { + // An operator's name is the keyword `operator` and what follows it, never one identifier. + if (name.starts_with("operator") && + (name.size() == 8 || + !(std::isalnum(static_cast(name[8])) || name[8] == '_'))) + return "operator"; + return name.str(); + } + UnitFacts const_suggestion::inspect(const UnitCommand& command, const SuggestionPlan* rewrittenBy, const std::map& rewritten, diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 4a345d3..f117a73 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -414,6 +414,10 @@ namespace ctrace::stack::analysis::const_suggestion // incomplete and must not be judged. std::map rewrite(const SuggestionPlan& plan, std::string& failure); + // The token by which the scan records a function's name: the name itself, or `operator` for + // an operator, whose name is not an identifier (a free operator, a conversion, a literal one). + std::string scannedName(llvm::StringRef name); + // The files a unit reads and its identifiers, from its preprocessing. UnitScan scan(const UnitCommand& command, bool timing); } // namespace ctrace::stack::analysis::const_suggestion From c38fd583e2fe61ea023e66dd2255771265e86176 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:17:56 +0100 Subject: [PATCH 140/152] fix(analysis): report a rewrite whose file cannot be read or has changed rewrite() skipped a file it could not read and threw std::out_of_range on an edit past the end of the file. It now names the file, and the suggestion is kept as not checked. --- src/analysis/ConstSuggestionCheck.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index d212933..fde80c0 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -144,20 +144,29 @@ namespace ctrace::stack::analysis std::map const_suggestion::rewrite(const SuggestionPlan& plan, std::string& failure) { - (void)failure; std::map out; for (const auto& [path, edits] : plan.edits) { llvm::ErrorOr> buffer = llvm::MemoryBuffer::getFile(path); if (!buffer) - continue; + { + failure = path + " cannot be read"; + return out; + } std::string text = (*buffer)->getBuffer().str(); std::vector sorted = edits; std::sort(sorted.begin(), sorted.end(), [](const Edit& a, const Edit& b) { return a.offset > b.offset; }); for (const Edit& edit : sorted) + { + if (edit.offset > text.size() || edit.length > text.size() - edit.offset) + { + failure = path + " changed after the plan was made"; + return out; + } text.replace(edit.offset, edit.length, edit.text); + } out[path] = std::move(text); } return out; @@ -795,6 +804,14 @@ namespace ctrace::stack::analysis std::string unreadable; const std::map rewritten = const_suggestion::rewrite(plan, unreadable); + if (!unreadable.empty()) + { + issue.checkLines.push_back(kNotChecked + unreadable); + trace(issue, "not checked, " + unreadable); + grounds(issue, &plan, {}, {}, indeterminate(unreadable)); + kept.push_back(std::move(issue)); + continue; + } std::set rewrittenFiles; for (const auto& [path, text] : rewritten) rewrittenFiles.insert(path); From 42c9a4d000e756d5221462c7b5d8099d2f2f52a7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:17:58 +0100 Subject: [PATCH 141/152] fix(analysis): keep a failed rewrite not checked when it adds no error A rewrite that does not compile, and adds no error that explains it, is not shown to be wrong: it is now kept as not checked instead of removed. --- src/analysis/ConstSuggestionCheck.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index fde80c0..ffa4eef 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -492,8 +492,15 @@ namespace ctrace::stack::analysis diagnostic.text); } } + // A rewrite that does not compile, and adds no error to tell why: it is not shown to be + // wrong (#239 review, M6). if (!after.module) - return refuted("does not compile: " + after.firstDiagnostic); + { + return indeterminate( + "the rewritten code does not compile, and no error that the rewrite adds " + "explains it" + + (after.firstDiagnostic.empty() ? "" : ": " + after.firstDiagnostic)); + } // Another entity's declaration that the rewrite merges into the entity: a declaration // it has after the rewrite only (ruling 3). if (!after.entities.empty() && index < before->unit.entities.size()) From 42fa604c146f7b62c1c255cdd196569207316f76 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 28 Oct 2026 23:18:03 +0100 Subject: [PATCH 142/152] docs(analysis): state the const suggestion check's verdicts in --help and the header Both still said that a suggestion is removed when the code no longer compiles or generates different code. A suggestion is removed when the rewrite adds an error or a tracked warning, or changes what Clang resolves, and kept as not checked when it cannot be shown valid. --- include/analysis/ConstSuggestionCheck.hpp | 8 +++++--- main.cpp | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index c1b07b6..9127ac2 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -95,9 +95,11 @@ namespace ctrace::stack::analysis // Checks the suggestions made for `mod` with Clang (#217): rewrites every declaration of the // parameter with the suggested type, recompiles every analyzed unit that sees a rewritten file - // or references the function, and compares their generated code. A suggestion that does not - // compile, or that changes the generated code, is removed; the others get detail lines that - // say what was checked, or why it could not be. + // or references the function, and compares their generated code. A suggestion is removed when + // the rewrite adds an error or a tracked warning, or changes a call or reference target, an + // override or a constant condition; it is kept as not checked when it cannot be shown valid, + // a generated code difference included; the others are kept as checked. Each gets detail lines + // that say what was checked, or why it could not be. void checkConstSuggestions(const llvm::Module& mod, std::vector& issues, const AnalysisConfig& config); } // namespace ctrace::stack::analysis diff --git a/main.cpp b/main.cpp index 232eadc..941268e 100644 --- a/main.cpp +++ b/main.cpp @@ -79,11 +79,11 @@ static void printHelp() << " --compile-ir-format=bc|ll Compilation IR format for source inputs (default: bc)\n" << " --resource-summary-cache-memory-only Use in-memory cache only for cross-TU " "summaries\n" - << " --experimental-const-suggestion-check Experimental: compile each const " + << " --experimental-const-suggestion-check Experimental: check each const " "suggestion\n" - << " with Clang and remove it if the code no longer compiles " - "or\n" - << " generates different code (off by default)\n" + << " with Clang: removed if the rewrite adds an error or a\n" + << " tracked warning or changes what Clang resolves, kept as\n" + << " not checked when it cannot be shown valid (off by default)\n" << " --uninitialized-cross-tu Enable cross-TU uninitialized summaries (default: on)\n" << " --no-uninitialized-cross-tu Disable cross-TU uninitialized summaries\n" << " --only-file= Only report functions from this source file\n" From 94deb0dba6d07132ae9b3b753a6a81c7cc847573 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 31 Oct 2026 14:09:41 +0100 Subject: [PATCH 143/152] test(analysis): cover identifiers starting with operator and -isystem declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cases from the targeted review of #239's fixes. A C function named by an identifier that starts with operator, operator$x or operatoré, and declared again with its current type in a file of the compilation database must keep the suggestion not checked; until the next commit it is reported checked. A declaration made by a header included with -isystem must keep it not checked too, for an operator and for an identifier. The check of a free operator declared elsewhere now asserts, from the trace, which units are judged and their verdicts. --- run_test.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/run_test.py b/run_test.py index ac1cbe4..ea3c4bb 100755 --- a/run_test.py +++ b/run_test.py @@ -4440,19 +4440,23 @@ def check_const_suggestion_operator_declared_elsewhere() -> bool: #217 (final review of #239, C2, D17): a free operator defined in a.cpp is declared again with its current type in b.cpp, which only the compilation database lists. An operator's name is not an identifier, so the units that write `operator` are checked for it: b.cpp is judged and the - suggestion is kept, not checked, for that declaration. The witness: c.cpp writes another + suggestion is kept, not checked, for that declaration. The witness: b.cpp writes another operator only; it is judged too, and does not keep the suggestion of a.cpp's own unit from - being checked. + being checked. In both, the trace names the units judged for each parameter, a.cpp and b.cpp, + with their verdicts. """ print("=== Testing a free operator declared again in a file of the database ===") + unit = re.compile(r"_ZeqR1AS0_ parameter ([xy]), unit (\S+) \[[0-9a-f]+\]: (\w+)") good = True with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_operator_elsewhere_") as tmp: root = Path(tmp).resolve() - for name, other in ( + for name, other, judged in ( ("declared again", "struct A { int v; };\nbool operator==(A& x, A& y);\n" - "bool same(A& x, A& y) { return x == y; }\n"), + "bool same(A& x, A& y) { return x == y; }\n", + {"a.cpp": "verified", "b.cpp": "indeterminate"}), ("another operator only", "struct B { int v; };\n" - "bool operator<(B x, B y) { return x.v < y.v; }\n"), + "bool operator<(B x, B y) { return x.v < y.v; }\n", + {"a.cpp": "verified", "b.cpp": "verified"}), ): case = root / name.replace(" ", "_") case.mkdir() @@ -4468,6 +4472,11 @@ def check_const_suggestion_operator_declared_elsewhere() -> bool: str(case / "a.cpp"), f"--compile-commands={case / 'compile_commands.json'}" ]) texts = run[0].get("_ZeqR1AS0_", []) if run else [] + units: dict[str, dict[str, str]] = {} + for line in run[1] if run else []: + match = unit.match(line) + if match: + units.setdefault(match.group(1), {})[Path(match.group(2)).name] = match.group(3) if name == "declared again": ok = ( len(texts) == 2 @@ -4478,11 +4487,95 @@ def check_const_suggestion_operator_declared_elsewhere() -> bool: ok = len(texts) == 2 and all( "checked in every file of the compilation database" in t for t in texts ) + ok = ok and units == {"x": judged, "y": judged} + print(f" {'✅' if ok else '❌'} {name}" + f"{'' if ok else f': {texts}, units judged: {units}'}") + good = good and ok + print() + return good + + +def check_const_suggestion_identifier_starting_with_operator() -> bool: + """ + #217 (review of #239's fixes): a C function named by one identifier that starts with + `operator`, `operator$x` or `operatoré`, is not an operator: Clang's kind of the name, not its + spelling, tells them apart. b.c, which only the compilation database lists, declares it again + with its current type: b.c is judged, and the suggestion is kept, not checked, for that + declaration (D17). + """ + print("=== Testing a function whose identifier starts with operator ===") + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_operator_identifier_") as tmp: + root = Path(tmp).resolve() + for index, name in enumerate(("operator$x", "operatoré")): + case = root / f"case{index}" + case.mkdir() + (case / "a.c").write_text(f"int {name}(int *p) {{ return *p; }}\n", encoding="utf-8") + (case / "b.c").write_text( + f"int {name}(int *p);\nint use(int *q) {{ return {name}(q); }}\n", encoding="utf-8" + ) + (case / "compile_commands.json").write_text(json.dumps([ + {"directory": str(case), "command": f"clang -c {f} -o {f}.o", "file": f} + for f in ("a.c", "b.c") + ])) + run = _const_suggestion_run([ + str(case / "a.c"), f"--compile-commands={case / 'compile_commands.json'}" + ]) + texts = [text for found in run[0].values() for text in found] if run else [] + ok = ( + len(texts) == 1 + and "not checked with Clang:" in texts[0] + and f"b.c declares {name} with its current type" in texts[0] + ) print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {texts}'}") good = good and ok print() return good + +def check_const_suggestion_declared_in_a_system_header() -> bool: + """ + #217 (review of #239's fixes): b, which only the compilation database lists, declares the + function again with its current type only through a header that its command includes with + -isystem; b's own text does not name it. That declaration counts as one b writes: b is judged, + and the suggestion is kept, not checked, for it (D17), for an operator and for a function + named by an identifier alike. The units judged must not leave system headers out. + """ + print("=== Testing a declaration made by a header included with -isystem ===") + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_system_header_") as tmp: + root = Path(tmp).resolve() + for name, extension, compiler, definition, declaration, function in ( + ("operator", "cpp", "clang++", + "struct A { int v; };\nbool operator==(A& x, A& y) { return x.v == y.v; }\n", + "struct A { int v; };\nbool operator==(A& x, A& y);\n", "_ZeqR1AS0_"), + ("identifier", "c", "clang", "int opx(int *p) { return *p; }\n", + "int opx(int *p);\n", "opx"), + ): + case = root / name + (case / "system").mkdir(parents=True) + (case / "system" / "declaration.h").write_text(declaration) + (case / f"a.{extension}").write_text(definition) + (case / f"b.{extension}").write_text("#include \nint other(void);\n") + (case / "compile_commands.json").write_text(json.dumps([ + {"directory": str(case), "file": f"a.{extension}", + "command": f"{compiler} -c a.{extension} -o a.o"}, + {"directory": str(case), "file": f"b.{extension}", + "command": f"{compiler} -isystem {case / 'system'} -c b.{extension} -o b.o"}, + ])) + run = _const_suggestion_run([ + str(case / f"a.{extension}"), f"--compile-commands={case / 'compile_commands.json'}" + ]) + texts = run[0].get(function, []) if run else [] + ok = bool(texts) and all( + "not checked with Clang:" in t and f"b.{extension} declares" in t for t in texts + ) + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {texts}'}") + good = good and ok + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7463,6 +7556,8 @@ def record_ok(ok: bool): check_const_suggestion_p2_owner_changed_by_the_rewrite, check_const_suggestion_external_dependency_only_declared, check_const_suggestion_operator_declared_elsewhere, + check_const_suggestion_identifier_starting_with_operator, + check_const_suggestion_declared_in_a_system_header, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 902e12da12047575f892cd4de0b891007ede9f72 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 31 Oct 2026 14:09:41 +0100 Subject: [PATCH 144/152] fix(analysis): tell an operator's name from an identifier by Clang's kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup in the compilation database took a name for an operator's when it started with operator and went on with neither a letter, a digit nor an underscore: operator$x and operatoré were looked up as the keyword operator, a file that declares them again was not judged, and the suggestion was reported checked. The plan now records whether Clang's kind of the name is an operator's, and the lookup uses that. For a free operator, every unit that writes the keyword is still judged, system headers included. --- src/analysis/ConstSuggestionCheck.cpp | 5 +++-- src/analysis/ConstSuggestionFacts.cpp | 18 ++++-------------- src/analysis/ConstSuggestionInternal.hpp | 10 +++++----- src/analysis/ConstSuggestionPlan.cpp | 3 +++ 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index ffa4eef..a68da1b 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -931,8 +931,9 @@ namespace ctrace::stack::analysis std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), [&](const std::string& path) { return seen.files.count(path); }) || (plan.declarableElsewhere && - seen.identifiers.count( - llvm::xxh3_64bits(const_suggestion::scannedName(plan.entity.name))) != 0); + seen.identifiers.count(llvm::xxh3_64bits( + plan.entity.operatorName ? llvm::StringRef("operator") + : llvm::StringRef(plan.entity.name))) != 0); if (!sees) continue; std::vector otherAccepted; diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 165fa47..5fbaee1 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -6,7 +6,6 @@ #include "analyzer/HotspotProfiler.hpp" #include -#include #include #include #include @@ -1412,7 +1411,9 @@ namespace ctrace::stack::analysis UnitFacts& out_; }; - // Preprocesses a unit and records every identifier, after macro expansion. + // Preprocesses a unit and records every identifier, and the keyword `operator` that an + // operator's name begins with, after macro expansion. System headers are read too: a + // declaration that one of them makes is the unit's own. class ScanAction : public clang::PreprocessorFrontendAction { public: @@ -1434,8 +1435,7 @@ namespace ctrace::stack::analysis identifiers_.insert( llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); else if (token.is(clang::tok::kw_operator)) - identifiers_.insert( - llvm::xxh3_64bits(const_suggestion::scannedName("operator"))); + identifiers_.insert(llvm::xxh3_64bits(llvm::StringRef("operator"))); } while (token.isNot(clang::tok::eof)); } @@ -1444,16 +1444,6 @@ namespace ctrace::stack::analysis }; } // namespace - std::string const_suggestion::scannedName(llvm::StringRef name) - { - // An operator's name is the keyword `operator` and what follows it, never one identifier. - if (name.starts_with("operator") && - (name.size() == 8 || - !(std::isalnum(static_cast(name[8])) || name[8] == '_'))) - return "operator"; - return name.str(); - } - UnitFacts const_suggestion::inspect(const UnitCommand& command, const SuggestionPlan* rewrittenBy, const std::map& rewritten, diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index f117a73..57b2c08 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -129,7 +129,11 @@ namespace ctrace::stack::analysis::const_suggestion std::vector symbols; // every variant's, before the rewrite (CodeGen) std::string templateArguments; // a specialization's, written canonically (ruling 7) SourceLinkage linkage = SourceLinkage::None; - std::uint8_t reservedPadding[7] = {}; + // Whether Clang's kind of the name is an operator's (operator==, operator""_x): such a + // name is the keyword `operator` and what follows it, never one identifier. A name is + // told by its kind, not its spelling: `operator$x` is an identifier. + bool operatorName = false; + std::uint8_t reservedPadding[6] = {}; }; // R, O and K (§5.4). @@ -414,10 +418,6 @@ namespace ctrace::stack::analysis::const_suggestion // incomplete and must not be judged. std::map rewrite(const SuggestionPlan& plan, std::string& failure); - // The token by which the scan records a function's name: the name itself, or `operator` for - // an operator, whose name is not an identifier (a free operator, a conversion, a literal one). - std::string scannedName(llvm::StringRef name); - // The files a unit reads and its identifiers, from its preprocessing. UnitScan scan(const UnitCommand& command, bool timing); } // namespace ctrace::stack::analysis::const_suggestion diff --git a/src/analysis/ConstSuggestionPlan.cpp b/src/analysis/ConstSuggestionPlan.cpp index 9dd1e57..0e18985 100644 --- a/src/analysis/ConstSuggestionPlan.cpp +++ b/src/analysis/ConstSuggestionPlan.cpp @@ -455,6 +455,9 @@ namespace ctrace::stack::analysis } std::sort(out.entity.redeclarations.begin(), out.entity.redeclarations.end()); out.entity.name = function.getDeclName().getAsString(); + const clang::DeclarationName::NameKind kind = function.getDeclName().getNameKind(); + out.entity.operatorName = kind == clang::DeclarationName::CXXOperatorName || + kind == clang::DeclarationName::CXXLiteralOperatorName; out.entity.linkage = linkageOf(function); if (instantiated) out.entity.templateOrigin = sites(instantiated->getFirstDecl()->getLocation()); From 6db974b0bc28539c61f5e215bcf5290da15df9dc Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 31 Oct 2026 14:09:41 +0100 Subject: [PATCH 145/152] fix(analysis): name a failed rewrite's first error, not an earlier warning When the rewritten code does not compile and the rewrite adds no error, the reason cited the first diagnostic, which can be a tracked warning the code already had. It now cites the first error. No input is known to reach this path (#239 review, M6). --- src/analysis/ConstSuggestionCheck.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index a68da1b..dfee605 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -493,13 +493,16 @@ namespace ctrace::stack::analysis } } // A rewrite that does not compile, and adds no error to tell why: it is not shown to be - // wrong (#239 review, M6). + // wrong (#239 review, M6). The reason names its first error, not an earlier warning. if (!after.module) { + const auto error = + std::find_if(after.reported.begin(), after.reported.end(), + [](const Diagnostic& diagnostic) { return diagnostic.error; }); return indeterminate( "the rewritten code does not compile, and no error that the rewrite adds " "explains it" + - (after.firstDiagnostic.empty() ? "" : ": " + after.firstDiagnostic)); + (error == after.reported.end() ? "" : ": " + error->text)); } // Another entity's declaration that the rewrite merges into the entity: a declaration // it has after the rewrite only (ruling 3). From c56c99d32673bed14f70c4d67a05132a9ca73803 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 31 Oct 2026 14:09:42 +0100 Subject: [PATCH 146/152] docs(analysis): state the check's exceptions, the operator lookup and its cost --help and ConstSuggestionCheck.hpp stated removal without the steps that stop the check first: a fatal error, or a change beyond the declarations it plans. The README now lists a renamed symbol that the file only declares and a failed rewrite that adds no error, and says that an operator's suggestion compiles most C++ files of the database, and what that costs. The comments on the check, on rewrite() and on the facts after an error claimed more than the code does. --- README.md | 20 +++++++++++++++----- include/analysis/ConstSuggestionCheck.hpp | 16 ++++++++++------ main.cpp | 7 +++++-- src/analysis/ConstSuggestionCheck.cpp | 5 +++-- src/analysis/ConstSuggestionFacts.cpp | 3 ++- src/analysis/ConstSuggestionInternal.hpp | 5 +++-- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 352d3bd..989b2ab 100644 --- a/README.md +++ b/README.md @@ -660,7 +660,7 @@ The option is a trade-off, not a fix: with it, a valid suggestion can still be r not checked; without it, suggestions whose type does not compile, drops a qualifier, or changes the generated code are reported. -Each suggestion gets one of three verdicts: +Each suggestion gets one of three verdicts, which step 3 below decides in its order: - verified: the suggestion is kept, with a `checked ...` line. In every file that sees the change, the rewrite compiles, adds none of the tracked warnings, changes none of the facts below, and the generated code matches; @@ -688,8 +688,14 @@ The check runs these steps: `--compile-commands`, every C or C++ file of the compilation database, analyzed or not. A file sees the change when it reads a rewritten file, when its analyzed code references the function, or, for a function outside any class with external linkage, when it names the function after - preprocessing. A function with internal linkage (`static`, anonymous namespace) and a function of - the same name in another file are two functions, whichever of them has internal linkage. + preprocessing, in its own text or in any header it includes, system headers included. An + operator's name is not an identifier: for an operator, a file sees the change when it writes the + keyword `operator`, which most C++ files do through the standard headers they include. A + suggestion on such an operator then compiles most C++ files of the compilation database as they + are and with the rewrite, which can cost far more than for a function named by an identifier, + and any of them that Clang cannot compile as it is leaves the suggestion not checked. A function + with internal linkage (`static`, anonymous namespace) and a function of the same name in another + file are two functions, whichever of them has internal linkage. 3. The judgement, in each file, in this order: - a file that cannot be preprocessed, or does not compile as it is, cannot be checked, nor a file whose compilation with the rewrite stops at a fatal error: not checked; @@ -700,6 +706,7 @@ The check runs these steps: `incompatible-function-pointer-types`, `int-conversion` and `overloaded-virtual`. Whatever the compile command says, the check enables them and keeps the level Clang gives them by default: a warning stays a warning under `-Werror`, and an error stays an error; + - a compilation with the rewrite that fails without an error that the rewrite adds: not checked; - a call or a reference that targets another function, a method of that name that overrides other methods, an `if constexpr` condition or a `_Generic` selection that changes: refuted. Facts that cannot be compared one to one leave the file not checked; @@ -711,8 +718,11 @@ The check runs these steps: specializations whose template arguments or type hold them, and the implicit members of the classes that hold them. Every other part of such an identity must match. One left without a pair, or two that would pair with the same symbol, leaves the file not checked; - - the generated code is compared through those pairs. It matches: verified. A difference that - none of these facts explains: not checked. + - the generated code is compared through those pairs. It matches: verified, unless a symbol + that the rewrite renames, other than the function's own and those of the constructors that + inherit it, is only declared in the file, before and after the rewrite: nothing shows that + another file defines the renamed one (`h` becoming `h`), so not checked. + A difference that none of these facts explains: not checked. Once every file is judged, a symbol that the rewrite gives the function and that another file defines, or another declaration that the rewrite merges into the function, could collide if the diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index 9127ac2..5f3a511 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -94,12 +94,16 @@ namespace ctrace::stack::analysis const PrintedSignature* signature = nullptr, std::vector* accepted = nullptr); // Checks the suggestions made for `mod` with Clang (#217): rewrites every declaration of the - // parameter with the suggested type, recompiles every analyzed unit that sees a rewritten file - // or references the function, and compares their generated code. A suggestion is removed when - // the rewrite adds an error or a tracked warning, or changes a call or reference target, an - // override or a constant condition; it is kept as not checked when it cannot be shown valid, - // a generated code difference included; the others are kept as checked. Each gets detail lines - // that say what was checked, or why it could not be. + // parameter with the suggested type, recompiles every unit that sees the change, and compares + // their generated code. The units are the analyzed ones and, with a compilation database, its + // C and C++ files; a unit sees the change when it reads a rewritten file, when its analyzed + // code references the function, or, for a function with external linkage outside any class, + // when it names the function after preprocessing (an operator: when it writes `operator`). A + // suggestion is removed when the rewrite adds an error or a tracked warning, or changes a call + // or reference target, an override or a constant condition, unless the check stops first at a + // fatal error or a change beyond the declarations it plans; it is kept as not checked when it + // cannot be shown valid, a generated code difference included; the others are kept as checked. + // Each gets detail lines that say what was checked, or why it could not be. void checkConstSuggestions(const llvm::Module& mod, std::vector& issues, const AnalysisConfig& config); } // namespace ctrace::stack::analysis diff --git a/main.cpp b/main.cpp index 941268e..fc496dc 100644 --- a/main.cpp +++ b/main.cpp @@ -82,8 +82,11 @@ static void printHelp() << " --experimental-const-suggestion-check Experimental: check each const " "suggestion\n" << " with Clang: removed if the rewrite adds an error or a\n" - << " tracked warning or changes what Clang resolves, kept as\n" - << " not checked when it cannot be shown valid (off by default)\n" + << " tracked warning, or changes a call or reference target,\n" + << " an override or a constant condition, unless the check\n" + << " stops first at a fatal error or a change beyond the\n" + << " declarations it plans; kept as not checked when it\n" + << " cannot be shown valid (off by default)\n" << " --uninitialized-cross-tu Enable cross-TU uninitialized summaries (default: on)\n" << " --no-uninitialized-cross-tu Disable cross-TU uninitialized summaries\n" << " --only-file= Only report functions from this source file\n" diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index dfee605..ba2434d 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Checks the const suggestions of ConstParameterNotModified with Clang (#217): the analysis of the -// IR decides whether an execution writes the pointee; Clang decides whether the suggested type -// compiles in every analyzed file and leaves the generated code as it is. +// IR decides whether an execution writes the pointee; Clang compiles every file that sees the +// change as it is and with the suggested type, and the two compilations decide whether the +// suggestion is verified, refuted or kept as not checked (§5.8). #include "analysis/ConstSuggestionCheck.hpp" #include "ConstSuggestionInternal.hpp" diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 5fbaee1..9a53cf0 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -381,7 +381,8 @@ namespace ctrace::stack::analysis context_ = &context; generator_ = action_.getCodeGenerator(); mangle_.reset(context.createMangleContext()); - // After an error, the rewrite is refuted by it (§5.8, step 4): no fact is needed. + // After an error, no fact is needed: the rewrite is refuted by the error it adds + // (§5.8, step 4), or kept as not checked when it adds none. collectFacts_ = generator_ && !context.getDiagnostics().hasErrorOccurred(); sites_.emplace(context.getSourceManager(), rewrittenBy_); out_.file = sites_->fileOf(context.getSourceManager().getMainFileID()); diff --git a/src/analysis/ConstSuggestionInternal.hpp b/src/analysis/ConstSuggestionInternal.hpp index 57b2c08..379cc58 100644 --- a/src/analysis/ConstSuggestionInternal.hpp +++ b/src/analysis/ConstSuggestionInternal.hpp @@ -414,8 +414,9 @@ namespace ctrace::stack::analysis::const_suggestion const std::vector& targets, std::string_view step, bool timing); // The text of each file that `plan` rewrites, its edits applied. `failure` names a file that - // cannot be read, or that no longer has the text the plan was made on; the result is then - // incomplete and must not be judged. + // cannot be read, or that has become too short for an edit, so changed after the plan was + // made; the result is then incomplete and must not be judged. A file that changed and is long + // enough is not detected. std::map rewrite(const SuggestionPlan& plan, std::string& failure); // The files a unit reads and its identifiers, from its preprocessing. From 5dcbae097a16350e9b32b8fee6e1d0c814cbfd2e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 19:55:57 +0100 Subject: [PATCH 147/152] test(analysis): check two callbacks that a rewrite makes differ in a conditional In C, a conditional between two functions whose types the rewrite of one makes differ is a constraint violation, which Clang reports as -Wpointer-type-mismatch, a warning by default. PCRE2's callout_enumerate_function_8 reaches pcre2_callout_enumerate that way, and its two suggestions are reported checked (#239). The test passes f1 or f2 through ?:, with the default warnings and with -Werror -Wno-pointer-type-mismatch: the four suggestions must be removed for that warning; until the next commit they are reported checked. A conditional between const blk * and blk *, which C allows, must keep its suggestions checked. --- run_test.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/run_test.py b/run_test.py index ea3c4bb..25039aa 100755 --- a/run_test.py +++ b/run_test.py @@ -3900,6 +3900,56 @@ def check_const_suggestion_werror_on_a_tracked_group() -> bool: return good +def check_const_suggestion_conditional_between_callbacks() -> bool: + """ + #239: in C, a conditional between pointers to incompatible types violates a constraint + (C11 6.5.15p3), which Clang reports only as the warning -Wpointer-type-mismatch. use passes f1 + or f2 through `?:`: a rewrite of one of them makes their types differ, so each suggestion on f1 + and f2 is removed for that warning. The witness: pick chooses between a pointer to const and a + pointer of the same type, which C allows: both suggestions on pick are checked. Both hold with + the project's -Werror and the group disabled (D6): the check keeps the group a warning in both + compilations, so the file, whose mix already mixes two incompatible pointers, compiles as it + is, and each removal names the warning, not an error. + """ + print("=== Testing a rewrite that makes the two callbacks of a conditional differ ===") + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_conditional_") as tmp: + path = Path(tmp).resolve() / "callbacks.c" + path.write_text( + "typedef struct blk { int x; } blk;\n" + "int api(int (*cb)(blk *, void *), void *data);\n" + "static int f1(blk *b, void *data) { (void)data; return b->x; }\n" + "static int f2(blk *b, void *data) { (void)b; (void)data; return 0; }\n" + "int use(int c) { return api(c ? f1 : f2, 0); }\n" + "int pick(int c, blk *b, blk *other) { const blk *q = c ? b : other; return q->x; }\n" + "int gi;\nlong gl;\n" + "void *mix(int c) { return c ? &gi : ≷ }\n" + ) + for name, extra in ( + ("default warnings", []), + ("-Werror, the group disabled", + ["--compile-arg=-Werror", "--compile-arg=-Wno-pointer-type-mismatch"]), + ): + run = _const_suggestion_run([str(path), *extra]) + messages, trace = (run[0], run[1]) if run else ({}, []) + removed = all( + any(line.startswith(f"{function} parameter {parameter}: removed, gains a checked " + f"warning: {path}:5: pointer type mismatch") for line in trace) + for function in ("f1", "f2") for parameter in ("b", "data") + ) + picked = messages.get("pick", []) + ok = ( + removed and "f1" not in messages and "f2" not in messages + and len(picked) == 2 + and all("checked in the analyzed files only" in t for t in picked) + ) + print(f" {'✅' if ok else '❌'} {name}" + f"{'' if ok else f': {messages}, trace: {trace}'}") + good = good and ok + print() + return good + + def check_const_suggestion_extern_c_user() -> bool: """ #217 (D8): a C function that a C++ file declares through a shared header (extern "C"). Each @@ -7543,6 +7593,7 @@ def record_ok(ok: bool): check_const_suggestion_cast_in_the_defining_file, check_const_suggestion_trace, check_const_suggestion_werror_on_a_tracked_group, + check_const_suggestion_conditional_between_callbacks, check_const_suggestion_extern_c_user, check_const_suggestion_top_level_qualifier, check_const_suggestion_deduced_type_error, From af1edbe9a24d996b58e713a62611ed8be2ad212a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 19:55:57 +0100 Subject: [PATCH 148/152] fix(analysis): track pointer-type-mismatch in the const suggestion check A rewrite that makes the two callbacks of a conditional differ adds this warning, and the suggestion was kept as checked. The group is now among the tracked warnings: the check enables it and, under -Werror, keeps it the warning it is by default. --- README.md | 8 +++++--- src/analysis/ConstSuggestionFacts.cpp | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 989b2ab..f8e8ef6 100644 --- a/README.md +++ b/README.md @@ -703,9 +703,11 @@ The check runs these steps: tried, or another declaration of a rewritten file whose type changes, leaves it not checked; - an error, or one of these warnings, that the rewrite adds: refuted. The tracked warnings are `incompatible-pointer-types-discards-qualifiers`, `incompatible-pointer-types`, - `incompatible-function-pointer-types`, `int-conversion` and `overloaded-virtual`. Whatever the - compile command says, the check enables them and keeps the level Clang gives them by default: - a warning stays a warning under `-Werror`, and an error stays an error; + `incompatible-function-pointer-types`, `int-conversion`, `overloaded-virtual` and + `pointer-type-mismatch` (in C, a conditional between two callbacks that the rewrite of one + makes differ). Whatever the compile command says, the check enables them and keeps the level + Clang gives them by default: a warning stays a warning under `-Werror`, and an error stays an + error; - a compilation with the rewrite that fails without an error that the rewrite adds: not checked; - a call or a reference that targets another function, a method of that name that overrides other methods, an `if constexpr` condition or a `_Generic` selection that changes: refuted. diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 9a53cf0..d1ae8e2 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -74,7 +74,8 @@ namespace ctrace::stack::analysis // turns on silently: the rewritten code must not add any. constexpr const char* kCheckedWarnings[] = { "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", - "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual"}; + "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual", + "pointer-type-mismatch"}; // Errors, and the checked warnings, by place and kind: their texts name types that the // rewrite changes. From 10adf29f35b07f91f97b7540b105cf6b92d24d47 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 19:55:57 +0100 Subject: [PATCH 149/152] test(analysis): check a file of the database that imports a module Preprocessing does not read a module that a file imports. A file that gets f's declaration from one and never names f is not judged, and the suggestion is reported checked. The test covers a Clang header module, a C++20 header unit, a named module and a module implementation unit: each must leave the suggestion not checked; until the next commit, each is reported checked. A PCH, which preprocessing reads, stays a witness: the file is judged and declares f (D17). A refutation by another file must still win, with the importing file in the trace, and a file that imports a module and includes the rewritten header must still be judged. A static function stays checked in every case. --- run_test.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/run_test.py b/run_test.py index 25039aa..d5b6d9d 100755 --- a/run_test.py +++ b/run_test.py @@ -4626,6 +4626,131 @@ def check_const_suggestion_declared_in_a_system_header() -> bool: return good +def _llvm_clang() -> Optional[Path]: + """ + The clang++ of the LLVM that the analyzer under test is built with, found through its build's + CMakeCache.txt: the analyzer's Clang reads the precompiled headers and header units it writes. + """ + cache = RUN_CONFIG.analyzer.resolve().parent / "CMakeCache.txt" + found = re.search(r"^LLVM_DIR:\w+=(.+)$", cache.read_text(), re.MULTILINE) if cache.exists() else None + clang = Path(found.group(1)).resolve().parents[2] / "bin" / "clang++" if found else None + return clang if clang is not None and clang.exists() else None + + +def check_const_suggestion_module_import() -> bool: + """ + #239: a.cpp defines f. b.cpp, which only the compilation database lists, gets a declaration of + f and a use of it from pre.h, and never names f in its own text. The check tells whether b.cpp + sees f from its preprocessing, which does not read a module that b.cpp imports. When b.cpp + imports one, as a Clang header module (its #include of pre.h becomes an import), a C++20 header + unit, a C++20 named module, or the interface of the module whose implementation unit it is + (neither interface is built: preprocessing does not read it), the suggestion is not checked + for that import. The witness: b.cpp gets pre.h from a precompiled header, which preprocessing + replaces with pre.h itself, so b.cpp is judged, and the suggestion stays not checked for + b.cpp's declaration (D17). In every case, a.cpp's static s, which no other file can declare, + stays checked. Last, an import does not hide a refutation: c.cpp, listed after b.cpp, which + imports the header module, refutes the suggestion, and the trace shows b.cpp's import; b.cpp + refutes it too when it imports an unrelated module and includes a.cpp's rewritten header. + """ + print("=== Testing a file of the compilation database that imports a module ===") + clang = _llvm_clang() + if clang is None: + print(" ❌ the clang++ of the analyzer's LLVM is needed (LLVM_DIR in its CMakeCache.txt)") + return False + sysroot = [] + if sys.platform == "darwin": + sdk = subprocess.run(["xcrun", "--show-sdk-path"], capture_output=True, text=True) + sysroot = ["-isysroot", sdk.stdout.strip()] + main = "int main() { return g() - 1; }\n" + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_module_") as tmp: + root = Path(tmp).resolve() + + def analyze(case: Path, b_text: str, b_flags: str, built: list[str], c_text: str = ""): + case.mkdir() + (case / "a.h").write_text("int f(int *p);\n") + (case / "a.cpp").write_text( + '#include "a.h"\nint f(int *p) { return *p; }\n' + "static int s(int *p) { return *p; }\nint use_s() { int x = 1; return s(&x); }\n" + ) + (case / "pre.h").write_text("int f(int *p);\ninline int g() { int x = 1; return f(&x); }\n") + (case / "other.h").write_text("int other();\n") + (case / "module.modulemap").write_text( + 'module Pre {\n header "pre.h"\n export *\n}\n' + 'module Other {\n header "other.h"\n export *\n}\n' + ) + (case / "b.cpp").write_text(b_text) + if built: + result = subprocess.run([str(clang), *sysroot, *built], cwd=case, + capture_output=True, text=True) + if result.returncode != 0: + print(result.stderr) + return None + units = [("a.cpp", "-std=c++17"), ("b.cpp", b_flags)] + if c_text: + (case / "c.cpp").write_text(c_text) + units.append(("c.cpp", "-std=c++17")) + (case / "compile_commands.json").write_text(json.dumps([ + {"directory": str(case), "file": name, + "command": shlex.join(["clang++", *sysroot, *shlex.split(flags), "-c", name])} + for name, flags in units + ])) + return _const_suggestion_run([ + str(case / "a.cpp"), f"--compile-commands={case / 'compile_commands.json'}" + ]) + + def static_checked(run) -> bool: + texts = run[0].get("_ZL1sPi", []) if run else [] + return len(texts) == 1 and "checked in every file of the compilation database" in texts[0] + + def modules(case: str) -> str: + return f"-std=c++17 -fmodules -fimplicit-module-maps -fmodules-cache-path={root}/{case}/cache" + + for directory, name, b_text, b_flags, built, reason in ( + ("header", "Clang header module", '#include "pre.h"\n' + main, modules("header"), [], + "b.cpp imports a module"), + ("unit", "C++20 header unit", 'import "pre.h";\n' + main, + f"-std=c++20 -fmodule-file={root}/unit/pre.pcm", + ["-std=c++20", "-fmodule-header=user", "-xc++-header", "pre.h", "-o", "pre.pcm"], + "b.cpp imports a module"), + ("named", "C++20 named module", "import M;\n" + main, "-std=c++20", [], + "b.cpp imports a module"), + ("implementation", "C++20 module implementation unit", + "module M;\nint h() { return g() - 1; }\n", "-std=c++20", [], "b.cpp imports a module"), + ("pch", "precompiled header, the witness", main, + f"-std=c++17 -include-pch {root}/pch/pre.h.pch", + ["-std=c++17", "-xc++-header", "pre.h", "-o", "pre.h.pch"], "b.cpp declares f(int*)"), + ): + run = analyze(root / directory, b_text, b_flags, built) + texts = run[0].get("_Z1fPi", []) if run else [] + ok = (len(texts) == 1 and "not checked with Clang:" in texts[0] and reason in texts[0] + and static_checked(run)) + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {run[0] if run else None}'}") + good = good and ok + + for directory, name, b_text, c_text, refuter, importer in ( + ("refuted", "refuted by c.cpp, b.cpp importing the module", '#include "pre.h"\n' + main, + '#include "a.h"\nint (*fp)(int *) = f;\n', "c.cpp:2", True), + ("importer", "refuted by b.cpp, which imports a module and reads the rewritten header", + '#include "other.h"\n#include "a.h"\nint (*fp)(int *) = f;\n', "", "b.cpp:3", False), + ): + case = root / directory + run = analyze(case, b_text, modules(directory), [], c_text) + messages, trace = (run[0], run[1]) if run else ({}, []) + ok = ( + "_Z1fPi" not in messages and static_checked(run) + and any(line.startswith(f"_Z1fPi parameter p: removed, does not compile: {case}/{refuter}:") + for line in trace) + and (not importer or any( + re.match(rf"_Z1fPi parameter p, unit {re.escape(str(case))}/b\.cpp \[[0-9a-f]+\]: " + rf"indeterminate: .*b\.cpp imports a module", line) for line in trace)) + ) + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {messages}, trace: {trace}'}") + good = good and ok + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7609,6 +7734,7 @@ def record_ok(ok: bool): check_const_suggestion_operator_declared_elsewhere, check_const_suggestion_identifier_starting_with_operator, check_const_suggestion_declared_in_a_system_header, + check_const_suggestion_module_import, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu, From 8658cd0532a0cd5c1a799caacb031a4d13058097 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 19:55:57 +0100 Subject: [PATCH 150/152] fix(analysis): keep a suggestion not checked when a file imports a module The scan now records whether a unit imports a module: the token of an #include that becomes an import or of a header unit, an import declaration, or the unit's own module declaration when it implements a module. A unit that imports one, and does not see the change otherwise, leaves the suggestion not checked, unless the rewrite changes only the defining file and the function is not a free function with external linkage: no other file can declare it then. A refutation still wins. --- README.md | 7 +++- include/analysis/ConstSuggestionCheck.hpp | 7 ++-- src/analysis/ConstSuggestionCheck.cpp | 13 +++++++ src/analysis/ConstSuggestionFacts.cpp | 42 ++++++++++++++++++----- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f8e8ef6..794ffb2 100644 --- a/README.md +++ b/README.md @@ -695,7 +695,12 @@ The check runs these steps: are and with the rewrite, which can cost far more than for a function named by an identifier, and any of them that Clang cannot compile as it is leaves the suggestion not checked. A function with internal linkage (`static`, anonymous namespace) and a function of the same name in another - file are two functions, whichever of them has internal linkage. + file are two functions, whichever of them has internal linkage. Preprocessing does not read a + module that a file imports: a Clang header module, a C++20 header unit or named module, the + interface that a module implementation unit imports included. A file that imports one, and does + not see the change otherwise, leaves the suggestion not checked, unless another file refutes it. + This does not apply when the rewrite changes only the defining file and the function is not a + free function with external linkage: no other file can declare it then. 3. The judgement, in each file, in this order: - a file that cannot be preprocessed, or does not compile as it is, cannot be checked, nor a file whose compilation with the rewrite stops at a fatal error: not checked; diff --git a/include/analysis/ConstSuggestionCheck.hpp b/include/analysis/ConstSuggestionCheck.hpp index 5f3a511..cc39e66 100644 --- a/include/analysis/ConstSuggestionCheck.hpp +++ b/include/analysis/ConstSuggestionCheck.hpp @@ -31,12 +31,15 @@ namespace ctrace::stack::analysis std::uint32_t reservedLanguagePadding = 0; }; - // What the preprocessing of one unit shows: the files it reads, and its identifiers (as - // 64-bit hashes: a collision only adds a recheck). + // What the preprocessing of one unit shows: the files it reads, its identifiers (as 64-bit + // hashes: a collision only adds a recheck), and whether it imports a module, which it does + // not read. struct UnitScan { std::set files; std::unordered_set identifiers; + bool importsModule = false; + std::uint8_t reservedPadding[7] = {}; }; // How the check compiles one unit. diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index ba2434d..6ae48d6 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -939,7 +939,20 @@ namespace ctrace::stack::analysis plan.entity.operatorName ? llvm::StringRef("operator") : llvm::StringRef(plan.entity.name))) != 0); if (!sees) + { + // The scan does not read a module that the unit imports: the unit may see + // the function there, unless only the defining file can declare it. + if (seen.importsModule && + (plan.declarableElsewhere || + std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), + [&](const std::string& path) { return path != definingFile; }))) + { + cannotCheck(key, &other, + unit.filename + " imports a module, whose declarations the " + "check does not see"); + } continue; + } std::vector otherAccepted; Judgement judgement = checkUnit(other, originalOf(other, key, targets, originals, timing), i, others, diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index d1ae8e2..0bfb148 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -1413,36 +1414,59 @@ namespace ctrace::stack::analysis UnitFacts& out_; }; + // An import declaration (`import M;`, `#pragma clang module import`), which gives no + // token of its own when it names a C++20 module. + class ImportRecorder : public clang::PPCallbacks + { + public: + explicit ImportRecorder(bool& imports) : imports_(imports) {} + + void moduleImport(clang::SourceLocation, clang::ModuleIdPath, + const clang::Module*) override + { + imports_ = true; + } + + private: + bool& imports_; + }; + // Preprocesses a unit and records every identifier, and the keyword `operator` that an // operator's name begins with, after macro expansion. System headers are read too: a - // declaration that one of them makes is the unit's own. + // declaration that one of them makes is the unit's own. A module that the unit imports + // is not read: the import is recorded, from its token (an #include that becomes an + // import, a header unit), from its declaration, or from the unit's own module + // declaration when the unit implements a module, whose interface it imports. class ScanAction : public clang::PreprocessorFrontendAction { public: - explicit ScanAction(std::unordered_set& identifiers) - : identifiers_(identifiers) - { - } + explicit ScanAction(UnitScan& out) : out_(out) {} protected: void ExecuteAction() override { clang::Preprocessor& preprocessor = getCompilerInstance().getPreprocessor(); + preprocessor.addPPCallbacks(std::make_unique(out_.importsModule)); preprocessor.EnterMainSourceFile(); clang::Token token; do { preprocessor.Lex(token); if (token.is(clang::tok::identifier)) - identifiers_.insert( + out_.identifiers.insert( llvm::xxh3_64bits(token.getIdentifierInfo()->getName())); else if (token.is(clang::tok::kw_operator)) - identifiers_.insert(llvm::xxh3_64bits(llvm::StringRef("operator"))); + out_.identifiers.insert(llvm::xxh3_64bits(llvm::StringRef("operator"))); + else if (token.isOneOf(clang::tok::annot_module_include, + clang::tok::annot_header_unit)) + out_.importsModule = true; } while (token.isNot(clang::tok::eof)); + if (preprocessor.isInImplementationUnit()) + out_.importsModule = true; } private: - std::unordered_set& identifiers_; + UnitScan& out_; }; } // namespace @@ -1581,7 +1605,7 @@ namespace ctrace::stack::analysis instance.setInvocation(std::move(invocation)); instance.createDiagnostics(*files, &ignore, false); instance.createFileManager(files); - ScanAction action(out.identifiers); + ScanAction action(out); instance.ExecuteAction(action); if (!instance.hasSourceManager() || instance.getDiagnostics().hasErrorOccurred()) return; From 8f875a723b318ce0bd7495bdb0937f4339cb8351 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:59:19 +0100 Subject: [PATCH 151/152] style(analysis): format the two fixes of the const suggestion check format-check failed on 8658cd0: clang-format 20 puts the tracked warnings one per line now that the list has six, and breaks a lambda of the import check before its brace. Whitespace only; the analyzer built from it is byte for byte the same. --- src/analysis/ConstSuggestionCheck.cpp | 3 ++- src/analysis/ConstSuggestionFacts.cpp | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/analysis/ConstSuggestionCheck.cpp b/src/analysis/ConstSuggestionCheck.cpp index 6ae48d6..4ba3f80 100644 --- a/src/analysis/ConstSuggestionCheck.cpp +++ b/src/analysis/ConstSuggestionCheck.cpp @@ -945,7 +945,8 @@ namespace ctrace::stack::analysis if (seen.importsModule && (plan.declarableElsewhere || std::any_of(rewrittenFiles.begin(), rewrittenFiles.end(), - [&](const std::string& path) { return path != definingFile; }))) + [&](const std::string& path) + { return path != definingFile; }))) { cannotCheck(key, &other, unit.filename + " imports a module, whose declarations the " diff --git a/src/analysis/ConstSuggestionFacts.cpp b/src/analysis/ConstSuggestionFacts.cpp index 0bfb148..1e930b0 100644 --- a/src/analysis/ConstSuggestionFacts.cpp +++ b/src/analysis/ConstSuggestionFacts.cpp @@ -74,8 +74,11 @@ namespace ctrace::stack::analysis // Warnings that the C and C++ standards make constraint violations, or that a rewrite // turns on silently: the rewritten code must not add any. constexpr const char* kCheckedWarnings[] = { - "incompatible-pointer-types-discards-qualifiers", "incompatible-pointer-types", - "incompatible-function-pointer-types", "int-conversion", "overloaded-virtual", + "incompatible-pointer-types-discards-qualifiers", + "incompatible-pointer-types", + "incompatible-function-pointer-types", + "int-conversion", + "overloaded-virtual", "pointer-type-mismatch"}; // Errors, and the checked warnings, by place and kind: their texts name types that the From c84b959036ba8608c25990ed243dd98b70309d09 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 1 Nov 2026 20:59:19 +0100 Subject: [PATCH 152/152] test(analysis): check a defining file that imports a module a.cpp, alone in the compilation database, defines f and imports the Clang header module Pre, whose header includes a.h and initializes an int (*)(int *) with f: the rewrite of f's parameter breaks it. a.cpp's compilation loads Pre, which the check does not rebuild with the rewritten a.h, so Pre's declaration of f keeps its type and the suggestion stays not checked. Without -fmodules, a.cpp reads pre.h as text, and the rewrite is refuted at pre.h:2. No code changes with this test: it passes as the check stands, and fails when the verdict or its reason changes. --- run_test.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/run_test.py b/run_test.py index d5b6d9d..b153a9d 100755 --- a/run_test.py +++ b/run_test.py @@ -4751,6 +4751,53 @@ def modules(case: str) -> str: return good +def check_const_suggestion_defining_file_imports_a_module() -> bool: + """ + #239: a.cpp, alone in the compilation database, defines f and imports the Clang header module + Pre, whose header includes a.h and initializes an int (*)(int *) with f: the rewrite of f's + parameter breaks that initialization. a.cpp is compiled, so its compilation loads Pre, but the + check does not rebuild Pre with the rewritten a.h: Pre's declaration of f keeps its type, and + the suggestion stays not checked. The witness: without -fmodules, a.cpp reads pre.h as text, and + the rewrite is refuted at pre.h:2. + """ + print("=== Testing a defining file that imports a module ===") + sysroot = [] + if sys.platform == "darwin": + sdk = subprocess.run(["xcrun", "--show-sdk-path"], capture_output=True, text=True) + sysroot = ["-isysroot", sdk.stdout.strip()] + good = True + with tempfile.TemporaryDirectory(prefix="ct_const_suggestion_defining_module_") as tmp: + root = Path(tmp).resolve() + for directory, name, flags, expected in ( + ("module", "a.cpp imports Pre, whose header uses f", + f"-std=c++17 -fmodules -fimplicit-module-maps -fmodules-cache-path={root}/module/cache", + "not checked, the rewritten declaration does not get the suggested type"), + ("textual", "witness, a.cpp reads pre.h as text", "-std=c++17", + f"removed, does not compile: {root}/textual/pre.h:2: "), + ): + case = root / directory + case.mkdir() + (case / "a.h").write_text("int f(int *p);\n") + (case / "pre.h").write_text( + '#include "a.h"\ninline int call(int *p) { int (*g)(int *) = f; return g(p); }\n' + ) + (case / "module.modulemap").write_text('module Pre {\n header "pre.h"\n export *\n}\n') + (case / "a.cpp").write_text('#include "pre.h"\n#include "a.h"\nint f(int *p) { return *p; }\n') + (case / "compile_commands.json").write_text(json.dumps([ + {"directory": str(case), "file": "a.cpp", + "command": shlex.join(["clang++", *sysroot, *shlex.split(flags), "-c", "a.cpp"])} + ])) + run = _const_suggestion_run([ + str(case / "a.cpp"), f"--compile-commands={case / 'compile_commands.json'}" + ]) + trace = run[1] if run else [] + ok = any(line.startswith(f"_Z1fPi parameter p: {expected}") for line in trace) + print(f" {'✅' if ok else '❌'} {name}{'' if ok else f': {trace}'}") + good = good and ok + print() + return good + + def check_duplicate_if_cross_tu() -> bool: """ #157: a function defined in another file is deterministic when every definition of it is, as @@ -7735,6 +7782,7 @@ def record_ok(ok: bool): check_const_suggestion_identifier_starting_with_operator, check_const_suggestion_declared_in_a_system_header, check_const_suggestion_module_import, + check_const_suggestion_defining_file_imports_a_module, check_duplicate_if_cross_tu, check_global_read_before_write_identity_across_files, check_size_minus_one_cross_tu,