diff --git a/changelog.d/rust-test-harness-dead-code.md b/changelog.d/rust-test-harness-dead-code.md new file mode 100644 index 000000000..35ec1a69e --- /dev/null +++ b/changelog.d/rust-test-harness-dead-code.md @@ -0,0 +1,3 @@ +### Fixed — Rust `#[test]` functions no longer read as dead code (#493) + +A `#[test]` or `#[tokio::test]` function is run by the test harness, not called by name, so the call graph has no caller for it. `--quality-delta` reported every new test as a `dead-code` row. Those functions are now exempt the way `TEST` and `TEST_CASE` already are, and they count toward `register-macro-excluded=`. An attribute that only looks like the test attribute (`#[testing]`, `#[tested]`, a `#[test]` inside a comment, a `#[test]` written after code on the same line as in `const A: u32 = 1; #[test]`) does not exempt the function below it, and an ordinary uncalled function is still reported. A trailing `//` comment after the test attribute (`#[test] // case`) does not hide the attribute either. diff --git a/changelog.d/versions.md b/changelog.d/versions.md index e5bc390d5..204c88c46 100644 --- a/changelog.d/versions.md +++ b/changelog.d/versions.md @@ -9,6 +9,6 @@ smart-pointer alias carries its pointee, a member whose written type is a templa branches (257–262), so a cache written before them would replay the older rows. 271 sits above every live branch and pull-request number (the string/literal records carry 263, 265 and 267 on theirs). `kCacheVersion` 46 → 48: a re-export specifier is a new binding kind appended to the cache record (its branch carried 47). A cache written by 0.6.9 is re-parsed -once. The quality-snapshot scheme moves 24 → 25 (#472: `isTestPath` reads more shell test names, which moves the dead set's test-script exemption; no snapshot record changes shape, and a v24 blob is rebuilt once). The session legend dictionary is `dictv=02b8cec3f8563e22 entries=1014`. +once. The quality-snapshot scheme moves 24 → 25 (#472: `isTestPath` reads more shell test names, which moves the dead set's test-script exemption; no snapshot record changes shape, and a v24 blob is rebuilt once). The session legend dictionary is `dictv=2451f0e77d06e369 entries=1014`. The history-walk cache's `kQChurnCacheScheme` moves 3 → 4 (#433: a blob written by a pre-fix binary holds the old line-frame reader's stream, which for the repositories the framing fix targets is the corrupted one, and no key component can tell that blob from a fresh read; every repository's history is walked once more after the upgrade, and on trees with ordinary names the walk produces the same stream, so the cost is one cold walk and no changed answer). diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 72e51a2eb..5f6bc51de 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -4624,7 +4624,7 @@ _The session legend dictionary the MCP server serves as ripwire://legend-dict/fu ``` $ ./build/ripwire . --legend-dict -ripwire legend dictionary ripwire.dict/v1 dictv=02b8cec3f8563e22 entries=1014 +ripwire legend dictionary ripwire.dict/v1 dictv=2451f0e77d06e369 entries=1014 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) diff --git a/docs/captures/COMMANDS_showcase_2026-11-02.md b/docs/captures/COMMANDS_showcase_2026-11-02.md index 0ff661d68..496f52e14 100644 --- a/docs/captures/COMMANDS_showcase_2026-11-02.md +++ b/docs/captures/COMMANDS_showcase_2026-11-02.md @@ -6052,7 +6052,7 @@ ripwire: --run-timeout=SECONDS modifies --run-trace — pass it too (e.g. ripwir *The session legend dictionary the MCP server serves as ripwire://legend-dict/full — one definition per line, headed by its dictv= version; no corpus needed. =roster lists the completeness attributes it defines.* ````` -ripwire legend dictionary ripwire.dict/v1 dictv=02b8cec3f8563e22 entries=1014 +ripwire legend dictionary ripwire.dict/v1 dictv=2451f0e77d06e369 entries=1014 : the answer's rows come first; its root keeps only task= changed= from= to=, and this LAST child carries every other root attribute unchanged (schema= included); legend="ref": a definition is sent once per session (this dictionary's core, or the first answer that ne … [line truncated: 83 more bytes on this line] schema=ripwire.KEY/v1: the line ripwire.KEY/v1 below reads the answer's rows window: shown= total= capped= has_more= next_offset= offset= limit= page a list (capped=1 cut; next_offset= pastes as offset=) diff --git a/src/compactlegend.h b/src/compactlegend.h index 7eeec2eb2..ca48c7942 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -829,7 +829,7 @@ inline constexpr CompactCompletenessTerm kCompactAttributeReadings[] = { "sig", "s sig=: the join symbol's declaration signature; dropped first when a max_tokens ceiling trims", true, "s", MapHeaderRead::No, {}, "connect" }, // dead-code: src/verbs_quality.h (the root emit) { "evidence", "evidence=: the rule every row met, internal linkage and no caller in the index; verify before deleting", false, "dead-code", MapHeaderRead::No, {}, "dead-code" }, - { "register-macro-excluded", "register-macro-excluded=N: symbols skipped as self-registering test/bench macros (TEST, BENCHMARK...); a floor", false, "dead-code", MapHeaderRead::No, {}, "dead-code" }, + { "register-macro-excluded", "register-macro-excluded=N: symbols skipped as self-registering test/bench macros (TEST, BENCHMARK...) or Rust #[test]; a floor", false, "dead-code", MapHeaderRead::No, {}, "dead-code" }, // 0.6.6 D4: present-only — absent at 0, so a tree with no Python runner root is byte-identical { "runner-root-excluded", "runner-root-excluded=N: Python defs skipped as pytest/unittest test-runner roots; a floor", true, "dead-code", MapHeaderRead::No, {}, "dead-code" }, // 0.6.6 D4 review: counted apart — a decorator MAY register the def; a wrapper (@property, @lru_cache) registers nothing @@ -931,7 +931,7 @@ inline constexpr CompactCompletenessTerm kCompactAttributeReadings[] = { "stale_next", "stale_next=: the call printing the sa rows and the full header", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, { "preexisting-worse", "preexisting-worse=N: regressions on symbols that existed at baseline; they gate when major", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, { "new-symbol", "new-symbol=N: findings on NEW code; new-symbol rows never gate, except defect-shape format-arity", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, - { "register-macro-excluded", "register-macro-excluded=N: symbols kept out of dead-code as self-registering test/bench macros; a floor", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, + { "register-macro-excluded", "register-macro-excluded=N: symbols skipped as self-registering test/bench macros or Rust #[test]; a floor", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, { "declined-call-excluded", "declined-call-excluded=N: symbols kept out of dead-code only because a declined call may mean them; a floor", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, { "api-new-surface", "api-new-surface=N: new PUBLIC symbols; a count, never gates, not in regressions=", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, { "renames", "renames=/rename_window_commits=: git rename pairs read over that many commits, to re-file baseline and acks", false, "quality-delta", MapHeaderRead::No, {}, "quality-delta" }, // also defines rename_window_commits= diff --git a/src/langfacts.h b/src/langfacts.h index 1d8dd3c67..0d30cb0a1 100644 --- a/src/langfacts.h +++ b/src/langfacts.h @@ -319,6 +319,10 @@ struct LangFacts /// TypeScript, JavaScript, Rust, Swift, Kotlin: a bare `{` assigned or returned inside a function body is an object literal (a /// call's options or a value), a block or a closure, never a table; in Python (a dict), C, C++, Java, Go it can hold data (forliteral.h). bool localBraceNotData = false; + + /// Rust: a `#[test]` or `#[tokio::test]` attribute registers the function with the test harness, which calls it by name + /// without any call in the tree (issue #493). quality.h's registersItself reads this column; nothing else does. + bool attributeRegistersTests = false; }; /// java.lang.Object's public methods an interface may redeclare abstract (LangFacts::samIgnoredMembers). @@ -338,7 +342,7 @@ inline constexpr std::array kLangFacts = { { { .lang = Lang::Go, .structuralTyping = true, .writtenModule = WrittenModuleRule::PackageDir, .memberNeverReachesFreeFunction = true, .moduleCallReachesFunctionsOnly = true, .signatureRows = true }, { .lang = Lang::Rust, .contractSignaturesUnindexed = true, .traitImplBlocks = true, .rule3NameProof = Rule3NameProof::RustUse, .returnTypedDispatchLet = true, .constructorMethod = "new", .memberSeparator = "::", .baseImportEvidence = BaseImportEvidence::RustUse, - .instanceReceiver = "self", .memberNeverReachesFreeFunction = true, .fileEdges = FileEdges::Resolved, .localBraceNotData = true }, + .instanceReceiver = "self", .memberNeverReachesFreeFunction = true, .fileEdges = FileEdges::Resolved, .localBraceNotData = true, .attributeRegistersTests = true }, { .lang = Lang::Swift, .writtenModule = WrittenModuleRule::DirsOnly, .instanceReceiver = "self", .implicitReceiver = true, .memberNeverReachesFreeFunction = true, .classNameReceiver = true, .localBraceNotData = true }, { .lang = Lang::ObjC, .noNestedNamedFunctions = true, .transitiveIncludes = true, .staticLinkage = true, .fileEdges = FileEdges::Resolved }, diff --git a/src/quality.h b/src/quality.h index 42248f98c..2d6fdbecb 100644 --- a/src/quality.h +++ b/src/quality.h @@ -1219,8 +1219,10 @@ struct DefectSiteAt // three call sites — which is precisely the finding that kind exists to catch, on the run that was adding // it. Two copies of "read each file once and hand me each body" is two places for the degrade rules to // drift, and those rules are the honesty contract here, not an implementation detail. +// visit( NodeId, const Symbol&, body, fileBytes ): the body slice plus the whole file, for a rule that reads text OUTSIDE +// the slice (forEachSymbolBody below is the three-argument spelling every other caller uses). template -inline void forEachSymbolBody( const IngestResult& ing, Fn&& visit ) +inline void forEachSymbolFile( const IngestResult& ing, Fn&& visit ) { // per-file def ids with a real body (see constructCountsBySym above on `symbols[i].id == i`). const SymbolsByFile byFile = symbolsByFileInIdOrder( ing, []( const Symbol& s ) { return s.endByte > s.sigStartByte; } ); @@ -1242,11 +1244,20 @@ inline void forEachSymbolBody( const IngestResult& ing, Fn&& visit ) { continue; } - visit( i, s, std::string_view( bytes.data() + s.sigStartByte, s.endByte - s.sigStartByte ) ); + visit( i, s, std::string_view( bytes.data() + s.sigStartByte, s.endByte - s.sigStartByte ), std::string_view( bytes ) ); } } } +template +inline void forEachSymbolBody( const IngestResult& ing, Fn&& visit ) +{ + forEachSymbolFile( ing, [ & ]( NodeId i, const Symbol& s, std::string_view body, std::string_view ) + { + visit( i, s, body ); + } ); +} + // Direct Python class members get an owner: the enclosing class. A def nested inside a method gets a host: the // nearest enclosing method, whose self/cls it reads as a free variable. Module-level functions have neither. struct PythonScopes @@ -1658,6 +1669,126 @@ inline std::vector trailingDefaultByNode( const IngestResult& ing } +// ---- Rust test-harness registration (issue #493) --------------------------------------------------------------- +// `#[test]` and `#[tokio::test]` (optionally with an argument list) register a function with the test harness, +// which calls it by name without any call in the tree. They are attributes, not macro calls, so the signature- +// text check above never sees them: the attribute stacks ABOVE the item, outside [sigStartByte, endByte). +// isRustTestAttribute answers whether the text between "#[" and "]" is one of the two spellings. +inline bool isRustTestAttribute( std::string_view attr ) noexcept +{ + if( attr == "test" ) + { + return true; + } + constexpr std::string_view tokio = "tokio::test"; + if( attr.substr( 0, tokio.size() ) == tokio ) + { + return attr.size() == tokio.size() || attr[ tokio.size() ] == '('; + } + return false; +} + +// rustCodeOfAttributeLine returns an attribute line without a trailing `//` comment. Rust reads that comment as +// whitespace, so `#[test] // case` is still `#[test]`. A `//` inside a double-quoted string is attribute text: +// `#[tokio::test(flavor = "http://x")]` keeps its argument whether or not a comment follows it. +inline std::string_view rustCodeOfAttributeLine( std::string_view line ) noexcept +{ + bool inString = false; + for( std::size_t i = 0; i < line.size(); ++i ) + { + const char c = line[ i ]; + if( inString ) + { + if( c == '\\' ) + { + ++i; + } + else if( c == '"' ) + { + inString = false; + } + } + else if( c == '"' ) + { + inString = true; + } + else if( c == '/' && i + 1 < line.size() && line[ i + 1 ] == '/' ) + { + line = line.substr( 0, i ); + break; + } + } + while( !line.empty() && std::isspace( static_cast( line.back() ) ) ) + { + line.remove_suffix( 1 ); + } + return line; +} + +// rustTestAttributed reads backwards from the item's signature start over blank lines, `//` comments and other +// attribute lines (`#[inline]`, `#[allow(...)]`), and answers true when one of them is a test attribute. Whole-token +// compare: `#[testing]` and a `#[test]` inside a comment do not count. An attribute line STARTS with `#[` once its leading +// whitespace is gone: a `#[test]` written after code on the same line (`const A: u32 = 1; #[test]`) is not an attribute +// of the item below it, so it ends the scan like any other code (issue #499 review). +// FLOOR, stated: a multi-line attribute, or a block comment between attribute and item, ends the scan and the item +// stays a dead-code candidate. That is a miss in the reporting direction, never a false exemption. +inline bool rustTestAttributed( std::string_view file, std::size_t sigStart ) noexcept +{ + std::size_t end = std::min( sigStart, file.size() ); // everything before `end` is still unread + for( ;; ) + { + while( end > 0 && std::isspace( static_cast( file[ end - 1 ] ) ) ) + { + --end; + } + if( end == 0 ) + { + return false; + } + const std::size_t nl = file.rfind( '\n', end - 1 ); + const std::size_t from = nl == std::string_view::npos ? 0 : nl + 1; + std::string_view line = file.substr( from, end - from ); + while( !line.empty() && std::isspace( static_cast( line.front() ) ) ) + { + line.remove_prefix( 1 ); + } + if( line.substr( 0, 2 ) == "//" ) + { + end = from; // a comment or doc line: keep reading upward + continue; + } + line = rustCodeOfAttributeLine( line ); + if( line.empty() ) + { + end = from; + continue; + } + if( line.substr( 0, 2 ) != "#[" || line.back() != ']' ) + { + return false; // any other code ends the attribute block + } + const std::size_t open = line.rfind( "#[" ); + if( isRustTestAttribute( line.substr( open + 2, line.size() - open - 3 ) ) ) + { + return true; + } + end = from; // another attribute on the way up: keep reading + } +} + +// The exemption a definition gets for registering itself with a runner the call graph cannot see: its own signature +// begins with a registered macro call (the list above), or, for Rust only, a test attribute stacks above it. Both +// --dead-code and --quality-delta read this one predicate, so the two verbs cannot disagree about one symbol. +inline bool registersItself( const Symbol& s, std::string_view file, std::string_view sigRegion, + const std::vector& names ) noexcept +{ + if( startsWithRegisteredMacro( sigRegion, names ) ) + { + return true; + } + return langFacts( s.lang ).attributeRegistersTests && rustTestAttributed( file, s.sigStartByte ); +} + // P2.2 — every symbol in THIS tree whose own signature text is a registered-macro call (built ONCE per // computeSnapshot/computeDelta run, exactly like topLevelCallees above), reading each file's bytes once via // forEachSymbolBody — whose per-symbol `body` view already starts at sigStartByte, which is precisely where @@ -1672,9 +1803,9 @@ inline std::vector registeredMacroSymbolIds( const IngestResult& ing, co { return ids; } - forEachSymbolBody( ing, [ & ]( NodeId i, const Symbol&, std::string_view body ) + forEachSymbolFile( ing, [ & ]( NodeId i, const Symbol& s, std::string_view body, std::string_view file ) { - if( startsWithRegisteredMacro( body, names ) ) + if( registersItself( s, file, body, names ) ) { ids.push_back( i ); } @@ -3673,6 +3804,10 @@ inline void evictOldHeadSnapCaches( const std::string& dir, const std::string& r // constexpr array, not a function, so this manifest mechanism // cannot hash IT — a future edit that only changes the built-in // NAME LIST (not these functions' text) will not trip this gate. +// registersItself (quality.h) — the self-registration exemption --dead-code and --quality-delta share (a registered macro call, or a Rust test attribute above) +// rustTestAttributed (quality.h) — the backward scan over blank, comment and attribute lines that finds a #[test] or #[tokio::test] above a Rust definition +// isRustTestAttribute (quality.h) — whole-token check that an attribute's text is `test` or `tokio::test`, with or without an argument list +// rustCodeOfAttributeLine (quality.h) — an attribute line without its trailing // comment (a // inside a double-quoted string does not start one) // defectSitesOf (quality.h) — the defect-shape site identity (anchor + facet + normalized text) // the baseline stores. NOTE (disclosed limitation): the scanners it // calls live in defectshape.h, outside this manifest, so a change @@ -3800,7 +3935,11 @@ inline void evictOldHeadSnapCaches( const std::string& dir, const std::string& r // v25 (2026-11-01, land-472, #466 / #472) — filter.h's isTestPath reads the hyphen test names `test-*.sh`, `*-test.sh` (and the // other shell/batch suffixes) and `.e2e-spec.` / `.perf-spec.` as tests, and isTestScriptPath calls it, so a shell test script the // dead-set exemption did not cover before is covered now. A v24 blob holds the old dead set. Renumbered above every live value (24). -constexpr std::uint32_t kQSnapCacheScheme = 25; +// v26 (2026-11-02, #493 / #499: Rust test-attribute functions) — no blob shape change; the dead SET's inputs moved. +// registeredMacroSymbolIds, which isDeadCandidate's register-macro exemption reads, now also holds a Rust definition that a +// #[test] or #[tokio::test] attribute stacks above (registersItself). The harness calls such a function, so it is no dead +// candidate. Renumbered above main's v25 (land-472); The open #494 bump takes v27, so the two never share a value. +constexpr std::uint32_t kQSnapCacheScheme = 26; constexpr char kQSnapMagic[4] = { 'Q', 'S', 'N', 'P' }; // The qsnap EXCLUDES-config key folds the qsnap SCHEME (independent of the ingest cache's kHeadSnapCacheScheme) diff --git a/src/verbs_quality.h b/src/verbs_quality.h index 4c1f3ab38..2393b4fb3 100644 --- a/src/verbs_quality.h +++ b/src/verbs_quality.h @@ -899,7 +899,7 @@ inline constexpr const char* kQdLegendCore = "whose target no longer applies. " "api-new-surface= COUNTS the new PUBLIC symbols (never gates, not in regressions=, printed even at zero). " "register-macro-excluded= is a FLOOR, not a finding: symbols this run excluded from the dead-code kind " - "because their own definition is a registered self-registering test/benchmark macro call. Never gates, " + "because their own definition is a registered self-registering test/benchmark macro call, or a Rust #[test] or #[tokio::test] function the test harness calls. Never gates, " "never counted in regressions=, printed even at zero (zero means none excluded, not that the check did " "not run). " // P3 (L7): next= on gating rows, defined where the reader meets it @@ -911,8 +911,9 @@ inline constexpr const char* kQdLegendCore = // excluded something, because on a repo with no such macro the roster explains an empty set. inline constexpr const char* kQdRegisterMacroLegend = "The registered families are doctest/Catch2 TEST_CASE, GoogleTest TEST/TEST_F/TEST_P, Google Benchmark " - "BENCHMARK, plus any name a .ripwire_config register_macros= line adds; each registers itself through a " - "static initializer the call graph cannot see, so zero in-edges on one is not evidence of anything. "; + "BENCHMARK, plus any name a .ripwire_config register_macros= line adds, and Rust #[test] / #[tokio::test] " + "functions; each registers itself through a static initializer or the test harness, which the call graph cannot " + "see, so zero in-edges on one is not evidence of anything. "; // F-13 (audit 2026-09-02) — printed only when .ripwire_config itself has something wrong with it, which // used to be a silent no-op: a typo'd key was skipped with no trace, and a register_macros= name that @@ -2974,7 +2975,7 @@ std::optional runQualityViews( const MainDispatch& d ) { return false; } - return quality::startsWithRegisteredMacro( std::string_view( src ).substr( symbol.sigStartByte ), registerMacroNames ); + return quality::registersItself( symbol, src, std::string_view( src ).substr( symbol.sigStartByte ), registerMacroNames ); }; std::size_t registerMacroExcluded = 0; // P2.2: disclosed count — see the header comment below std::size_t runnerRootExcluded = 0; // 0.6.6 D4: disclosed count (runner-root-excluded=, absent at 0) @@ -3122,8 +3123,9 @@ std::optional runQualityViews( const MainDispatch& d ) "(test/x/src/y.cpp included); anchor with ./ (filter=\"./src\") to pin the root-level directory only. " "register-macro-excluded= counts symbols excluded because their OWN definition is a registered " "self-registering test/benchmark macro call (doctest/Catch2 TEST_CASE family, GoogleTest TEST/TEST_F/TEST_P, " - "Google Benchmark BENCHMARK family, plus any name a .ripwire_config register_macros= line adds): such a " - "symbol registers itself through a static initializer the call graph cannot see, so zero in-edges on it is " + "Google Benchmark BENCHMARK family, plus any name a .ripwire_config register_macros= line adds), or a Rust " + "#[test] or #[tokio::test] function: such a symbol registers itself through a static initializer or the test " + "harness, which the call graph cannot see, so zero in-edges on it is " "not evidence of anything — never a finding, never gating, absent nothing (0 is printed, not omitted). " "config-warnings= counts two DISCLOSED .ripwire_config problems, each also written to stderr — an " "unrecognized key, and a register_macros= name matching no indexed symbol — never gating, present " diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index 3fc0b2208..4845837dc 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -ab16162e16ee6d0bdb2cd00423f2cb644c66f35c6d58bc5c7c729faed0fdd45f +32fa1194f79e61290d97c44337de3434fcd770e40ab6813a0f273a8f8e0a0b62 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index 8ffb1cb19..8c3578a24 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,11 @@ SRC="$ROOT/src/quality.h" ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds PIN="$ROOT/test/qschemetrip.hash" # RE-PIN LOG (the pin is a bare hash, so its justification has to live here). +# 2026-11-02, PR fix/rust-test-harness-dead-code (#493, maintainer review of #499): RE-PINNED with UPDATE_GOLDEN=1 after merging main +# (main holds kQSnapCacheScheme 25). Watched text: registeredMacroSymbolIds now reads registersItself (a Rust #[test] / #[tokio::test] +# attribute above a definition exempts it); rustTestAttributed reads an attribute line only when it starts with `#[`; the manifest +# now lists registersItself, rustTestAttributed, isRustTestAttribute and rustCodeOfAttributeLine. kQSnapCacheScheme 25 -> 26 (v26) +# (quality.h's v26 note). kParserVer and kCacheVersion stay: no extraction change, no record shape change. # 2026-11-02, lane rule3-member-guard fix 4 (base main 00a16550; PLACEHOLDER, the train renumbers): RE-PINNED ONCE with UPDATE_GOLDEN=1. # Watched text: kParserVer 283 -> 285 (rich 286; 284 is 283's rich tag): a Rust path call whose qualifier has more than one segment keeps # it whole in Reference::fieldName; quality.h's kIngestParserVerMirror with it. kCacheVersion stays 48 and kQSnapCacheScheme stays 24: diff --git a/test/registermacrocheck.sh b/test/registermacrocheck.sh index d8787d1d5..50c4d0cca 100755 --- a/test/registermacrocheck.sh +++ b/test/registermacrocheck.sh @@ -358,5 +358,130 @@ printf '%s' "$DC8B_TAG" | grep -q 'config-warnings=' \ && { no "arm8: a genuinely-used register_macros= name still warned as inert"; printf '%s\n' "$DC8B_TAG"; } \ || ok "arm8: a genuinely-used config name draws no warning (negative control)" +# ── ARM 9: Rust test-harness registration, via --quality-delta (issue #493) ────────────────────────────── +# A #[test] (or #[tokio::test…]) function is called by the test harness, and no line in the tree names it, so the +# name-based call graph sees zero callers. The attribute sits ABOVE the item, outside the signature text the macro +# check reads, so it needed its own rule (quality.h rustTestAttributed). Positive shapes must be exempt; each +# negative lookalike must still be flagged: an attribute inside a comment, '#[testing]', '#[tested]', a test attribute +# written after code on the same line (`const A: u32 = 1; #[test]`), a `#[test]` that only sits in a trailing // comment +# (`static X: u32 = 1; // used to be #[test]`), a dead fn placed directly after a #[test] item, and an ordinary dead fn. +# A one-line `#[test] fn ...` and a #[test] indented inside `#[cfg(test)] mod tests { }` are positives. +A9="$WORK/a9"; newrepo "$A9" +cat > "$A9/lib.rs" <<'EOF' +fn live_helper() -> u32 { 1 } +EOF +commit "$A9" "baseline: no tests yet" +cat > "$A9/lib.rs" <<'EOF' +fn live_helper() -> u32 { 1 } + +#[test] +fn plain_case() { assert_eq!(live_helper(), 1); } + +#[tokio::test(flavor = "current_thread")] +async fn flavored_case() { assert_eq!(live_helper(), 1); } + +#[inline] +#[test] +fn stacked_case() { assert_eq!(live_helper(), 1); } + +#[test] + +fn blank_line_case() { assert_eq!(live_helper(), 1); } + +#[test] +/// A documented harness case: the doc line sits between the attribute and the item. +fn doc_comment_case() { assert_eq!(live_helper(), 1); } + +#[test] fn one_line_case() { assert_eq!(live_helper(), 1); } + +#[cfg(test)] mod tests { + #[test] + fn indented_in_test_mod() { assert_eq!(live_helper(), 1); } +} + +#[testing] +fn testing_attribute_is_not_a_test() { } + +// #[test] +fn commented_attribute_is_not_a_test() { } + +#[tested] +fn tested_attribute_is_not_a_test() { } + +const A: u32 = 1; #[test] +fn not_a_test_after_code() { } + +static X: u32 = 1; // used to be #[test] +fn really_dead() { } + +#[test] +fn test_is_here() { assert_eq!(live_helper(), 1); } +fn dead_after_test_attribute() { } + +fn dead_plain() { } +EOF +QD9="$( cd "$A9" && "$BIN" . --quality-delta --no-cache 2>/dev/null )" +for sym in plain_case flavored_case stacked_case blank_line_case doc_comment_case one_line_case indented_in_test_mod test_is_here; do + printf '%s' "$QD9" | grep -q "kind=\"dead-code\" sym=\"$sym\"" \ + && no "arm9: '$sym' is a #[test] function but was reported as dead-code" \ + || ok "arm9: '$sym' (#[test] shape) excluded from dead-code" +done +for sym in testing_attribute_is_not_a_test commented_attribute_is_not_a_test tested_attribute_is_not_a_test not_a_test_after_code really_dead dead_after_test_attribute dead_plain; do + printf '%s' "$QD9" | grep -q "kind=\"dead-code\" sym=\"$sym\"" \ + && ok "arm9: '$sym' (negative lookalike) still flagged" \ + || { no "arm9: '$sym' should still be flagged — the test-attribute exemption leaked"; printf '%s\n' "$QD9" | grep -oE 'sym="[^"]*"' | head -12; } +done +printf '%s' "$QD9" | grep -q 'kind="dead-code" sym="live_helper"' \ + && no "arm9: live_helper (called from tests) wrongly reported" \ + || ok "arm9: live_helper (called from the tests) not reported" +printf '%s' "$QD9" | grep -q 'register-macro-excluded="8"' \ + && ok "arm9: register-macro-excluded=\"8\" (the eight #[test] functions)" \ + || { no "arm9: register-macro-excluded count wrong"; printf '%s\n' "$QD9" | grep -oE 'register-macro-excluded="[0-9]*"'; } + +# ── ARM 10: a test attribute with no item after it must not break the scan ───────────────────────────── +A10="$WORK/a10"; newrepo "$A10" +printf 'fn live() -> u32 { 1 }\n\n#[test]\n' > "$A10/lib.rs" +commit "$A10" "a dangling test attribute at end of file" +"$BIN" "$A10" --quality-delta --no-cache >/dev/null 2>&1; RC10=$? +# 0 = clean, 2 = a gating finding; anything else (a signal, an abort) is the crash this arm guards against. +if [ "$RC10" -eq 0 ] || [ "$RC10" -eq 2 ]; then ok "arm10: a dangling #[test] at end of file does not crash the scan (exit $RC10)" +else no "arm10: a dangling #[test] at end of file made ripwire exit $RC10"; fi + +# ── ARM 11: a trailing // comment after a test attribute (issue #499 review) ─────────────────────────────── +# Rust reads `#[test] // case` as `#[test]` followed by a comment, so the harness still registers the fn and the +# exemption must hold. A `//` inside an attribute's string argument is text, not a comment: the +# `#[tokio::test(flavor = "http://x")]` shape stays exempt with no trailing comment at all. A trailing comment on a +# NON-test attribute changes nothing: `#[testing] // note` stays flagged, and so does an ordinary dead fn. +A11="$WORK/a11"; newrepo "$A11" +printf 'fn live_helper() -> u32 { 1 }\n' > "$A11/lib.rs" +commit "$A11" "baseline for the trailing-comment arms" +cat >> "$A11/lib.rs" <<'EOF' + +#[test] // regression case +fn trailing_comment_case() { assert_eq!(live_helper(), 1); } + +#[tokio::test(flavor = "current_thread")] // harness case +fn flavored_trailing_case() { assert_eq!(live_helper(), 1); } + +#[tokio::test(flavor = "http://x")] +fn slashes_in_string_case() { assert_eq!(live_helper(), 1); } + +#[testing] // not a test attribute +fn testing_attr_with_comment() { } + +fn dead_plain() { } +EOF +QD11="$( cd "$A11" && "$BIN" . --quality-delta --no-cache 2>/dev/null )" +for sym in trailing_comment_case flavored_trailing_case slashes_in_string_case; do + printf '%s' "$QD11" | grep -q "kind=\"dead-code\" sym=\"$sym\"" \ + && no "arm11: '$sym' is a #[test] function but was reported as dead-code" \ + || ok "arm11: '$sym' (#[test] shape with trailing comment or // in its string) excluded from dead-code" +done +for sym in testing_attr_with_comment dead_plain; do + printf '%s' "$QD11" | grep -q "kind=\"dead-code\" sym=\"$sym\"" \ + && ok "arm11: '$sym' (negative lookalike) still flagged" \ + || no "arm11: '$sym' should still be flagged — the trailing comment leaked into the exemption" +done + [ "$fail" -eq 0 ] && echo "ALL PASS" || echo "SOME FAILED" exit "$fail"