diff --git a/CHANGELOG.md b/CHANGELOG.md index 4faf9eb..1a37f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to srcwalk are documented here. +## [1.8.0] - 2026-08-16 + +### Added +- Structural owner attribution for C, C++, and Ruby in `discover --match any --as text`, extending owner coverage from 8 to 11 languages. C++ owners carry namespace/type qualification (`ns::Class::method`) including out-of-line definitions and operators; templates are transparent and lambdas are barriers. Ruby owners distinguish instance (`A::B#m`) from singleton (`A::B.m`) definitions, support one `class << self` level, and keep reopened classes as independent sites. +- Fail-closed abstention across the new languages: macro-generated (e.g. `TEST(...)`) and K&R C definitions, Ruby dynamic metaprogramming (`define_method`, eval/exec families, `alias`, `attr_*`), unprovable qualified compositions, and error-degraded regions never fabricate owners. +- Typed owner-abstention transparency: a file that was analyzed but declined attribution now reports parser-known reasons once per file (`parse-failed`, `error-line`, `barrier`, `top-level`, `tie`); silence now reliably means the language has no owner analyzer. Existing named-owner output is byte-for-byte unchanged. +- `srcwalk update`/`update --check`: installation-aware self-update that detects the npm/pnpm/yarn/bun channel and re-runs its global install command, or, for a standalone/cargo install, downloads the matching release archive, verifies its SHA-256, and swaps it in with a verified, transactional replace. Network access is explicit-only through `update`, `update --check`, or the compatibility command `version --check`; plain version and navigation commands remain offline. + +### Fixed +- Valid C++ `operator()` definitions (inline, const, out-of-line, namespace-qualified) attribute owners instead of degrading to anonymous barriers. +- GUIDE and README no longer describe the multi-language owner rollup as Go-only; the mechanical call appendix remains Go-only. + +### Evidence boundary +- Owner and abstention labels are structural lexical evidence, not runtime binding, dispatch, or importance ranking; `top-level` is a lexical position and top-level configuration may still be the most relevant evidence. +- No call analysis runs for any non-Go language. + +### Known limitations +- The C++ comma operator spelling (`operator,`) still abstains. +- Dotted bare terms inside batch discover queries still resolve as free-text for the dotted term (unchanged from 1.7.0). + ## [1.7.0] - 2026-08-13 ### Added diff --git a/Cargo.lock b/Cargo.lock index 74420ef..8eb8113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -155,6 +164,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -180,6 +198,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -194,6 +222,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.16.0" @@ -252,6 +290,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -664,6 +712,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -678,7 +737,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "srcwalk" -version = "1.7.0" +version = "1.8.0" dependencies = [ "aho-corasick", "clap", @@ -698,6 +757,7 @@ dependencies = [ "regex-syntax", "serde", "serde_json", + "sha2", "streaming-iterator", "tempfile", "toml", @@ -1010,6 +1070,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1028,6 +1094,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d2f7f25..6cb3024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "srcwalk" -version = "1.7.0" +version = "1.8.0" edition = "2021" description = "Tree-sitter indexed lookups — smart code reading for AI agents" license = "MIT" @@ -74,6 +74,9 @@ tree-sitter-scss = "1.0.0" tree-sitter-less = "1.0.0" tree-sitter-html = "0.23.2" +# Standalone update integrity verification (US-074) +sha2 = "0.10" + [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index e828aa7..6ca6afc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,15 @@ curl -L https://github.com/sting8k/srcwalk/releases/latest/download/srcwalk-aarc After installing the CLI, `srcwalk guide` prints the full embedded, version-matched agent guide. The installable skill entry is [`skills/srcwalk/SKILL.md`](./skills/srcwalk/SKILL.md); it bootstraps agents to that embedded guide in the installed binary. +## Upgrade + +```sh +srcwalk update # apply the latest release +srcwalk update --check # resolve and compare only, no download or install +``` + +npm/pnpm/yarn/bun installs re-run that package manager's global install command. A `cargo install`/pre-built-binary install instead downloads the matching release archive, verifies its SHA-256, and swaps it in with a verified, transactional replace. Network access only happens when you explicitly run `update`, `update --check`, or the compatibility command `version --check` — never on a plain `version` or any navigation command. + ## Release notes See [`CHANGELOG.md`](./CHANGELOG.md) for curated release notes. Maintainers should update the matching changelog section before pushing a `vX.Y.Z` tag; the release workflow uses that section as the GitHub Release body. @@ -409,9 +418,9 @@ source: text · kind: name occurrence · confidence: text evidence
-Discover — Go owner rollup and mechanical call evidence +Discover — owner rollup and Go-only mechanical call evidence -For compact Go `discover --as text --match any` output, matching file rows may include a narrowest-owner rollup and a bounded mechanical call appendix. Owner ranges are candidate exact reads (dereference them with `srcwalk show`), not runtime binding proof. +For compact `discover --as text --match any` output, matching file rows may include a narrowest-owner rollup (multi-language) and a bounded mechanical call appendix (Go-only). Owner ranges are candidate exact reads (dereference them with `srcwalk show`), not runtime binding proof. ``` $ srcwalk discover "apply body,set body,connect body" --match any --as text --scope . diff --git a/npm/install.test.js b/npm/install.test.js index 739ab77..94dd7b6 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -74,6 +74,25 @@ async function testTarRoundTrip() { } } +// Shared pinned fixture (kept outside npm/, so it is never part of the +// packed payload): cross-checks PLATFORM_MAP against the exact same table +// `release_target_for` asserts against in src/main_version_tests.rs, so the +// two cannot silently drift. +const fixturePath = path.join(__dirname, "..", "tests", "fixtures", "release-targets.json"); +const fixtureRows = JSON.parse(fs.readFileSync(fixturePath, "utf8")); +assert.strictEqual( + fixtureRows.length, + Object.keys(PLATFORM_MAP).length, + "fixture row count must match PLATFORM_MAP", +); +for (const row of fixtureRows) { + const key = `${row.nodePlatform}-${row.nodeArch}`; + const platform = PLATFORM_MAP[key]; + assert.ok(platform, `PLATFORM_MAP missing ${key}`); + assert.strictEqual(platform.target, row.target, `${key} target mismatch`); + assert.strictEqual(platform.binName, row.binary, `${key} binary mismatch`); +} + testTarRoundTrip() .then(() => console.log("PASS: npm installer checksum, redirect, archive, tar, and platform guards")) .catch((error) => { diff --git a/npm/package.json b/npm/package.json index 6a9d85c..949c79c 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "srcwalk", - "version": "1.7.0", + "version": "1.8.0", "description": "Code-intelligence CLI for AI agents — tree-sitter outlines, symbol search, caller/callee graphs, deps, overview", "bin": { "srcwalk": "run.js" diff --git a/skills/srcwalk/GUIDE.md b/skills/srcwalk/GUIDE.md index 877dc12..47c8b78 100644 --- a/skills/srcwalk/GUIDE.md +++ b/skills/srcwalk/GUIDE.md @@ -2,7 +2,7 @@ Default to srcwalk first for code-structure work. Use this contract to find exact evidence, choose the next read, and keep claims bounded before shell search. -Keep `--scope` narrow. Use `rg`, `read`, `fd`, `find`, or shell filesystem tools only for final text/regex confirmation, filesystem metadata, generated-output cleanup, or when srcwalk lacks structural support. If you bypass srcwalk for a code claim, say why. +Keep `--scope` narrow. Use `rg`, `read`, `fd`, `find`, or shell filesystem tools only for final text/regex confirmation, filesystem metadata, generated-output cleanup, or when srcwalk lacks structural support. If you bypass srcwalk for a code claim, say why. srcwalk's evidence/navigation commands are all offline; network access is explicit through `srcwalk update`, `update --check`, or compatibility `version --check`. ## Choose one route first @@ -70,8 +70,8 @@ If `discover` prints `## Confirmed structural targets`, run the printed `> Next: Symbol discovery separates parser-backed definitions from text-matched name occurrences. Repeated same-name definitions receive an ambiguity caveat. Text discovery remains literal evidence; `--match all` is same-file co-occurrence, not semantic relation proof. -For Go `discover --as text --match any`, matched lines may carry a structural owner rollup and a bounded mechanical call appendix: -- `owners (#N=...; *K=hits)` lists each matched line's narrowest structural owner by query-term position; the range is a candidate exact read (`srcwalk show`), not relation or runtime binding proof. +For `discover --as text --match any`, matched lines may carry a multi-language structural owner rollup and a Go-only bounded mechanical call appendix: +- `owners (#N=...; *K=hits)` lists each matched line's narrowest structural owner by query-term position; the range is a candidate exact read (`srcwalk show`), not relation or runtime binding proof. An analyzed file that declined reports `owners: none — abstained (...)` or `owner abstentions: ...` (`parse-failed`, `error-line`, `barrier`, `top-level`, `tie`); silence means the language has no owner analyzer, and absence is not an abstention claim. `top-level` is a lexical position, not an importance judgment. - Edge labels: `[recv]`=same package-qualified receiver type, `[local]`=single-assignment constructor local, `[bare]`=same-package invocation. - `calls NAME` names the call expression, not the candidate binding; the candidate is a mechanically filtered definition candidate, not a proven binding. - Edges are mechanically filtered direct-call navigation (absence does not rule out dynamic dispatch, DI, callbacks, or protocol wiring); `@:` = candidate in the same call file, cross-file keeps the repo-relative path. Capped, deterministically ordered; zero-edge owners abstain with an explicit caveat. diff --git a/src/cli.rs b/src/cli.rs index a17110d..9e571ea 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,7 +52,7 @@ pub(crate) struct Cli { } pub(crate) const ROOT_HELP: &str = "\ -Start here:\n srcwalk guide Full embedded, version-matched agent guide for agents\n\nCommon:\n srcwalk overview Show repo orientation and dependency groups\n srcwalk context Understand one known target\n srcwalk trace callers Show who calls a symbol\n srcwalk trace callees Show what a symbol calls\n srcwalk deps Show imports and dependents\n srcwalk assess Heuristic blast-radius triage\n srcwalk review Review a change set with Flow Map evidence\n srcwalk compare Compare two known source targets structurally\n srcwalk discover Find candidate symbols/name occurrences/text\n srcwalk discover --as file Find files by glob\n srcwalk show : -C 10 Read exact evidence with extra line context\n srcwalk Read a file smartly\n srcwalk : Read around a line\n srcwalk version Show version; add --check for latest"; +Start here:\n srcwalk guide Full embedded, version-matched agent guide for agents\n\nCommon:\n srcwalk overview Show repo orientation and dependency groups\n srcwalk context Understand one known target\n srcwalk trace callers Show who calls a symbol\n srcwalk trace callees Show what a symbol calls\n srcwalk deps Show imports and dependents\n srcwalk assess Heuristic blast-radius triage\n srcwalk review Review a change set with Flow Map evidence\n srcwalk compare Compare two known source targets structurally\n srcwalk discover Find candidate symbols/name occurrences/text\n srcwalk discover --as file Find files by glob\n srcwalk show : -C 10 Read exact evidence with extra line context\n srcwalk Read a file smartly\n srcwalk : Read around a line\n srcwalk version Show version; add --check for latest\n srcwalk update Update to the latest release; add --check to preview"; pub(crate) const GUIDE: &str = include_str!("../skills/srcwalk/GUIDE.md"); @@ -80,6 +80,8 @@ pub(crate) enum Command { Show(ShowCmd), /// Show version, optionally checking the latest release. Version(VersionCmd), + /// Update srcwalk to the latest release, or check with --check. + Update(UpdateCmd), /// Structural decision-flow compatibility primitive for review internals. #[command(hide = true)] DecisionFlow(DecisionFlowCmd), @@ -90,7 +92,14 @@ pub(crate) enum Command { #[derive(Args)] pub(crate) struct VersionCmd { - /// Check GitHub for the latest release and print update commands if newer. + /// Check GitHub for the latest release and print "Run: srcwalk update" if newer. + #[arg(long)] + pub(crate) check: bool, +} + +#[derive(Args)] +pub(crate) struct UpdateCmd { + /// Resolve and compare only; never downloads, execs a package manager, or writes files. #[arg(long)] pub(crate) check: bool, } @@ -492,7 +501,7 @@ impl RunConfig { pub(crate) fn from_command(command: Command) -> Option { match command { - Command::Guide | Command::Version(_) => None, + Command::Guide | Command::Version(_) | Command::Update(_) => None, Command::Discover(cmd) => Some(Self::from_discover(cmd)), Command::Show(cmd) => Some(Self::from_show(cmd)), Command::Trace(cmd) => Some(Self::from_trace(cmd)), diff --git a/src/commands/decision_flow.rs b/src/commands/decision_flow.rs index 95741d5..35f7497 100644 --- a/src/commands/decision_flow.rs +++ b/src/commands/decision_flow.rs @@ -150,11 +150,9 @@ fn resolve_existing_file(raw: &str, scope: &Path) -> Option { } } - candidates.into_iter().find(|candidate| { - std::fs::metadata(candidate) - .ok() - .is_some_and(|meta| meta.is_file()) - }) + candidates + .into_iter() + .find(|candidate| std::fs::metadata(candidate).is_ok_and(|meta| meta.is_file())) } fn looks_like_path(value: &str) -> bool { diff --git a/src/commands/find.rs b/src/commands/find.rs index 498de89..211141b 100644 --- a/src/commands/find.rs +++ b/src/commands/find.rs @@ -15,6 +15,7 @@ use crate::evidence::owner_links::{ build_owner_link_evidence, OwnerAnchor, OwnerCallMechanism, OwnerLinkEvidence, OwnerLinkHitInput, OWNER_LINK_CAVEAT, OWNER_LINK_EDGE_CAP, OWNER_LINK_ZERO_EDGE, }; +use crate::evidence::owners::OwnerAbstentionReason; use crate::evidence::{render_next_actions, NextAction}; use crate::types::{Match, QueryType, RegexCoOccurrenceQuery, RegexTextKind, RegexTextQuery}; use crate::OutlineCache; @@ -27,6 +28,8 @@ const TEXT_OR_COMPACT_MIN_TERMS: usize = 3; const TEXT_OR_COMPACT_MIN_MATCHES: usize = 30; const TEXT_OR_ROLLUP_FILE_LIMIT: usize = 8; const TEXT_OR_ROLLUP_LINE_LIMIT: usize = 6; +/// Max files listed in the detailed `## Owner abstentions` section. +const OWNER_ABSTENTION_FILE_LIMIT: usize = 8; const TEXT_OR_WINDOW_CONTEXT_LINES: u32 = 10; const TEXT_OR_WINDOW_LIMIT: usize = 3; const TEXT_OR_WINDOW_MAX_SPAN: u32 = 80; @@ -345,6 +348,11 @@ fn render_text_or_term_details( ); } } + rendered.push_str(&render_owner_abstention_section( + term_results, + scope, + owner_links, + )); rendered } @@ -687,6 +695,7 @@ fn render_text_or_owner_rollup( } } if owners.is_empty() { + render_compact_owner_abstentions(rendered, path, owner_links, term_results, false); return; } let summaries = owners @@ -718,6 +727,115 @@ fn render_text_or_owner_rollup( rendered, "\n owners (#N=Nth query term; *K=hits): {summaries}" ); + render_compact_owner_abstentions(rendered, path, owner_links, term_results, true); +} + +/// Append this file's aggregated abstention line, if any. Placed directly after +/// the existing owners line (when present) and before windows, so no line that +/// currently carries a named owner changes by a single byte. `has_named` picks +/// the approved shape: a fully abstained file states `owners: none`, a mixed +/// file adds only `owner abstentions`. +fn render_compact_owner_abstentions( + rendered: &mut String, + path: &Path, + owner_links: &OwnerLinkEvidence, + term_results: &[TextOrTermResult], + has_named: bool, +) { + use std::fmt::Write as _; + + let counts = owner_abstention_counts(path, owner_links, term_results); + if counts.is_empty() { + return; + } + let summary = format_owner_abstention_counts(&counts); + if has_named { + let _ = write!(rendered, "\n owner abstentions: {summary}"); + } else { + let _ = write!(rendered, "\n owners: none — abstained ({summary})"); + } +} + +/// Count one file's abstained SHOWN hits by reason, using the same occurrence +/// semantics as the existing owners line: a line matched by two query terms +/// contributes two shown-hit records. The `BTreeMap` key order is the reason +/// enum's declaration order, which IS the canonical render order. +fn owner_abstention_counts( + path: &Path, + owner_links: &OwnerLinkEvidence, + term_results: &[TextOrTermResult], +) -> BTreeMap { + let mut counts: BTreeMap = BTreeMap::new(); + for result in term_results { + for m in &result.matches { + if m.path != *path { + continue; + } + if let Some(reason) = owner_links.abstention_for(&m.path, m.line) { + *counts.entry(reason).or_insert(0) += 1; + } + } + } + counts +} + +/// `parse-failed ×1, barrier ×2` in canonical reason order. +fn format_owner_abstention_counts(counts: &BTreeMap) -> String { + counts + .iter() + .map(|(reason, count)| format!("{} ×{count}", reason.label())) + .collect::>() + .join(", ") +} + +/// Detailed mode: one bounded `## Owner abstentions` section with a single line +/// per affected shown file in deterministic path order. Omitted entirely when +/// no shown hit abstained, so named-only and unsupported output gains zero +/// new lines. +fn render_owner_abstention_section( + term_results: &[TextOrTermResult], + scope: &Path, + owner_links: &OwnerLinkEvidence, +) -> String { + use std::fmt::Write as _; + + let mut paths: BTreeSet<&PathBuf> = BTreeSet::new(); + for result in term_results { + for m in &result.matches { + if owner_links.abstention_for(&m.path, m.line).is_some() { + paths.insert(&m.path); + } + } + } + let rows = paths + .into_iter() + .filter_map(|path| { + let counts = owner_abstention_counts(path, owner_links, term_results); + if counts.is_empty() { + return None; + } + Some((path, format_owner_abstention_counts(&counts))) + }) + .collect::>(); + if rows.is_empty() { + return String::new(); + } + let mut rendered = String::from("\n\n## Owner abstentions"); + for (path, summary) in rows.iter().take(OWNER_ABSTENTION_FILE_LIMIT) { + let _ = write!( + rendered, + "\n{} — {summary}", + format::rel_nonempty(path, scope) + ); + } + if rows.len() > OWNER_ABSTENTION_FILE_LIMIT { + let _ = write!( + rendered, + "\n> Note: {} more files with owner abstentions omitted; narrow terms.", + rows.len() - OWNER_ABSTENTION_FILE_LIMIT + ); + } + rendered } fn render_owner_link_appendix(owner_links: &OwnerLinkEvidence, scope: &Path) -> String { @@ -2096,6 +2214,7 @@ mod tests { owner: anchor, }]; let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits, edges: edges.to_vec(), go_call_analysis_attempted: true, @@ -2290,6 +2409,7 @@ mod tests { }, ]; let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits, edges: Vec::new(), go_call_analysis_attempted: true, @@ -2368,6 +2488,7 @@ mod tests { }) .collect(); let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits, edges: Vec::new(), go_call_analysis_attempted: true, @@ -2407,6 +2528,7 @@ mod tests { let py1 = py_anchor("app.py", "apply", 1, 2); let py2 = py_anchor("app.py", "set", 3, 4); let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits: vec![go_hit(py1, 1), go_hit(py2, 3)], edges: Vec::new(), go_call_analysis_attempted: false, @@ -2433,6 +2555,7 @@ mod tests { let py1 = py_anchor("app.py", "apply", 1, 2); let py2 = py_anchor("app.py", "set", 3, 4); let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits: vec![go_hit(go, 12), go_hit(py1, 1), go_hit(py2, 3)], edges: Vec::new(), go_call_analysis_attempted: true, @@ -2456,6 +2579,7 @@ mod tests { let go1 = anchor("a.go", "Run", "DB", 10, 40); let go2 = anchor("a.go", "Apply", "DB", 50, 80); let ev = OwnerLinkEvidence { + abstained: Vec::new(), hits: vec![go_hit(go1, 12), go_hit(go2, 52)], edges: Vec::new(), go_call_analysis_attempted: true, @@ -2831,4 +2955,256 @@ mod tests { std::env::var("SRCWALK_US067_AUDIT_OUT").expect("SRCWALK_US067_AUDIT_OUT required"); run_us067_replay(Path::new(&scope), &query, limit, Path::new(&out)); } + + // ---- US-073: owner abstention rendering ---- + + use crate::evidence::owner_links::AbstainedTextHit; + + fn abstain_match(path: &Path, line: u32) -> Match { + Match { + path: path.to_path_buf(), + line, + text: String::new(), + is_definition: false, + exact: false, + file_lines: 100, + mtime: std::time::SystemTime::UNIX_EPOCH, + def_range: None, + def_name: None, + def_weight: 0, + impl_target: None, + base_target: None, + in_comment: false, + } + } + + fn abstain_term(index: usize, term: &str, path: &Path, lines: &[u32]) -> TextOrTermResult { + TextOrTermResult { + query_term_index: index, + term: term.into(), + total_found: lines.len(), + file_count: 1, + matches: lines + .iter() + .map(|line| abstain_match(path, *line)) + .collect(), + omitted: 0, + } + } + + fn abstained(path: &Path, line: u32, reason: OwnerAbstentionReason) -> AbstainedTextHit { + AbstainedTextHit { + path: path.to_path_buf(), + line, + reason, + } + } + + #[test] + fn compact_all_abstained_file_renders_owners_none_line() { + let path = PathBuf::from("pkg/a.py"); + let terms = vec![abstain_term(1, "alpha", &path, &[3, 7, 9])]; + let ev = OwnerLinkEvidence { + hits: Vec::new(), + abstained: vec![ + abstained(&path, 3, OwnerAbstentionReason::TopLevel), + abstained(&path, 7, OwnerAbstentionReason::TopLevel), + abstained(&path, 9, OwnerAbstentionReason::Barrier), + ], + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + assert_eq!( + rendered, + "\n owners: none — abstained (barrier ×1, top-level ×2)" + ); + } + + #[test] + fn compact_mixed_file_keeps_owners_line_and_appends_abstentions() { + let path = PathBuf::from("pkg/a.py"); + let owner = py_anchor("pkg/a.py", "f", 10, 20); + let terms = vec![abstain_term(1, "alpha", &path, &[12, 3, 30])]; + let ev = OwnerLinkEvidence { + hits: vec![OwnedTextHit { + path: path.clone(), + line: 12, + owner, + }], + abstained: vec![ + abstained(&path, 3, OwnerAbstentionReason::TopLevel), + abstained(&path, 30, OwnerAbstentionReason::Tie), + ], + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + // Existing owners line byte-identical, then the abstention line. + assert_eq!( + rendered, + "\n owners (#N=Nth query term; *K=hits): f:10-20[#1]\n owner abstentions: top-level ×1, tie ×1" + ); + } + + #[test] + fn compact_reason_order_is_canonical_not_alphabetical() { + let path = PathBuf::from("pkg/a.py"); + let terms = vec![abstain_term(1, "alpha", &path, &[1, 2, 3, 4, 5])]; + let ev = OwnerLinkEvidence { + hits: Vec::new(), + abstained: vec![ + abstained(&path, 1, OwnerAbstentionReason::Tie), + abstained(&path, 2, OwnerAbstentionReason::TopLevel), + abstained(&path, 3, OwnerAbstentionReason::Barrier), + abstained(&path, 4, OwnerAbstentionReason::ErrorLine), + abstained(&path, 5, OwnerAbstentionReason::ParseFailed), + ], + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + assert_eq!( + rendered, + "\n owners: none — abstained (parse-failed ×1, error-line ×1, barrier ×1, top-level ×1, tie ×1)" + ); + } + + #[test] + fn named_only_file_gains_zero_new_lines() { + let path = PathBuf::from("pkg/a.py"); + let owner = py_anchor("pkg/a.py", "f", 10, 20); + let terms = vec![abstain_term(1, "alpha", &path, &[12])]; + let ev = OwnerLinkEvidence { + hits: vec![OwnedTextHit { + path: path.clone(), + line: 12, + owner, + }], + abstained: Vec::new(), + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + assert_eq!( + rendered, + "\n owners (#N=Nth query term; *K=hits): f:10-20[#1]" + ); + } + + #[test] + fn unsupported_file_gains_zero_new_lines() { + let path = PathBuf::from("pkg/notes.txt"); + let terms = vec![abstain_term(1, "alpha", &path, &[1])]; + let ev = OwnerLinkEvidence::default(); + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + assert_eq!(rendered, ""); + } + + #[test] + fn compact_abstention_counts_use_shown_hit_occurrence_semantics() { + // The same abstained line matched by two query terms counts twice, the + // same occurrence semantics the existing owners line already uses. + let path = PathBuf::from("pkg/a.py"); + let terms = vec![ + abstain_term(1, "alpha", &path, &[3]), + abstain_term(2, "beta", &path, &[3]), + ]; + let ev = OwnerLinkEvidence { + hits: Vec::new(), + abstained: vec![abstained(&path, 3, OwnerAbstentionReason::TopLevel)], + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let mut rendered = String::new(); + render_text_or_owner_rollup(&mut rendered, &path, &ev, &terms); + assert_eq!(rendered, "\n owners: none — abstained (top-level ×2)"); + } + + #[test] + fn detailed_owner_abstentions_section_is_path_sorted_and_one_line_per_file() { + let b = PathBuf::from("pkg/b.py"); + let a = PathBuf::from("pkg/a.py"); + let terms = vec![ + abstain_term(1, "alpha", &b, &[4, 4]), + abstain_term(2, "beta", &a, &[1]), + ]; + let ev = OwnerLinkEvidence { + hits: Vec::new(), + abstained: vec![ + abstained(&b, 4, OwnerAbstentionReason::Barrier), + abstained(&a, 1, OwnerAbstentionReason::TopLevel), + ], + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let rendered = render_owner_abstention_section(&terms, Path::new(""), &ev); + // One line per affected file, deterministic path order, no per-term or + // per-hit duplication. + assert_eq!( + rendered, + "\n\n## Owner abstentions\npkg/a.py — top-level ×1\npkg/b.py — barrier ×2" + ); + } + + #[test] + fn detailed_owner_abstentions_section_is_omitted_when_nothing_abstained() { + let path = PathBuf::from("pkg/a.py"); + let owner = py_anchor("pkg/a.py", "f", 10, 20); + let terms = vec![abstain_term(1, "alpha", &path, &[12])]; + let ev = OwnerLinkEvidence { + hits: vec![OwnedTextHit { + path: path.clone(), + line: 12, + owner, + }], + abstained: Vec::new(), + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + assert_eq!( + render_owner_abstention_section(&terms, Path::new(""), &ev), + "" + ); + assert_eq!( + render_owner_abstention_section(&[], Path::new(""), &OwnerLinkEvidence::default()), + "" + ); + } + + #[test] + fn detailed_owner_abstentions_section_is_bounded_by_a_file_cap() { + let mut terms = Vec::new(); + let mut abstained_hits = Vec::new(); + let paths: Vec = (0..OWNER_ABSTENTION_FILE_LIMIT + 3) + .map(|i| PathBuf::from(format!("pkg/f{i:02}.py"))) + .collect(); + for (i, path) in paths.iter().enumerate() { + terms.push(abstain_term(i + 1, "alpha", path, &[1])); + abstained_hits.push(abstained(path, 1, OwnerAbstentionReason::TopLevel)); + } + let ev = OwnerLinkEvidence { + hits: Vec::new(), + abstained: abstained_hits, + edges: Vec::new(), + go_call_analysis_attempted: false, + }; + let rendered = render_owner_abstention_section(&terms, Path::new(""), &ev); + let file_lines = rendered.lines().filter(|line| line.contains(" — ")).count(); + assert_eq!(file_lines, OWNER_ABSTENTION_FILE_LIMIT); + assert!( + rendered.contains("3 more files with owner abstentions omitted"), + "{rendered}" + ); + // Determinism: identical inputs render identical bytes. + assert_eq!( + rendered, + render_owner_abstention_section(&terms, Path::new(""), &ev) + ); + } } diff --git a/src/evidence/owner_links.rs b/src/evidence/owner_links.rs index 516a2da..42229aa 100644 --- a/src/evidence/owner_links.rs +++ b/src/evidence/owner_links.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; +use crate::evidence::owners::{ErrorRange, OwnerAbstentionReason, OwnerAttribution, OwnerRegion}; use crate::lang::outline::{get_outline_entries, outline_language}; use crate::lang::qualified::normalize_receiver_type; use crate::search::callees::{extract_call_sites, CallSite}; @@ -57,6 +58,16 @@ pub(crate) struct OwnedTextHit { pub(crate) owner: OwnerAnchor, } +/// One shown hit whose language IS owner-supported but whose structural owner +/// attribution stopped for a parser-known reason. Unsupported languages create +/// neither this nor an `OwnedTextHit`. +#[derive(Debug, Clone)] +pub(crate) struct AbstainedTextHit { + pub(crate) path: PathBuf, + pub(crate) line: u32, + pub(crate) reason: OwnerAbstentionReason, +} + #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] pub(crate) enum OwnerCallMechanism { SingleAssignmentLocalConstructor, @@ -77,6 +88,10 @@ pub(crate) struct OwnerCallEvidence { #[derive(Debug, Default)] pub(crate) struct OwnerLinkEvidence { pub(crate) hits: Vec, + /// Analyzed-but-abstained shown hits, kept strictly separate from `hits`. + /// Named-owner queries, edge construction, and owner thresholds never read + /// this collection. + pub(crate) abstained: Vec, pub(crate) edges: Vec, /// True when Go call/edge analysis was actually attempted for this query /// (at least one Go file was parsed for edge evidence). This is the only @@ -97,6 +112,15 @@ impl OwnerLinkEvidence { !self.hits.is_empty() } + /// The abstention reason recorded for one shown hit, if any. A hit has at + /// most one attribution result, so this never coexists with `owner_for`. + pub(crate) fn abstention_for(&self, path: &Path, line: u32) -> Option { + self.abstained + .iter() + .find(|hit| hit.path == path && hit.line == line) + .map(|hit| hit.reason) + } + /// Whether any attributed owner is a non-Go language. Derived from the /// anchors' required `language` field so it cannot drift from the evidence. pub(crate) fn has_non_go_owners(&self) -> bool { @@ -165,6 +189,58 @@ enum Initializer { Constructor(String), } +/// An owner-only adapter's parse result: its owner regions plus collected +/// parser error ranges, or `None` when the analyzer produced no usable +/// analysis. +type OwnerParse = Option<(Vec, Vec)>; + +/// One owner-only language's dispatch pair: its parse result and the adapter +/// lookup wrapper that classifies each line through the shared decision. +type OwnerOnlyDispatch = (OwnerParse, OwnerLookup); + +/// An owner-only adapter's line-lookup wrapper. Every one of them delegates to +/// the shared typed decision, so dispatch routes through the adapter's own +/// entry point without letting any adapter classify reasons itself. +type OwnerLookup = for<'a> fn(&'a [OwnerRegion], &[ErrorRange], u32) -> OwnerAttribution<'a>; + +/// Record one typed attribution per shown hit for an owner-only (non-Go) +/// language. `parsed` is the adapter's parse result: `None` means the +/// owner-supported analyzer produced no usable analysis, which is +/// `parse-failed` for every shown hit in that file (US-073). +fn record_owner_only_attributions( + path: &Path, + path_inputs: &[&OwnerLinkHitInput<'_>], + parsed: Option<&(Vec, Vec)>, + lookup: OwnerLookup, + hits: &mut Vec, + abstained: &mut Vec, +) { + let Some((regions, errors)) = parsed else { + for input in path_inputs { + abstained.push(AbstainedTextHit { + path: path.to_path_buf(), + line: input.line, + reason: OwnerAbstentionReason::ParseFailed, + }); + } + return; + }; + for input in path_inputs { + match lookup(regions, errors, input.line) { + OwnerAttribution::Named(owner) => hits.push(OwnedTextHit { + path: path.to_path_buf(), + line: input.line, + owner: owner.clone(), + }), + OwnerAttribution::Abstained(reason) => abstained.push(AbstainedTextHit { + path: path.to_path_buf(), + line: input.line, + reason, + }), + } + } +} + pub(crate) fn build_owner_link_evidence(inputs: &[OwnerLinkHitInput<'_>]) -> OwnerLinkEvidence { // Group inputs by path, tracking which go through the Go (owners + edges) // pipeline versus a non-Go owner-only pipeline. Non-Go files are routed by @@ -183,6 +259,8 @@ pub(crate) fn build_owner_link_evidence(inputs: &[OwnerLinkHitInput<'_>]) -> Own let is_kotlin = detected == crate::types::FileType::Code(Lang::Kotlin); let is_csharp = detected == crate::types::FileType::Code(Lang::CSharp); let is_php = detected == crate::types::FileType::Code(Lang::Php); + let is_c_cpp = matches!(detected, crate::types::FileType::Code(Lang::C | Lang::Cpp)); + let is_ruby = detected == crate::types::FileType::Code(Lang::Ruby); if is_go || is_python || is_rust @@ -191,6 +269,8 @@ pub(crate) fn build_owner_link_evidence(inputs: &[OwnerLinkHitInput<'_>]) -> Own || is_kotlin || is_csharp || is_php + || is_c_cpp + || is_ruby { by_path .entry(input.path.to_path_buf()) @@ -201,165 +281,104 @@ pub(crate) fn build_owner_link_evidence(inputs: &[OwnerLinkHitInput<'_>]) -> Own let mut files = BTreeMap::new(); let mut hits = Vec::new(); + let mut abstained = Vec::new(); for (path, path_inputs) in &by_path { + // A read failure is not a parser fact: omit the file entirely rather + // than mislabelling it `parse-failed` (US-073). let Ok(content) = fs::read_to_string(path) else { continue; }; let detected = crate::lang::detect_file_type(path); - if detected == crate::types::FileType::Code(Lang::Python) { - // Python is owner-only: no call edges are inferred in phase 1. - let Some((regions, errors)) = - crate::evidence::owners::python::python_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::python::python_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if detected == crate::types::FileType::Code(Lang::Rust) { - // Rust is owner-only: no call edges are inferred in phase 2. - let Some((regions, errors)) = - crate::evidence::owners::rust::rust_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::rust::rust_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if let crate::types::FileType::Code( - owner_lang @ (Lang::JavaScript | Lang::TypeScript | Lang::Tsx), - ) = detected - { - // JS/TS/TSX is owner-only: no call edges are inferred in phases 3B/3C. - let Some((regions, errors)) = - crate::evidence::owners::js_ts::regions_for(owner_lang, path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::js_ts::owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if detected == crate::types::FileType::Code(Lang::Java) { - // Java is owner-only: no call edges are inferred in Wave 2A. - let Some((regions, errors)) = - crate::evidence::owners::java::java_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::java::java_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if detected == crate::types::FileType::Code(Lang::Kotlin) { - // Kotlin is owner-only: no call edges are inferred in Wave 2A. - let Some((regions, errors)) = - crate::evidence::owners::kotlin::kotlin_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::kotlin::kotlin_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if detected == crate::types::FileType::Code(Lang::CSharp) { - // C# is owner-only: no call edges are inferred in Wave 2A. - let Some((regions, errors)) = - crate::evidence::owners::csharp::csharp_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::csharp::csharp_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } - continue; - } - if detected == crate::types::FileType::Code(Lang::Php) { - // PHP is owner-only: no call edges are inferred in Wave 2A. - let Some((regions, errors)) = crate::evidence::owners::php::php_regions(path, &content) - else { - continue; - }; - for input in path_inputs { - if let Some(owner) = - crate::evidence::owners::php::php_owner_for(®ions, &errors, input.line) - { - hits.push(OwnedTextHit { - path: path.clone(), - line: input.line, - owner: owner.clone(), - }); - } - } + // Owner-only languages: no call edges are inferred for any of them. Each + // arm supplies its adapter's parse result; the shared recorder maps a + // `None` parse to `parse-failed` and every analyzed line to one typed + // decision, so no adapter classifies reasons itself. + let owner_only: Option = match detected { + crate::types::FileType::Code(Lang::Python) => Some(( + crate::evidence::owners::python::python_regions(path, &content), + crate::evidence::owners::python::python_owner_for, + )), + crate::types::FileType::Code(Lang::Rust) => Some(( + crate::evidence::owners::rust::rust_regions(path, &content), + crate::evidence::owners::rust::rust_owner_for, + )), + crate::types::FileType::Code( + owner_lang @ (Lang::JavaScript | Lang::TypeScript | Lang::Tsx), + ) => Some(( + crate::evidence::owners::js_ts::regions_for(owner_lang, path, &content), + crate::evidence::owners::js_ts::owner_for, + )), + crate::types::FileType::Code(Lang::Java) => Some(( + crate::evidence::owners::java::java_regions(path, &content), + crate::evidence::owners::java::java_owner_for, + )), + crate::types::FileType::Code(Lang::Kotlin) => Some(( + crate::evidence::owners::kotlin::kotlin_regions(path, &content), + crate::evidence::owners::kotlin::kotlin_owner_for, + )), + crate::types::FileType::Code(Lang::CSharp) => Some(( + crate::evidence::owners::csharp::csharp_regions(path, &content), + crate::evidence::owners::csharp::csharp_owner_for, + )), + crate::types::FileType::Code(Lang::Php) => Some(( + crate::evidence::owners::php::php_regions(path, &content), + crate::evidence::owners::php::php_owner_for, + )), + crate::types::FileType::Code(lang @ (Lang::C | Lang::Cpp)) => Some(( + crate::evidence::owners::c_cpp::regions_for(lang, path, &content), + crate::evidence::owners::c_cpp::owner_for, + )), + crate::types::FileType::Code(Lang::Ruby) => Some(( + crate::evidence::owners::ruby::ruby_regions(path, &content), + crate::evidence::owners::ruby::ruby_owner_for, + )), + _ => None, + }; + if let Some((parsed, lookup)) = owner_only { + record_owner_only_attributions( + path, + path_inputs, + parsed.as_ref(), + lookup, + &mut hits, + &mut abstained, + ); continue; } let package_dir = canonical_package_dir(path); let Some((owners, functions)) = go_file_owners(path, &package_dir, &content) else { - // Malformed/unparsed Go: preserve raw hits, omit owner/edge evidence. + // Malformed/unparsed Go: preserve raw hits and omit owner/edge + // evidence, but record the parser-known abstention. No + // `FileAnalysis` is inserted, so call analysis stays success-only. + for input in path_inputs { + abstained.push(AbstainedTextHit { + path: path.clone(), + line: input.line, + reason: OwnerAbstentionReason::ParseFailed, + }); + } continue; }; + // Go reuses the shared decision primitive over its own owners. Go's + // parser contract rejects any tree with errors up front, so no error + // ranges exist here, and Go has no anonymous-region model: only + // `Named`, `TopLevel`, and `Tie` are reachable. + let go_regions = owners + .iter() + .map(|owner| OwnerRegion::Named(owner.anchor.clone())) + .collect::>(); for input in path_inputs { - if let Some(owner) = narrowest_owner(&owners, input.line) { - hits.push(OwnedTextHit { + match crate::evidence::owners::attribute_line(&go_regions, &[], input.line) { + OwnerAttribution::Named(owner) => hits.push(OwnedTextHit { path: path.clone(), line: input.line, - owner: owner.anchor.clone(), - }); + owner: owner.clone(), + }), + OwnerAttribution::Abstained(reason) => abstained.push(AbstainedTextHit { + path: path.clone(), + line: input.line, + reason, + }), } } files.insert( @@ -440,6 +459,7 @@ pub(crate) fn build_owner_link_evidence(inputs: &[OwnerLinkHitInput<'_>]) -> Own OwnerLinkEvidence { hits, + abstained, edges, go_call_analysis_attempted, } @@ -758,41 +778,6 @@ fn collect_file_owners( } } -/// Pick the unique owner whose `[start_line, end_line]` contains `line` and has -/// the smallest span. Returns `None` when nothing contains `line` or when two -/// or more distinct owners tie for the narrowest span: without a column there -/// is no deterministic way to choose, so we abstain rather than risk a false -/// owner. The minimum is computed first, then uniqueness is required, so a -/// later narrower owner is never lost to an earlier tie. -fn narrowest_owner(owners: &[FileOwner], line: u32) -> Option<&FileOwner> { - let containing = owners - .iter() - .filter(|owner| owner.anchor.start_line <= line && line <= owner.anchor.end_line) - .collect::>(); - let min_span = containing - .iter() - .map(|owner| { - owner - .anchor - .end_line - .saturating_sub(owner.anchor.start_line) - }) - .min()?; - let mut narrowest = containing.iter().filter(|owner| { - owner - .anchor - .end_line - .saturating_sub(owner.anchor.start_line) - == min_span - }); - let first = narrowest.next()?; - if narrowest.next().is_some() { - None - } else { - Some(&**first) - } -} - /// Pick the unique `OwnerAnchor` whose `[start_line, end_line]` contains /// `line` and has the smallest span, abstaining on any tie (column-less lines /// cannot be disambiguated). @@ -1914,7 +1899,16 @@ mod tests { }, }, ]; - assert!(narrowest_owner(&owners, 4).is_none()); + // Go now reaches the shared decision, so an equal-span tie must surface + // as `Tie` rather than a silent `None` (US-073 Go parity). + let go_regions = owners + .iter() + .map(|owner| OwnerRegion::Named(owner.anchor.clone())) + .collect::>(); + assert_eq!( + crate::evidence::owners::attribute_line(&go_regions, &[], 4).abstained(), + Some(OwnerAbstentionReason::Tie) + ); fs::remove_dir_all(dir).unwrap(); } @@ -2053,3 +2047,331 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } } + +// ---- US-073: typed abstention transport, dispatch, and Go parity ---- + +#[cfg(test)] +mod abstention_tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("srcwalk-abstain-{name}-{nonce}")); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn inputs<'a>(path: &'a Path, lines: &[u32]) -> Vec> { + lines + .iter() + .map(|line| OwnerLinkHitInput { path, line: *line }) + .collect() + } + + #[test] + fn supported_parse_failure_records_parse_failed_for_every_shown_hit() { + let dir = temp_dir("parse-failed"); + let path = dir.join("broken.go"); + // Go's owner contract rejects any tree containing errors, so a + // malformed supported file is `parse-failed` for every shown hit -- + // a parser fact, not missing product support. + fs::write(&path, "package p\nfunc F( {\n return\n}\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[1, 3])); + + assert!(evidence.hits.is_empty()); + assert_eq!( + evidence.abstention_for(&path, 1), + Some(OwnerAbstentionReason::ParseFailed) + ); + assert_eq!( + evidence.abstention_for(&path, 3), + Some(OwnerAbstentionReason::ParseFailed) + ); + fs::remove_dir_all(dir).unwrap(); + } + + /// The non-Go dispatch mapping: a `None` parse result becomes + /// `parse-failed` for every shown hit, and never an `error-line`/ + /// `top-level` guess. + /// + /// This is asserted directly at the dispatch seam because no file CONTENT + /// can force a non-Go adapter to return `None`: tree-sitter always yields a + /// root of the language's start rule with `ERROR` children, so those + /// adapters' `root.kind() == "ERROR"` guard is only reachable through + /// grammar-load/parser-setup/cancellation failure, not hostile text. + #[test] + fn non_go_none_parse_maps_to_parse_failed_at_dispatch() { + let path = PathBuf::from("x.py"); + let hit_inputs = inputs(&path, &[1, 7]); + let borrowed = hit_inputs.iter().collect::>(); + let mut hits = Vec::new(); + let mut abstained = Vec::new(); + record_owner_only_attributions( + &path, + &borrowed, + None, + crate::evidence::owners::python::python_owner_for, + &mut hits, + &mut abstained, + ); + + assert!(hits.is_empty()); + assert_eq!(abstained.len(), 2); + assert!(abstained + .iter() + .all(|hit| hit.reason == OwnerAbstentionReason::ParseFailed)); + assert_eq!(abstained[0].line, 1); + assert_eq!(abstained[1].line, 7); + } + + #[test] + fn unsupported_extension_records_neither_named_nor_abstained() { + let dir = temp_dir("unsupported"); + let path = dir.join("notes.txt"); + fs::write(&path, "alpha\nbeta\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[1, 2])); + + assert!(evidence.hits.is_empty()); + assert!(evidence.abstained.is_empty()); + assert_eq!(evidence.abstention_for(&path, 1), None); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn missing_file_is_omitted_and_never_labeled_parse_failed() { + let dir = temp_dir("read-fail"); + let path = dir.join("gone.py"); + // A read failure is not a parser fact (spec: file read races must not + // be mislabeled `parse-failed`). + let evidence = build_owner_link_evidence(&inputs(&path, &[1])); + + assert!(evidence.hits.is_empty()); + assert!(evidence.abstained.is_empty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn every_shown_hit_has_at_most_one_attribution_result() { + let dir = temp_dir("one-result"); + let path = dir.join("m.py"); + fs::write(&path, "X = 1\ndef f():\n return 2\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[1, 3])); + + // Line 1 is top-level, line 3 is owned; neither appears twice and no + // line appears in both collections. + assert_eq!( + evidence.abstention_for(&path, 1), + Some(OwnerAbstentionReason::TopLevel) + ); + assert!(evidence.owner_for(&path, 1).is_none()); + assert!(evidence.owner_for(&path, 3).is_some()); + assert_eq!(evidence.abstention_for(&path, 3), None); + assert_eq!(evidence.hits.len(), 1); + assert_eq!(evidence.abstained.len(), 1); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn abstentions_never_widen_go_call_analysis_or_edges() { + let dir = temp_dir("go-isolation"); + let broken = dir.join("broken.go"); + // Unparseable Go: records `parse-failed` but must NOT insert a + // FileAnalysis, so call analysis stays un-attempted. + fs::write(&broken, ")))\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&broken, &[1])); + + assert_eq!( + evidence.abstention_for(&broken, 1), + Some(OwnerAbstentionReason::ParseFailed) + ); + assert!(!evidence.go_call_analysis_attempted); + assert!(evidence.edges.is_empty()); + assert!(!evidence.has_owners()); + assert!(!evidence.has_go_owners()); + assert_eq!(evidence.attributed_go_owner_count(), 0); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn go_success_yields_named_and_top_level_without_error_or_barrier_reasons() { + let dir = temp_dir("go-parity"); + let path = dir.join("svc.go"); + fs::write(&path, "package p\n\nfunc F() {\n return\n}\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[1, 3, 4])); + + // Package clause is top-level; the function body is named. + assert_eq!( + evidence.abstention_for(&path, 1), + Some(OwnerAbstentionReason::TopLevel) + ); + assert_eq!( + evidence + .owner_for(&path, 4) + .map(OwnerAnchor::qualified_name), + Some("F".to_string()) + ); + // Go must not invent error-line or barrier reasons. + assert!(evidence + .abstained + .iter() + .all(|hit| hit.reason == OwnerAbstentionReason::TopLevel)); + assert!(evidence.go_call_analysis_attempted); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn abstained_go_hits_do_not_satisfy_the_two_owner_zero_edge_threshold() { + let dir = temp_dir("go-threshold"); + let path = dir.join("one.go"); + // One named Go owner plus two top-level abstentions: the distinct Go + // owner count must stay 1, below the zero-edge threshold of 2. + fs::write(&path, "package p\n\nvar X = 1\nfunc F() {\n return\n}\n").unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[1, 3, 5])); + + assert_eq!(evidence.attributed_go_owner_count(), 1); + assert_eq!(evidence.abstained.len(), 2); + assert!(evidence.edges.is_empty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn barrier_and_error_line_reasons_surface_for_a_non_go_language() { + let dir = temp_dir("reasons"); + let path = dir.join("m.py"); + // A lambda body is an anonymous barrier; a hit inside it abstains as + // `barrier` rather than falling through to the enclosing function. + fs::write( + &path, + "def outer():\n f = lambda x: (\n x + 1\n )\n return f\n", + ) + .unwrap(); + let evidence = build_owner_link_evidence(&inputs(&path, &[3])); + assert_eq!( + evidence.abstention_for(&path, 3), + Some(OwnerAbstentionReason::Barrier) + ); + fs::remove_dir_all(dir).unwrap(); + } + + /// Every currently routed owner `Lang` variant must reach the shared typed + /// decision. Each fixture has one owned line and one top-level line, so a + /// missing dispatch arm (silence) fails loudly. + #[test] + fn every_routed_language_variant_reaches_the_typed_decision() { + struct Case { + file: &'static str, + source: &'static str, + top_level_line: u32, + owned_line: u32, + } + let cases = [ + Case { + file: "a.go", + source: "package p\n\nfunc F() {\n return\n}\n", + top_level_line: 1, + owned_line: 4, + }, + Case { + file: "a.py", + source: "X = 1\ndef f():\n return 2\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "a.rs", + source: "const X: u8 = 1;\nfn f() {\n let _ = 2;\n}\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "a.js", + source: "const X = 1;\nfunction f() {\n return 2;\n}\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "a.ts", + source: "const X: number = 1;\nfunction f(): number {\n return 2;\n}\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "a.tsx", + source: "const X = 1;\nfunction f() {\n return
;\n}\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "A.java", + source: "class A {\n int x = 1;\n void f() {\n int y = 2;\n }\n}\n", + top_level_line: 2, + owned_line: 4, + }, + Case { + file: "a.kt", + source: "val x = 1\nfun f() {\n val y = 2\n}\n", + top_level_line: 1, + owned_line: 3, + }, + Case { + file: "A.cs", + source: "class A {\n int x = 1;\n void F() {\n int y = 2;\n }\n}\n", + top_level_line: 2, + owned_line: 4, + }, + Case { + file: "a.php", + source: "::set`). Templates are transparent for identity (template parameter +//! text is never part of the owner name); constructor/destructor/operator +//! terminals are preserved only when the grammar exposes one complete identity +//! (`Foo::Foo`, `Foo::~Foo`, `Foo::operator=`). +//! +//! Abstentions/barriers: every C++ `lambda_expression` is a full-range +//! `AnonymousBarrier`; anonymous namespaces/types and malformed containers +//! establish identity barriers so nested functions cannot leak an incorrectly +//! shortened name; `friend_declaration` is a barrier (a friend function is not +//! a lexical member of the enclosing class and the grammar provides no +//! namespace evidence for it); body-less callables (`= default`, `= delete`, +//! prototypes) are not owners; K&R C definitions parse without a complete +//! `function_definition` and abstain naturally; macro definitions and macro +//! invocations never fabricate owners; a C++ type-less `function_definition` +//! (e.g. googletest `TEST(Foo, Bar) { ... }`) is treated as a macro hazard and +//! abstains unless its terminal is a destructor or a constructor whose name +//! matches the innermost lexical type or the last declarator scope component; +//! local `ERROR`/missing-node degradation flows through the shared primitives. +//! +//! This module is syntax-only. It never claims which preprocessor branch is +//! active, what a macro expands into, binding/linkage, or any call edge. + +use std::path::Path; + +use tree_sitter::Node; + +use crate::evidence::owner_links::OwnerAnchor; +use crate::evidence::owners::{ + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, +}; +use crate::lang::outline::outline_language; +use crate::types::Lang; + +/// Disposition of every grammar callable/preprocessor hazard relevant to the C +/// adapter (anti-drift contract, US-072 §10.2): named, transparent, barrier, or +/// abstain. Every entry must exist in the pinned grammar's `NODE_TYPES`, and +/// every `preproc_*` / callable / macro kind in `NODE_TYPES` must appear here. +#[cfg(test)] +const C_CALLABLE_OR_HAZARD_DISPOSITIONS: &[(&str, &str)] = &[ + ("function_definition", "named"), + ("preproc_if", "transparent"), + ("preproc_ifdef", "transparent"), + ("preproc_elif", "transparent"), + ("preproc_elifdef", "transparent"), + ("preproc_else", "transparent"), + ("preproc_include", "transparent"), + ("preproc_defined", "transparent"), + ("preproc_arg", "transparent"), + ("preproc_params", "transparent"), + ("preproc_directive", "transparent"), + ("preproc_function_def", "abstain"), + ("preproc_def", "abstain"), + ("preproc_call", "abstain"), + ("macro_type_specifier", "abstain"), +]; + +/// C++ disposition table: the C table plus C++-only callable/container hazards. +#[cfg(test)] +const CPP_CALLABLE_OR_HAZARD_DISPOSITIONS: &[(&str, &str)] = &[ + ("function_definition", "named"), + ("lambda_expression", "barrier"), + ("template_declaration", "transparent"), + ("friend_declaration", "barrier"), + ("preproc_if", "transparent"), + ("preproc_ifdef", "transparent"), + ("preproc_elif", "transparent"), + ("preproc_elifdef", "transparent"), + ("preproc_else", "transparent"), + ("preproc_include", "transparent"), + ("preproc_defined", "transparent"), + ("preproc_arg", "transparent"), + ("preproc_params", "transparent"), + ("preproc_directive", "transparent"), + ("preproc_function_def", "abstain"), + ("preproc_def", "abstain"), + ("preproc_call", "abstain"), +]; + +/// Container node kinds that qualify C++ identities. C struct/union are +/// traversed without qualification (C functions cannot be lexical members). +#[cfg(test)] +const C_CONTAINER_KINDS: &[&str] = &["struct_specifier", "union_specifier"]; + +#[cfg(test)] +const CPP_CONTAINER_KINDS: &[&str] = &[ + "namespace_definition", + "nested_namespace_specifier", + "class_specifier", + "struct_specifier", + "union_specifier", +]; + +/// Declarator wrapper kinds unwrapped (via their `declarator` field or a single +/// declarator-shaped named child) before reaching the identity terminal. +/// `reference_declarator` exists only in the C++ grammar. +#[cfg(test)] +const C_WRAPPER_KINDS: &[&str] = &[ + "function_declarator", + "pointer_declarator", + "parenthesized_declarator", + "array_declarator", + "attributed_declarator", +]; + +#[cfg(test)] +const CPP_WRAPPER_KINDS: &[&str] = &[ + "function_declarator", + "pointer_declarator", + "reference_declarator", + "parenthesized_declarator", + "array_declarator", + "attributed_declarator", +]; + +/// FNV-1a-64 fingerprint of the bundled `tree-sitter-c` `NODE_TYPES` JSON +/// (tree-sitter-c 0.24.2). Pinned so any grammar metadata change fails the +/// manifest gate and forces a re-audit. Computed from the exact bundled crate. +#[cfg(test)] +const C_NODE_TYPES_FINGERPRINT: u64 = 0x954a_3d94_fd42_a08e; + +/// FNV-1a-64 fingerprint of the bundled `tree-sitter-cpp` `NODE_TYPES` JSON +/// (tree-sitter-cpp 0.23.4). Pinned so any grammar metadata change fails the +/// manifest gate and forces a re-audit. +#[cfg(test)] +const CPP_NODE_TYPES_FINGERPRINT: u64 = 0x709a_08d1_1ae7_0d51; + +/// Stable FNV-1a 64-bit hash, dependency-free and reproducible across builds. +#[cfg(test)] +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + hash +} + +/// Parse a C or C++ file and produce its owner regions and local error ranges. +/// Accepts only `Lang::C | Lang::Cpp`. Returns `None` when the language is +/// unsupported, parser setup/parsing fails, or the root itself is `ERROR` +/// (preserve raw hits, emit no owner evidence). +pub(crate) fn regions_for( + lang: Lang, + path: &Path, + content: &str, +) -> Option<(Vec, Vec)> { + if !matches!(lang, Lang::C | Lang::Cpp) { + return None; + } + let language = outline_language(lang)?; + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&language).is_err() { + return None; + } + let tree = parser.parse(content, None)?; + if tree.root_node().kind() == "ERROR" { + return None; + } + let bytes = content.as_bytes(); + let errors = collect_error_ranges(&tree, bytes); + let mut regions = Vec::new(); + let mut containers: Vec<(String, bool)> = Vec::new(); + walk( + tree.root_node(), + bytes, + path, + lang, + false, + &mut containers, + &errors, + &mut regions, + ); + Some((regions, errors)) +} + +/// Attribute a C/C++ hit line to a named owner, honoring local errors. +pub(crate) fn owner_for<'a>( + regions: &'a [OwnerRegion], + errors: &[ErrorRange], + line: u32, +) -> OwnerAttribution<'a> { + attribute_line(regions, errors, line) +} + +/// Emit a full-range `AnonymousBarrier` for `node` and descend `in_anonymous`. +fn barrier_and_descend( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + regions.push(OwnerRegion::AnonymousBarrier { + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + }); + walk_children(node, bytes, path, lang, true, containers, errors, regions); +} + +fn walk_children( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + in_anonymous: bool, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + walk( + child, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } +} + +/// Walk a C/C++ tree, emitting `Named` regions for body-bearing identifiable +/// functions and `AnonymousBarrier` regions for lambdas, friend declarations, +/// anonymous/malformed containers, function-likes inside anonymous contexts, +/// and every function whose identity abstains. `containers` holds the C++ +/// lexical path as `(name, is_type)` pairs so the type-less constructor gate +/// can distinguish a lexical class from a namespace. +fn walk( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + in_anonymous: bool, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let kind = node.kind(); + if lang == Lang::Cpp { + // Every lambda is a full-range identity barrier; hits inside abstain + // rather than falling through to the enclosing named function. + if kind == "lambda_expression" { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + // A friend function is not a lexical member of the enclosing class and + // the grammar exposes no namespace evidence for it: fail closed so its + // hits never attribute to a fabricated `Class::friend` identity. + if kind == "friend_declaration" { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + } + // Any function-like construct encountered inside an anonymous context must + // remain a barrier and never regain a fabricated named identity. + if in_anonymous && kind == "function_definition" { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + match kind { + "namespace_definition" if lang == Lang::Cpp => { + handle_namespace( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } + "class_specifier" | "struct_specifier" | "union_specifier" if lang == Lang::Cpp => { + handle_type( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } + // C struct/union: traverse but never qualify free function owners (C + // functions cannot be lexical members). Anonymous C structs are + // transparent for the same reason. + "struct_specifier" | "union_specifier" if lang == Lang::C => { + walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } + // Templates are transparent for identity: descend and attribute the + // contained body-bearing function_definition normally. + "template_declaration" if lang == Lang::Cpp => { + walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } + "function_definition" => { + handle_function( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + } + // Preprocessor nodes, enum_specifier, and everything else descend + // transparently; only complete function_definition nodes become owners. + _ => walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ), + } +} + +/// Handle a C++ `namespace_definition`: push its complete name (flattening +/// nested `a::b` syntax into `a`, `b`), or establish an identity barrier for an +/// anonymous namespace (no fabricated text, descendants must not leak). +fn handle_namespace( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + in_anonymous: bool, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let Some(components) = namespace_components(node, bytes) else { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + }; + for component in &components { + containers.push((component.clone(), false)); + } + walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + for _ in &components { + containers.pop(); + } +} + +/// Handle a C++ class/struct/union: push a complete named type identity, or +/// fail closed with an identity barrier when the name is missing/malformed so +/// nested functions cannot leak an incorrectly shortened owner. +fn handle_type( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + in_anonymous: bool, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let Some(components) = type_identity(node, bytes) else { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + }; + for component in &components { + containers.push((component.clone(), true)); + } + walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); + for _ in &components { + containers.pop(); + } +} + +/// Handle a `function_definition`: emit a `Named` owner only when the body +/// exists and the declarator unwraps to one complete identity, then descend. +fn handle_function( + node: Node<'_>, + bytes: &[u8], + path: &Path, + lang: Lang, + in_anonymous: bool, + containers: &mut Vec<(String, bool)>, + errors: &[ErrorRange], + regions: &mut Vec, +) { + // Body-bearing only: C++ `= default` / `= delete` and prototypes have no + // `body` field and are never owners. + if node.child_by_field_name("body").is_none() { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + let Some(declarator) = node.child_by_field_name("declarator") else { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + }; + let Some((terminal_kind, terminal, scope_components)) = extract_identity(declarator, bytes) + else { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + }; + + // C++ type-less definitions are constructors/destructors/conversion + // operators OR macro hazards. A type-less body-bearing function whose + // terminal is not a destructor and does not match the innermost lexical + // type or the last declarator scope component (e.g. googletest + // `TEST(Foo, Bar) { ... }`) is macro-generated syntax and must abstain + // (US-072 §7.2). C grammar requires a `type` on every function_definition. + if lang == Lang::Cpp && node.child_by_field_name("type").is_none() { + let is_destructor = terminal_kind == "destructor_name"; + // Only a lexical TYPE (class/struct/union) can be a constructor name; + // a namespace component must never qualify a type-less function + // (`namespace Faux { Faux() { ... } }` is not a constructor). + let matches_lexical_class = containers + .iter() + .rev() + .find(|(_, is_type)| *is_type) + .is_some_and(|(name, _)| name == &terminal); + let matches_declarator_scope = scope_components.last().is_some_and(|c| c == &terminal); + if !is_destructor && !matches_lexical_class && !matches_declarator_scope { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + } + + // Build the qualified display path. An unqualified declarator prepends the + // lexical containers. A qualified declarator is authoritative; lexical + // scope is prepended only when structurally compatible: if any lexical + // component already appears in the declarator path, combining would + // duplicate or contradict the qualification and the whole function + // abstains (US-072 §5.2). Duplicates within the declarator path itself are + // legitimate (`Foo::Foo` constructor, `A::A::f` nested member). + let display: Vec = if scope_components.is_empty() { + let mut display_path: Vec = + containers.iter().map(|(name, _)| name.clone()).collect(); + display_path.push(terminal.clone()); + display_path + } else { + let declarator_path: Vec<&str> = scope_components + .iter() + .map(String::as_str) + .chain(std::iter::once(terminal.as_str())) + .collect(); + if containers + .iter() + .any(|(name, _)| declarator_path.contains(&name.as_str())) + { + barrier_and_descend(node, bytes, path, lang, containers, errors, regions); + return; + } + let mut display_path: Vec = + containers.iter().map(|(name, _)| name.clone()).collect(); + display_path.extend(scope_components.iter().cloned()); + display_path.push(terminal.clone()); + display_path + }; + + let start_line = node.start_position().row as u32 + 1; + let end_line = node.end_position().row as u32 + 1; + let anchor = OwnerAnchor { + path: path.to_path_buf(), + name: terminal.clone(), + receiver_var: None, + receiver_type: None, + package_dir: Path::new(".").to_path_buf(), + start_line, + end_line, + language: lang, + display_name: display.join("::"), + }; + let region = OwnerRegion::Named(anchor); + let region = degrade_named_on_error(region, errors, node.start_byte(), node.end_byte()); + regions.push(region); + walk_children( + node, + bytes, + path, + lang, + in_anonymous, + containers, + errors, + regions, + ); +} + +/// Flatten a namespace name into ordered components. `namespace ns` -> `[ns]`; +/// `namespace a::b::c` -> `[a, b, c]`. Returns `None` for a missing/empty name +/// (anonymous namespace) or any unreadable component. +fn namespace_components(node: Node<'_>, bytes: &[u8]) -> Option> { + let name = node.child_by_field_name("name")?; + let mut components = Vec::new(); + let mut stack: Vec = vec![name]; + while let Some(current) = stack.pop() { + if current.is_error() || current.is_missing() { + return None; + } + match current.kind() { + "namespace_identifier" => { + components.push(identifier_text(current, bytes)?); + } + "nested_namespace_specifier" => { + let mut cursor = current.walk(); + let mut children: Vec = current.named_children(&mut cursor).collect(); + children.reverse(); + stack.extend(children); + } + _ => return None, + } + } + if components.is_empty() { + return None; + } + Some(components) +} + +/// Extract a complete C++ class/struct/union type identity. Supports +/// `type_identifier` (`Foo`), `template_type` (`Foo`), and +/// `qualified_identifier` (`ns::Foo`). Returns `None` for anonymous or +/// malformed names (caller fails closed). +fn type_identity(node: Node<'_>, bytes: &[u8]) -> Option> { + let name = node.child_by_field_name("name")?; + if name.is_error() || name.is_missing() { + return None; + } + match name.kind() { + "type_identifier" => Some(vec![identifier_text(name, bytes)?]), + "template_type" => Some(vec![template_type_text(name, bytes)?]), + "qualified_identifier" => extract_qualified_identifier(name, bytes) + .map(|path| path.into_iter().map(|(_, text)| text).collect()), + _ => None, + } +} + +/// Unwrap declarator wrappers down to the identity terminal, returning +/// `(kind, terminal_text, scope_components)` for a complete identity. +/// +/// Wrappers (`function_declarator`, `pointer_declarator`, +/// `reference_declarator`, `parenthesized_declarator`, `array_declarator`, +/// `attributed_declarator`) are unwrapped through their `declarator` field or, +/// when the grammar exposes none, through a single declarator-shaped named +/// child. The terminal must be an identifier-like node, a C++ +/// `qualified_identifier` (whose scope yields the left-to-right components), or +/// a `destructor_name`/`operator_name`. Anything else (preprocessor fragments, +/// `ERROR`, `operator_cast`, `template_function`, `dependent_name`, `decltype`, +/// multiple plausible terminals) abstains. +fn extract_identity(node: Node<'_>, bytes: &[u8]) -> Option<(String, String, Vec)> { + let mut node = node; + let mut guard = 0; + loop { + guard += 1; + if guard > 64 { + return None; + } + if node.is_error() || node.is_missing() { + return None; + } + if !is_wrapper_kind(node.kind()) { + break; + } + if let Some(inner) = node.child_by_field_name("declarator") { + node = inner; + continue; + } + // Wrappers without a `declarator` field: exactly one declarator-shaped + // named child (e.g. `parenthesized_declarator`, `attributed_declarator`, + // `reference_declarator`). + let mut cursor = node.walk(); + let candidates: Vec = node + .named_children(&mut cursor) + .filter(|c| is_wrapper_kind(c.kind()) || is_terminal_kind(c.kind())) + .collect(); + if candidates.len() != 1 { + return None; + } + node = candidates[0]; + } + if node.is_error() || node.is_missing() { + return None; + } + let kind = node.kind().to_string(); + match node.kind() { + "qualified_identifier" => { + let path = extract_qualified_identifier(node, bytes)?; + if path.is_empty() { + return None; + } + let (terminal_kind, terminal) = path.last()?.clone(); + let scope = path[..path.len() - 1] + .iter() + .map(|(_, text)| text.clone()) + .collect(); + Some((terminal_kind, terminal, scope)) + } + "identifier" | "field_identifier" | "type_identifier" | "statement_identifier" => { + Some((kind, identifier_text(node, bytes)?, Vec::new())) + } + "destructor_name" | "operator_name" => { + Some((kind, terminal_text(node, bytes)?, Vec::new())) + } + _ => None, + } +} + +/// Recursively extract a C++ `qualified_identifier` path in left-to-right +/// order as `(kind, text)` pairs (`ns::Foo::bar` -> `[(ns, ns), (ns, Foo), +/// (identifier, bar)]`). The `scope` field contributes its single component; +/// the `name` field must contain exactly one supported named child (an inner +/// `qualified_identifier` or a terminal). An `ERROR` inside the path (e.g. +/// `Foo::operator int`), a missing component, or an unsupported terminal +/// abstains. The terminal's own kind is preserved so callers can distinguish a +/// destructor (`Foo::~Foo`) from a plain qualified name. +fn extract_qualified_identifier(node: Node<'_>, bytes: &[u8]) -> Option> { + if node.is_error() || node.is_missing() { + return None; + } + // Reject ERROR/missing children anywhere in the identity path (e.g. + // `Foo::operator int` surfaces an ERROR in the name position that is not + // field-assigned); a damaged qualification must abstain (§5.1). + let mut direct = node.walk(); + if node + .named_children(&mut direct) + .any(|c| c.is_error() || c.is_missing()) + { + return None; + } + let mut path = Vec::new(); + if let Some(scope) = node.child_by_field_name("scope") { + let kind = scope.kind().to_string(); + path.push((kind, scope_component_text(scope, bytes)?)); + } + let name_children = named_children_in_field(node, "name"); + if name_children.len() != 1 { + return None; + } + let name = name_children[0]; + match name.kind() { + "qualified_identifier" => { + path.extend(extract_qualified_identifier(name, bytes)?); + } + "identifier" | "field_identifier" | "type_identifier" => { + let kind = name.kind().to_string(); + path.push((kind, identifier_text(name, bytes)?)); + } + "destructor_name" | "operator_name" => { + let kind = name.kind().to_string(); + path.push((kind, terminal_text(name, bytes)?)); + } + _ => return None, + } + Some(path) +} + +/// Named children of `node` assigned to the given field (field names are read +/// through the parent, covering `multiple` fields like `qualified_identifier` +/// `name`). +fn named_children_in_field<'a>(node: Node<'a>, field: &str) -> Vec> { + let mut cursor = node.walk(); + let children: Vec = node.named_children(&mut cursor).collect(); + children + .into_iter() + .enumerate() + .filter(|(index, _)| node.field_name_for_named_child(*index as u32) == Some(field)) + .map(|(_, child)| child) + .collect() +} + +/// One deterministic scope component: `namespace_identifier` text or a +/// `template_type` spelling (`Box` + ``). `decltype`/`dependent_name` scopes +/// and anything unreadable abstain. +fn scope_component_text(node: Node<'_>, bytes: &[u8]) -> Option { + if node.is_error() || node.is_missing() { + return None; + } + match node.kind() { + "namespace_identifier" => identifier_text(node, bytes), + "template_type" => template_type_text(node, bytes), + _ => None, + } +} + +/// Deterministic `template_type` spelling: the `name` field text joined with +/// the `arguments` field text (`Box` + `` -> `Box`). Template arguments +/// attached to scope components may be retained in display only when extraction +/// is complete and deterministic (US-072 §6); missing/unreadable parts abstain. +fn template_type_text(node: Node<'_>, bytes: &[u8]) -> Option { + let name = node.child_by_field_name("name")?; + let arguments = node.child_by_field_name("arguments")?; + if name.is_error() || name.is_missing() || arguments.is_error() || arguments.is_missing() { + return None; + } + let name = identifier_text(name, bytes)?; + let arguments = raw_text(arguments, bytes)?; + Some(format!("{name}{arguments}")) +} + +/// A simple identifier-like terminal: trimmed, non-empty, no whitespace, +/// newline, or structural punctuation. +fn identifier_text(node: Node<'_>, bytes: &[u8]) -> Option { + let text = node.utf8_text(bytes).ok()?.trim().to_string(); + if text.is_empty() + || text.chars().any(|c| { + c.is_whitespace() || matches!(c, '(' | ')' | '{' | '}' | ';' | ',' | ':' | '<' | '>') + }) + { + return None; + } + Some(text) +} + +/// A grammar-terminal spelling (`~Foo`, `operator+`, `operator new`, +/// `operator()`): trimmed, non-empty, no newline or structural punctuation. +/// Spaces are allowed because `operator new`/`operator delete` spellings +/// contain them. Parentheses are allowed because `operator()` is a valid +/// terminal spelling; this helper only ever reads `destructor_name` / +/// `operator_name` nodes, so plain identifiers stay protected by +/// `identifier_text`. +fn terminal_text(node: Node<'_>, bytes: &[u8]) -> Option { + let text = node.utf8_text(bytes).ok()?.trim().to_string(); + if text.is_empty() + || text + .chars() + .any(|c| c == '\n' || matches!(c, '{' | '}' | ';' | ',' | ':')) + { + return None; + } + Some(text) +} + +/// Raw structural text (`template_argument_list`): trimmed, non-empty, no +/// newline or braces/semicolon. +fn raw_text(node: Node<'_>, bytes: &[u8]) -> Option { + let text = node.utf8_text(bytes).ok()?.trim().to_string(); + if text.is_empty() + || text + .chars() + .any(|c| c == '\n' || matches!(c, '{' | '}' | ';')) + { + return None; + } + Some(text) +} + +fn is_wrapper_kind(kind: &str) -> bool { + matches!( + kind, + "function_declarator" + | "pointer_declarator" + | "reference_declarator" + | "parenthesized_declarator" + | "array_declarator" + | "attributed_declarator" + ) +} + +/// Kinds that can terminate an identity path after wrapper unwrapping. Unwrap +/// keeps them so they can reach the terminal match; unsupported terminals +/// (`operator_cast`, `template_function`, `dependent_name`, ...) abstain there. +fn is_terminal_kind(kind: &str) -> bool { + matches!( + kind, + "identifier" + | "field_identifier" + | "type_identifier" + | "statement_identifier" + | "qualified_identifier" + | "destructor_name" + | "operator_name" + | "operator_cast" + | "template_type" + | "template_function" + | "template_method" + | "dependent_name" + | "decltype" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::path::PathBuf; + + fn path(lang: Lang) -> PathBuf { + match lang { + Lang::C => PathBuf::from("tests/fixtures/x.c"), + Lang::Cpp => PathBuf::from("tests/fixtures/x.cpp"), + other => panic!("unexpected lang {other:?}"), + } + } + + fn parse_c(src: &str) -> (Vec, Vec) { + regions_for(Lang::C, &path(Lang::C), src).expect("C fixture should parse") + } + + fn parse_cpp(src: &str) -> (Vec, Vec) { + regions_for(Lang::Cpp, &path(Lang::Cpp), src).expect("C++ fixture should parse") + } + + fn assert_owner( + regions: &[OwnerRegion], + errors: &[ErrorRange], + line: u32, + name: &str, + lang: Lang, + ) { + let owner = owner_for(regions, errors, line).named(); + assert_eq!( + owner.map(OwnerAnchor::qualified_name), + Some(name.to_string()), + "line {line} should attribute to {name}" + ); + if let Some(owner) = owner { + assert_eq!(owner.language, lang, "line {line} language"); + } + } + + fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { + assert!( + owner_for(regions, errors, line).named().is_none(), + "line {line} should abstain" + ); + } + + // ---- C focused cases ---- + + #[test] + fn c_top_level_ansi_function_exact_full_range() { + let (r, e) = parse_c("int add(int a, int b) {\n return a + b;\n}\n"); + assert_owner(&r, &e, 1, "add", Lang::C); + assert_owner(&r, &e, 2, "add", Lang::C); + assert_owner(&r, &e, 3, "add", Lang::C); + let owner = owner_for(&r, &e, 1).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (1, 3)); + assert_eq!(owner.name, "add"); + } + + #[test] + fn c_pointer_and_parenthesized_declarators() { + let (r, e) = + parse_c("int *get_ptr(void) {\n return 0;\n}\nint (f)(int x) {\n return x;\n}\n"); + assert_owner(&r, &e, 1, "get_ptr", Lang::C); + assert_owner(&r, &e, 2, "get_ptr", Lang::C); + assert_owner(&r, &e, 4, "f", Lang::C); + } + + #[test] + fn c_prototype_without_body_abstains() { + let (r, e) = parse_c("int add(int a, int b);\nint real(int x) {\n return x;\n}\n"); + assert_abstain(&r, &e, 1); + assert_owner(&r, &e, 2, "real", Lang::C); + } + + #[test] + fn c_nested_blocks_do_not_change_owner() { + let (r, e) = parse_c("void outer(void) {\n if (1) {\n while (1) {\n break;\n }\n }\n}\n"); + for line in 1..=7 { + assert_owner(&r, &e, line, "outer", Lang::C); + } + } + + #[test] + fn c_struct_union_do_not_qualify_free_functions() { + let (r, e) = parse_c("struct S {\n int x;\n int (*handler)(int);\n};\nunion U {\n int y;\n};\nvoid f(void) {\n return;\n}\n"); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + assert_abstain(&r, &e, 5); + assert_owner(&r, &e, 8, "f", Lang::C); + assert_owner(&r, &e, 9, "f", Lang::C); + } + + #[test] + fn c_kr_definition_abstains() { + let (r, e) = parse_c("sum(a, b)\nint a;\nint b;\n{\n return a + b;\n}\n"); + for line in 1..=6 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn c_conditional_children_are_structural_owners() { + let (r, e) = parse_c("#ifdef FOO\nint a(void) {\n return 1;\n}\n#else\nint b(void) {\n return 2;\n}\n#endif\n"); + assert_owner(&r, &e, 2, "a", Lang::C); + assert_owner(&r, &e, 3, "a", Lang::C); + assert_owner(&r, &e, 6, "b", Lang::C); + assert_abstain(&r, &e, 1); // directive lines have no owner + assert_abstain(&r, &e, 5); + } + + #[test] + fn c_macro_never_fabricates_an_owner() { + // A macro definition and a semicolon-terminated macro invocation never + // become owners; the following real function stays clean and named. + let (r, e) = parse_c("#define MAKE_FN(name) int name(void) { return 1; }\nMAKE_FN(foo);\nint real(void) {\n return 1;\n}\n"); + assert_abstain(&r, &e, 1); + assert_abstain(&r, &e, 2); + assert_owner(&r, &e, 3, "real", Lang::C); + assert_owner(&r, &e, 4, "real", Lang::C); + } + + #[test] + fn c_macro_fragmented_definition_abstains() { + // An unterminated macro invocation directly before a definition merges + // into one damaged function_definition with an ERROR: no owner. + let (r, e) = parse_c("#define MAKE_FN(name) int name(void) { return 1; }\nMAKE_FN(foo)\nint real(void) { return 1; }\n"); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + } + + #[test] + fn c_macro_tokens_inside_body_still_attribute_to_enclosing_function() { + let (r, e) = parse_c("void f(void) {\n LOG(\"x\");\n int y = TRACE_LEVEL;\n}\n"); + for line in 1..=4 { + assert_owner(&r, &e, line, "f", Lang::C); + } + } + + #[test] + fn c_local_error_degrades_only_affected_function() { + // The C grammar keeps `broken` as a function_definition with a local + // ERROR inside it, so the whole function degrades to a barrier while + // the clean `also_good` sibling keeps its owner. + let (r, e) = parse_c("int good(void) {\n return 1;\n}\nint broken(void) {\n int x = (\n}\nint also_good(void) {\n return 2;\n}\n"); + assert_owner(&r, &e, 1, "good", Lang::C); + assert_owner(&r, &e, 2, "good", Lang::C); + assert_abstain(&r, &e, 4); // broken opening (degraded barrier) + assert_abstain(&r, &e, 5); // error line + assert_abstain(&r, &e, 6); // broken closing (degraded barrier) + assert_owner(&r, &e, 7, "also_good", Lang::C); + assert_owner(&r, &e, 8, "also_good", Lang::C); + } + + // ---- C++ focused cases ---- + + #[test] + fn cpp_namespace_free_function() { + let (r, e) = parse_cpp("namespace ns {\nvoid run(void) {\n return;\n}\n}\n"); + assert_owner(&r, &e, 2, "ns::run", Lang::Cpp); + assert_owner(&r, &e, 3, "ns::run", Lang::Cpp); + } + + #[test] + fn cpp_nested_namespace_syntax_and_nodes() { + let (r, e) = parse_cpp("namespace a::b {\nvoid run(void) {\n}\n}\nnamespace x {\nnamespace y {\nvoid go(void) {\n}\n}\n}\n"); + assert_owner(&r, &e, 2, "a::b::run", Lang::Cpp); + assert_owner(&r, &e, 7, "x::y::go", Lang::Cpp); + } + + #[test] + fn cpp_inline_class_struct_union_methods() { + let (r, e) = parse_cpp("class A {\npublic:\n void m(void) {\n }\n};\nstruct S {\n void n(void) {\n }\n};\nunion U {\n void o(void) {\n }\n};\n"); + assert_owner(&r, &e, 3, "A::m", Lang::Cpp); + assert_owner(&r, &e, 4, "A::m", Lang::Cpp); + assert_owner(&r, &e, 8, "S::n", Lang::Cpp); + assert_owner(&r, &e, 12, "U::o", Lang::Cpp); + } + + #[test] + fn cpp_nested_types() { + let (r, e) = parse_cpp("class Outer {\n class Inner {\n void handle(void) {\n }\n };\n};\n"); + assert_owner(&r, &e, 3, "Outer::Inner::handle", Lang::Cpp); + assert_owner(&r, &e, 4, "Outer::Inner::handle", Lang::Cpp); + } + + #[test] + fn cpp_templates_transparent_for_identity() { + let (r, e) = parse_cpp("template T max_value(T a, T b) {\n return a;\n}\nnamespace n {\ntemplate T make() {\n return T();\n}\n}\n"); + assert_owner(&r, &e, 1, "max_value", Lang::Cpp); + assert_owner(&r, &e, 2, "max_value", Lang::Cpp); + assert_owner(&r, &e, 5, "n::make", Lang::Cpp); + } + + #[test] + fn cpp_out_of_line_definitions() { + let (r, e) = parse_cpp("void Foo::bar() {\n}\nvoid ns::Foo::bar() {\n}\nnamespace ns2 {\nvoid Foo::bar() {\n}\n}\n"); + assert_owner(&r, &e, 1, "Foo::bar", Lang::Cpp); + assert_owner(&r, &e, 3, "ns::Foo::bar", Lang::Cpp); + assert_owner(&r, &e, 6, "ns2::Foo::bar", Lang::Cpp); + } + + #[test] + fn cpp_out_of_line_template_member() { + let (r, e) = parse_cpp("template void Box::set(T v) {\n this->v = v;\n}\n"); + assert_owner(&r, &e, 1, "Box::set", Lang::Cpp); + assert_owner(&r, &e, 2, "Box::set", Lang::Cpp); + } + + #[test] + fn cpp_duplicate_qualification_abstains() { + // `namespace A { namespace B { void A::C::f() {} } }` would duplicate A + // when combined; the whole function abstains rather than guessing. + let (r, e) = parse_cpp("namespace A {\nnamespace B {\nvoid A::C::f() {\n}\n}\n}\n"); + assert_abstain(&r, &e, 3); + assert_abstain(&r, &e, 4); + } + + #[test] + fn cpp_constructor_destructor_operator() { + let (r, e) = parse_cpp("struct Foo {\n Foo() {\n }\n ~Foo() {\n }\n Foo& operator=(const Foo& o) {\n return *this;\n }\n};\nFoo::Foo() {\n}\nFoo::~Foo() {\n}\n"); + assert_owner(&r, &e, 2, "Foo::Foo", Lang::Cpp); + assert_owner(&r, &e, 4, "Foo::~Foo", Lang::Cpp); + assert_owner(&r, &e, 6, "Foo::operator=", Lang::Cpp); + assert_owner(&r, &e, 7, "Foo::operator=", Lang::Cpp); + assert_owner(&r, &e, 10, "Foo::Foo", Lang::Cpp); + assert_owner(&r, &e, 12, "Foo::~Foo", Lang::Cpp); + } + + #[test] + fn cpp_call_operator_inline_member() { + let (r, e) = + parse_cpp("struct F {\n int operator()(int x) {\n return x;\n }\n};\n"); + assert_owner(&r, &e, 2, "F::operator()", Lang::Cpp); + assert_owner(&r, &e, 3, "F::operator()", Lang::Cpp); + let owner = owner_for(&r, &e, 2).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (2, 4)); + assert_eq!(owner.name, "operator()"); + } + + #[test] + fn cpp_call_operator_const_overload() { + let (r, e) = parse_cpp( + "struct F {\n int operator()(int x) const {\n return x;\n }\n};\n", + ); + assert_owner(&r, &e, 2, "F::operator()", Lang::Cpp); + assert_owner(&r, &e, 3, "F::operator()", Lang::Cpp); + } + + #[test] + fn cpp_call_operator_out_of_line() { + let (r, e) = parse_cpp("int Foo::operator()(int x) {\n return x;\n}\n"); + assert_owner(&r, &e, 1, "Foo::operator()", Lang::Cpp); + assert_owner(&r, &e, 2, "Foo::operator()", Lang::Cpp); + let owner = owner_for(&r, &e, 1).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (1, 3)); + } + + #[test] + fn cpp_call_operator_out_of_line_namespace_qualified() { + let (r, e) = parse_cpp("int ns::Foo::operator()() {\n return 0;\n}\n"); + assert_owner(&r, &e, 1, "ns::Foo::operator()", Lang::Cpp); + assert_owner(&r, &e, 2, "ns::Foo::operator()", Lang::Cpp); + } + + #[test] + fn cpp_overloads_keep_structural_display() { + let (r, e) = + parse_cpp("class A {\n void f(int) {\n }\n void f(double) {\n }\n};\n"); + assert_owner(&r, &e, 2, "A::f", Lang::Cpp); + assert_owner(&r, &e, 4, "A::f", Lang::Cpp); + // Distinct ranges: overload 1 is 2-3, overload 2 is 4-5. Four owned + // hit lines across the two distinct anchors. + let owners: Vec<&OwnerAnchor> = (1..=5) + .filter_map(|line| owner_for(&r, &e, line).named()) + .collect(); + assert_eq!(owners.len(), 4); + let mut ranges: std::collections::HashSet<(u32, u32)> = + owners.iter().map(|o| (o.start_line, o.end_line)).collect(); + assert_eq!(ranges.len(), 2); + } + + #[test] + fn cpp_lambda_barriers_nested_and_in_method() { + let (r, e) = parse_cpp("void outer() {\n auto l = [](int x) {\n return x;\n };\n auto m = []() {\n return []() {\n return 1;\n };\n };\n}\n"); + assert_owner(&r, &e, 1, "outer", Lang::Cpp); + assert_abstain(&r, &e, 3); // inside first lambda + assert_abstain(&r, &e, 7); // inside nested lambda + assert_owner(&r, &e, 10, "outer", Lang::Cpp); + } + + #[test] + fn cpp_anonymous_namespace_and_type_prevent_leakage() { + let (r, e) = parse_cpp("namespace outer {\nnamespace {\nvoid f() {\n}\n}\nclass {\npublic:\n void g() {\n }\n} anon;\nvoid h() {\n}\n}\n"); + assert_abstain(&r, &e, 3); // f in anonymous namespace + assert_abstain(&r, &e, 4); + assert_abstain(&r, &e, 8); // g in anonymous class + assert_owner(&r, &e, 11, "outer::h", Lang::Cpp); // sibling unaffected + } + + #[test] + fn cpp_malformed_container_abstains_nested_function() { + let (r, e) = parse_cpp("class A {\n class {\n void leaf() {\n }\n };\n void sibling() {\n }\n};\n"); + assert_abstain(&r, &e, 3); // leaf inside anonymous class + assert_owner(&r, &e, 6, "A::sibling", Lang::Cpp); // clean sibling survives + } + + #[test] + fn cpp_friend_definition_is_a_barrier() { + let (r, e) = parse_cpp("class A {\n friend void f() {\n }\n};\n"); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + } + + #[test] + fn cpp_test_macro_generated_function_abstains() { + // googletest-style macro-generated body: parses as a type-less + // function_definition but must never become owner `TEST` (§7.2). + let (r, e) = + parse_cpp("TEST(Foo, Bar) {\n EXPECT_EQ(1, 1);\n}\nvoid real() {\n return;\n}\n"); + assert_abstain(&r, &e, 1); + assert_abstain(&r, &e, 2); + assert_owner(&r, &e, 4, "real", Lang::Cpp); + } + + #[test] + fn cpp_defaulted_and_deleted_abstain() { + let (r, e) = parse_cpp( + "struct S {\n S() = default;\n ~S() = delete;\n void real() {\n }\n};\n", + ); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + assert_owner(&r, &e, 4, "S::real", Lang::Cpp); + } + + #[test] + fn cpp_conditional_and_macro_hazards_mirror_c() { + let (r, e) = parse_cpp("#ifdef FOO\nvoid a() {\n}\n#else\nvoid b() {\n}\n#endif\n#define FN(x) void x() {}\nFN(c)\n"); + assert_owner(&r, &e, 2, "a", Lang::Cpp); + assert_owner(&r, &e, 5, "b", Lang::Cpp); + assert_abstain(&r, &e, 7); // #define line + assert_abstain(&r, &e, 8); // FN(c) macro invocation + } + + #[test] + fn cpp_clean_sibling_remains_eligible_beside_malformed_function() { + let (r, e) = parse_cpp("void good() {\n return;\n}\nvoid broken( {\n int x = 1;\n}\nvoid also_good() {\n return;\n}\n"); + assert_owner(&r, &e, 1, "good", Lang::Cpp); + assert_owner(&r, &e, 2, "good", Lang::Cpp); + assert_abstain(&r, &e, 5); // malformed region + assert_abstain(&r, &e, 6); + } + + #[test] + fn cpp_bodyless_prototype_abstains() { + let (r, e) = + parse_cpp("class A {\n void declared();\n void defined() {\n }\n};\n"); + assert_abstain(&r, &e, 2); + assert_owner(&r, &e, 3, "A::defined", Lang::Cpp); + } + + // ---- US-072 P1 regression (Biscuit review) ---- + + #[test] + fn cpp_type_less_namespace_name_is_not_a_constructor() { + // P1-1: `Faux()` inside `namespace Faux` is NOT a constructor. The + // type-less gate must match only a lexical class/struct/union + // container or the final declarator scope component, never a + // namespace, so the body abstains. + let (r, e) = parse_cpp("namespace Faux {\nFaux() {\n namespace_hit();\n}\n}\n"); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + } + + #[test] + fn cpp_type_less_constructor_inside_class_control() { + // Control for P1-1: a real constructor (terminal matches the innermost + // lexical CLASS) still owns with the full lexical path. + let (r, e) = parse_cpp("namespace N {\nclass Faux {\n Faux() {\n }\n};\n}\n"); + assert_owner(&r, &e, 3, "N::Faux::Faux", Lang::Cpp); + assert_owner(&r, &e, 4, "N::Faux::Faux", Lang::Cpp); + } + + #[test] + fn cpp_abstained_macro_function_does_not_leak_nested_owner() { + // P1-2: a macro-generated type-less function (TEST) becomes a + // full-range AnonymousBarrier; a nested function inside its body must + // not regain a fabricated named identity. + let (r, e) = + parse_cpp("TEST(Foo, Bar) {\n void leaked() {\n return;\n }\n}\n"); + for line in 1..=4 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn cpp_malformed_identity_does_not_leak_nested_owner() { + // P1-2: an unidentifiable declarator (ERROR inside `operator int`) + // abstains as a barrier; a nested function must not leak. + let (r, e) = parse_cpp( + "void Foo::operator int() {\n void leaked2() {\n return;\n }\n}\n", + ); + for line in 1..=3 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn cpp_duplicate_qualification_does_not_leak_nested_owner() { + // P1-2: the duplicate-qualification abstention is also a barrier; a + // nested function inside the abstained definition must not leak. + let (r, e) = parse_cpp( + "namespace A {\nnamespace B {\nvoid A::C::f() {\n void leaked3() {\n return;\n }\n}\n}\n}\n", + ); + for line in 1..=7 { + assert_abstain(&r, &e, line); + } + } + + /// A single independent fixture, parsed on its own with 1-based local line + /// numbers. `owners` lists `(hit_line, name, start_line, end_line)` that + /// must attribute to the exact owner; `abstain` lists lines that must + /// abstain and count toward the intentional-abstention floor; `incidental` + /// lists lines that must abstain but do NOT count. + struct Case { + label: &'static str, + source: &'static str, + lang: Lang, + owners: &'static [(u32, &'static str, u32, u32)], + abstain: &'static [u32], + incidental: &'static [u32], + } + + /// US-072 curated C/C++ accuracy gate. + #[test] + fn c_cpp_owner_matrix_meets_quality_gates() { + let cases: &[Case] = &[ + Case { + label: "C multi-line function", + source: "int add(int a, int b) {\n return a + b;\n}\n", + lang: Lang::C, + owners: &[(1, "add", 1, 3), (2, "add", 1, 3), (3, "add", 1, 3)], + abstain: &[4], + incidental: &[], + }, + Case { + label: "C pointer return", + source: "int *get_ptr(void) {\n return 0;\n}\n", + lang: Lang::C, + owners: &[(1, "get_ptr", 1, 3), (2, "get_ptr", 1, 3)], + abstain: &[], + incidental: &[4], + }, + Case { + label: "C parenthesized declarator", + source: "int (f)(int x) {\n return x;\n}\n", + lang: Lang::C, + owners: &[(1, "f", 1, 3), (2, "f", 1, 3)], + abstain: &[], + incidental: &[4], + }, + Case { + label: "C two functions exact ranges", + source: "int a(void) {\n return 1;\n}\nint b(void) {\n return 2;\n}\n", + lang: Lang::C, + owners: &[ + (1, "a", 1, 3), + (2, "a", 1, 3), + (3, "a", 1, 3), + (4, "b", 4, 6), + (5, "b", 4, 6), + (6, "b", 4, 6), + ], + abstain: &[], + incidental: &[7], + }, + Case { + label: "C conditional branches structural", + source: "#ifdef FOO\nint a(void) {\n return 1;\n}\n#else\nint b(void) {\n return 2;\n}\n#endif\n", + lang: Lang::C, + owners: &[(2, "a", 2, 4), (3, "a", 2, 4), (6, "b", 6, 8), (7, "b", 6, 8)], + abstain: &[1, 5], + incidental: &[9], + }, + Case { + label: "C macro never owner", + source: "#define MAKE_FN(name) int name(void) { return 1; }\nMAKE_FN(foo);\nint real(void) {\n return 1;\n}\n", + lang: Lang::C, + owners: &[(3, "real", 3, 5), (4, "real", 3, 5)], + abstain: &[1, 2], + incidental: &[6], + }, + Case { + label: "C K&R abstains", + source: "sum(a, b)\nint a;\nint b;\n{\n return a + b;\n}\n", + lang: Lang::C, + owners: &[], + abstain: &[1, 2, 3, 4, 5], + incidental: &[6], + }, + Case { + label: "C struct does not qualify", + source: "struct S {\n int x;\n};\nvoid f(void) {\n return;\n}\n", + lang: Lang::C, + owners: &[(4, "f", 4, 6)], + abstain: &[1, 2, 3], + incidental: &[7], + }, + Case { + label: "C++ namespace free function", + source: "namespace ns {\nvoid run(void) {\n return;\n}\n}\n", + lang: Lang::Cpp, + owners: &[(2, "ns::run", 2, 4), (3, "ns::run", 2, 4), (4, "ns::run", 2, 4)], + abstain: &[1], + incidental: &[5], + }, + Case { + label: "C++ nested namespace syntax", + source: "namespace a::b {\nvoid run(void) {\n}\n}\n", + lang: Lang::Cpp, + owners: &[(2, "a::b::run", 2, 3)], + abstain: &[1], + incidental: &[4], + }, + Case { + label: "C++ nested namespace nodes", + source: "namespace x {\nnamespace y {\nvoid go(void) {\n}\n}\n}\n", + lang: Lang::Cpp, + owners: &[(3, "x::y::go", 3, 4), (4, "x::y::go", 3, 4)], + abstain: &[1, 2], + incidental: &[5, 6], + }, + Case { + label: "C++ inline methods", + source: "class A {\n void m(void) {\n }\n};\nstruct S {\n void n(void) {\n }\n};\nunion U {\n void o(void) {\n }\n};\n", + lang: Lang::Cpp, + owners: &[ + (2, "A::m", 2, 3), + (3, "A::m", 2, 3), + (6, "S::n", 6, 7), + (10, "U::o", 10, 11), + ], + abstain: &[1, 5, 9], + incidental: &[4, 8, 12, 13], + }, + Case { + label: "C++ nested types", + source: "class Outer {\n class Inner {\n void handle(void) {\n }\n };\n};\n", + lang: Lang::Cpp, + owners: &[(3, "Outer::Inner::handle", 3, 4), (4, "Outer::Inner::handle", 3, 4)], + abstain: &[1, 2], + incidental: &[6, 7], + }, + Case { + label: "C++ template free function", + source: "template T max_value(T a, T b) {\n return a;\n}\n", + lang: Lang::Cpp, + owners: &[(1, "max_value", 1, 3), (2, "max_value", 1, 3)], + abstain: &[], + incidental: &[], + }, + Case { + label: "C++ template member in namespace", + source: "namespace n {\ntemplate T make() {\n return T();\n}\n}\n", + lang: Lang::Cpp, + owners: &[(2, "n::make", 2, 4), (3, "n::make", 2, 4)], + abstain: &[1], + incidental: &[5], + }, + Case { + label: "C++ out-of-line Foo::bar", + source: "void Foo::bar() {\n}\n", + lang: Lang::Cpp, + owners: &[(1, "Foo::bar", 1, 2)], + abstain: &[], + incidental: &[3], + }, + Case { + label: "C++ out-of-line ns::Foo::bar", + source: "void ns::Foo::bar() {\n}\n", + lang: Lang::Cpp, + owners: &[(1, "ns::Foo::bar", 1, 2)], + abstain: &[], + incidental: &[3], + }, + Case { + label: "C++ lexical namespace + qualified declarator", + source: "namespace ns2 {\nvoid Foo::bar() {\n}\n}\n", + lang: Lang::Cpp, + owners: &[(2, "ns2::Foo::bar", 2, 3)], + abstain: &[1], + incidental: &[4], + }, + Case { + label: "C++ out-of-line template member", + source: "template void Box::set(T v) {\n this->v = v;\n}\n", + lang: Lang::Cpp, + owners: &[(1, "Box::set", 1, 3), (2, "Box::set", 1, 3)], + abstain: &[], + incidental: &[4], + }, + Case { + label: "C++ ctor/dtor/operator", + source: "struct Foo {\n Foo() {\n }\n ~Foo() {\n }\n Foo& operator=(const Foo& o) {\n return *this;\n }\n};\n", + lang: Lang::Cpp, + owners: &[ + (2, "Foo::Foo", 2, 3), + (3, "Foo::Foo", 2, 3), + (4, "Foo::~Foo", 4, 5), + (6, "Foo::operator=", 6, 8), + (7, "Foo::operator=", 6, 8), + ], + abstain: &[1], + incidental: &[9], + }, + Case { + label: "C++ overloads", + source: "class A {\n void f(int) {\n }\n void f(double) {\n }\n};\n", + lang: Lang::Cpp, + owners: &[(2, "A::f", 2, 3), (4, "A::f", 4, 5)], + abstain: &[1], + incidental: &[6], + }, + Case { + label: "C++ lambda barrier", + source: "void outer() {\n auto l = [](int x) {\n return x;\n };\n}\n", + lang: Lang::Cpp, + owners: &[(1, "outer", 1, 5), (5, "outer", 1, 5)], + abstain: &[2, 3], + incidental: &[6], + }, + Case { + label: "C++ nested lambda barrier", + source: "void outer() {\n auto m = []() {\n return []() {\n return 1;\n };\n };\n}\n", + lang: Lang::Cpp, + owners: &[(1, "outer", 1, 7), (7, "outer", 1, 7)], + abstain: &[3, 4], + incidental: &[2, 5, 6], + }, + Case { + label: "C++ anonymous namespace", + source: "namespace {\nvoid f() {\n}\n}\nvoid g() {\n}\n", + lang: Lang::Cpp, + owners: &[(5, "g", 5, 6)], + abstain: &[1, 2, 3], + incidental: &[4, 7], + }, + Case { + label: "C++ anonymous class", + source: "class A {\n class {\n void leaf() {\n }\n };\n void sibling() {\n }\n};\n", + lang: Lang::Cpp, + owners: &[(6, "A::sibling", 6, 7), (7, "A::sibling", 6, 7)], + abstain: &[1, 2, 3, 4], + incidental: &[5, 8, 9], + }, + Case { + label: "C++ TEST macro abstains", + source: "TEST(Foo, Bar) {\n EXPECT_EQ(1, 1);\n}\nvoid real() {\n return;\n}\n", + lang: Lang::Cpp, + owners: &[(4, "real", 4, 6)], + abstain: &[1, 2], + incidental: &[3, 7], + }, + Case { + label: "C++ friend barrier", + source: "class A {\n friend void f() {\n }\n};\n", + lang: Lang::Cpp, + owners: &[], + abstain: &[1, 2, 3], + incidental: &[4], + }, + Case { + label: "C++ malformed function degrades clean sibling survives", + source: "void good() {\n return;\n}\nvoid broken( {\n int x = 1;\n}\nvoid also_good() {\n return;\n}\n", + lang: Lang::Cpp, + owners: &[(1, "good", 1, 3), (2, "good", 1, 3)], + abstain: &[5, 6], + incidental: &[4, 7, 8, 9], + }, + Case { + label: "C++ duplicate qualification abstains", + source: "namespace A {\nnamespace B {\nvoid A::C::f() {\n}\n}\n}\n", + lang: Lang::Cpp, + owners: &[], + abstain: &[1, 2, 3, 4], + incidental: &[5, 6], + }, + Case { + label: "C++ method multi-line body", + source: "class Service {\n void Load() {\n int x = 1;\n apply(x);\n }\n}\n", + lang: Lang::Cpp, + owners: &[ + (2, "Service::Load", 2, 5), + (3, "Service::Load", 2, 5), + (4, "Service::Load", 2, 5), + (5, "Service::Load", 2, 5), + ], + abstain: &[1], + incidental: &[6], + }, + Case { + label: "C++ out-of-line ctor/dtor", + source: "Foo::Foo() {\n init();\n}\nFoo::~Foo() {\n done();\n}\n", + lang: Lang::Cpp, + owners: &[ + (1, "Foo::Foo", 1, 3), + (2, "Foo::Foo", 1, 3), + (4, "Foo::~Foo", 4, 6), + (5, "Foo::~Foo", 4, 6), + ], + abstain: &[], + incidental: &[7], + }, + Case { + label: "C multi-statement body", + source: "int compute(int a) {\n int b = a + 1;\n b *= 2;\n return b;\n}\n", + lang: Lang::C, + owners: &[ + (1, "compute", 1, 5), + (2, "compute", 1, 5), + (3, "compute", 1, 5), + (4, "compute", 1, 5), + (5, "compute", 1, 5), + ], + abstain: &[], + incidental: &[6], + }, + Case { + label: "C++ local class member in method", + source: "class A {\n void f() {\n struct L {\n void g() {\n }\n };\n }\n};\n", + lang: Lang::Cpp, + owners: &[ + (2, "A::f", 2, 7), + (3, "A::f", 2, 7), + (4, "A::L::g", 4, 5), + (5, "A::L::g", 4, 5), + (6, "A::f", 2, 7), + (7, "A::f", 2, 7), + ], + abstain: &[1], + incidental: &[8], + }, + Case { + label: "C++ out-of-line operator=", + source: "Foo& Foo::operator=(const Foo& o) {\n return *this;\n}\n", + lang: Lang::Cpp, + owners: &[(1, "Foo::operator=", 1, 3), (2, "Foo::operator=", 1, 3)], + abstain: &[], + incidental: &[4], + }, + ]; + + let mut positives = 0u32; + let mut abstentions = 0u32; + for case in cases { + let (regions, errors) = match case.lang { + Lang::C => parse_c(case.source), + Lang::Cpp => parse_cpp(case.source), + other => panic!("unexpected lang {other:?}"), + }; + for &(hit_line, name, start, end) in case.owners { + positives += 1; + let owner = owner_for(®ions, &errors, hit_line).named(); + assert!( + owner.is_some(), + "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", + case.label + ); + let owner = owner.unwrap(); + assert_eq!(owner.qualified_name(), name, "[{}] name", case.label); + assert_eq!(owner.start_line, start, "[{}] start", case.label); + assert_eq!(owner.end_line, end, "[{}] end", case.label); + assert_eq!(owner.language, case.lang, "[{}] language", case.label); + } + for &line in case.abstain { + abstentions += 1; + assert!( + owner_for(®ions, &errors, line).named().is_none(), + "[{}] line {line} should abstain", + case.label + ); + } + for &line in case.incidental { + assert!( + owner_for(®ions, &errors, line).named().is_none(), + "[{}] incidental line {line} should abstain", + case.label + ); + } + } + assert!( + positives >= 70, + "positive floor: only {positives} assertions" + ); + assert!( + abstentions >= 30, + "intentional-abstention floor: only {abstentions} assertions" + ); + } + + /// US-072 §10.2: pinned fingerprints + explicit disposition manifests. Any + /// grammar metadata change must fail here with an actionable re-audit + /// message rather than silently widening claims. + #[test] + fn manifest_gate_verifies_kinds_exist_and_fingerprints_are_pinned() { + for (name, node_types, dispositions, fingerprint) in [ + ( + "tree-sitter-c", + tree_sitter_c::NODE_TYPES, + C_CALLABLE_OR_HAZARD_DISPOSITIONS, + C_NODE_TYPES_FINGERPRINT, + ), + ( + "tree-sitter-cpp", + tree_sitter_cpp::NODE_TYPES, + CPP_CALLABLE_OR_HAZARD_DISPOSITIONS, + CPP_NODE_TYPES_FINGERPRINT, + ), + ] { + let v: serde_json::Value = serde_json::from_str(node_types).unwrap(); + let kinds: HashSet = v + .as_array() + .unwrap() + .iter() + .filter_map(|e| e.get("type").and_then(|t| t.as_str()).map(String::from)) + .collect(); + assert!( + !kinds.is_empty(), + "{name} NODE_TYPES must declare node kinds" + ); + + let mut all: Vec<&str> = Vec::new(); + let container_kinds: &[&str] = if name == "tree-sitter-c" { + C_CONTAINER_KINDS + } else { + CPP_CONTAINER_KINDS + }; + all.extend(container_kinds); + let wrapper_kinds: &[&str] = if name == "tree-sitter-c" { + C_WRAPPER_KINDS + } else { + CPP_WRAPPER_KINDS + }; + all.extend(wrapper_kinds); + for (kind, _) in dispositions { + all.push(kind); + } + let uniq: HashSet<&str> = all.iter().copied().collect(); + assert_eq!( + uniq.len(), + all.len(), + "{name} manifest inventories must be disjoint" + ); + for kind in &uniq { + assert!( + kinds.contains(*kind), + "{name}: manifest kind {kind} missing from NODE_TYPES" + ); + } + + // Every grammar callable/hazard relevant to this adapter has an + // explicit disposition: named, transparent, barrier, or abstain. + let disposition_of: std::collections::HashMap<&str, &str> = + dispositions.iter().copied().collect(); + for kind in &kinds { + let is_relevant = kind.starts_with("preproc_") + || matches!( + kind.as_str(), + "function_definition" + | "lambda_expression" + | "template_declaration" + | "friend_declaration" + | "macro_type_specifier" + ); + if !is_relevant { + continue; + } + assert!( + disposition_of.contains_key(kind.as_str()), + "{name}: grammar callable/hazard {kind} has no explicit disposition; add one (named/transparent/barrier/abstain)" + ); + } + for (kind, disposition) in dispositions { + assert!( + matches!( + *disposition, + "named" | "transparent" | "barrier" | "abstain" + ), + "{name}: invalid disposition {disposition} for {kind}" + ); + } + + assert_eq!( + fnv1a(node_types.as_bytes()), + fingerprint, + "{name} NODE_TYPES changed; re-audit the manifest and recompute the fingerprint" + ); + } + } +} diff --git a/src/evidence/owners/csharp.rs b/src/evidence/owners/csharp.rs index 83921b0..0298ad2 100644 --- a/src/evidence/owners/csharp.rs +++ b/src/evidence/owners/csharp.rs @@ -29,7 +29,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -240,8 +241,7 @@ fn walk( let has_malformed_fs_ns = node.named_children(&mut cur3).any(|c| { c.kind() == "ERROR" && c.utf8_text(bytes) - .ok() - .is_some_and(|t| t.trim_start().starts_with("namespace")) + .is_ok_and(|t| t.trim_start().starts_with("namespace")) }); // A file-scoped namespace exists but its name is missing/unreadable: // fail closed so sibling declarations cannot leak unqualified to an @@ -917,7 +917,7 @@ pub(crate) fn csharp_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -936,7 +936,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = csharp_owner_for(regions, errors, line); + let owner = csharp_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -946,7 +946,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - csharp_owner_for(regions, errors, line).is_none(), + csharp_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -1415,7 +1415,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = csharp_owner_for(®ions, &errors, hit_line); + let owner = csharp_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", @@ -1429,14 +1429,14 @@ mod tests { for &line in case.abstain { abstentions += 1; assert!( - csharp_owner_for(®ions, &errors, line).is_none(), + csharp_owner_for(®ions, &errors, line).named().is_none(), "[{}] line {line} should abstain", case.label ); } for &line in case.incidental { assert!( - csharp_owner_for(®ions, &errors, line).is_none(), + csharp_owner_for(®ions, &errors, line).named().is_none(), "[{}] incidental line {line} should abstain", case.label ); diff --git a/src/evidence/owners/java.rs b/src/evidence/owners/java.rs index a623add..a4085f4 100644 --- a/src/evidence/owners/java.rs +++ b/src/evidence/owners/java.rs @@ -22,7 +22,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -277,7 +278,7 @@ pub(crate) fn java_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -296,7 +297,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = java_owner_for(regions, errors, line); + let owner = java_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -306,7 +307,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - java_owner_for(regions, errors, line).is_none(), + java_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -658,7 +659,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = java_owner_for(®ions, &errors, hit_line); + let owner = java_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", @@ -681,14 +682,14 @@ mod tests { for &line in case.abstain { abstentions += 1; assert!( - java_owner_for(®ions, &errors, line).is_none(), + java_owner_for(®ions, &errors, line).named().is_none(), "[{}] line {line} should abstain", case.label ); } for &line in case.incidental { assert!( - java_owner_for(®ions, &errors, line).is_none(), + java_owner_for(®ions, &errors, line).named().is_none(), "[{}] incidental line {line} should abstain", case.label ); diff --git a/src/evidence/owners/js_ts.rs b/src/evidence/owners/js_ts.rs index 3932f2c..1fafb9b 100644 --- a/src/evidence/owners/js_ts.rs +++ b/src/evidence/owners/js_ts.rs @@ -50,7 +50,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -214,7 +215,7 @@ pub(crate) fn owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -891,6 +892,7 @@ mod tests { end: u32, ) { let owner = owner_for(regions, errors, hit_line) + .named() .unwrap_or_else(|| panic!("line {hit_line} should attribute to {name}")); assert_eq!(owner.qualified_name(), name, "line {hit_line} display name"); assert_eq!(owner.start_line, start, "line {hit_line} start line"); @@ -899,7 +901,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - owner_for(regions, errors, line).is_none(), + owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } diff --git a/src/evidence/owners/kotlin.rs b/src/evidence/owners/kotlin.rs index 31349b5..cb706ad 100644 --- a/src/evidence/owners/kotlin.rs +++ b/src/evidence/owners/kotlin.rs @@ -23,7 +23,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -429,7 +430,7 @@ pub(crate) fn kotlin_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -448,7 +449,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = kotlin_owner_for(regions, errors, line); + let owner = kotlin_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -458,7 +459,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - kotlin_owner_for(regions, errors, line).is_none(), + kotlin_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -810,7 +811,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = kotlin_owner_for(®ions, &errors, hit_line); + let owner = kotlin_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", @@ -824,14 +825,14 @@ mod tests { for &line in case.abstain { abstentions += 1; assert!( - kotlin_owner_for(®ions, &errors, line).is_none(), + kotlin_owner_for(®ions, &errors, line).named().is_none(), "[{}] line {line} should abstain", case.label ); } for &line in case.incidental { assert!( - kotlin_owner_for(®ions, &errors, line).is_none(), + kotlin_owner_for(®ions, &errors, line).named().is_none(), "[{}] incidental line {line} should abstain", case.label ); diff --git a/src/evidence/owners/mod.rs b/src/evidence/owners/mod.rs index 5c634a3..1acce85 100644 --- a/src/evidence/owners/mod.rs +++ b/src/evidence/owners/mod.rs @@ -4,21 +4,23 @@ //! Each supported language adapter parses a file into a flat list of nested //! callable `OwnerRegion`s. A region is either a `Named` owner (an `OwnerAnchor`) //! or an `AnonymousBarrier` (an anonymous callable that must not let a hit fall -//! through to an enclosing named owner). For a given hit line, the shared -//! `narrowest_named_owner` selects the unique narrowest containing region and -//! returns its anchor only when that region is `Named`; a barrier or an -//! equal-width tie abstains. +//! through to an enclosing named owner). For a given hit line, shared +//! `attribute_line` returns one typed `OwnerAttribution`: a unique narrowest +//! `Named` anchor or the explicit `ErrorLine`, `TopLevel`, `Tie`, or `Barrier` +//! reason. Whole-file `ParseFailed` is recorded at the dispatch boundary. //! //! Language-specific traversal and container logic stay in each adapter //! (`python.rs`, `rust.rs`, `js_ts.rs`); this module owns only the shared region //! model, the unique-narrowest selection, and local-`ERROR` primitives. +pub(crate) mod c_cpp; pub(crate) mod csharp; pub(crate) mod java; pub(crate) mod js_ts; pub(crate) mod kotlin; pub(crate) mod php; pub(crate) mod python; +pub(crate) mod ruby; pub(crate) mod rust; use crate::evidence::owner_links::OwnerAnchor; @@ -57,24 +59,101 @@ impl OwnerRegion { } } -/// Select the unique narrowest containing region for `line` and return its -/// anchor only when that region is `Named`. Abstains when no region contains -/// the line, when two or more regions tie for the narrowest span, or when the +/// One typed owner-attribution decision for a shown hit line: either the +/// unique narrowest named owner, or the parser-known reason attribution +/// conservatively stopped (US-073). Unsupported languages never reach this +/// type; they produce no attribution record at all. +#[derive(Debug, Clone, Copy)] +pub(crate) enum OwnerAttribution<'a> { + Named(&'a OwnerAnchor), + Abstained(OwnerAbstentionReason), +} + +/// Projection helpers for asserting one half of the decision. Production code +/// consumes `OwnerAttribution` by exhaustive `match` (so a future reason cannot +/// be silently dropped); these accessors exist for the adapter-level tests that +/// only care about one side of the decision. +#[cfg(test)] +impl<'a> OwnerAttribution<'a> { + /// The named owner, or `None` for any abstention. + pub(crate) fn named(self) -> Option<&'a OwnerAnchor> { + match self { + OwnerAttribution::Named(anchor) => Some(anchor), + OwnerAttribution::Abstained(_) => None, + } + } + + /// The abstention reason, or `None` when a named owner was selected. + pub(crate) fn abstained(self) -> Option { + match self { + OwnerAttribution::Named(_) => None, + OwnerAttribution::Abstained(reason) => Some(reason), + } + } +} + +/// Why structural owner attribution stopped. These describe only the parser's +/// attribution decision; they never rank a hit's relevance (in particular +/// `TopLevel` is a lexical-location fact, not an importance judgment). +/// +/// Declaration order IS the canonical render order, and the derived `Ord` is +/// relied on by the renderer's ordered collections. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) enum OwnerAbstentionReason { + /// The language is owner-supported but its parser/setup/tree acceptance + /// contract returned no usable analysis. Recorded at the dispatch boundary, + /// never by an adapter. + ParseFailed, + /// A collected parser `ERROR`/missing-token line range contains the hit. + ErrorLine, + /// The unique narrowest containing region is an `AnonymousBarrier`. V1 + /// intentionally combines true anonymous callables with named regions + /// degraded to barriers by a local error. + Barrier, + /// Analysis succeeded and no owner region contains the hit line. + TopLevel, + /// Two or more containing regions share the narrowest line span, so + /// line-only evidence cannot choose safely. + Tie, +} + +impl OwnerAbstentionReason { + /// The rendered lowercase label. + pub(crate) fn label(self) -> &'static str { + match self { + OwnerAbstentionReason::ParseFailed => "parse-failed", + OwnerAbstentionReason::ErrorLine => "error-line", + OwnerAbstentionReason::Barrier => "barrier", + OwnerAbstentionReason::TopLevel => "top-level", + OwnerAbstentionReason::Tie => "tie", + } + } +} + +/// Select the unique narrowest containing region for `line` and classify the +/// outcome. Abstains as `TopLevel` when no region contains the line, `Tie` when +/// two or more regions share the narrowest span, and `Barrier` when the unique /// narrowest region is an `AnonymousBarrier`. -pub(crate) fn narrowest_named_owner(regions: &[OwnerRegion], line: u32) -> Option<&OwnerAnchor> { +fn narrowest_region_decision(regions: &[OwnerRegion], line: u32) -> OwnerAttribution<'_> { let containing = regions .iter() .filter(|region| region.contains(line)) .collect::>(); - let min_span = containing.iter().map(|region| region.span()).min()?; + let Some(min_span) = containing.iter().map(|region| region.span()).min() else { + return OwnerAttribution::Abstained(OwnerAbstentionReason::TopLevel); + }; let mut narrowest = containing.iter().filter(|region| region.span() == min_span); - let first = narrowest.next()?; + let Some(first) = narrowest.next() else { + return OwnerAttribution::Abstained(OwnerAbstentionReason::TopLevel); + }; if narrowest.next().is_some() { - return None; + return OwnerAttribution::Abstained(OwnerAbstentionReason::Tie); } match first { - OwnerRegion::Named(anchor) => Some(anchor), - OwnerRegion::AnonymousBarrier { .. } => None, + OwnerRegion::Named(anchor) => OwnerAttribution::Named(anchor), + OwnerRegion::AnonymousBarrier { .. } => { + OwnerAttribution::Abstained(OwnerAbstentionReason::Barrier) + } } } @@ -186,18 +265,20 @@ pub(crate) fn any_error_contains_line(ranges: &[ErrorRange], line: u32) -> bool ranges.iter().any(|range| range.contains_line(line)) } -/// Attribute a hit line: abstain if it intersects a local error range (line -/// inputs cannot be narrowed by parser columns), otherwise select the unique -/// narrowest named owner. +/// Attribute a hit line to one typed decision. Fixed precedence: a local error +/// range containing the line wins over every region decision (line inputs +/// cannot be narrowed by parser columns), then the unique-narrowest region +/// classification applies. `ParseFailed` is not decided here; it is a +/// dispatch-boundary fact about the whole file. pub(crate) fn attribute_line<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { if any_error_contains_line(errors, line) { - return None; + return OwnerAttribution::Abstained(OwnerAbstentionReason::ErrorLine); } - narrowest_named_owner(regions, line) + narrowest_region_decision(regions, line) } /// A `Named` region whose byte span overlaps an error range is degraded to an @@ -245,10 +326,20 @@ mod tests { } } + fn error_line(line: u32) -> ErrorRange { + ErrorRange { + start_byte: 0, + end_byte: 1, + start_line: line, + end_line: line, + is_point: false, + } + } + #[test] fn picks_unique_narrowest_named_region() { let regions = vec![named("outer", 1, 10), named("inner", 3, 5)]; - let owner = narrowest_named_owner(®ions, 4).unwrap(); + let owner = attribute_line(®ions, &[], 4).named().unwrap(); assert_eq!(owner.name, "inner"); } @@ -257,14 +348,20 @@ mod tests { // A lambda (barrier) nested inside a named function: a hit inside the // lambda must not fall through to the enclosing named owner. let regions = vec![named("outer", 1, 10), barrier(3, 5)]; - assert!(narrowest_named_owner(®ions, 4).is_none()); + assert_eq!( + attribute_line(®ions, &[], 4).abstained(), + Some(OwnerAbstentionReason::Barrier) + ); } #[test] fn equal_span_tie_abstains() { let regions = vec![named("a", 1, 4), named("b", 2, 5)]; // Both span 3; a hit on line 3 is contained by both -> tie -> abstain. - assert!(narrowest_named_owner(®ions, 3).is_none()); + assert_eq!( + attribute_line(®ions, &[], 3).abstained(), + Some(OwnerAbstentionReason::Tie) + ); } #[test] @@ -272,10 +369,103 @@ mod tests { // A named def nested inside a lambda remains eligible within its own // narrower range. let regions = vec![named("outer", 1, 20), barrier(3, 10), named("inner", 4, 6)]; - let owner = narrowest_named_owner(®ions, 5).unwrap(); + let owner = attribute_line(®ions, &[], 5).named().unwrap(); assert_eq!(owner.name, "inner"); } + // ---- US-073: typed attribution decisions and fixed precedence ---- + + #[test] + fn no_containing_region_is_top_level() { + let regions = vec![named("f", 5, 9)]; + assert_eq!( + attribute_line(®ions, &[], 2).abstained(), + Some(OwnerAbstentionReason::TopLevel) + ); + // An empty region list is still an analyzed file: top-level, never + // parse-failed (parse failure is a dispatch-level fact). + assert_eq!( + attribute_line(&[], &[], 1).abstained(), + Some(OwnerAbstentionReason::TopLevel) + ); + } + + #[test] + fn error_line_wins_over_every_region_decision() { + // Precedence 3 beats 4/5/6/7: the same line would otherwise be named, + // top-level, tie, or barrier. + let errors = [error_line(4)]; + let named_regions = vec![named("outer", 1, 10), named("inner", 3, 5)]; + assert_eq!( + attribute_line(&named_regions, &errors, 4).abstained(), + Some(OwnerAbstentionReason::ErrorLine) + ); + let tie_regions = vec![named("a", 2, 5), named("b", 4, 7)]; + assert_eq!( + attribute_line(&tie_regions, &errors, 4).abstained(), + Some(OwnerAbstentionReason::ErrorLine) + ); + let barrier_regions = vec![barrier(3, 5)]; + assert_eq!( + attribute_line(&barrier_regions, &errors, 4).abstained(), + Some(OwnerAbstentionReason::ErrorLine) + ); + assert_eq!( + attribute_line(&[], &errors, 4).abstained(), + Some(OwnerAbstentionReason::ErrorLine) + ); + } + + #[test] + fn tie_wins_over_barrier_when_narrowest_span_is_shared() { + // Precedence 5 beats 6: a barrier tied with a named region of equal + // span is a tie, not a barrier. + let regions = vec![named("a", 2, 5), barrier(4, 7)]; + assert_eq!( + attribute_line(®ions, &[], 4).abstained(), + Some(OwnerAbstentionReason::Tie) + ); + } + + #[test] + fn named_decision_carries_the_anchor_and_no_reason() { + let regions = vec![named("inner", 3, 5)]; + let decision = attribute_line(®ions, &[], 4); + assert_eq!( + decision.named().map(|a| a.name.clone()), + Some("inner".into()) + ); + assert_eq!(decision.abstained(), None); + } + + #[test] + fn reason_labels_are_the_approved_lowercase_strings() { + assert_eq!(OwnerAbstentionReason::ParseFailed.label(), "parse-failed"); + assert_eq!(OwnerAbstentionReason::ErrorLine.label(), "error-line"); + assert_eq!(OwnerAbstentionReason::Barrier.label(), "barrier"); + assert_eq!(OwnerAbstentionReason::TopLevel.label(), "top-level"); + assert_eq!(OwnerAbstentionReason::Tie.label(), "tie"); + } + + #[test] + fn reason_sort_order_is_the_canonical_render_order() { + // Rendering groups reasons through ordered collections, so the derived + // `Ord` must equal the canonical render order. + let mut reasons = [ + OwnerAbstentionReason::Tie, + OwnerAbstentionReason::TopLevel, + OwnerAbstentionReason::Barrier, + OwnerAbstentionReason::ErrorLine, + OwnerAbstentionReason::ParseFailed, + ]; + reasons.sort(); + let labels = reasons.iter().map(|r| r.label()).collect::>(); + assert_eq!( + labels, + vec!["parse-failed", "error-line", "barrier", "top-level", "tie"] + ); + } + #[test] fn error_range_lines_abstain_and_overlapping_named_degrades() { let tree_less = vec![ErrorRange { diff --git a/src/evidence/owners/php.rs b/src/evidence/owners/php.rs index 9eb1b6e..d7e5532 100644 --- a/src/evidence/owners/php.rs +++ b/src/evidence/owners/php.rs @@ -26,7 +26,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -531,7 +532,7 @@ pub(crate) fn php_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -550,7 +551,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = php_owner_for(regions, errors, line); + let owner = php_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -560,7 +561,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - php_owner_for(regions, errors, line).is_none(), + php_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -975,7 +976,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = php_owner_for(®ions, &errors, hit_line); + let owner = php_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", @@ -989,14 +990,14 @@ mod tests { for &line in case.abstain { abstentions += 1; assert!( - php_owner_for(®ions, &errors, line).is_none(), + php_owner_for(®ions, &errors, line).named().is_none(), "[{}] line {line} should abstain", case.label ); } for &line in case.incidental { assert!( - php_owner_for(®ions, &errors, line).is_none(), + php_owner_for(®ions, &errors, line).named().is_none(), "[{}] incidental line {line} should abstain", case.label ); diff --git a/src/evidence/owners/python.rs b/src/evidence/owners/python.rs index 7b305c1..69034b9 100644 --- a/src/evidence/owners/python.rs +++ b/src/evidence/owners/python.rs @@ -19,7 +19,8 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ - attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerRegion, + attribute_line, collect_error_ranges, degrade_named_on_error, ErrorRange, OwnerAttribution, + OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -212,7 +213,7 @@ pub(crate) fn python_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } #[cfg(test)] @@ -232,7 +233,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = python_owner_for(regions, errors, line); + let owner = python_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -242,7 +243,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - python_owner_for(regions, errors, line).is_none(), + python_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -347,7 +348,9 @@ mod tests { language: Lang::Python, display_name: "f".into(), }; - assert!(attribute_line(&[OwnerRegion::Named(anchor)], &e, 1).is_none()); + assert!(attribute_line(&[OwnerRegion::Named(anchor)], &e, 1) + .named() + .is_none()); } #[test] @@ -601,7 +604,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = python_owner_for(®ions, &errors, hit_line); + let owner = python_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", diff --git a/src/evidence/owners/ruby.rs b/src/evidence/owners/ruby.rs new file mode 100644 index 0000000..4ba8139 --- /dev/null +++ b/src/evidence/owners/ruby.rs @@ -0,0 +1,1571 @@ +//! Ruby owner-region extraction (US-072 Wave 2). +//! +//! Supported named owners: grammar `method` and `singleton_method` definition +//! nodes with a complete parser-backed identity, covering ordinary multi-line, +//! empty (`def m; end`), endless, predicate/bang, setter/index, and operator +//! names. Display follows Ruby source-language convention: instance methods +//! render as `A::B#run`, singleton methods as `A::B.run` (or `self.run` with no +//! lexical class/module), and receiver-specific singleton methods render the +//! receiver authoritatively (`obj.m`, `@target.go`) without any lexical class +//! prefix. `OwnerAnchor.name` stays the flat terminal method name for shared +//! internals; the qualified identity lives in `display_name`. +//! +//! Containers (`class`/`module`) qualify descendants but are never owner +//! regions; entering a named container resets an inherited `class << self` +//! singleton mode. `singleton_class` is never a named owner: `class << self` +//! establishes exactly one lexical singleton level (`Path.m`), while non-`self` +//! receivers, higher-order eigenclasses (`def self.m` or another `class << +//! self` inside an active `class << self`), and error-contaminated values are +//! full-range barriers. +//! +//! Ordinary `block`/`do_block`/`lambda` closures are transparent (unlike the +//! C++ lambda barrier policy): a hit inside a block inherits the containing +//! method. Dynamic metaprogramming constructs — `define_method`, +//! `define_singleton_method`, the eval/exec families, keyword `alias`, +//! `alias_method`, and the `attr_*` accessors — are exact grammar-backed +//! hazards: the complete call/alias expression is an `AnonymousBarrier` before +//! descent so an attached block or a parser-recovered nested `def` can never +//! leak a fabricated owner. +//! +//! Barrier-before-descend is a release invariant (Wave 1 P1 lesson): every +//! identity-fail branch — malformed method name, malformed class/module path, +//! unsupported singleton receiver, non-`self`/higher-order singleton class, +//! metaprogramming hazard, and any error-degraded candidate — emits a full-range +//! `AnonymousBarrier` and descends in anonymous state, where a nested +//! `method`/`singleton_method` can never regain a name. Local `ERROR`/missing +//! degradation flows through the shared primitives. +//! +//! This module is syntax-only. A Ruby owner means the pinned grammar places the +//! hit in one complete lexical method definition; it never proves runtime +//! receiver identity, method-table mutation, dispatch, mixin ancestry, +//! reopening equivalence, or call binding. + +use std::path::Path; + +use tree_sitter::Node; + +use crate::evidence::owner_links::OwnerAnchor; +use crate::evidence::owners::{ + any_error_overlaps_bytes, attribute_line, collect_error_ranges, degrade_named_on_error, + ErrorRange, OwnerAttribution, OwnerRegion, +}; +use crate::lang::outline::outline_language; +use crate::types::Lang; + +/// Explicit disposition of every Ruby definition/container/closure/hazard kind +/// relevant to ownership (anti-drift contract, US-072 Wave 2 §Grammar): named, +/// container, transparent, barrier, or abstain. Every entry must exist in the +/// pinned grammar's `NODE_TYPES`. `call` is transparent for ordinary calls and +/// a barrier when its audited `method` terminal is one of +/// [`RUBY_METAPROGRAMMING_HAZARD_METHODS`]; keyword `alias` is always a barrier. +#[cfg(test)] +const RUBY_DISPOSITIONS: &[(&str, &str)] = &[ + ("method", "named"), + ("singleton_method", "named"), + ("class", "container"), + ("module", "container"), + ("singleton_class", "container"), + ("body_statement", "transparent"), + ("block", "transparent"), + ("do_block", "transparent"), + ("lambda", "transparent"), + ("block_body", "transparent"), + ("alias", "barrier"), + ("call", "transparent"), +]; + +/// Complete name/receiver component kinds the adapter reads as identity +/// evidence. `self` is the structural singleton receiver; `scope_resolution` +/// yields a complete `A::B` path; `_method_name` is the grammar supertype of +/// the `name` field. All must exist in `NODE_TYPES`. +#[cfg(test)] +const RUBY_NAME_OR_RECEIVER_KINDS: &[&str] = &[ + "identifier", + "constant", + "setter", + "operator", + "simple_symbol", + "delimited_symbol", + "instance_variable", + "class_variable", + "global_variable", + "self", + "scope_resolution", + "_method_name", + "_variable", + "_primary", + "_arg", +]; + +/// Audited dynamic-metaprogramming call hazards (12 call-style names; keyword +/// `alias` is handled by node kind). Recognition is intentionally narrow: only +/// an exact `call` whose `method` field terminal is one of these names becomes +/// a barrier — never arbitrary text or comments containing the words. +const RUBY_METAPROGRAMMING_HAZARD_METHODS: &[&str] = &[ + "define_method", + "define_singleton_method", + "class_eval", + "module_eval", + "instance_eval", + "class_exec", + "module_exec", + "instance_exec", + "alias_method", + "attr_accessor", + "attr_reader", + "attr_writer", +]; + +/// FNV-1a-64 fingerprint of the bundled `tree-sitter-ruby` `NODE_TYPES` JSON +/// (tree-sitter-ruby 0.23.1). Pinned so any grammar metadata change fails the +/// manifest gate and forces a re-audit. Computed from the exact bundled crate. +#[cfg(test)] +const RUBY_NODE_TYPES_FINGERPRINT: u64 = 0x3bdf_a906_ceee_d4a1; + +/// Stable FNV-1a 64-bit hash, dependency-free and reproducible across builds. +#[cfg(test)] +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + hash +} + +/// Lexical walk state. `containers` is the class/module path (joined with +/// `::`); `singleton_mode` is active exactly inside a `class << self` body; +/// `in_anonymous` is set while descending under an `AnonymousBarrier` so +/// nested definitions can never regain a fabricated name. +#[derive(Clone)] +struct WalkState { + containers: Vec, + singleton_mode: bool, + in_anonymous: bool, +} + +/// Parse a Ruby file and produce its owner regions and local error ranges. +/// Returns `None` when parser setup/parsing fails or the root itself is +/// `ERROR` (preserve raw hits, emit no owner evidence). +pub(crate) fn ruby_regions( + path: &Path, + content: &str, +) -> Option<(Vec, Vec)> { + let language = outline_language(Lang::Ruby)?; + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&language).is_err() { + return None; + } + let tree = parser.parse(content, None)?; + if tree.root_node().kind() == "ERROR" { + return None; + } + let bytes = content.as_bytes(); + let errors = collect_error_ranges(&tree, bytes); + let mut regions = Vec::new(); + let mut state = WalkState { + containers: Vec::new(), + singleton_mode: false, + in_anonymous: false, + }; + walk( + tree.root_node(), + bytes, + path, + &mut state, + &errors, + &mut regions, + ); + Some((regions, errors)) +} + +/// Attribute a Ruby hit line to a named owner, honoring local errors. +pub(crate) fn ruby_owner_for<'a>( + regions: &'a [OwnerRegion], + errors: &[ErrorRange], + line: u32, +) -> OwnerAttribution<'a> { + attribute_line(regions, errors, line) +} + +/// Emit a full-range `AnonymousBarrier` for `node` and descend in anonymous +/// state: every identity-fail branch must barrier before descent (US-072 Wave 2 +/// §ERROR, release invariant). +fn barrier_and_descend( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + regions.push(OwnerRegion::AnonymousBarrier { + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + }); + let saved_anonymous = state.in_anonymous; + state.in_anonymous = true; + walk_children(node, bytes, path, state, errors, regions); + state.in_anonymous = saved_anonymous; +} + +fn walk_children( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + walk(child, bytes, path, state, errors, regions); + } +} + +/// Walk a Ruby tree, emitting `Named` regions for complete identifiable +/// `method`/`singleton_method` definitions, `AnonymousBarrier` regions for +/// every abstaining definition, malformed container, metaprogramming hazard, +/// and higher-order eigenclass, and descending transparently through ordinary +/// closures (`block`, `do_block`, `lambda`, `body_statement`). +fn walk( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + match node.kind() { + "class" | "module" => { + handle_class_module(node, bytes, path, state, errors, regions); + } + "singleton_class" => { + handle_singleton_class(node, bytes, path, state, errors, regions); + } + "method" => { + handle_method(node, bytes, path, state, errors, regions); + } + "singleton_method" => { + handle_singleton_method(node, bytes, path, state, errors, regions); + } + // Keyword `alias new old` is a dynamic metaprogramming hazard: the + // complete expression is a barrier before descent. + "alias" => { + barrier_and_descend(node, bytes, path, state, errors, regions); + } + // `call` is transparent for ordinary calls and a barrier when its + // audited method terminal is one of the dynamic-definition hazards. + "call" if is_hazard_call(node, bytes) => { + barrier_and_descend(node, bytes, path, state, errors, regions); + } + // Blocks, procs, lambdas, body wrappers, and everything else descend + // transparently; only complete definition nodes become owners. + _ => { + walk_children(node, bytes, path, state, errors, regions); + } + } +} + +/// Handle a `class`/`module` container. A plain constant name is relative to +/// the lexical stack (`module A; class B` -> `A::B`). A complete +/// `scope_resolution` name is authoritative (Wave 1 §5.2 precedent): it is +/// merged with the lexical stack only when the qualified path's prefix matches +/// the stack's suffix without duplication (`module A` + `class A::B` -> +/// `A::B`); an unprovable combination (`module C` + `class A::B`) establishes a +/// full-range barrier before descent — never a guessed `C::A::B`. A leading +/// `::` root-qualified name (`class ::Rooted`) is explicitly absolute and +/// replaces the lexical stack (`Rooted`, never `M::Rooted`). Entering a named +/// container resets any inherited `class << self` singleton mode. +fn handle_class_module( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let Some(identity) = container_identity(node, bytes) else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + let saved_containers = state.containers.clone(); + let saved_singleton = state.singleton_mode; + match identity { + ContainerIdentity::Relative(components) => state.containers.extend(components), + ContainerIdentity::RootQualified(components) => state.containers = components, + ContainerIdentity::Qualified(components) => { + let Some(merged) = merge_qualified(&saved_containers, &components) else { + state.containers = saved_containers; + state.singleton_mode = saved_singleton; + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + state.containers = merged; + } + } + state.singleton_mode = false; + walk_children(node, bytes, path, state, errors, regions); + state.containers = saved_containers; + state.singleton_mode = saved_singleton; +} + +/// Handle a `singleton_class`. `class << self` establishes one lexical +/// singleton level; everything else — non-`self` value, unreadable/errored +/// value, a nested `class << self` while singleton mode is already active +/// (higher-order eigenclass), or any occurrence inside an anonymous barrier — +/// is a full-range barrier before descent. Never emits a named owner. +fn handle_singleton_class( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + if state.in_anonymous { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + let Some(value) = node.child_by_field_name("value") else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + if value.is_error() + || value.is_missing() + || value.kind() != "self" + || any_error_overlaps_bytes(errors, value.start_byte(), value.end_byte()) + { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + if state.singleton_mode { + // A second `class << self` targets a higher-order eigenclass that this + // lexical display cannot represent; fail closed rather than flattening + // its methods to the wrong `Path.m`. + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + state.singleton_mode = true; + walk_children(node, bytes, path, state, errors, regions); + state.singleton_mode = false; +} + +/// Handle an ordinary `method` definition. Emits `Named` only when the name +/// resolves to exactly one readable `_method_name` spelling; a body is not +/// required (an empty `def m; end` is a complete body-bearing definition node). +/// Inside an active `class << self` the display becomes `Path.m`; otherwise +/// `Path#m`, or the bare name at top level. +fn handle_method( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + if state.in_anonymous { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + let Some(name) = method_name(node, bytes) else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + let display = if state.singleton_mode { + let prefix = singleton_prefix(state); + format!("{prefix}.{name}") + } else if state.containers.is_empty() { + name.clone() + } else { + format!("{}#{name}", state.containers.join("::")) + }; + emit_anchor(node, bytes, path, state, &name, &display, errors, regions); +} + +/// Handle a `singleton_method` definition. `def self.m` renders `Path.m` (or +/// `self.m` outside a container). `def obj.m` with a supported complete +/// receiver (`identifier`, `constant`, instance/class/global variable, or a +/// complete `scope_resolution`) renders the receiver authoritatively with no +/// lexical class prefix. Any unsupported/errored receiver abstains as a +/// barrier. Inside an active `class << self` every `singleton_method` targets a +/// higher-order eigenclass and is a barrier before descent. +fn handle_singleton_method( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + errors: &[ErrorRange], + regions: &mut Vec, +) { + if state.in_anonymous { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + if state.singleton_mode { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + let Some(name) = method_name(node, bytes) else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + let Some(object) = node.child_by_field_name("object") else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + if object.is_error() || object.is_missing() { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + // Parenthesized receivers (`def (x).m`) are explicitly unsupported: the + // grammar unwraps the parens into a bare object, but the parens prove the + // receiver was an expression, so the whole definition must abstain. + if has_direct_paren_tokens(node) { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + } + let display = if object.kind() == "self" { + let prefix = singleton_prefix(state); + format!("{prefix}.{name}") + } else { + let Some(receiver) = receiver_text(object, bytes) else { + barrier_and_descend(node, bytes, path, state, errors, regions); + return; + }; + format!("{receiver}.{name}") + }; + emit_anchor(node, bytes, path, state, &name, &display, errors, regions); +} + +/// `self` when no lexical class/module exists, else the `::`-joined path. +fn singleton_prefix(state: &WalkState) -> String { + if state.containers.is_empty() { + "self".to_string() + } else { + state.containers.join("::") + } +} + +/// Build the `Named` anchor for a complete definition, pass it through the +/// shared error degradation, and descend. When degradation converts the +/// candidate to a barrier, descend in anonymous state (do not continue with +/// the prior transparent state), per the Wave 2 release invariant. +fn emit_anchor( + node: Node<'_>, + bytes: &[u8], + path: &Path, + state: &mut WalkState, + name: &str, + display: &str, + errors: &[ErrorRange], + regions: &mut Vec, +) { + let start_line = node.start_position().row as u32 + 1; + let end_line = node.end_position().row as u32 + 1; + let anchor = OwnerAnchor { + path: path.to_path_buf(), + name: name.to_string(), + receiver_var: None, + receiver_type: None, + package_dir: Path::new(".").to_path_buf(), + start_line, + end_line, + language: Lang::Ruby, + display_name: display.to_string(), + }; + let region = OwnerRegion::Named(anchor); + let region = degrade_named_on_error(region, errors, node.start_byte(), node.end_byte()); + let degraded = matches!(region, OwnerRegion::AnonymousBarrier { .. }); + regions.push(region); + let saved_anonymous = state.in_anonymous; + if degraded { + state.in_anonymous = true; + } + walk_children(node, bytes, path, state, errors, regions); + state.in_anonymous = saved_anonymous; +} + +/// Whether a `call` node's audited `method` terminal is a dynamic +/// metaprogramming hazard. Receiver-agnostic by design: `Foo.class_eval { }` +/// and bare `class_eval { }` are both barriers because the expression's +/// method-table effect is dynamic. +fn is_hazard_call(node: Node<'_>, bytes: &[u8]) -> bool { + let Some(method) = node.child_by_field_name("method") else { + return false; + }; + if method.is_error() || method.is_missing() { + return false; + } + let Ok(text) = method.utf8_text(bytes) else { + return false; + }; + RUBY_METAPROGRAMMING_HAZARD_METHODS.contains(&text.trim()) +} + +/// How a complete container name relates to the lexical container stack. +/// `Relative` (plain constant) appends to the stack; `Qualified` +/// (`scope_resolution` without a leading root marker) is authoritative and +/// merges only when structurally compatible; `RootQualified` (leading `::`) is +/// explicitly absolute and replaces the stack. +enum ContainerIdentity { + Relative(Vec), + Qualified(Vec), + RootQualified(Vec), +} + +/// Extract a complete `class`/`module` container identity: one `constant` or +/// one complete recursive `scope_resolution`, normalized with Ruby `::` (a +/// leading `::` root marker is tracked, not part of the display). Superclass +/// text is never part of the identity. Returns `None` for a +/// missing/unsupported/error-contaminated name (caller fails closed). +fn container_identity(node: Node<'_>, bytes: &[u8]) -> Option { + let name = node.child_by_field_name("name")?; + if name.is_error() || name.is_missing() { + return None; + } + match name.kind() { + "constant" => Some(ContainerIdentity::Relative(vec![readable_text( + name, bytes, + )?])), + "scope_resolution" => { + let (components, rooted) = scope_resolution_components(name, bytes)?; + Some(if rooted { + ContainerIdentity::RootQualified(components) + } else { + ContainerIdentity::Qualified(components) + }) + } + _ => None, + } +} + +/// Merge an authoritative qualified container path with the lexical stack +/// without duplication (Wave 1 §5.2): when the qualified path's prefix matches +/// the stack's suffix, the qualified path replaces that suffix +/// (`module A` + `class A::B` -> `[A, B]`). An empty stack is absolute. Any +/// other combination is unprovable and returns `None` so the caller barriers +/// the container instead of guessing (e.g. never `C::A::B`). +fn merge_qualified(lexical: &[String], qualified: &[String]) -> Option> { + if lexical.is_empty() { + return Some(qualified.to_vec()); + } + let max_k = qualified.len().min(lexical.len()); + for k in (1..=max_k).rev() { + if qualified[..k] == lexical[lexical.len() - k..] { + let mut merged = lexical[..lexical.len() - k].to_vec(); + merged.extend_from_slice(qualified); + return Some(merged); + } + } + None +} + +/// Recursively extract a `scope_resolution` path in left-to-right order +/// (`A::B::C` -> `[A, B, C]`), plus whether the leftmost component is +/// root-anchored (`::A::B` -> `([A, B], true)`; the grammar makes the `scope` +/// field optional for a leading `::`). Rejects any `ERROR`/missing node +/// anywhere in the path (Wave 1 P1 lesson: an unfield-assigned ERROR must not +/// slip past the field-name checks) and any unsupported scope component. +fn scope_resolution_components(node: Node<'_>, bytes: &[u8]) -> Option<(Vec, bool)> { + if node.is_error() || node.is_missing() { + return None; + } + let mut direct = node.walk(); + if node + .named_children(&mut direct) + .any(|c| c.is_error() || c.is_missing()) + { + return None; + } + let mut components = Vec::new(); + let mut rooted = false; + if let Some(scope) = node.child_by_field_name("scope") { + match scope.kind() { + "constant" => components.push(readable_text(scope, bytes)?), + "scope_resolution" => { + let (inner, inner_rooted) = scope_resolution_components(scope, bytes)?; + components.extend(inner); + rooted = inner_rooted; + } + _ => return None, + } + } else { + // No scope field: a leading `::` root marker (`::Rooted`). + rooted = true; + } + let name = node.child_by_field_name("name")?; + match name.kind() { + "constant" => components.push(readable_text(name, bytes)?), + _ => return None, + } + if components.is_empty() { + return None; + } + Some((components, rooted)) +} + +/// The method `name` field, resolved to exactly one readable `_method_name` +/// spelling (`identifier`, `constant`, `setter`, `operator`, `simple_symbol`, +/// or `delimited_symbol`). Never scans arbitrary source tokens or takes the +/// first identifier. +fn method_name(node: Node<'_>, bytes: &[u8]) -> Option { + let name = node.child_by_field_name("name")?; + if name.is_error() || name.is_missing() { + return None; + } + let mut cursor = name.walk(); + if name + .named_children(&mut cursor) + .any(|c| c.is_error() || c.is_missing()) + { + return None; + } + match name.kind() { + "identifier" | "constant" | "setter" | "operator" | "simple_symbol" + | "delimited_symbol" => readable_text(name, bytes), + _ => None, + } +} + +/// Whether a definition node has a `(` or `)` token among its direct +/// children. The grammar unwraps parenthesized receivers into a bare object +/// (`def (x).m` -> object `x`), so this detects the parenthesization that a +/// receiver-kind check alone would miss. Parameter parens live inside the +/// `method_parameters` field node, so ordinary `def obj.m(v)` is unaffected. +fn has_direct_paren_tokens(node: Node<'_>) -> bool { + let mut cursor = node.walk(); + let kids: Vec> = node.children(&mut cursor).collect(); + kids.iter().any(|c| matches!(c.kind(), "(" | ")")) +} + +/// A complete supported singleton receiver: `identifier`, `constant`, +/// instance/class/global variable, or a complete `scope_resolution` path. +/// `self` is handled by the caller. Anything else (call, index expression, +/// arithmetic/parenthesized expression, interpolation, chained invocation, +/// missing node, `ERROR`) returns `None` so the whole `singleton_method` +/// becomes a barrier — no fallback to a bare name and no fabricated lexical +/// `Class.m`. +fn receiver_text(node: Node<'_>, bytes: &[u8]) -> Option { + if node.is_error() || node.is_missing() { + return None; + } + let mut cursor = node.walk(); + if node + .named_children(&mut cursor) + .any(|c| c.is_error() || c.is_missing()) + { + return None; + } + match node.kind() { + "identifier" | "constant" | "instance_variable" | "class_variable" | "global_variable" => { + readable_text(node, bytes) + } + "scope_resolution" => { + scope_resolution_components(node, bytes).map(|(components, _)| components.join("::")) + } + _ => None, + } +} + +/// A readable identity spelling: trimmed, non-empty, with no whitespace, +/// newline, or structural/punctuation characters that could make the spelling +/// ambiguous or non-round-trippable. +fn readable_text(node: Node<'_>, bytes: &[u8]) -> Option { + let text = node.utf8_text(bytes).ok()?.trim().to_string(); + if text.is_empty() + || text.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '(' | ')' | '{' | '}' | ';' | ',' | ':' | '"' | '\'' | '#' | '.' + ) + }) + { + return None; + } + Some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::path::PathBuf; + + fn ruby_path() -> PathBuf { + PathBuf::from("tests/fixtures/x.rb") + } + + fn parse_ruby(src: &str) -> (Vec, Vec) { + ruby_regions(&ruby_path(), src).expect("Ruby fixture should parse") + } + + fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { + let owner = ruby_owner_for(regions, errors, line).named(); + assert_eq!( + owner.map(OwnerAnchor::qualified_name), + Some(name.to_string()), + "line {line} should attribute to {name}" + ); + if let Some(owner) = owner { + assert_eq!(owner.language, Lang::Ruby, "line {line} language"); + } + } + + fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { + assert!( + ruby_owner_for(regions, errors, line).named().is_none(), + "line {line} should abstain" + ); + } + + // ---- Method forms and exact ranges ---- + + #[test] + fn ruby_top_level_method_forms_exact_full_ranges() { + let (r, e) = parse_ruby( + "def run\n work\nend\ndef empty; end\ndef endless = 1\ndef pred?; end\ndef bang!; end\ndef m=(v); end\ndef [](i); end\n", + ); + assert_owner(&r, &e, 1, "run"); + assert_owner(&r, &e, 2, "run"); + assert_owner(&r, &e, 3, "run"); + let owner = ruby_owner_for(&r, &e, 1).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (1, 3)); + assert_eq!(owner.name, "run"); + // Empty, endless, predicate, bang, setter, index forms. + assert_owner(&r, &e, 4, "empty"); + assert_owner(&r, &e, 5, "endless"); + assert_owner(&r, &e, 6, "pred?"); + assert_owner(&r, &e, 7, "bang!"); + assert_owner(&r, &e, 8, "m="); + assert_owner(&r, &e, 9, "[]"); + } + + #[test] + fn ruby_operator_methods() { + let (r, e) = parse_ruby("def +(o)\n o\nend\ndef [](k, v)\nend\ndef <=>(o); end\n"); + assert_owner(&r, &e, 1, "+"); + assert_owner(&r, &e, 2, "+"); + assert_owner(&r, &e, 4, "[]"); + assert_owner(&r, &e, 6, "<=>"); + } + + #[test] + fn ruby_multiline_empty_endless_methods_keep_owner_inside() { + // A hit on any body line attributes to the full method range. + let (r, e) = parse_ruby("def outer\n if x\n hit_here\n end\nend\n"); + for line in 1..=5 { + assert_owner(&r, &e, line, "outer"); + } + let owner = ruby_owner_for(&r, &e, 1).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (1, 5)); + } + + // ---- Containers and qualification ---- + + #[test] + fn ruby_nested_module_class_qualification() { + let (r, e) = parse_ruby( + "module A\n class B\n def run\n end\n end\nend\nmodule X\n module Y\n def go; end\n end\nend\n", + ); + assert_owner(&r, &e, 3, "A::B#run"); + assert_owner(&r, &e, 4, "A::B#run"); + assert_owner(&r, &e, 9, "X::Y#go"); + } + + #[test] + fn ruby_complete_scope_resolution_container_paths() { + let (r, e) = + parse_ruby("class A::B\n def run; end\nend\nmodule X::Y\n def go; end\nend\n"); + assert_owner(&r, &e, 2, "A::B#run"); + assert_owner(&r, &e, 5, "X::Y#go"); + } + + #[test] + fn ruby_qualified_container_merges_matching_lexical_suffix_without_duplication() { + // Wave 2 P1: `module A; class A::B` defines A::B, not A::A::B. The + // qualified path is authoritative; the matching lexical suffix is + // consumed, never duplicated. + let (r, e) = parse_ruby( + "module A\n class A::B\n def hit_method\n duplicate_container_hit\n end\n end\nend\n", + ); + assert_owner(&r, &e, 3, "A::B#hit_method"); + assert_owner(&r, &e, 4, "A::B#hit_method"); + assert_owner(&r, &e, 5, "A::B#hit_method"); + let owner = ruby_owner_for(&r, &e, 4).named().unwrap(); + assert_eq!((owner.start_line, owner.end_line), (3, 5)); + // Deeper stack: the qualified prefix matches the stack suffix, so the + // outer lexical component is kept exactly once. + let (r, e) = parse_ruby( + "module X\n module A\n class A::B\n def m; end\n end\n end\nend\n", + ); + assert_owner(&r, &e, 4, "X::A::B#m"); + // Longest match wins: the whole stack is consumed, not just one level. + let (r, e) = parse_ruby( + "module A\n class B\n class A::B::C\n def m; end\n end\n end\nend\n", + ); + assert_owner(&r, &e, 4, "A::B::C#m"); + } + + #[test] + fn ruby_unprovable_qualified_container_barriers_before_descent() { + // `module C; class A::B` — the grammar cannot prove where `A` resolves + // (Module.nesting could find C::A or ::A, decided in another file), so + // the container is a full-range barrier. Never a guessed C::A::B. + let (r, e) = + parse_ruby("module C\n class A::B\n def m\n hit\n end\n end\nend\n"); + for line in 2..=6 { + assert_abstain(&r, &e, line); + } + // The barrier is scoped: a sibling container after it still attributes. + let (r, e) = parse_ruby( + "module C\n class A::B\n def hidden; end\n end\n class Ok\n def shown; end\n end\nend\n", + ); + assert_abstain(&r, &e, 3); + assert_owner(&r, &e, 6, "C::Ok#shown"); + // Deeper stack with no witness anywhere in it: still a barrier, and a + // nested def inside the barriered container cannot leak a shortened + // owner either. + let (r, e) = parse_ruby( + "module X\n module Y\n class A::B\n def m\n def inner; end\n end\n end\n end\nend\n", + ); + for line in 3..=7 { + assert_abstain(&r, &e, line); + } + // Sibling scoping holds at depth too. + let (r, e) = parse_ruby( + "module X\n module Y\n class A::B\n def hidden; end\n end\n class Ok\n def shown; end\n end\n end\nend\n", + ); + assert_abstain(&r, &e, 4); + assert_owner(&r, &e, 7, "X::Y::Ok#shown"); + } + + #[test] + fn ruby_root_qualified_container_is_absolute() { + // Wave 2 P2: the grammar makes `scope` optional for a leading `::`. + // A root-qualified name is complete and absolute. + let (r, e) = + parse_ruby("class ::Rooted\n def rooted_method\n rooted_hit\n end\nend\n"); + assert_owner(&r, &e, 2, "Rooted#rooted_method"); + assert_owner(&r, &e, 3, "Rooted#rooted_method"); + // Absolute ignores the lexical stack: never M::Rooted. + let (r, e) = + parse_ruby("module M\n class ::Rooted\n def nested_rooted; end\n end\nend\n"); + assert_owner(&r, &e, 3, "Rooted#nested_rooted"); + // Multi-component root path, inside and outside a module. + let (r, e) = parse_ruby("class ::A::B\n def deep; end\nend\nmodule M\n module ::X::Y\n def go; end\n end\nend\n"); + assert_owner(&r, &e, 2, "A::B#deep"); + assert_owner(&r, &e, 6, "X::Y#go"); + // The lexical stack is restored after an absolute container closes. + let (r, e) = parse_ruby( + "module M\n class ::Rooted\n def rooted; end\n end\n class Sibling\n def sib; end\n end\nend\n", + ); + assert_owner(&r, &e, 3, "Rooted#rooted"); + assert_owner(&r, &e, 6, "M::Sibling#sib"); + } + + #[test] + fn ruby_merge_qualified_combination_table() { + fn owned(parts: &[&str]) -> Vec { + parts.iter().copied().map(String::from).collect() + } + /// (lexical stack, qualified path, merged result or `None` = barrier). + type MergeCase = ( + &'static [&'static str], + &'static [&'static str], + Option<&'static [&'static str]>, + ); + let cases: &[MergeCase] = &[ + // Empty stack: absolute. + (&[], &["A", "B"], Some(&["A", "B"])), + // Suffix match consumes the duplicate exactly once. + (&["A"], &["A", "B"], Some(&["A", "B"])), + (&["X", "A"], &["A", "B"], Some(&["X", "A", "B"])), + (&["A", "B"], &["A", "B", "C"], Some(&["A", "B", "C"])), + // Longest match wins over a shorter accidental one. + (&["A", "A"], &["A", "A", "B"], Some(&["A", "A", "B"])), + // Mid-stack witness: the qualified prefix matches the stack's + // suffix, so the merge is syntactically supported. Verified against + // the Ruby runtime: `module A; class B; class B::C` -> A::B::C. + (&["A", "B"], &["B", "C"], Some(&["A", "B", "C"])), + // No witness anywhere: caller barriers instead of guessing. + (&["C"], &["A", "B"], None), + (&["A"], &["B", "A"], None), + (&["X", "Y"], &["A", "B"], None), + ]; + for (lexical, qualified, expected) in cases { + let got = merge_qualified(&owned(lexical), &owned(qualified)); + assert_eq!( + got, + expected.map(owned), + "lexical {lexical:?} + qualified {qualified:?}" + ); + } + } + + #[test] + fn ruby_instance_vs_singleton_display() { + let (r, e) = parse_ruby( + "module A\n class B\n def run; end\n def self.find; end\n end\nend\ndef self.top; end\n", + ); + assert_owner(&r, &e, 3, "A::B#run"); + assert_owner(&r, &e, 4, "A::B.find"); + assert_owner(&r, &e, 7, "self.top"); + } + + // ---- Receiver-specific singleton methods ---- + + #[test] + fn ruby_supported_receiver_singleton_methods() { + let (r, e) = parse_ruby( + "def obj.m; end\ndef @target.go; end\ndef @@shared.hit; end\ndef $global.kick; end\ndef CONST.poke; end\n", + ); + assert_owner(&r, &e, 1, "obj.m"); + assert_owner(&r, &e, 2, "@target.go"); + assert_owner(&r, &e, 3, "@@shared.hit"); + assert_owner(&r, &e, 4, "$global.kick"); + assert_owner(&r, &e, 5, "CONST.poke"); + } + + #[test] + fn ruby_receiver_is_authoritative_no_lexical_prefix() { + // The receiver object defines the singleton method, not the lexical + // class: `obj.m` inside `class A` stays `obj.m`, never `A.obj.m`. + let (r, e) = parse_ruby("class A\n def obj.m; end\nend\n"); + assert_owner(&r, &e, 2, "obj.m"); + } + + #[test] + fn ruby_complex_or_errored_receiver_abstains() { + // Chained invocation, arithmetic, parenthesized/expression receivers, + // and the grammar-unsupported `def A::B.m` (ERROR inside the node) all + // abstain as barriers — never a bare `m` and never a fabricated + // `A.m`/`A::B.m`. + let (r, e) = + parse_ruby("def a.b.c; end\ndef (x).m; end\ndef 1+2.m; end\ndef A::B.static; end\n"); + for line in 1..=4 { + assert_abstain(&r, &e, line); + } + } + + // ---- class << self ---- + + #[test] + fn ruby_class_self_singleton_mode() { + let (r, e) = parse_ruby( + "class A\n class << self\n def build\n end\n def find; end\n end\nend\nclass << self\n def top_self; end\nend\n", + ); + assert_owner(&r, &e, 3, "A.build"); + assert_owner(&r, &e, 4, "A.build"); + assert_owner(&r, &e, 5, "A.find"); + assert_owner(&r, &e, 9, "self.top_self"); + } + + #[test] + fn ruby_class_self_nested_body_wrappers_transparent() { + // Body wrappers inside `class << self` stay transparent; methods + // defined under an ordinary block still render `Path.m`. + let (r, e) = parse_ruby( + "class A\n class << self\n if cond\n def guarded; end\n end\n end\nend\n", + ); + assert_owner(&r, &e, 4, "A.guarded"); + } + + #[test] + fn ruby_named_container_resets_singleton_mode() { + // A named class inside `class << self` resets inherited singleton + // mode: its direct method is an instance method of the new container. + let (r, e) = parse_ruby( + "class << self\n class Inner\n def m; end\n end\n def outer_m; end\nend\n", + ); + assert_owner(&r, &e, 3, "Inner#m"); + assert_owner(&r, &e, 5, "self.outer_m"); + } + + #[test] + fn ruby_higher_order_eigenclass_barriers() { + // `def self.m` and a nested `class << self` inside an active + // `class << self` target a higher-order eigenclass: full barriers, + // and nested definitions never leak. + let (r, e) = parse_ruby( + "class A\n class << self\n def self.ho; end\n class << self\n def deep; end\n end\n end\nend\n", + ); + for line in 1..=7 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_non_self_singleton_class_barrier() { + let (r, e) = parse_ruby("class A\n class << obj\n def om; end\n end\nend\n"); + assert_abstain(&r, &e, 1); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + assert_abstain(&r, &e, 4); + } + + // ---- Transparent closures ---- + + #[test] + fn ruby_ordinary_closures_inherit_enclosing_method() { + let (r, e) = parse_ruby( + "def outer\n [1, 2].each do |i|\n puts i\n end\n x = ->(a) { a }\n proc { hit }\n items.map { |it| it }\nend\n", + ); + for line in 1..=8 { + assert_owner(&r, &e, line, "outer"); + } + } + + #[test] + fn ruby_top_level_closures_are_ownerless() { + let (r, e) = parse_ruby("[1].each { |i| hit(i) }\ndo_it do\n x\nend\n->(a) { a }\n"); + for line in 1..=5 { + assert_abstain(&r, &e, line); + } + } + + // ---- Nested definitions ---- + + #[test] + fn ruby_nested_def_is_independent_owner() { + // `def inner` inside `def outer` is an independent definition site + // qualified only by the class/module context, never `outer#inner`. + let (r, e) = + parse_ruby("class A\n def outer\n def inner\n end\n hit_body\n end\nend\n"); + assert_owner(&r, &e, 2, "A#outer"); + assert_owner(&r, &e, 3, "A#inner"); + assert_owner(&r, &e, 4, "A#inner"); + assert_owner(&r, &e, 5, "A#outer"); + assert_owner(&r, &e, 6, "A#outer"); + assert_abstain(&r, &e, 7); + } + + #[test] + fn ruby_malformed_nested_def_is_barrier() { + // A nested def that cannot establish identity (parenthesized receiver + // is an unsupported singleton form) becomes its own barrier; the + // surrounding outer method body still attributes normally. + let (r, e) = parse_ruby("def outer\n def (x).inner; end\n tail\nend\n"); + assert_owner(&r, &e, 1, "outer"); + assert_abstain(&r, &e, 2); + assert_owner(&r, &e, 3, "outer"); + assert_owner(&r, &e, 4, "outer"); + } + + // ---- Dynamic metaprogramming barriers ---- + + #[test] + fn ruby_define_method_family_produces_no_owner_and_blocks_do_not_leak() { + let (r, e) = parse_ruby( + "define_method(:run) { |x| x }\ndefine_singleton_method(:go) do\n def leaked\n end\nend\n", + ); + for line in 1..=5 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_eval_and_exec_families_are_barriers_with_no_block_leak() { + let (r, e) = parse_ruby( + "class_eval { def leaker1; end }\nmodule_eval do\n def leaker2; end\nend\ninstance_eval { }\nclass_exec { def leaker3; end }\nmodule_exec { }\ninstance_exec { }\n", + ); + for line in 1..=7 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_alias_and_alias_method_produce_no_owner() { + let (r, e) = parse_ruby("alias new_name old_name\nalias_method :new_name, :old_name\n"); + assert_abstain(&r, &e, 1); + assert_abstain(&r, &e, 2); + } + + #[test] + fn ruby_attr_accessors_produce_no_owner() { + let (r, e) = parse_ruby( + "class A\n attr_accessor :name\n attr_reader :read_only\n attr_writer :write_only\n def real; end\nend\n", + ); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + assert_abstain(&r, &e, 4); + assert_owner(&r, &e, 5, "A#real"); + } + + #[test] + fn ruby_metaprogramming_hazard_barrier_blocks_enclosing_fallthrough() { + // A hazard line inside a method must not attribute to the method; the + // surrounding body still does. + let (r, e) = parse_ruby("def outer\n define_method(:x) { }\n hit_tail\nend\n"); + assert_owner(&r, &e, 1, "outer"); + assert_abstain(&r, &e, 2); + assert_owner(&r, &e, 3, "outer"); + } + + #[test] + fn ruby_ordinary_calls_remain_transparent() { + // `proc`, `Proc.new`, `send`, and plain calls are NOT hazards; their + // bodies inherit the enclosing method. + let (r, e) = parse_ruby( + "def outer\n proc { hit }\n Proc.new { hit2 }\n send(:define_method, :x) { hit3 }\n helper\nend\n", + ); + for line in 1..=6 { + assert_owner(&r, &e, line, "outer"); + } + } + + // ---- Reopened sites and determinism ---- + + #[test] + fn ruby_reopened_classes_keep_independent_ranges() { + let (r, e) = parse_ruby("class A\n def m; end\nend\nclass A\n def m; end\nend\n"); + let owners: Vec<&OwnerAnchor> = (1..=6) + .filter_map(|line| ruby_owner_for(&r, &e, line).named()) + .collect(); + let mut ranges: HashSet<(u32, u32)> = + owners.iter().map(|o| (o.start_line, o.end_line)).collect(); + assert_eq!(ranges.len(), 2, "two reopened sites must stay distinct"); + assert!(ranges.remove(&(2, 2))); + assert!(ranges.remove(&(5, 5))); + assert_owner(&r, &e, 2, "A#m"); + assert_owner(&r, &e, 5, "A#m"); + } + + // ---- Error degradation and anti-leak controls ---- + + #[test] + fn ruby_local_error_degrades_method_while_clean_sibling_survives() { + // An unclosed parameter list leaves a local missing token inside + // `broken` only: the whole method degrades to a barrier while the + // clean `also_good` sibling keeps its owner. + let (r, e) = + parse_ruby("def good\n hit\nend\ndef broken(\n x\nend\ndef also_good\n hit2\nend\n"); + assert_owner(&r, &e, 1, "good"); + assert_owner(&r, &e, 2, "good"); + assert_abstain(&r, &e, 4); + assert_abstain(&r, &e, 5); + assert_abstain(&r, &e, 6); + assert_owner(&r, &e, 8, "also_good"); + assert_owner(&r, &e, 9, "also_good"); + } + + #[test] + fn ruby_error_degraded_method_does_not_leak_recovered_nested_method() { + // An error-overlapping outer method degrades to a barrier AND descends + // in anonymous state: a parser-recovered nested method must not regain + // a name (Wave 2 §ERROR point 5/7). + let (r, e) = parse_ruby("def broken\n x = (\n def recovered\n hit\n end\nend\n"); + for line in 1..=5 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_metaprogramming_block_nested_def_never_leaks() { + // The mandatory anti-leak repro: a `define_method` block containing a + // nested `def leaked` emits zero owner evidence. + let (r, e) = parse_ruby("define_method(:sym) do\n def leaked\n hit\n end\nend\n"); + for line in 1..=5 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_non_self_singleton_class_block_nested_def_never_leaks() { + let (r, e) = parse_ruby("class A\n class << obj\n def om\n def leaked\n end\n end\n end\nend\n"); + for line in 1..=7 { + assert_abstain(&r, &e, line); + } + } + + #[test] + fn ruby_malformed_class_path_never_leaks() { + let (r, e) = + parse_ruby("class A::\n def leaked; end\nend\nclass Good\n def fine; end\nend\n"); + // `class A::` parses with a missing constant -> the container is a + // barrier; the leaked method never regains a name. + assert_abstain(&r, &e, 1); + assert_abstain(&r, &e, 2); + assert_abstain(&r, &e, 3); + assert_owner(&r, &e, 5, "Good#fine"); + assert_abstain(&r, &e, 6); + } + + /// A single independent fixture, parsed on its own with 1-based local line + /// numbers. `owners` lists `(hit_line, name, start_line, end_line)` that + /// must attribute to the exact owner; `abstain` lists lines that must + /// abstain and count toward the intentional-abstention floor; `incidental` + /// lists lines that must abstain but do NOT count. + struct Case { + label: &'static str, + source: &'static str, + owners: &'static [(u32, &'static str, u32, u32)], + abstain: &'static [u32], + incidental: &'static [u32], + } + + /// US-072 Wave 2 curated Ruby accuracy gate. + #[test] + fn ruby_owner_matrix_meets_quality_gates() { + let cases: &[Case] = &[ + Case { + label: "top-level method", + source: "def run\n work\nend\n", + owners: &[(1, "run", 1, 3), (2, "run", 1, 3), (3, "run", 1, 3)], + abstain: &[4], + incidental: &[], + }, + Case { + label: "empty and endless methods", + source: "def empty; end\ndef endless = 1\n", + owners: &[(1, "empty", 1, 1), (2, "endless", 2, 2)], + abstain: &[3], + incidental: &[], + }, + Case { + label: "predicate bang setter index operator", + source: "def p?; end\ndef b!; end\ndef s=(v); end\ndef [](i); end\ndef +(o); end\n", + owners: &[ + (1, "p?", 1, 1), + (2, "b!", 2, 2), + (3, "s=", 3, 3), + (4, "[]", 4, 4), + (5, "+", 5, 5), + ], + abstain: &[6], + incidental: &[], + }, + Case { + label: "nested module class", + source: "module A\n class B\n def run; end\n end\nend\n", + owners: &[(3, "A::B#run", 3, 3)], + abstain: &[1, 2, 4], + incidental: &[5], + }, + Case { + label: "scope resolution container", + source: "class A::B\n def run; end\nend\n", + owners: &[(2, "A::B#run", 2, 2)], + abstain: &[1], + incidental: &[3], + }, + Case { + label: "qualified container suffix-witness merge", + source: "module A\n class A::B\n def m; end\n end\nend\n", + owners: &[(3, "A::B#m", 3, 3)], + abstain: &[1, 2, 4], + incidental: &[5], + }, + Case { + label: "unprovable qualified container barrier", + source: "module C\n class A::B\n def m; end\n end\nend\n", + owners: &[], + abstain: &[1, 2, 3, 4], + incidental: &[5], + }, + Case { + label: "root-qualified container is absolute", + source: "module M\n class ::Rooted\n def m; end\n end\nend\n", + owners: &[(3, "Rooted#m", 3, 3)], + abstain: &[1, 2, 4], + incidental: &[5], + }, + Case { + label: "instance vs singleton", + source: "class A\n def run; end\n def self.find; end\nend\n", + owners: &[(2, "A#run", 2, 2), (3, "A.find", 3, 3)], + abstain: &[1], + incidental: &[4], + }, + Case { + label: "top-level def self", + source: "def self.top; end\n", + owners: &[(1, "self.top", 1, 1)], + abstain: &[], + incidental: &[2], + }, + Case { + label: "supported receivers", + source: "def obj.m; end\ndef @t.go; end\ndef $g.kick; end\n", + owners: &[(1, "obj.m", 1, 1), (2, "@t.go", 2, 2), (3, "$g.kick", 3, 3)], + abstain: &[4], + incidental: &[], + }, + Case { + label: "complex receiver abstains", + source: "def a.b.c; end\ndef (x).m; end\ndef A::B.static; end\n", + owners: &[], + abstain: &[1, 2, 3], + incidental: &[4], + }, + Case { + label: "class << self", + source: "class A\n class << self\n def build; end\n end\nend\n", + owners: &[(3, "A.build", 3, 3)], + abstain: &[1, 2, 4], + incidental: &[5], + }, + Case { + label: "class << self top-level", + source: "class << self\n def top_self; end\nend\n", + owners: &[(2, "self.top_self", 2, 2)], + abstain: &[1], + incidental: &[3], + }, + Case { + label: "singleton mode reset", + source: "class << self\n class Inner\n def m; end\n end\nend\n", + owners: &[(3, "Inner#m", 3, 3)], + abstain: &[1, 2, 4], + incidental: &[5], + }, + Case { + label: "higher-order eigenclass barriers", + source: "class A\n class << self\n def self.ho; end\n end\nend\n", + owners: &[], + abstain: &[1, 2, 3, 4], + incidental: &[5], + }, + Case { + label: "non-self singleton class barrier", + source: "class A\n class << obj\n def om; end\n end\nend\n", + owners: &[], + abstain: &[1, 2, 3, 4], + incidental: &[5], + }, + Case { + label: "transparent block inside method", + source: "def outer\n [1].each do |i|\n puts i\n end\nend\n", + owners: &[ + (1, "outer", 1, 5), + (2, "outer", 1, 5), + (3, "outer", 1, 5), + (4, "outer", 1, 5), + (5, "outer", 1, 5), + ], + abstain: &[], + incidental: &[6], + }, + Case { + label: "top-level closure ownerless", + source: "[1].each { |i| hit }\n->(a) { a }\n", + owners: &[], + abstain: &[1, 2], + incidental: &[3], + }, + Case { + label: "nested def independent", + source: "def outer\n def inner; end\n tail\nend\n", + owners: &[ + (1, "outer", 1, 4), + (2, "inner", 2, 2), + (3, "outer", 1, 4), + (4, "outer", 1, 4), + ], + abstain: &[5], + incidental: &[], + }, + Case { + label: "define_method barrier", + source: "define_method(:run) { |x| x }\n", + owners: &[], + abstain: &[1], + incidental: &[2], + }, + Case { + label: "eval family barrier", + source: "class_eval { def leaker; end }\n", + owners: &[], + abstain: &[1], + incidental: &[2], + }, + Case { + label: "alias and alias_method barrier", + source: "alias new_name old_name\nalias_method :a, :b\n", + owners: &[], + abstain: &[1, 2], + incidental: &[3], + }, + Case { + label: "attr_* barrier with clean sibling", + source: "class A\n attr_accessor :name\n def real; end\nend\n", + owners: &[(3, "A#real", 3, 3)], + abstain: &[1, 2], + incidental: &[4], + }, + Case { + label: "hazard line abstains body keeps owner", + source: "def outer\n define_method(:x) { }\n tail\nend\n", + owners: &[(1, "outer", 1, 4), (3, "outer", 1, 4), (4, "outer", 1, 4)], + abstain: &[2], + incidental: &[5], + }, + Case { + label: "reopened class sites", + source: "class A\n def m; end\nend\nclass A\n def m; end\nend\n", + owners: &[(2, "A#m", 2, 2), (5, "A#m", 5, 5)], + abstain: &[1, 3, 4, 6], + incidental: &[], + }, + Case { + label: "error degrades method sibling survives", + source: "def good\n hit\nend\ndef broken(\n x\nend\ndef also\n hit2\nend\n", + owners: &[ + (1, "good", 1, 3), + (2, "good", 1, 3), + (3, "good", 1, 3), + (7, "also", 7, 9), + (8, "also", 7, 9), + (9, "also", 7, 9), + ], + abstain: &[4, 5, 6], + incidental: &[], + }, + Case { + label: "malformed class path barrier", + source: "class A::\n def leaked; end\nend\nclass Good\n def fine; end\nend\n", + owners: &[(5, "Good#fine", 5, 5)], + abstain: &[1, 2, 3], + incidental: &[4, 6], + }, + ]; + + let mut positives = 0u32; + let mut abstentions = 0u32; + for case in cases { + let (regions, errors) = parse_ruby(case.source); + for &(hit_line, name, start, end) in case.owners { + positives += 1; + let owner = ruby_owner_for(®ions, &errors, hit_line).named(); + assert!( + owner.is_some(), + "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", + case.label + ); + let owner = owner.unwrap(); + assert_eq!(owner.qualified_name(), name, "[{}] name", case.label); + assert_eq!(owner.start_line, start, "[{}] start", case.label); + assert_eq!(owner.end_line, end, "[{}] end", case.label); + assert_eq!(owner.language, Lang::Ruby, "[{}] language", case.label); + } + for &line in case.abstain { + abstentions += 1; + assert!( + ruby_owner_for(®ions, &errors, line).named().is_none(), + "[{}] line {line} should abstain", + case.label + ); + } + for &line in case.incidental { + assert!( + ruby_owner_for(®ions, &errors, line).named().is_none(), + "[{}] incidental line {line} should abstain", + case.label + ); + } + } + assert!( + positives >= 40, + "positive floor: only {positives} assertions" + ); + assert!( + abstentions >= 40, + "intentional-abstention floor: only {abstentions} assertions" + ); + } + + /// US-072 Wave 2 §Grammar: pinned fingerprint + explicit disposition + /// manifests. Any grammar metadata change must fail here with an + /// actionable re-audit message rather than silently widening claims. + #[test] + fn manifest_gate_verifies_kinds_exist_and_fingerprint_is_pinned() { + let v: serde_json::Value = serde_json::from_str(tree_sitter_ruby::NODE_TYPES).unwrap(); + let kinds: HashSet = v + .as_array() + .unwrap() + .iter() + .filter_map(|e| e.get("type").and_then(|t| t.as_str()).map(String::from)) + .collect(); + assert!( + !kinds.is_empty(), + "tree-sitter-ruby NODE_TYPES must declare node kinds" + ); + + let mut all: Vec<&str> = Vec::new(); + all.extend(RUBY_DISPOSITIONS.iter().map(|(kind, _)| *kind)); + all.extend(RUBY_NAME_OR_RECEIVER_KINDS); + let uniq: HashSet<&str> = all.iter().copied().collect(); + assert_eq!( + uniq.len(), + all.len(), + "tree-sitter-ruby manifest inventories must be disjoint" + ); + for kind in &uniq { + assert!( + kinds.contains(*kind), + "tree-sitter-ruby: manifest kind {kind} missing from NODE_TYPES" + ); + } + + // Every Ruby definition/container/closure/hazard kind relevant to + // ownership has an explicit disposition. + let disposition_of: std::collections::HashMap<&str, &str> = + RUBY_DISPOSITIONS.iter().copied().collect(); + for kind in &kinds { + let is_relevant = matches!( + kind.as_str(), + "method" + | "singleton_method" + | "class" + | "module" + | "singleton_class" + | "body_statement" + | "block" + | "do_block" + | "lambda" + | "block_body" + | "alias" + | "call" + ); + if !is_relevant { + continue; + } + assert!( + disposition_of.contains_key(kind.as_str()), + "tree-sitter-ruby: grammar kind {kind} has no explicit disposition; add one (named/container/transparent/barrier/abstain)" + ); + } + for (kind, disposition) in RUBY_DISPOSITIONS { + assert!( + matches!( + *disposition, + "named" | "container" | "transparent" | "barrier" | "abstain" + ), + "tree-sitter-ruby: invalid disposition {disposition} for {kind}" + ); + } + + // The audited hazard-name manifest is a set of distinct, non-empty + // call method terminals (keyword `alias` is covered by node kind). + let hazard_uniq: HashSet<&str> = RUBY_METAPROGRAMMING_HAZARD_METHODS + .iter() + .copied() + .collect(); + assert_eq!( + hazard_uniq.len(), + RUBY_METAPROGRAMMING_HAZARD_METHODS.len(), + "hazard manifest must not duplicate names" + ); + assert!( + hazard_uniq.iter().all(|h| !h.is_empty()), + "hazard manifest must not contain empty names" + ); + + assert_eq!( + fnv1a(tree_sitter_ruby::NODE_TYPES.as_bytes()), + RUBY_NODE_TYPES_FINGERPRINT, + "tree-sitter-ruby NODE_TYPES changed; re-audit the manifest and recompute the fingerprint" + ); + } +} diff --git a/src/evidence/owners/rust.rs b/src/evidence/owners/rust.rs index 5263373..2959fe9 100644 --- a/src/evidence/owners/rust.rs +++ b/src/evidence/owners/rust.rs @@ -19,7 +19,7 @@ use tree_sitter::Node; use crate::evidence::owner_links::OwnerAnchor; use crate::evidence::owners::{ any_error_overlaps_bytes, attribute_line, collect_error_ranges, degrade_named_on_error, - ErrorRange, OwnerRegion, + ErrorRange, OwnerAttribution, OwnerRegion, }; use crate::lang::outline::outline_language; use crate::types::Lang; @@ -444,7 +444,7 @@ pub(crate) fn rust_owner_for<'a>( regions: &'a [OwnerRegion], errors: &[ErrorRange], line: u32, -) -> Option<&'a OwnerAnchor> { +) -> OwnerAttribution<'a> { attribute_line(regions, errors, line) } @@ -463,7 +463,7 @@ mod tests { } fn assert_owner(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32, name: &str) { - let owner = rust_owner_for(regions, errors, line); + let owner = rust_owner_for(regions, errors, line).named(); assert_eq!( owner.map(OwnerAnchor::qualified_name), Some(name.to_string()), @@ -473,7 +473,7 @@ mod tests { fn assert_abstain(regions: &[OwnerRegion], errors: &[ErrorRange], line: u32) { assert!( - rust_owner_for(regions, errors, line).is_none(), + rust_owner_for(regions, errors, line).named().is_none(), "line {line} should abstain" ); } @@ -1060,7 +1060,7 @@ mod tests { let (regions, errors) = parse(case.source); for &(hit_line, name, start, end) in case.owners { positives += 1; - let owner = rust_owner_for(®ions, &errors, hit_line); + let owner = rust_owner_for(®ions, &errors, hit_line).named(); assert!( owner.is_some(), "[{}] line {hit_line}: expected {name}@{start}-{end}, got abstain", diff --git a/src/main.rs b/src/main.rs index f5b3ce4..0ca2550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; mod cli; mod cli_run; mod output; +mod update; mod version; use clap::{error::ErrorKind, CommandFactory, Parser}; @@ -375,6 +376,10 @@ fn main() { version::run_version(cmd.check); return; } + Some(Command::Update(cmd)) => { + update::run_update(cmd.check); + return; + } _ => {} } diff --git a/src/main_version_tests.rs b/src/main_version_tests.rs index a831026..c8e6c25 100644 --- a/src/main_version_tests.rs +++ b/src/main_version_tests.rs @@ -1,4 +1,41 @@ -use super::{parse_latest_tag_from_headers, parse_npm_version, version_is_newer}; +use super::{ + compare_versions, decision_status_line, parse_latest_tag_from_headers, parse_npm_version, + parse_strict_semver, release_target_for, render_check_result, resolve_update_decision, + run_bounded, UpdateDecision, VersionOrdering, UPDATE_HINT, +}; +/// Writes an executable fake `curl` into `dir` that serves a GitHub latest-release +/// redirect header for `version`, so resolver tests never touch the network. +/// Unix-only: relies on a `#!/bin/sh` shebang, which Windows cannot execute directly. +#[cfg(unix)] +fn write_fake_curl_github_redirect(dir: &std::path::Path, version: &str) { + let script = format!( + "#!/bin/sh\necho 'HTTP/2 302'\necho 'location: https://github.com/sting8k/srcwalk/releases/tag/v{version}'\n" + ); + let path = dir.join("curl"); + std::fs::write(&path, script).unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&path, perms).unwrap(); +} + +#[cfg(unix)] +fn write_fake_curl_failure(dir: &std::path::Path) { + let path = dir.join("curl"); + std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&path, perms).unwrap(); +} + +#[cfg(unix)] +fn write_fake_curl_failure_with_stderr(dir: &std::path::Path, marker: &str) { + let script = format!("#!/bin/sh\necho '{marker}' >&2\nexit 1\n"); + let path = dir.join("curl"); + std::fs::write(&path, script).unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&path, perms).unwrap(); +} #[test] fn parses_latest_release_redirect_tag() { @@ -15,13 +52,6 @@ fn parses_npm_registry_version() { assert_eq!(parse_npm_version(json).as_deref(), Some("0.2.8")); } -#[test] -fn compares_semver_triplets() { - assert!(version_is_newer("0.2.8", "0.2.7")); - assert!(!version_is_newer("0.2.7", "0.2.7")); - assert!(!version_is_newer("0.2.6", "0.2.7")); -} - #[test] fn version_line_matches_contract_shape() { // Contract: `srcwalk \d+\.\d+\.\d+ \(.+\)` — tolerates `unknown`. @@ -54,3 +84,362 @@ fn unknown_label_yields_unknown_suffix() { assert!(line.ends_with("(unknown)"), "{line}"); } } + +#[test] +fn strict_semver_accepts_exact_triplet() { + assert_eq!(parse_strict_semver("1.2.3").unwrap(), (1, 2, 3)); + assert_eq!(parse_strict_semver("0.0.0").unwrap(), (0, 0, 0)); +} + +#[test] +fn strict_semver_rejects_prerelease_and_build_metadata() { + assert!(parse_strict_semver("1.2.3-rc1").is_err()); + assert!(parse_strict_semver("1.2.3+build5").is_err()); +} + +#[test] +fn strict_semver_rejects_wrong_component_count() { + assert!(parse_strict_semver("1.2").is_err()); + assert!(parse_strict_semver("1.2.3.4").is_err()); +} + +#[test] +fn strict_semver_rejects_whitespace_and_non_digit() { + assert!(parse_strict_semver(" 1.2.3").is_err()); + assert!(parse_strict_semver("1.2.3 ").is_err()); + assert!(parse_strict_semver("1.x.3").is_err()); + assert!(parse_strict_semver("1..3").is_err()); +} + +#[test] +fn strict_semver_rejects_overflow() { + assert!(parse_strict_semver("99999999999999999999.0.0").is_err()); +} + +#[test] +fn compare_versions_distinguishes_equal_local_newer_latest_newer() { + assert!(matches!( + compare_versions("1.8.0", "1.8.0"), + Ok(VersionOrdering::Equal) + )); + assert!(matches!( + compare_versions("1.9.0", "1.8.0"), + Ok(VersionOrdering::LocalNewer) + )); + assert!(matches!( + compare_versions("1.8.0", "1.9.0"), + Ok(VersionOrdering::LatestNewer) + )); +} + +#[test] +fn compare_versions_rejects_malformed_either_side() { + assert!(compare_versions("bad", "1.9.0").is_err()); + assert!(compare_versions("1.8.0", "bad").is_err()); +} + +#[test] +fn target_map_covers_all_six_release_rows() { + let linux_x64 = release_target_for("linux", "x86_64").expect("linux x86_64"); + assert_eq!(linux_x64.triple, "x86_64-unknown-linux-musl"); + assert_eq!(linux_x64.binary, "srcwalk"); + + let linux_arm = release_target_for("linux", "aarch64").expect("linux aarch64"); + assert_eq!(linux_arm.triple, "aarch64-unknown-linux-musl"); + assert_eq!(linux_arm.binary, "srcwalk"); + + let mac_x64 = release_target_for("macos", "x86_64").expect("macos x86_64"); + assert_eq!(mac_x64.triple, "x86_64-apple-darwin"); + assert_eq!(mac_x64.binary, "srcwalk"); + + let mac_arm = release_target_for("macos", "aarch64").expect("macos aarch64"); + assert_eq!(mac_arm.triple, "aarch64-apple-darwin"); + assert_eq!(mac_arm.binary, "srcwalk"); + + let win_x64 = release_target_for("windows", "x86_64").expect("windows x86_64"); + assert_eq!(win_x64.triple, "x86_64-pc-windows-msvc"); + assert_eq!(win_x64.binary, "srcwalk.exe"); + + let win_arm = release_target_for("windows", "aarch64").expect("windows aarch64"); + assert_eq!( + win_arm.triple, win_x64.triple, + "Windows ARM64 must emulate the x64 asset, per npm/install.js precedent" + ); + assert_eq!(win_arm.binary, win_x64.binary); +} + +#[test] +fn target_map_rejects_unsupported_os_arch_pairs() { + assert!(release_target_for("freebsd", "x86_64").is_none()); + assert!(release_target_for("linux", "riscv64").is_none()); +} + +/// Mirrors `npm/install.js`'s `PLATFORM_MAP` via a shared pinned fixture +/// (kept outside `npm/`, so it never enters the packed payload) so the two +/// tables cannot silently drift. +#[derive(serde::Deserialize)] +struct ReleaseTargetFixtureRow { + #[serde(rename = "rustOs")] + rust_os: String, + #[serde(rename = "rustArch")] + rust_arch: String, + target: String, + binary: String, +} + +#[test] +fn release_target_matches_shared_fixture_used_by_npm_install_js() { + const FIXTURE: &str = include_str!("../tests/fixtures/release-targets.json"); + let rows: Vec = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!(rows.len(), 6, "fixture must cover all 6 release rows"); + for row in rows { + let resolved = release_target_for(&row.rust_os, &row.rust_arch).unwrap_or_else(|| { + panic!( + "missing release_target_for({}, {})", + row.rust_os, row.rust_arch + ) + }); + assert_eq!( + resolved.triple, row.target, + "{}/{}", + row.rust_os, row.rust_arch + ); + assert_eq!( + resolved.binary, row.binary, + "{}/{}", + row.rust_os, row.rust_arch + ); + } +} + +#[test] +fn decision_status_line_matches_spec_exact_text() { + assert_eq!( + decision_status_line(&UpdateDecision::Equal { + current: "1.8.0".to_string(), + latest: "1.8.0".to_string(), + }) + .as_deref(), + Some("Already up to date.") + ); + assert_eq!( + decision_status_line(&UpdateDecision::LocalNewer { + current: "1.9.0".to_string(), + latest: "1.8.0".to_string(), + }) + .as_deref(), + Some("Local build 1.9.0 is newer than latest release 1.8.0.") + ); + assert_eq!( + decision_status_line(&UpdateDecision::Newer { + current: "1.8.0".to_string(), + latest: "1.9.0".to_string(), + }), + None, + "Newer has no single-line status: caller renders --check vs update-action text" + ); +} + +#[test] +#[cfg(unix)] +fn resolve_update_decision_reaches_latest_newer_via_fake_curl_only() { + let dir = tempfile::tempdir().unwrap(); + write_fake_curl_github_redirect(dir.path(), "9.9.9"); + let decision = resolve_update_decision(Some(dir.path().as_os_str())).unwrap(); + match decision { + UpdateDecision::Newer { current, latest } => { + assert_eq!(latest, "9.9.9"); + assert_eq!(current, env!("CARGO_PKG_VERSION")); + } + other => panic!("expected Newer, got a different decision: {other:?}"), + } +} + +#[test] +#[cfg(unix)] +fn resolve_update_decision_aggregates_errors_when_every_source_fails() { + let dir = tempfile::tempdir().unwrap(); + write_fake_curl_failure(dir.path()); + let err = resolve_update_decision(Some(dir.path().as_os_str())).unwrap_err(); + assert!(err.contains("curl"), "{err}"); +} + +#[test] +#[cfg(unix)] +fn resolve_update_decision_includes_bounded_stderr_detail_on_failure() { + let dir = tempfile::tempdir().unwrap(); + write_fake_curl_failure_with_stderr(dir.path(), "CURL_STDERR_MARKER"); + let err = resolve_update_decision(Some(dir.path().as_os_str())).unwrap_err(); + assert!(err.contains("CURL_STDERR_MARKER"), "{err}"); +} + +// ---- P1-3: strict GitHub redirect validation — only an exact HTTPS +// github.com/sting8k/srcwalk/releases/tag/vX.Y.Z redirect is ever accepted +// as a version candidate. ---- + +#[test] +fn parse_latest_tag_from_headers_accepts_exact_expected_redirect() { + let headers = "HTTP/2 302\nlocation: https://github.com/sting8k/srcwalk/releases/tag/v1.2.3\n"; + assert_eq!( + parse_latest_tag_from_headers(headers).as_deref(), + Some("1.2.3") + ); +} + +#[test] +fn parse_latest_tag_from_headers_rejects_evil_host() { + let headers = + "HTTP/2 302\nlocation: https://evil.example/sting8k/srcwalk/releases/tag/v1.2.3\n"; + assert_eq!(parse_latest_tag_from_headers(headers), None); +} + +#[test] +fn parse_latest_tag_from_headers_rejects_plain_http() { + let headers = "HTTP/2 302\nlocation: http://github.com/sting8k/srcwalk/releases/tag/v1.2.3\n"; + assert_eq!(parse_latest_tag_from_headers(headers), None); +} + +#[test] +fn parse_latest_tag_from_headers_rejects_wrong_repo() { + let headers = "HTTP/2 302\nlocation: https://github.com/other/repo/releases/tag/v1.2.3\n"; + assert_eq!(parse_latest_tag_from_headers(headers), None); +} + +#[test] +fn parse_latest_tag_from_headers_rejects_query_string_smuggling() { + let headers = + "HTTP/2 302\nlocation: https://github.com/sting8k/srcwalk/releases/tag/v1.2.3?x=evil\n"; + assert_eq!(parse_latest_tag_from_headers(headers), None); +} + +#[test] +fn parse_latest_tag_from_headers_rejects_trailing_path() { + let headers = + "HTTP/2 302\nlocation: https://github.com/sting8k/srcwalk/releases/tag/v1.2.3/extra\n"; + assert_eq!(parse_latest_tag_from_headers(headers), None); +} + +// ---- P1-7: `latest X.Y.Z` must print for every decision, not only `Newer` — +// this is the pre-existing `version --check` contract; one shared renderer +// keeps `update --check`/`version --check` from forking wording. ---- + +#[test] +fn render_check_result_preserves_latest_line_for_every_decision() { + let equal = UpdateDecision::Equal { + current: "1.8.0".to_string(), + latest: "1.8.0".to_string(), + }; + assert_eq!( + render_check_result(&equal), + vec![ + "latest 1.8.0".to_string(), + "Already up to date.".to_string() + ] + ); + + let local_newer = UpdateDecision::LocalNewer { + current: "1.9.0".to_string(), + latest: "1.8.0".to_string(), + }; + assert_eq!( + render_check_result(&local_newer), + vec![ + "latest 1.8.0".to_string(), + "Local build 1.9.0 is newer than latest release 1.8.0.".to_string(), + ] + ); + + let newer = UpdateDecision::Newer { + current: "1.8.0".to_string(), + latest: "1.9.0".to_string(), + }; + assert_eq!( + render_check_result(&newer), + vec![ + "latest 1.9.0".to_string(), + String::new(), + UPDATE_HINT.to_string(), + ] + ); +} + +// ---- P1-2: run_bounded enforces both a byte cap and a wall-clock timeout ---- + +#[cfg(unix)] +#[test] +fn run_bounded_kills_a_hanging_child_after_its_timeout() { + // Spawn `sleep` directly (not via `sh -c`): `Child::kill` only signals the + // tracked PID, not any grandchild a shell would fork for an external + // command, so a shell wrapper here would leave the real sleep running and + // falsely fail this test. Every real call site (curl/wget/npm/verify_exe) + // is already spawned directly, so this matches production shape. + let mut cmd = std::process::Command::new("sleep"); + cmd.arg("5"); + let started = std::time::Instant::now(); + let err = run_bounded(cmd, 4096, std::time::Duration::from_millis(200)).unwrap_err(); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(2), + "a hung child must be killed near the timeout, not after its full sleep: {elapsed:?}" + ); + assert!(err.contains("timed out"), "{err}"); +} + +#[cfg(unix)] +#[test] +fn run_bounded_does_not_kill_a_child_that_closes_stdout_before_exiting() { + // Closes fd 1 immediately, then keeps running a bit longer before exiting + // 0. The read loop unblocks as soon as stdout closes (long before the + // 2s timeout), so the watchdog must see that as "done", not "timed out", + // and must never kill this still-legitimately-running child. + let mut cmd = std::process::Command::new("sh"); + cmd.args(["-c", "exec 1>&-; sleep 0.3; exit 0"]); + let output = run_bounded(cmd, 4096, std::time::Duration::from_secs(2)).unwrap(); + assert!( + output.status.success(), + "a child that only closed stdout early must be waited for normally, not killed: {:?}", + output.status + ); +} + +#[cfg(unix)] +#[test] +fn run_bounded_times_out_a_child_that_closes_stdout_then_hangs_past_timeout() { + // This is the exact regression this test guards: closing stdout early + // must NOT disarm the watchdog. The read loop unblocks almost + // immediately (stdout closed), but the child keeps running well past + // `timeout` before it would naturally exit — the watchdog must still + // fire and the wait loop must still notice, instead of blocking forever + // because `done` was signaled too early. + // + // Uses a pure shell-builtin busy loop, not `sleep`: a preceding `exec` + // statement defeats sh's single-command tail-call exec optimization, so + // `sh -c "exec 1>&-; sleep 5"` forks a genuine grandchild `sleep` + // process that `Child::kill` cannot reach (confirmed via `ps`), which + // would make this test flaky/fail for a reason unrelated to the fix + // under test. `while`/`[`/arithmetic stay in the same shell process, so + // killing the direct child reliably stops it. + let mut cmd = std::process::Command::new("sh"); + cmd.args([ + "-c", + "exec 1>&-; n=0; while [ $n -lt 100000000 ]; do n=$((n+1)); done", + ]); + let started = std::time::Instant::now(); + let err = run_bounded(cmd, 4096, std::time::Duration::from_millis(200)).unwrap_err(); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(2), + "a child that hangs after closing stdout must still be killed near the timeout, not after its full sleep: {elapsed:?}" + ); + assert!(err.contains("timed out"), "{err}"); +} + +#[cfg(unix)] +#[test] +fn run_bounded_returns_full_output_for_a_fast_child_under_the_cap() { + let mut cmd = std::process::Command::new("sh"); + cmd.args(["-c", "printf hello"]); + let output = run_bounded(cmd, 4096, std::time::Duration::from_secs(5)).unwrap(); + assert_eq!(output.stdout, b"hello"); + assert!(output.status.success()); +} diff --git a/src/read/section.rs b/src/read/section.rs index 3209eb3..b552350 100644 --- a/src/read/section.rs +++ b/src/read/section.rs @@ -1188,11 +1188,9 @@ fn resolve_existing_file(raw: &str, scope: &Path) -> Option { } } } - candidates.into_iter().find(|candidate| { - std::fs::metadata(candidate) - .ok() - .is_some_and(|meta| meta.is_file()) - }) + candidates + .into_iter() + .find(|candidate| std::fs::metadata(candidate).is_ok_and(|meta| meta.is_file())) } /// Resolve a symbol name to its first matching `(start_line, end_line)` against diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..439eeee --- /dev/null +++ b/src/update.rs @@ -0,0 +1,2298 @@ +//! Installation-aware `srcwalk update` (US-074): channel detection, package-manager +//! action, and download/integrity/transaction orchestration. Latest-version +//! resolution, strict semver, and target mapping stay in `version.rs`; this module +//! only decides *how* to apply an already-resolved newer version. + +use std::ffi::OsStr; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::Duration; + +/// Which package manager (if any) owns the running executable, detected from +/// `current_exe()`'s normalized path components. Conservative: anything not +/// recognizably under a `node_modules` tree is `Standalone`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallChannel { + Npm, + Pnpm, + Yarn, + Bun, + Standalone, +} + +/// Pure channel classification for an explicit `windows` matching mode, so +/// tests can pin exact platform semantics regardless of which host runs +/// them. Splits on both `/` and `\` (path-component normalization applies on +/// every host). `windows == true` compares markers ASCII case-insensitively; +/// `windows == false` compares components exactly, so e.g. an uppercase +/// `NODE_MODULES` component never matches on Unix. Component-exact either +/// way: a decoy like `my_node_modules_backup` never matches `node_modules` +/// by substring, and a bare `yarn` directory name (no leading dot) is not a +/// Yarn marker on its own. +pub(crate) fn detect_channel_for(path: &Path, windows: bool) -> InstallChannel { + let components: Vec = path + .to_string_lossy() + .split(['/', '\\']) + .filter(|c| !c.is_empty()) + .map(|c| c.to_string()) + .collect(); + + let matches = |component: &str, marker: &str| { + if windows { + component.eq_ignore_ascii_case(marker) + } else { + component == marker + } + }; + let any_matches = |markers: &[&str]| { + components + .iter() + .any(|c| markers.iter().any(|m| matches(c, m))) + }; + + if !any_matches(&["node_modules"]) { + return InstallChannel::Standalone; + } + if any_matches(&[".pnpm", "pnpm-global"]) { + return InstallChannel::Pnpm; + } + if any_matches(&[".bun"]) { + return InstallChannel::Bun; + } + if any_matches(&[".yarn"]) { + return InstallChannel::Yarn; + } + InstallChannel::Npm +} + +/// Runtime wrapper: uses the host's real matching mode. +pub(crate) fn detect_channel(path: &Path) -> InstallChannel { + detect_channel_for(path, cfg!(windows)) +} + +/// Fixed allowlisted program + argv per channel; `Standalone` has none. +pub(crate) fn package_manager_argv( + channel: InstallChannel, +) -> Option<(&'static str, &'static [&'static str])> { + match channel { + InstallChannel::Npm => Some(("npm", &["install", "-g", "srcwalk@latest"])), + InstallChannel::Pnpm => Some(("pnpm", &["add", "-g", "srcwalk@latest"])), + InstallChannel::Yarn => Some(("yarn", &["global", "add", "srcwalk@latest"])), + InstallChannel::Bun => Some(("bun", &["add", "-g", "srcwalk@latest"])), + InstallChannel::Standalone => None, + } +} + +const VERIFY_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_VERSION_CHECK_BYTES: usize = 4 * 1024; + +/// npm/pnpm/yarn/bun ship their global-install entry points as `.cmd` shims +/// on Windows, which `CreateProcess` cannot execute directly (only +/// `cmd.exe` resolves and runs `.cmd`/`.bat` files). This is the one +/// dedicated, fixed-argv helper permitted to go through `cmd.exe /C`: every +/// argument is still passed structurally via `.arg()`/`.args()`, never +/// concatenated into a shell string, so nothing here interpolates +/// user-controlled or network-derived text into a shell command line. +#[cfg(windows)] +fn spawn_package_manager( + program: &str, + args: &[&str], + path_override: Option<&std::ffi::OsStr>, +) -> process::Command { + let mut cmd = crate::version::command_with_path("cmd", path_override); + cmd.arg("/C").arg(program).args(args); + cmd +} + +#[cfg(not(windows))] +fn spawn_package_manager( + program: &str, + args: &[&str], + path_override: Option<&std::ffi::OsStr>, +) -> process::Command { + let mut cmd = crate::version::command_with_path(program, path_override); + cmd.args(args); + cmd +} + +/// Prints the exact package-manager command, executes it with inherited +/// stdio (prompts/errors stay visible), then spawns `verify_exe --version` +/// with bounded output and a finite timeout, requiring it to report exactly +/// `expected_version` before claiming success. `path_override` replaces the +/// child's `PATH` (tests only). +pub(crate) fn run_package_manager_update( + channel: InstallChannel, + expected_version: &str, + verify_exe: &Path, + path_override: Option<&OsStr>, +) -> Result<(), String> { + let (program, args) = package_manager_argv(channel) + .ok_or_else(|| "no package-manager command for a standalone install".to_string())?; + + println!("{program} {}", args.join(" ")); + + let status = spawn_package_manager(program, args, path_override) + .status() + .map_err(|e| format!("could not run {program}: {e}"))?; + if !status.success() { + return Err(format!("{program} {} exited with {status}", args.join(" "))); + } + + let reported = spawn_reported_version(verify_exe)?; + if reported != expected_version { + return Err(format!( + "installed version reports {reported}, expected {expected_version}" + )); + } + Ok(()) +} + +/// Spawns `path --version` with bounded output and a finite timeout, and +/// parses the reported `X.Y.Z`. Shared by the package-manager post-check +/// above and the standalone transaction post-check (the `verify` closure +/// passed to `unix_transactional_swap`/`windows_transactional_swap`), so +/// both post-checks apply the exact same bound/parse contract. +fn spawn_reported_version(path: &Path) -> Result { + let mut cmd = process::Command::new(path); + cmd.arg("--version"); + let output = crate::version::run_bounded(cmd, MAX_VERSION_CHECK_BYTES, VERIFY_TIMEOUT) + .map_err(|e| format!("could not verify installed version: {e}"))?; + if !output.status.success() { + return Err(format!( + "version check exited with {}{}", + output.status, + crate::version::stderr_detail(&output.stderr) + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + parse_reported_version(&stdout) + .ok_or_else(|| format!("could not parse reported version from `{stdout}`")) +} + +/// Extracts `X.Y.Z` from the canonical `srcwalk X.Y.Z (...)` first line. +fn parse_reported_version(stdout: &str) -> Option { + let line = stdout.lines().next()?; + let rest = line.strip_prefix("srcwalk ")?; + let version = rest.split_whitespace().next()?; + Some(version.to_string()) +} + +/// Mirrors `npm/install.js`'s `parseChecksum`: exactly one non-empty line, +/// 64 hex characters, optional `*` prefix, and a basename exactly equal to +/// `expected_filename`. +pub(crate) fn parse_checksum(text: &str, expected_filename: &str) -> Result { + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + if lines.len() != 1 { + return Err("checksum file must contain exactly one non-empty line".to_string()); + } + let (hash, rest) = lines[0] + .split_once(char::is_whitespace) + .ok_or_else(|| "checksum file has an invalid SHA-256 format".to_string())?; + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("checksum file has an invalid SHA-256 format".to_string()); + } + let rest = rest + .trim_start() + .strip_prefix('*') + .unwrap_or(rest.trim_start()); + let listed_filename = rest.trim().rsplit(['/', '\\']).next().unwrap_or(""); + if listed_filename != expected_filename { + return Err(format!( + "checksum names {listed_filename}, expected {expected_filename}" + )); + } + Ok(hash.to_ascii_lowercase()) +} + +/// Mirrors `npm/install.js`'s `validateArchiveEntries`: exactly one listed +/// entry, normalized to forward slashes with any leading `./` stripped, must +/// equal `expected_binary` exactly — rejecting extra entries, absolute paths, +/// and traversal by construction (they never equal the plain binary name). +pub(crate) fn validate_archive_entries(listing: &str, expected_binary: &str) -> Result<(), String> { + let entries: Vec<&str> = listing + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + if entries.len() != 1 { + return Err(format!( + "archive must contain exactly one entry, found {}", + entries.len() + )); + } + let mut normalized = entries[0].replace('\\', "/"); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped.to_string(); + } + if normalized != expected_binary { + return Err(format!( + "archive entry {} does not match expected binary {expected_binary}", + entries[0] + )); + } + Ok(()) +} + +/// Post-extract type check: the tar listing text alone never proves the +/// extracted entry's real filesystem type. Uses `lstat` semantics (does not +/// follow symlinks) and rejects anything but a regular file. +pub(crate) fn validate_extracted_binary(path: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|e| format!("could not stat extracted binary {}: {e}", path.display()))?; + if metadata.is_symlink() { + return Err(format!( + "extracted entry {} is a symlink, not a regular file", + path.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "extracted entry {} is not a regular file", + path.display() + )); + } + Ok(()) +} + +fn run_downloader( + program: &str, + args: &[String], + max_bytes: usize, + timeout: Duration, + path_override: Option<&OsStr>, +) -> Result, String> { + let mut cmd = crate::version::command_with_path(program, path_override); + cmd.args(args); + let output = crate::version::run_bounded(cmd, max_bytes, timeout) + .map_err(|e| format!("{program}: {e}"))?; + if !output.status.success() { + return Err(format!( + "{program}: exited with {}{}", + output.status, + crate::version::stderr_detail(&output.stderr) + )); + } + Ok(output.stdout) +} + +fn curl_download_args(url: &str, max_bytes: usize, timeout_secs: &str) -> Vec { + vec![ + "-fsSL".to_string(), + "--proto".to_string(), + "=https".to_string(), + "--proto-redir".to_string(), + "=https".to_string(), + "--max-time".to_string(), + timeout_secs.to_string(), + format!("--max-filesize={max_bytes}"), + url.to_string(), + ] +} + +fn wget_download_args(url: &str, timeout_secs: &str) -> Vec { + vec![ + "-qO-".to_string(), + "--https-only".to_string(), + format!("--timeout={timeout_secs}"), + url.to_string(), + ] +} + +fn fetch_bytes_via_curl_or_wget( + url: &str, + max_bytes: usize, + timeout: Duration, + timeout_secs: &str, + path_override: Option<&OsStr>, +) -> Result, String> { + let curl_args = curl_download_args(url, max_bytes, timeout_secs); + let wget_args = wget_download_args(url, timeout_secs); + + match run_downloader("curl", &curl_args, max_bytes, timeout, path_override) { + Ok(bytes) => Ok(bytes), + Err(curl_err) => { + match run_downloader("wget", &wget_args, max_bytes, timeout, path_override) { + Ok(bytes) => Ok(bytes), + Err(wget_err) => Err(format!("curl: {curl_err}; wget: {wget_err}")), + } + } + } +} + +/// Downloads small text (e.g. a `.sha256` file) via system curl, falling back +/// to wget, HTTPS-only with finite timeout and bounded output. Zero network +/// in tests: `path_override` replaces the child's `PATH`. +pub(crate) fn download_text( + url: &str, + max_bytes: usize, + path_override: Option<&OsStr>, +) -> Result { + let bytes = + fetch_bytes_via_curl_or_wget(url, max_bytes, Duration::from_secs(20), "20", path_override)?; + // `run_bounded` already enforces `max_bytes`; this is a defensive + // invariant check, not a second cap. + debug_assert!(bytes.len() <= max_bytes); + String::from_utf8(bytes).map_err(|e| format!("invalid UTF-8 response: {e}")) +} + +/// Downloads a release archive via curl/wget and writes it to `destination` +/// with create-new semantics: an existing destination is refused, never +/// overwritten. Nothing is written until the byte cap is already satisfied. +/// Returns the archive's lowercase-hex SHA-256, computed from the same +/// in-memory bytes that get written to disk (no second read pass). +pub(crate) fn download_archive( + url: &str, + destination: &Path, + max_bytes: usize, + path_override: Option<&OsStr>, +) -> Result { + let bytes = + fetch_bytes_via_curl_or_wget(url, max_bytes, Duration::from_secs(60), "60", path_override)?; + debug_assert!(bytes.len() <= max_bytes); + + let mut file = reserve_create_new(destination)?; + if let Err(e) = file.write_all(&bytes) { + drop(file); + let _ = std::fs::remove_file(destination); + return Err(format!("could not write archive: {e}")); + } + Ok(sha256_hex(&bytes)) +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Creates `path` with `O_EXCL`/`CREATE_NEW` semantics: fails honestly if +/// anything — a regular file or an attacker-planted symlink — already sits at +/// that path, instead of following or overwriting it. +fn reserve_create_new(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| format!("could not create {}: {e}", path.display())) +} + +/// Two same-directory paths reserved for a transactional swap. Names only: +/// on Unix `backup` is created immediately (holds a byte-for-byte copy of +/// the current executable); on Windows `.old` must stay absent so the first +/// move step can claim it, so its path is planned but never pre-created. +pub(crate) struct StagingPaths { + pub(crate) staged: PathBuf, + pub(crate) backup: PathBuf, +} + +/// Runs `create` then requires `remove` to also succeed — a write-probe that +/// could be created but not removed is not proof of usable same-directory +/// write capability, so the caller must never treat it as clean. Closures so +/// the "create succeeded, remove failed" branch is provable without forcing +/// that split on a real filesystem. +fn require_probe_cleanup( + create: impl FnOnce() -> Result<(), String>, + remove: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + create()?; + remove() +} + +/// Shared preflight: the current target must be an existing regular file — +/// checked with `symlink_metadata` (lstat) so a symlink at `current` is +/// rejected outright, never followed — and the same directory must accept a +/// create/write/remove probe before any staging name is trusted. +/// Staged/backup names are collision-planned (not yet created) next to the +/// current executable. +pub(crate) fn preflight_same_dir(current: &Path) -> Result { + let metadata = std::fs::symlink_metadata(current).map_err(|e| { + format!( + "could not stat current executable {}: {e}", + current.display() + ) + })?; + if !metadata.is_file() { + return Err(format!( + "current executable target {} is not a regular file", + current.display() + )); + } + + let dir = current + .parent() + .ok_or_else(|| format!("{} has no parent directory", current.display()))?; + let file_name = current + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("{} has a non-UTF-8 file name", current.display()))?; + + let probe = dir.join(format!( + ".{file_name}.srcwalk-update-probe-{}", + process::id() + )); + require_probe_cleanup( + || { + reserve_create_new(&probe) + .map(|_| ()) + .map_err(|e| format!("cannot write next to {}: {e}", current.display())) + }, + || { + std::fs::remove_file(&probe).map_err(|e| { + format!( + "created a write probe next to {} but could not remove it ({}): {e}", + current.display(), + probe.display() + ) + }) + }, + )?; + + let unique = format!( + "{}-{}", + process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let staged = dir.join(format!("{file_name}.srcwalk-update-{unique}")); + let backup = dir.join(format!("{file_name}.srcwalk-update-{unique}.bak")); + + Ok(StagingPaths { staged, backup }) +} + +/// create_new the backup path (never overwrite/follow an existing file or +/// attacker symlink there), then copy the current executable's bytes and +/// permissions into it. No rename of the original: `current` stays in place +/// until the caller's own placement step. +#[cfg(unix)] +fn unix_backup_current(current: &Path, backup: &Path) -> Result<(), String> { + unix_backup_current_with(current, backup, |p: &Path| std::fs::remove_file(p)) +} + +/// Every failure after `reserve_create_new` leaves a create_new-reserved +/// backup file (empty or partially written) sitting in the install +/// directory; any error from the inner write step must trigger cleanup +/// here, not just a copy failure, and a cleanup failure is folded into the +/// reported error too, never silently swallowed by a bare +/// `let _ = remove_file(...)`. `cleanup_remove` is injectable so the +/// "backup was created, then the write step failed, and the backup's own +/// removal then also fails" branch is testable: create and remove of the +/// same path are gated by the same directory permission bit, so the two +/// cannot be decoupled purely via chmod from outside this function (same +/// rationale as `windows_move_non_overwrite_with`). +#[cfg(unix)] +fn unix_backup_current_with( + current: &Path, + backup: &Path, + cleanup_remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + let backup_file = reserve_create_new(backup)?; + match unix_backup_current_write(current, backup, backup_file) { + Ok(()) => Ok(()), + Err(e) => Err(fold_cleanup_failure_with( + e, + backup, + "backup", + cleanup_remove, + )), + } +} + +#[cfg(unix)] +fn unix_backup_current_write( + current: &Path, + backup: &Path, + mut backup_file: std::fs::File, +) -> Result<(), String> { + let mut source = std::fs::File::open(current).map_err(|e| { + format!( + "could not read current executable {}: {e}", + current.display() + ) + })?; + // Streamed, not buffered whole: avoids holding the entire executable in + // memory just to copy it next to itself. + std::io::copy(&mut source, &mut backup_file) + .map_err(|e| format!("could not write backup {}: {e}", backup.display()))?; + let perms = std::fs::metadata(current) + .map_err(|e| { + format!( + "could not stat current executable {}: {e}", + current.display() + ) + })? + .permissions(); + std::fs::set_permissions(backup, perms) + .map_err(|e| format!("could not set backup permissions: {e}"))?; + Ok(()) +} + +#[cfg(unix)] +fn unix_rollback(backup: &Path, current: &Path) -> Result<(), String> { + std::fs::rename(backup, current).map_err(|e| format!("rollback failed: {e}")) +} + +/// The placement-failure cleanup step in isolation: `rename(staged, +/// current)` failed, so the create_new-reserved `backup` created just +/// before it is now redundant and must be removed. `cleanup_remove` is +/// injectable so the "backup exists, but its own removal then also fails" +/// branch is testable without relying on a real filesystem race (create +/// and remove of the same path are gated by the same directory permission +/// bit; see `unix_backup_current_with` for the same rationale). +#[cfg(unix)] +fn placement_failed( + place_err: String, + backup: &Path, + cleanup_remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> String { + let primary = format!("could not place updated binary: {place_err}"); + fold_cleanup_failure_with(primary, backup, "backup", cleanup_remove) +} + +/// The successful-placement-and-verify cleanup step in isolation. Once +/// `current` is placed and verified, the swap itself is already correct +/// and complete: a failure to then remove the now-redundant `backup` must +/// never roll back an already-verified-good binary just to "clean up" — +/// it is reported honestly instead (`Ok` is never returned on a removal +/// failure), so the caller never prints a clean `Updated OLD -> NEW.` +/// while a stray backup still sits next to the real executable. +/// `cleanup_remove` is injectable for the same reason as `placement_failed`. +#[cfg(unix)] +fn verified_swap_cleanup( + backup: &Path, + cleanup_remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + match cleanup_remove(backup) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!( + "updated and verified, but could not remove backup {}: {e}", + backup.display() + )), + } +} + +/// A same-directory staged/backup file that a swap never consumed (creation +/// or placement never finished, or had to be rolled back) is a leftover +/// artifact next to the real executable, not a harmless temp file — every +/// failure path that could leave one behind routes its error through this, +/// so cleanup is never skipped for one branch and not another, and a +/// cleanup failure is never silently swallowed by a bare +/// `let _ = remove_file(...)`. `NotFound` means `path` was already consumed +/// (e.g. moved into `current`'s place, or never created), so it is not +/// itself folded into the reported error; any other cleanup failure is. +fn fold_cleanup_failure(primary: String, path: &Path, label: &str) -> String { + fold_cleanup_failure_with(primary, path, label, |p: &Path| std::fs::remove_file(p)) +} + +/// Injectable core of [`fold_cleanup_failure`], so a genuine cleanup-removal +/// failure is testable without relying on a real filesystem race. +fn fold_cleanup_failure_with( + primary: String, + path: &Path, + label: &str, + remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> String { + match remove(path) { + Ok(()) => primary, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => primary, + Err(e) => format!( + "{primary}; also could not remove leftover {label} {}: {e}", + path.display() + ), + } +} + +/// Unix transactional swap: create_new-backup the current bytes, atomically +/// rename the already-verified `staged` binary over `current`, then require +/// `verify(current)` to report `expected_version` before deleting the +/// backup. Any placement/post-check failure restores the backup; a failed +/// rollback never silently discards the primary failure, the returned +/// error always names both. Every failure also folds in a `staged`-cleanup +/// attempt via [`fold_cleanup_failure`]. Once `current` is placed and +/// verified, the swap itself is already correct and complete: a failure to +/// then remove the now-redundant `backup` is reported honestly (`Ok` is +/// never returned) instead of rolling back a proven-good binary just to +/// "clean up" — the caller must not print a clean success while a stray +/// backup still sits next to the real executable. +#[cfg(unix)] +pub(crate) fn unix_transactional_swap( + current: &Path, + staged: &Path, + backup: &Path, + expected_version: &str, + verify: impl Fn(&Path) -> Result, +) -> Result<(), String> { + unix_transactional_swap_inner(current, staged, backup, expected_version, verify) + .map_err(|e| fold_cleanup_failure(e, staged, "staged binary")) +} + +#[cfg(unix)] +fn unix_transactional_swap_inner( + current: &Path, + staged: &Path, + backup: &Path, + expected_version: &str, + verify: impl Fn(&Path) -> Result, +) -> Result<(), String> { + unix_backup_current(current, backup)?; + + if let Err(e) = std::fs::rename(staged, current) { + return Err(placement_failed(e.to_string(), backup, |p: &Path| { + std::fs::remove_file(p) + })); + } + + let primary = match verify(current) { + // The swap itself is already done and proven correct here: `current` + // now reports `expected_version`. See `verified_swap_cleanup` for + // why a failure to remove `backup` from this point on is never + // treated as a rollback trigger. + Ok(reported) if reported == expected_version => { + return verified_swap_cleanup(backup, |p: &Path| std::fs::remove_file(p)); + } + Ok(reported) => format!("post-check reported {reported}, expected {expected_version}"), + Err(e) => format!("post-check failed: {e}"), + }; + match unix_rollback(backup, current) { + Ok(()) => Err(primary), + Err(rollback_err) => Err(format!("{primary}; rollback also failed: {rollback_err}")), + } +} + +/// Same-directory non-overwrite move: hard-link then remove the source. +/// Unlike `fs::rename` on Windows (which passes `MOVEFILE_REPLACE_EXISTING`), +/// this fails honestly if `destination` is already occupied — by a stale +/// leftover or a race — instead of silently overwriting or following it. +/// +/// This is a genuine *move*, not "link with an optional leftover source": if +/// the source cannot be removed after linking, the just-created destination +/// link is cleaned up so the operation never claims a state it cannot prove +/// (a lingering source would make the caller's transaction/cleanup +/// non-deterministic). If that cleanup itself fails, both failures are +/// reported. +// Only reachable in production via the `#[cfg(windows)]` wrapper below; on a +// non-Windows production build it is otherwise dead. Kept unguarded (rather +// than `#[cfg(windows)]` itself) so its fault-injection tests can run on any +// host, per review — forcing a genuine post-hard_link remove failure isn't +// reliably reproducible on a real filesystem. +#[cfg_attr(not(any(windows, test)), allow(dead_code))] +fn windows_move_non_overwrite_with( + source: &Path, + destination: &Path, + link: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + remove_source: impl FnOnce(&Path) -> std::io::Result<()>, + remove_destination: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + link(source, destination).map_err(|e| { + format!( + "could not link {} to {}: {e}", + source.display(), + destination.display() + ) + })?; + if let Err(remove_err) = remove_source(source) { + return match remove_destination(destination) { + Ok(()) => Err(format!( + "could not remove {} after linking to {}: {remove_err}", + source.display(), + destination.display() + )), + Err(cleanup_err) => Err(format!( + "could not remove {} after linking to {} ({remove_err}), and cleanup of {} also failed: {cleanup_err}", + source.display(), + destination.display(), + destination.display() + )), + }; + } + Ok(()) +} + +#[cfg(windows)] +fn windows_move_non_overwrite(source: &Path, destination: &Path) -> Result<(), String> { + windows_move_non_overwrite_with( + source, + destination, + |s: &Path, d: &Path| std::fs::hard_link(s, d), + |p: &Path| std::fs::remove_file(p), + |p: &Path| std::fs::remove_file(p), + ) +} + +/// Restores `.old` back onto `current` after a failed second move or a +/// failed post-check. `current`'s prior state is already fully owned by this +/// transaction here, so an overwrite is the intended recovery, not an +/// attacker surface. +#[cfg(windows)] +fn windows_restore(old: &Path, current: &Path) -> Result<(), String> { + std::fs::rename(old, current).map_err(|e| format!("rollback failed: {e}")) +} + +/// Best-effort removal of a known stale `.old` artifact left by a previous +/// successful Windows update that Windows was still holding open. Never +/// fails an otherwise successful update solely because this is deferred. +#[cfg(windows)] +pub(crate) fn cleanup_stale_old(old: &Path) { + let _ = std::fs::remove_file(old); +} + +/// Windows transactional swap. `.old` must stay absent going in, so the +/// first move is non-overwrite by construction (see +/// [`windows_move_non_overwrite`]); a race that occupies it fails the update +/// honestly before `current` ever leaves its place. On a second-move or +/// post-check failure, `.old` is restored immediately; a failed rollback +/// never silently discards the primary failure, the returned error always +/// names both. +#[cfg(windows)] +pub(crate) fn windows_transactional_swap( + current: &Path, + staged: &Path, + old: &Path, + expected_version: &str, + verify: impl Fn(&Path) -> Result, +) -> Result<(), String> { + windows_transactional_swap_inner(current, staged, old, expected_version, verify) + .map_err(|e| fold_cleanup_failure(e, staged, "staged binary")) +} + +#[cfg(windows)] +fn windows_transactional_swap_inner( + current: &Path, + staged: &Path, + old: &Path, + expected_version: &str, + verify: impl Fn(&Path) -> Result, +) -> Result<(), String> { + windows_move_non_overwrite(current, old).map_err(|e| { + format!( + "could not rename running executable to {}: {e}", + old.display() + ) + })?; + + if let Err(e) = windows_move_non_overwrite(staged, current) { + return match windows_restore(old, current) { + Ok(()) => Err(format!("could not place updated binary: {e}")), + Err(restore_err) => Err(format!( + "could not place updated binary ({e}), and rollback to .old also failed ({restore_err})" + )), + }; + } + + let primary = match verify(current) { + Ok(reported) if reported == expected_version => { + cleanup_stale_old(old); + return Ok(()); + } + Ok(reported) => format!("post-check reported {reported}, expected {expected_version}"), + Err(e) => format!("post-check failed: {e}"), + }; + match windows_restore(old, current) { + Ok(()) => Err(primary), + Err(rollback_err) => Err(format!("{primary}; rollback also failed: {rollback_err}")), + } +} + +const MAX_CHECKSUM_BYTES: usize = 4 * 1024; +const MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024; +const MAX_TAR_LISTING_BYTES: usize = 4 * 1024; +const TAR_TIMEOUT: Duration = Duration::from_secs(30); + +const GITHUB_RELEASE_BASE: &str = "https://github.com/sting8k/srcwalk/releases/download"; + +/// Top-level orchestration for `srcwalk update`/`srcwalk update --check`. +/// Prints the current version, resolves the shared decision, and for +/// `Equal`/`LocalNewer` stops there (identical text for both `--check` and a +/// full run). Only `Newer` diverges: `--check` prints the shared +/// check-only render; a full run detects the install channel and performs +/// the channel action, printing `Updated OLD -> NEW.` only after the exact +/// installed binary is verified to report the resolved latest version. +pub(crate) fn run_update(check: bool) { + println!("{}", crate::version::version_line()); + + let decision = match crate::version::resolve_update_decision(None) { + Ok(decision) => decision, + Err(err) => { + crate::version::print_resolution_failure(&err); + process::exit(1); + } + }; + + let (current, latest) = match &decision { + crate::version::UpdateDecision::Equal { .. } + | crate::version::UpdateDecision::LocalNewer { .. } => { + crate::version::print_check_result(&decision); + return; + } + crate::version::UpdateDecision::Newer { current, latest } => { + (current.clone(), latest.clone()) + } + }; + + if check { + crate::version::print_check_result(&decision); + return; + } + println!("latest {latest}"); + + let current_exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!("error: could not resolve current executable path: {e}"); + eprintln!(); + eprintln!("cargo install srcwalk --locked --force"); + process::exit(1); + } + }; + let channel = detect_channel(¤t_exe); + + let result = match channel { + InstallChannel::Standalone => standalone_update(&latest, ¤t_exe), + _ => run_package_manager_update(channel, &latest, ¤t_exe, None), + }; + + match result { + Ok(()) => println!("Updated {current} -> {latest}."), + Err(err) => { + eprintln!("error: {err}"); + print_channel_fallback(channel); + process::exit(1); + } + } +} + +/// One channel-appropriate manual command, plus the generic Cargo fallback +/// always offered on failure. +fn print_channel_fallback(channel: InstallChannel) { + eprintln!(); + match channel { + InstallChannel::Standalone => { + if let Some(target) = crate::version::release_target() { + eprintln!( + "Manual fallback: curl -L https://github.com/sting8k/srcwalk/releases/latest/download/srcwalk-{}.tar.gz | tar xz -C ~/.local/bin", + target.triple + ); + } + } + _ => { + if let Some((program, args)) = package_manager_argv(channel) { + eprintln!("Manual fallback: {program} {}", args.join(" ")); + } + } + } + eprintln!("cargo install srcwalk --locked --force"); +} + +/// Creates a fresh, create-new (never-existed) directory under the system +/// temp root, private on Unix (`0700`), for extracting/staging the +/// downloaded archive away from the installed executable's directory. On +/// Unix the mode is applied atomically by the underlying `mkdir(..., 0700)` +/// syscall itself — not by a separate post-create `chmod` — so there is no +/// window where the directory briefly exists with broader permissions +/// (0700 has no group/other bits for a permissive umask to strip anyway). +/// `create_dir`/`DirBuilder::create` never leaves a partial directory behind +/// on failure: the syscall either creates it or it doesn't. +fn create_private_temp_dir() -> Result { + let base = std::env::temp_dir(); + let unique = format!( + "srcwalk-update-{}-{}", + process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let dir = base.join(unique); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|e| format!("could not create temp dir {}: {e}", dir.display()))?; + } + #[cfg(not(unix))] + { + std::fs::create_dir(&dir) + .map_err(|e| format!("could not create temp dir {}: {e}", dir.display()))?; + } + Ok(dir) +} + +/// Runs system `tar` with bounded, timed-out output, relative to `cwd`. +/// Relative operands (never an absolute archive path) avoid drive-letter +/// paths being parsed as remote archives by GNU tar on Windows. +fn run_tar(args: &[&str], cwd: &Path) -> Result { + let mut cmd = process::Command::new("tar"); + cmd.args(args).current_dir(cwd); + let output = crate::version::run_bounded(cmd, MAX_TAR_LISTING_BYTES, TAR_TIMEOUT) + .map_err(|e| format!("tar: {e}"))?; + if !output.status.success() { + return Err(format!( + "tar {} failed: exited with {}{}", + args.first().copied().unwrap_or(""), + output.status, + crate::version::stderr_detail(&output.stderr) + )); + } + String::from_utf8(output.stdout).map_err(|e| format!("invalid UTF-8 from tar: {e}")) +} + +/// Verified standalone replacement: resolve the release asset for this +/// target, download + verify the checksum text, download the archive and +/// compare its real SHA-256, list then extract with exactly one validated +/// entry, stage it next to the current executable, and hand off to the +/// platform transactional swap. Every failure before the swap step leaves +/// the installed executable untouched; the temp directory is always +/// best-effort removed on every path. +fn standalone_update(latest: &str, current_exe: &Path) -> Result<(), String> { + let temp_root = create_private_temp_dir()?; + let result = standalone_update_in(latest, current_exe, &temp_root); + let _ = std::fs::remove_dir_all(&temp_root); + result +} + +fn standalone_update_in(latest: &str, current_exe: &Path, temp_root: &Path) -> Result<(), String> { + let target = crate::version::release_target() + .ok_or_else(|| "unsupported OS/architecture for a standalone update".to_string())?; + + // Preflight before any large download: prove same-directory write + // capability and reserve collision-safe staging names first. + let staging = preflight_same_dir(current_exe)?; + + let archive_name = format!("srcwalk-{}.tar.gz", target.triple); + let archive_url = format!("{GITHUB_RELEASE_BASE}/v{latest}/{archive_name}"); + let checksum_url = format!("{archive_url}.sha256"); + + let checksum_text = download_text(&checksum_url, MAX_CHECKSUM_BYTES, None)?; + let expected_sha256 = parse_checksum(&checksum_text, &archive_name)?; + + let archive_path = temp_root.join(&archive_name); + let actual_sha256 = download_archive(&archive_url, &archive_path, MAX_ARCHIVE_BYTES, None)?; + if actual_sha256 != expected_sha256 { + return Err(format!("SHA-256 mismatch for {archive_name}")); + } + + let listing = run_tar(&["tzf", &archive_name], temp_root)?; + validate_archive_entries(&listing, target.binary)?; + + let extract_dir = temp_root.join("staging"); + std::fs::create_dir(&extract_dir).map_err(|e| format!("could not create staging dir: {e}"))?; + run_tar(&["xzf", &archive_name, "-C", "staging"], temp_root)?; + + let extracted_binary = extract_dir.join(target.binary); + validate_extracted_binary(&extracted_binary)?; + + #[cfg(unix)] + { + let mut perms = std::fs::metadata(&extracted_binary) + .map_err(|e| format!("could not stat extracted binary: {e}"))? + .permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&extracted_binary, perms) + .map_err(|e| format!("could not set extracted binary permissions: {e}"))?; + } + + stage_same_dir(&extracted_binary, &staging.staged)?; + + #[cfg(unix)] + { + unix_transactional_swap( + current_exe, + &staging.staged, + &staging.backup, + latest, + spawn_reported_version, + ) + } + #[cfg(windows)] + { + windows_transactional_swap( + current_exe, + &staging.staged, + &staging.backup, + latest, + spawn_reported_version, + ) + } +} + +/// Copies the validated extracted binary into its create-new same-directory +/// staged path, preserving executable permissions on Unix. +fn stage_same_dir(extracted_binary: &Path, staged: &Path) -> Result<(), String> { + stage_same_dir_with(extracted_binary, staged, |p: &Path| std::fs::remove_file(p)) +} + +/// Any failure below — open, copy, stat, or chmod — leaves a +/// create_new-reserved staged file (empty or partially written) sitting +/// next to the real executable, so every error path here must clean it up, +/// not just the copy failure, and a cleanup failure is folded into the +/// reported error too, never silently swallowed by a bare +/// `let _ = remove_file(...)`. `cleanup_remove` is injectable for the same +/// reason as `unix_backup_current_with`. +fn stage_same_dir_with( + extracted_binary: &Path, + staged: &Path, + cleanup_remove: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + let dest = reserve_create_new(staged)?; + match stage_same_dir_write(extracted_binary, staged, dest) { + Ok(()) => Ok(()), + Err(e) => Err(fold_cleanup_failure_with( + e, + staged, + "staged binary", + cleanup_remove, + )), + } +} + +fn stage_same_dir_write( + extracted_binary: &Path, + staged: &Path, + mut dest: std::fs::File, +) -> Result<(), String> { + let mut src = std::fs::File::open(extracted_binary) + .map_err(|e| format!("could not open extracted binary: {e}"))?; + std::io::copy(&mut src, &mut dest).map_err(|e| { + format!( + "could not stage updated binary at {}: {e}", + staged.display() + ) + })?; + #[cfg(unix)] + { + drop(dest); + let perms = std::fs::metadata(extracted_binary) + .map_err(|e| format!("could not stat extracted binary: {e}"))? + .permissions(); + std::fs::set_permissions(staged, perms) + .map_err(|e| format!("could not set staged binary permissions: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn write_fake_tool(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&path, perms).unwrap(); + } + + // ---- RED 3: channel detection (pure, no I/O). Every test below pins an + // explicit `windows` matching mode via `detect_channel_for` so the + // asserted semantics never depend on which host CI runs the suite. ---- + + #[test] + fn detect_channel_for_standalone_when_no_node_modules_component() { + assert_eq!( + detect_channel_for(Path::new("/usr/local/bin/srcwalk"), false), + InstallChannel::Standalone + ); + assert_eq!( + detect_channel_for(Path::new("/Users/dev/.cargo/bin/srcwalk"), false), + InstallChannel::Standalone + ); + } + + #[test] + fn detect_channel_for_npm_under_generic_node_modules() { + assert_eq!( + detect_channel_for( + Path::new("/usr/lib/node_modules/srcwalk/bin/srcwalk"), + false + ), + InstallChannel::Npm + ); + } + + #[test] + fn detect_channel_for_pnpm_marker() { + assert_eq!( + detect_channel_for( + Path::new( + "/home/dev/.local/share/pnpm/global/5/node_modules/.pnpm/srcwalk@1.8.0/node_modules/srcwalk/bin/srcwalk" + ), + false + ), + InstallChannel::Pnpm + ); + } + + #[test] + fn detect_channel_for_bun_marker() { + assert_eq!( + detect_channel_for( + Path::new("/home/dev/.bun/install/global/node_modules/srcwalk/bin/srcwalk"), + false + ), + InstallChannel::Bun + ); + } + + #[test] + fn detect_channel_for_yarn_marker() { + assert_eq!( + detect_channel_for( + Path::new("/home/dev/.yarn/berry/global/node_modules/srcwalk/bin/srcwalk"), + false + ), + InstallChannel::Yarn + ); + } + + #[test] + fn detect_channel_for_bare_yarn_directory_name_is_not_a_marker() { + // A plain `yarn` component (no leading dot) is just a project/directory + // name, not a Yarn-global marker — this must route to Npm, matching an + // ordinary npm install that merely happens to live under a `yarn/` dir. + assert_eq!( + detect_channel_for( + Path::new("/home/dev/yarn/node_modules/srcwalk/bin/srcwalk"), + false + ), + InstallChannel::Npm + ); + } + + #[test] + fn detect_channel_for_windows_mode_is_case_insensitive_and_accepts_backslashes() { + assert_eq!( + detect_channel_for( + Path::new(r"C:\Users\dev\AppData\Roaming\NPM\Node_Modules\srcwalk\bin\srcwalk.exe"), + true + ), + InstallChannel::Npm + ); + assert_eq!( + detect_channel_for( + Path::new( + r"C:\Users\dev\AppData\Local\Yarn\.YARN\global\node_modules\srcwalk\bin\srcwalk.exe" + ), + true + ), + InstallChannel::Yarn + ); + } + + #[test] + fn detect_channel_for_unix_mode_is_case_sensitive() { + // Unix matching is component-exact: an uppercase `NODE_MODULES` must + // never match the lowercase marker, regardless of which host CI runs + // this test. + assert_eq!( + detect_channel_for( + Path::new("/usr/lib/NODE_MODULES/srcwalk/bin/srcwalk"), + false + ), + InstallChannel::Standalone + ); + } + + #[test] + fn detect_channel_for_ignores_misleading_substrings() { + // "my_node_modules_backup" is a decoy component, not the exact `node_modules` + // marker; component-exact matching must not treat it as a substring hit. + assert_eq!( + detect_channel_for(Path::new("/home/dev/my_node_modules_backup/srcwalk"), false), + InstallChannel::Standalone + ); + } + + #[test] + fn detect_channel_wrapper_delegates_to_detect_channel_for() { + // Standalone has no OS-conditional branch to exercise; this just + // proves the thin runtime wrapper is actually wired up. + assert_eq!( + detect_channel(Path::new("/usr/local/bin/srcwalk")), + InstallChannel::Standalone + ); + } + + // ---- RED 4: package-manager argv table ---- + + #[test] + fn package_manager_argv_matches_spec_table() { + assert_eq!( + package_manager_argv(InstallChannel::Npm), + Some(("npm", ["install", "-g", "srcwalk@latest"].as_slice())) + ); + assert_eq!( + package_manager_argv(InstallChannel::Pnpm), + Some(("pnpm", ["add", "-g", "srcwalk@latest"].as_slice())) + ); + assert_eq!( + package_manager_argv(InstallChannel::Yarn), + Some(("yarn", ["global", "add", "srcwalk@latest"].as_slice())) + ); + assert_eq!( + package_manager_argv(InstallChannel::Bun), + Some(("bun", ["add", "-g", "srcwalk@latest"].as_slice())) + ); + assert_eq!(package_manager_argv(InstallChannel::Standalone), None); + } + + #[cfg(unix)] + #[test] + fn run_package_manager_update_succeeds_and_verifies_reported_version() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "npm", "exit 0"); + let verify_exe = dir.path().join("fake-srcwalk"); + std::fs::write( + &verify_exe, + "#!/bin/sh\necho 'srcwalk 1.9.0 (abc1234, 2026-01-01)'\n", + ) + .unwrap(); + let mut perms = std::fs::metadata(&verify_exe).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&verify_exe, perms).unwrap(); + + let result = run_package_manager_update( + InstallChannel::Npm, + "1.9.0", + &verify_exe, + Some(dir.path().as_os_str()), + ); + assert!(result.is_ok(), "{result:?}"); + } + + #[cfg(unix)] + #[test] + fn run_package_manager_update_fails_on_nonzero_exit() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "npm", "exit 1"); + let verify_exe = dir.path().join("unused"); + let err = run_package_manager_update( + InstallChannel::Npm, + "1.9.0", + &verify_exe, + Some(dir.path().as_os_str()), + ) + .unwrap_err(); + assert!(err.contains("npm"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn run_package_manager_update_fails_when_reported_version_mismatches() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "npm", "exit 0"); + let verify_exe = dir.path().join("fake-srcwalk"); + std::fs::write( + &verify_exe, + "#!/bin/sh\necho 'srcwalk 1.8.0 (abc1234, 2026-01-01)'\n", + ) + .unwrap(); + let mut perms = std::fs::metadata(&verify_exe).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&verify_exe, perms).unwrap(); + + let err = run_package_manager_update( + InstallChannel::Npm, + "1.9.0", + &verify_exe, + Some(dir.path().as_os_str()), + ) + .unwrap_err(); + assert!(err.contains("1.9.0") && err.contains("1.8.0"), "{err}"); + } + + // ---- RED 6: checksum / archive-listing helpers (pure) ---- + + #[test] + fn parse_checksum_accepts_exact_one_line_hex_filename() { + let hash = "a".repeat(64); + let text = format!("{hash} srcwalk-x86_64-unknown-linux-musl.tar.gz\n"); + assert_eq!( + parse_checksum(&text, "srcwalk-x86_64-unknown-linux-musl.tar.gz").unwrap(), + hash + ); + } + + #[test] + fn parse_checksum_accepts_uppercase_and_star_prefix() { + let hash = "B".repeat(64); + let text = format!("{hash} *archive.tar.gz\n"); + assert_eq!( + parse_checksum(&text, "archive.tar.gz").unwrap(), + hash.to_lowercase() + ); + } + + #[test] + fn parse_checksum_rejects_wrong_filename() { + let hash = "a".repeat(64); + let text = format!("{hash} other.tar.gz\n"); + assert!(parse_checksum(&text, "archive.tar.gz").is_err()); + } + + #[test] + fn parse_checksum_rejects_multi_line_or_malformed_hex() { + let hash = "a".repeat(64); + assert!( + parse_checksum(&format!("{hash} a.tar.gz\n{hash} a.tar.gz\n"), "a.tar.gz").is_err() + ); + assert!(parse_checksum("nothex a.tar.gz\n", "a.tar.gz").is_err()); + } + + #[test] + fn validate_archive_entries_accepts_single_expected_binary() { + assert!(validate_archive_entries("srcwalk\n", "srcwalk").is_ok()); + assert!(validate_archive_entries("./srcwalk.exe\r\n", "srcwalk.exe").is_ok()); + } + + #[test] + fn validate_archive_entries_rejects_extra_traversal_and_wrong_name() { + assert!(validate_archive_entries("srcwalk\nREADME.md\n", "srcwalk").is_err()); + assert!(validate_archive_entries("../srcwalk\n", "srcwalk").is_err()); + assert!(validate_archive_entries("/etc/srcwalk\n", "srcwalk").is_err()); + assert!(validate_archive_entries("wrong-binary\n", "srcwalk").is_err()); + } + + // ---- P1-1: HTTPS-only argv (curl/wget must never be able to follow a + // plain-HTTP redirect for either the resolver or the asset downloader) ---- + + #[test] + fn curl_download_args_forces_https_only() { + let args = curl_download_args("https://example.test/a", 4096, "20"); + assert!(args.contains(&"--proto".to_string())); + assert!(args.contains(&"=https".to_string())); + assert!(args.contains(&"--proto-redir".to_string())); + assert_eq!( + args.iter().filter(|a| a.as_str() == "=https").count(), + 2, + "both --proto and --proto-redir must be pinned to https: {args:?}" + ); + } + + #[test] + fn wget_download_args_forces_https_only() { + let args = wget_download_args("https://example.test/a", "20"); + assert!(args.contains(&"--https-only".to_string()), "{args:?}"); + } + + // ---- RED 7: curl-then-wget downloader abstraction (fake tools, zero network) ---- + + #[cfg(unix)] + #[test] + fn download_text_uses_curl_when_curl_succeeds() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "printf 'hello-from-curl'"); + write_fake_tool(dir.path(), "wget", "exit 1"); + let text = + download_text("https://example.test/x", 4096, Some(dir.path().as_os_str())).unwrap(); + assert_eq!(text, "hello-from-curl"); + } + + #[cfg(unix)] + #[test] + fn download_text_falls_back_to_wget_when_curl_fails() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "exit 1"); + write_fake_tool(dir.path(), "wget", "printf 'hello-from-wget'"); + let text = + download_text("https://example.test/x", 4096, Some(dir.path().as_os_str())).unwrap(); + assert_eq!(text, "hello-from-wget"); + } + + #[cfg(unix)] + #[test] + fn download_text_reports_both_failures_when_no_tool_succeeds() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "exit 1"); + write_fake_tool(dir.path(), "wget", "exit 1"); + let err = download_text("https://example.test/x", 4096, Some(dir.path().as_os_str())) + .unwrap_err(); + assert!(err.contains("curl") && err.contains("wget"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn download_text_includes_bounded_stderr_detail_on_failure() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "echo 'CURL_ERR_DETAIL' >&2; exit 1"); + write_fake_tool(dir.path(), "wget", "echo 'WGET_ERR_DETAIL' >&2; exit 1"); + let err = download_text("https://example.test/x", 4096, Some(dir.path().as_os_str())) + .unwrap_err(); + assert!( + err.contains("CURL_ERR_DETAIL") && err.contains("WGET_ERR_DETAIL"), + "{err}" + ); + } + + #[cfg(unix)] + #[test] + fn download_text_enforces_byte_cap() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "printf '0123456789'"); + write_fake_tool(dir.path(), "wget", "exit 1"); + let err = + download_text("https://example.test/x", 4, Some(dir.path().as_os_str())).unwrap_err(); + assert!(err.contains("exceeds") || err.contains("bytes"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn download_archive_writes_create_new_destination_and_cleans_up_on_failure() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "printf 'archive-bytes'"); + write_fake_tool(dir.path(), "wget", "exit 1"); + let dest = dir.path().join("out.tar.gz"); + let sha256 = download_archive( + "https://example.test/a.tar.gz", + &dest, + 4096, + Some(dir.path().as_os_str()), + ) + .unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"archive-bytes"); + assert_eq!( + sha256, + "0c982986710a026635603031674053ca851fc0e3ea760094a34f59b84f7f6da6", + "sha256_hex must match the real SHA-256 of the downloaded bytes, not just a checksum-shaped string" + ); + + let dir2 = tempfile::tempdir().unwrap(); + write_fake_tool(dir2.path(), "curl", "exit 1"); + write_fake_tool(dir2.path(), "wget", "exit 1"); + let dest2 = dir2.path().join("out2.tar.gz"); + let err = download_archive( + "https://example.test/a.tar.gz", + &dest2, + 4096, + Some(dir2.path().as_os_str()), + ) + .unwrap_err(); + assert!( + !dest2.exists(), + "{err}: partial archive must not be left behind" + ); + } + + #[cfg(unix)] + #[test] + fn download_archive_refuses_to_overwrite_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "printf 'new-bytes'"); + let dest = dir.path().join("out.tar.gz"); + std::fs::write(&dest, b"pre-existing").unwrap(); + + let err = download_archive( + "https://example.test/a.tar.gz", + &dest, + 4096, + Some(dir.path().as_os_str()), + ) + .unwrap_err(); + + assert_eq!( + std::fs::read(&dest).unwrap(), + b"pre-existing", + "{err}: an existing destination must never be overwritten" + ); + } + + #[cfg(unix)] + #[test] + fn download_archive_cleans_up_when_content_exceeds_cap() { + let dir = tempfile::tempdir().unwrap(); + write_fake_tool(dir.path(), "curl", "printf '0123456789'"); + write_fake_tool(dir.path(), "wget", "exit 1"); + let dest = dir.path().join("out.tar.gz"); + + let err = download_archive( + "https://example.test/a.tar.gz", + &dest, + 4, + Some(dir.path().as_os_str()), + ) + .unwrap_err(); + + assert!( + !dest.exists(), + "{err}: oversize archive must not leave partial bytes on disk" + ); + } + + // ---- RED (gap 1): post-extract type safety — tar listing text alone never + // proves the extracted entry's real filesystem type. ---- + + #[cfg(unix)] + #[test] + fn validate_extracted_binary_accepts_regular_file() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("srcwalk"); + std::fs::write(&bin, b"binary-bytes").unwrap(); + assert!(validate_extracted_binary(&bin).is_ok()); + } + + #[cfg(unix)] + #[test] + fn validate_extracted_binary_rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("elsewhere"); + std::fs::write(&target, b"binary-bytes").unwrap(); + let link = dir.path().join("srcwalk"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(validate_extracted_binary(&link).is_err()); + } + + #[cfg(unix)] + #[test] + fn validate_extracted_binary_rejects_directory_and_missing_path() { + let dir = tempfile::tempdir().unwrap(); + let subdir = dir.path().join("srcwalk"); + std::fs::create_dir(&subdir).unwrap(); + assert!(validate_extracted_binary(&subdir).is_err()); + assert!(validate_extracted_binary(&dir.path().join("missing")).is_err()); + } + + // ---- P1: temp dir must be private atomically (mkdir(0700), not a + // separate post-create chmod — no window with broader permissions) ---- + + #[cfg(unix)] + #[test] + fn create_private_temp_dir_is_mode_0700_atomically() { + let dir = create_private_temp_dir().unwrap(); + let perms = std::fs::metadata(&dir).unwrap().permissions(); + let mode = std::os::unix::fs::PermissionsExt::mode(&perms) & 0o777; + std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!( + mode, 0o700, + "temp dir must be created private, mode was {mode:o}" + ); + } + + // ---- RED (gap 2): preflight / create-new safety ---- + + #[test] + fn require_probe_cleanup_succeeds_when_both_create_and_remove_succeed() { + assert!(require_probe_cleanup(|| Ok(()), || Ok(())).is_ok()); + } + + #[test] + fn require_probe_cleanup_fails_when_remove_fails_even_though_create_succeeded() { + // A write-probe that was created but could not be removed is not + // proof of usable write capability — the caller must see this as a + // failure, not silently continue as if the directory were clean. + let err = require_probe_cleanup(|| Ok(()), || Err("simulated remove failure".to_string())) + .unwrap_err(); + assert!(err.contains("simulated remove failure"), "{err}"); + } + + #[test] + fn require_probe_cleanup_never_calls_remove_when_create_fails() { + let err = require_probe_cleanup( + || Err("simulated create failure".to_string()), + || panic!("remove must not run when create never succeeded"), + ) + .unwrap_err(); + assert!(err.contains("simulated create failure"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn reserve_create_new_succeeds_for_a_fresh_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("reserved"); + assert!(reserve_create_new(&path).is_ok()); + assert!(path.exists()); + } + + #[cfg(unix)] + #[test] + fn reserve_create_new_refuses_an_existing_regular_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("taken"); + std::fs::write(&path, b"already here").unwrap(); + assert!(reserve_create_new(&path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"already here"); + } + + #[cfg(unix)] + #[test] + fn reserve_create_new_refuses_an_attacker_planted_symlink() { + let dir = tempfile::tempdir().unwrap(); + let victim = dir.path().join("victim"); + std::fs::write(&victim, b"do not touch").unwrap(); + let trap = dir.path().join("reserved"); + std::os::unix::fs::symlink(&victim, &trap).unwrap(); + assert!( + reserve_create_new(&trap).is_err(), + "create-new must never follow an existing symlink, attacker-planted or not" + ); + assert_eq!(std::fs::read(&victim).unwrap(), b"do not touch"); + } + + #[cfg(unix)] + #[test] + fn preflight_same_dir_reserves_distinct_paths_next_to_current() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + std::fs::write(¤t, b"old-bytes").unwrap(); + + let paths = preflight_same_dir(¤t).unwrap(); + assert_ne!(paths.staged, paths.backup); + assert_ne!(paths.staged, current); + assert_ne!(paths.backup, current); + assert_eq!(paths.staged.parent(), Some(dir.path())); + assert_eq!(paths.backup.parent(), Some(dir.path())); + } + + #[cfg(unix)] + #[test] + fn preflight_same_dir_rejects_non_regular_current_target() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk-dir"); + std::fs::create_dir(¤t).unwrap(); + assert!(preflight_same_dir(¤t).is_err()); + } + + #[cfg(unix)] + #[test] + fn preflight_same_dir_rejects_current_symlink() { + // symlink_metadata (lstat) must be used, not metadata (which follows + // the link): a symlink at `current` must be rejected outright, never + // treated as if it were the regular file it points to. + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("elsewhere"); + std::fs::write(&target, b"old-bytes").unwrap(); + let current = dir.path().join("srcwalk"); + std::os::unix::fs::symlink(&target, ¤t).unwrap(); + assert!(preflight_same_dir(¤t).is_err()); + } + + #[cfg(unix)] + #[test] + fn preflight_same_dir_rejects_unwritable_directory() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + std::fs::write(¤t, b"old-bytes").unwrap(); + let mut perms = std::fs::metadata(dir.path()).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o555); + std::fs::set_permissions(dir.path(), perms.clone()).unwrap(); + let result = preflight_same_dir(¤t); + // Restore write permission so tempfile can clean up the directory afterward. + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(dir.path(), perms).unwrap(); + + if result.is_ok() { + // Effective root (or another privilege bypass) ignores the mode + // bit and the write-probe genuinely succeeded; nothing to assert. + eprintln!( + "skipping preflight_same_dir_rejects_unwritable_directory: chmod 0555 did not block writes in this environment (likely running as root)" + ); + return; + } + assert!( + result.is_err(), + "a same-dir write probe must fail before any swap is attempted" + ); + } + + // ---- P1: same-dir artifact leaks on error — a create_new-reserved + // staged/backup file left behind on a non-copy failure (open/stat/chmod + // before or after the copy) is a leftover artifact next to the real + // executable, not a harmless temp file. ---- + + #[test] + fn stage_same_dir_cleans_up_staged_when_extracted_binary_cannot_be_opened() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("does-not-exist-extracted-binary"); + let staged = dir.path().join("srcwalk.staged"); + + let err = stage_same_dir(&missing, &staged).unwrap_err(); + + assert!(err.contains("could not open extracted binary"), "{err}"); + assert!( + !staged.exists(), + "a create_new-reserved staged file must not be left behind when opening the source fails: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn unix_backup_current_cleans_up_reserved_backup_when_current_cannot_be_opened() { + let dir = tempfile::tempdir().unwrap(); + let missing_current = dir.path().join("does-not-exist-current"); + let backup = dir.path().join("srcwalk.bak"); + + let err = unix_backup_current(&missing_current, &backup).unwrap_err(); + + assert!(err.contains("could not read current executable"), "{err}"); + assert!( + !backup.exists(), + "a create_new-reserved backup file must not be left behind when opening current fails: {err}" + ); + } + + // The two tests above prove the ordinary case (cleanup succeeds); the + // two below prove a genuine cleanup-removal failure is folded into the + // reported error rather than swallowed by a bare `let _ = ...`. This is + // not reproducible via real chmod (create and remove of the same path + // are gated by the identical directory permission bit, and there is no + // way to intervene between them from outside a single function call), + // so `cleanup_remove` is fault-injected instead — same rationale as + // `windows_move_non_overwrite_with`. + + #[test] + fn stage_same_dir_with_reports_both_errors_when_reserved_staged_cleanup_also_fails() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("does-not-exist-extracted-binary"); + let staged = dir.path().join("srcwalk.staged"); + + let err = stage_same_dir_with(&missing, &staged, |_p| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup-locked", + )) + }) + .unwrap_err(); + + assert!(err.contains("could not open extracted binary"), "{err}"); + assert!( + err.contains("also could not remove leftover staged binary") && err.contains("cleanup-locked"), + "{err}: a cleanup failure for the reserved staged file must be folded into the reported error, not silently swallowed" + ); + } + + #[cfg(unix)] + #[test] + fn unix_backup_current_with_reports_both_errors_when_reserved_backup_cleanup_also_fails() { + let dir = tempfile::tempdir().unwrap(); + let missing_current = dir.path().join("does-not-exist-current"); + let backup = dir.path().join("srcwalk.bak"); + + let err = unix_backup_current_with(&missing_current, &backup, |_p| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup-locked", + )) + }) + .unwrap_err(); + + assert!(err.contains("could not read current executable"), "{err}"); + assert!( + err.contains("also could not remove leftover backup") && err.contains("cleanup-locked"), + "{err}: a cleanup failure for the reserved backup file must be folded into the reported error, not silently swallowed" + ); + } + + // ---- P1: `unix_transactional_swap_inner`'s own two backup-cleanup + // steps (placement failure, and post-verify success) in isolation — + // same fault-injection rationale as above. ---- + + #[cfg(unix)] + #[test] + fn placement_failed_reports_both_errors_when_backup_cleanup_also_fails() { + let err = placement_failed( + "rename failed".to_string(), + Path::new("/some/backup"), + |_p| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup-locked", + )) + }, + ); + + assert!( + err.contains("could not place updated binary") && err.contains("rename failed"), + "{err}" + ); + assert!( + err.contains("also could not remove leftover backup") && err.contains("cleanup-locked"), + "{err}" + ); + } + + #[cfg(unix)] + #[test] + fn placement_failed_reports_only_primary_when_backup_cleanup_succeeds() { + let err = placement_failed( + "rename failed".to_string(), + Path::new("/some/backup"), + |_p| Ok(()), + ); + assert_eq!(err, "could not place updated binary: rename failed"); + } + + #[cfg(unix)] + #[test] + fn verified_swap_cleanup_succeeds_when_backup_removal_succeeds() { + assert!(verified_swap_cleanup(Path::new("/some/backup"), |_p| Ok(())).is_ok()); + } + + #[cfg(unix)] + #[test] + fn verified_swap_cleanup_succeeds_when_backup_is_already_gone() { + assert!(verified_swap_cleanup(Path::new("/some/backup"), |_p| { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")) + }) + .is_ok()); + } + + #[cfg(unix)] + #[test] + fn verified_swap_cleanup_reports_honest_error_without_claiming_success_when_removal_fails() { + let err = verified_swap_cleanup(Path::new("/some/backup"), |_p| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup-locked", + )) + }) + .unwrap_err(); + + assert!(err.contains("updated and verified"), "{err}"); + assert!(err.contains("could not remove backup"), "{err}"); + assert!(err.contains("cleanup-locked"), "{err}"); + } + + // ---- RED 8: Unix transactional swap + rollback ---- + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_replaces_current_on_successful_verify() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + let staged = dir.path().join("srcwalk.staged"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + unix_transactional_swap(¤t, &staged, &backup, "1.9.0", |_path| { + Ok("1.9.0".to_string()) + }) + .unwrap(); + + assert_eq!(std::fs::read(¤t).unwrap(), b"new-bytes"); + assert!( + !backup.exists(), + "backup must be removed after a verified swap" + ); + } + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_rolls_back_on_verify_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + let staged = dir.path().join("srcwalk.staged"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + let err = unix_transactional_swap(¤t, &staged, &backup, "1.9.0", |_path| { + Ok("1.8.0".to_string()) + }) + .unwrap_err(); + + assert_eq!( + std::fs::read(¤t).unwrap(), + b"old-bytes", + "current executable must be restored after a failed post-check: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_preserves_current_when_placement_fails() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + // Rename source is deliberately missing, forcing the placement step + // itself to fail — distinct from a post-check verify mismatch. + let staged = dir.path().join("does-not-exist"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + + let err = unix_transactional_swap(¤t, &staged, &backup, "1.9.0", |_path| { + Ok("1.9.0".to_string()) + }) + .unwrap_err(); + + assert_eq!( + std::fs::read(¤t).unwrap(), + b"old-bytes", + "placement failure must never touch the original executable: {err}" + ); + assert!( + !backup.exists(), + "a stray backup must not be left when placement never happened: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_reports_both_errors_when_rollback_itself_fails() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + let staged = dir.path().join("srcwalk.staged"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + let backup_for_closure = backup.clone(); + let err = unix_transactional_swap(¤t, &staged, &backup, "1.9.0", move |_path| { + // Simulate the backup becoming unavailable exactly when rollback + // needs it, forcing the rollback step itself to fail. + std::fs::remove_file(&backup_for_closure).unwrap(); + Ok("1.8.0".to_string()) + }) + .unwrap_err(); + + assert!( + err.contains("1.9.0") && err.contains("1.8.0"), + "{err}: primary post-check detail must survive a failed rollback" + ); + assert!( + err.contains("rollback"), + "{err}: rollback failure detail must be reported too, not silently swallowed" + ); + } + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_cleans_up_leftover_staged_when_backup_creation_fails() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + let staged = dir.path().join("srcwalk.staged"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + // Pre-occupy the backup path so create_new fails before placement + // ever starts, leaving `staged` fully intact and never consumed. + std::fs::write(&backup, b"pre-existing").unwrap(); + + let err = unix_transactional_swap(¤t, &staged, &backup, "1.9.0", |_path| { + panic!("verify must not run when backup creation never succeeded") + }) + .unwrap_err(); + + assert!(err.contains("could not create"), "{err}"); + assert!( + !staged.exists(), + "a staged binary must never be left behind when the swap never even started placing it: {err}" + ); + assert_eq!( + std::fs::read(&backup).unwrap(), + b"pre-existing", + "a pre-existing backup path must never be clobbered by a failed create_new attempt" + ); + assert_eq!(std::fs::read(¤t).unwrap(), b"old-bytes"); + } + + #[cfg(unix)] + #[test] + fn unix_transactional_swap_reports_cleanup_failure_when_leftover_staged_cannot_be_removed() { + let dir = tempfile::tempdir().unwrap(); + let staged_dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk"); + let staged = staged_dir.path().join("srcwalk.staged"); + let backup = dir.path().join("srcwalk.bak"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + // Pre-occupy the backup path so create_new fails before placement + // ever starts — the same forced failure as above, but this time + // `staged`'s own directory is made unwritable so its cleanup attempt + // itself fails and must be folded into the reported error. + std::fs::write(&backup, b"pre-existing").unwrap(); + let mut perms = std::fs::metadata(staged_dir.path()).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o555); + std::fs::set_permissions(staged_dir.path(), perms.clone()).unwrap(); + + let err = unix_transactional_swap(¤t, &staged, &backup, "1.9.0", |_path| { + panic!("verify must not run when backup creation never succeeded") + }) + .unwrap_err(); + + // Restore write permission unconditionally so tempfile can clean up afterward. + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(staged_dir.path(), perms).unwrap(); + + assert!(err.contains("could not create"), "{err}"); + if staged.exists() { + assert!( + err.contains("leftover staged binary"), + "{err}: a cleanup failure for the leftover staged binary must be folded into the reported error, not silently swallowed" + ); + } else { + eprintln!( + "skipping strict cleanup-failure assertion in unix_transactional_swap_reports_cleanup_failure_when_leftover_staged_cannot_be_removed: chmod 0555 did not block unlink in this environment (likely running as root)" + ); + } + } + + // ---- P1-4: windows_move_non_overwrite_with (pure, fault-injected — forcing a + // genuine post-hard_link remove-source failure on a real filesystem is not + // reliably reproducible, so the branch is proven with injected closures + // instead; this also makes it testable on any host, not just Windows CI) ---- + + #[test] + fn windows_move_non_overwrite_with_succeeds_when_link_and_remove_both_succeed() { + let result = windows_move_non_overwrite_with( + Path::new("src"), + Path::new("dst"), + |_s, _d| Ok(()), + |_s| Ok(()), + |_d| panic!("cleanup must not run when remove_source succeeded"), + ); + assert!(result.is_ok()); + } + + #[test] + fn windows_move_non_overwrite_with_fails_honestly_when_link_fails() { + let result = windows_move_non_overwrite_with( + Path::new("src"), + Path::new("dst"), + |_s, _d| { + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "occupied", + )) + }, + |_s| panic!("remove_source must not run when link never succeeded"), + |_d| panic!("cleanup must not run when link never succeeded"), + ); + assert!(result.is_err()); + } + + #[test] + fn windows_move_non_overwrite_with_cleans_up_link_when_remove_source_fails() { + use std::cell::Cell; + let destination_removed = Cell::new(false); + let result = windows_move_non_overwrite_with( + Path::new("src"), + Path::new("dst"), + |_s, _d| Ok(()), + |_s| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "locked", + )) + }, + |_d| { + destination_removed.set(true); + Ok(()) + }, + ); + assert!( + result.is_err(), + "a move that could not remove its source is a failure, not a harmless leftover link" + ); + assert!( + destination_removed.get(), + "the just-created link must be cleaned up when the source cannot be removed" + ); + } + + #[test] + fn windows_move_non_overwrite_with_reports_both_errors_when_cleanup_also_fails() { + let err = windows_move_non_overwrite_with( + Path::new("src"), + Path::new("dst"), + |_s, _d| Ok(()), + |_s| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "remove-source-locked", + )) + }, + |_d| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup-also-locked", + )) + }, + ) + .unwrap_err(); + assert!( + err.contains("remove-source-locked") && err.contains("cleanup-also-locked"), + "{err}: both the remove-source failure and the cleanup failure must be reported" + ); + } + + // ---- P1: the first-move step's hard_link+remove semantics against a + // *genuinely running* process image — the tests below this marker use a + // stand-in file that is never actually loaded/executing, which does not + // prove the load-bearing assumption behind step 1 of the Windows swap + // (current -> .old) on a real Windows loader. This test copies a + // disposable, throwaway `cmd.exe` (the real system one is never touched) + // and keeps it genuinely running — deterministically, via a piped stdin + // that is never written to or closed, no sleep/timing race and no + // network — then exercises the exact `windows_move_non_overwrite` helper + // used in production against that running copy's own file. ---- + + #[cfg(windows)] + #[test] + fn windows_move_non_overwrite_succeeds_against_a_genuinely_running_process_image() { + let dir = tempfile::tempdir().unwrap(); + let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); + let system_cmd = Path::new(&system_root).join("System32").join("cmd.exe"); + let running = dir.path().join("srcwalk-running-copy.exe"); + std::fs::copy(&system_cmd, &running).expect( + "copy a disposable cmd.exe for this test only; the real system cmd.exe is never touched", + ); + + let mut child = process::Command::new(&running) + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .spawn() + .expect("spawn the disposable running copy"); + + let old = dir.path().join("srcwalk-running-copy.exe.old"); + let result = windows_move_non_overwrite(&running, &old); + + // Always terminate + reap the disposable child before asserting, so + // a failed assertion never leaks a live process. + let _ = child.kill(); + let _ = child.wait(); + + result.expect( + "hard_link + remove_file must succeed against a currently-running process image on \ + Windows -- this is the load-bearing assumption behind the first-move step of the \ + Windows transactional swap (current -> .old). If this fails on real Windows CI, the \ + swap design needs to change before shipping; do not silently patch over it.", + ); + assert!( + old.exists(), + "the running copy must have been moved to .old" + ); + assert!( + !running.exists(), + "the running copy's original path must be gone after the move" + ); + } + + // ---- RED 9: Windows transactional swap + rollback (Windows CI only) ---- + + #[cfg(windows)] + #[test] + fn windows_transactional_swap_replaces_current_on_successful_verify() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk.exe"); + let staged = dir.path().join("srcwalk.exe.staged"); + let old = dir.path().join("srcwalk.exe.old"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + windows_transactional_swap(¤t, &staged, &old, "1.9.0", |_path| { + Ok("1.9.0".to_string()) + }) + .unwrap(); + + assert_eq!(std::fs::read(¤t).unwrap(), b"new-bytes"); + } + + #[cfg(windows)] + #[test] + fn windows_transactional_swap_rolls_back_on_verify_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk.exe"); + let staged = dir.path().join("srcwalk.exe.staged"); + let old = dir.path().join("srcwalk.exe.old"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + let err = windows_transactional_swap(¤t, &staged, &old, "1.9.0", |_path| { + Ok("1.8.0".to_string()) + }) + .unwrap_err(); + + assert_eq!( + std::fs::read(¤t).unwrap(), + b"old-bytes", + "current executable must be restored after a failed post-check: {err}" + ); + } + + #[cfg(windows)] + #[test] + fn windows_transactional_swap_restores_old_when_second_move_fails() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk.exe"); + // Staged is deliberately missing, forcing the second move (staged -> + // original) to fail after the first rename (current -> old) already + // succeeded — the contract requires immediate restoration of `.old`. + let staged = dir.path().join("does-not-exist.exe"); + let old = dir.path().join("srcwalk.exe.old"); + std::fs::write(¤t, b"old-bytes").unwrap(); + + let err = windows_transactional_swap(¤t, &staged, &old, "1.9.0", |_path| { + Ok("1.9.0".to_string()) + }) + .unwrap_err(); + + assert_eq!( + std::fs::read(¤t).unwrap(), + b"old-bytes", + "second-move failure must restore .old onto the original path: {err}" + ); + assert!( + !old.exists(), + "`.old` must be moved back, not left behind, after a restored second-move failure: {err}" + ); + } + + #[cfg(windows)] + #[test] + fn cleanup_stale_old_best_effort_removes_a_leftover_old_file() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("srcwalk.exe.old"); + std::fs::write(&old, b"stale-from-previous-update").unwrap(); + cleanup_stale_old(&old); + assert!(!old.exists()); + } + + #[cfg(windows)] + #[test] + fn cleanup_stale_old_is_a_harmless_no_op_when_nothing_is_stale() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("srcwalk.exe.old"); + // Must never panic or error just because there is nothing to clean up; + // an update must not fail solely because `.old` cleanup is deferred. + cleanup_stale_old(&old); + } + + #[cfg(windows)] + #[test] + fn windows_transactional_swap_reports_both_errors_when_rollback_itself_fails() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("srcwalk.exe"); + let staged = dir.path().join("srcwalk.exe.staged"); + let old = dir.path().join("srcwalk.exe.old"); + std::fs::write(¤t, b"old-bytes").unwrap(); + std::fs::write(&staged, b"new-bytes").unwrap(); + + let old_for_closure = old.clone(); + let err = windows_transactional_swap(¤t, &staged, &old, "1.9.0", move |_path| { + // Simulate `.old` becoming unavailable exactly when rollback + // needs it, forcing the rollback step itself to fail. + std::fs::remove_file(&old_for_closure).unwrap(); + Ok("1.8.0".to_string()) + }) + .unwrap_err(); + + assert!( + err.contains("1.9.0") && err.contains("1.8.0"), + "{err}: primary post-check detail must survive a failed rollback" + ); + assert!( + err.contains("rollback"), + "{err}: rollback failure detail must be reported too, not silently swallowed" + ); + } +} diff --git a/src/version.rs b/src/version.rs index 801c982..94a0aa1 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,49 +1,35 @@ +use std::ffi::OsStr; +use std::io::Read; use std::process; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Text of the single primary action srcwalk prints when a newer release +/// exists, shared verbatim between `update`, `update --check`, and the +/// compatibility `version --check` path (US-074). +pub(crate) const UPDATE_HINT: &str = "Run: srcwalk update"; + +/// Cap on captured child stderr across every bounded process call. Only used +/// for error diagnostics, so a small cap is enough and keeps every call site +/// bounded without a dedicated cap parameter. +pub(crate) const MAX_STDERR_BYTES: usize = 4 * 1024; + +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_NPM_JSON_BYTES: usize = 16 * 1024; +const MAX_NPM_CLI_BYTES: usize = 1024; +const RESOLVER_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) fn run_version(check: bool) { - let current = env!("CARGO_PKG_VERSION"); println!("{}", version_line()); if !check { return; } - match fetch_latest_version() { - Ok(latest) => { - println!("latest {latest}"); - if version_is_newer(&latest, current) { - println!(); - println!("Update available:"); - println!(" npm install -g srcwalk@latest"); - println!(); - println!("Other install methods:"); - println!(" cargo install srcwalk --locked --force"); - if let Some(target) = release_target() { - println!( - " curl -L https://github.com/sting8k/srcwalk/releases/latest/download/srcwalk-{target}.tar.gz | tar xz -C ~/.local/bin" - ); - } - } else { - println!("Already up to date."); - } - } + match resolve_update_decision(None) { + Ok(decision) => print_check_result(&decision), Err(err) => { - eprintln!("error: could not check latest srcwalk release."); - eprintln!(); - eprintln!("Tried:"); - for source in err.split(';') { - let label = source - .split_once(':') - .map_or(source, |(label, _)| label) - .trim(); - if !label.is_empty() { - eprintln!(" - {label}"); - } - } - eprintln!(); - eprintln!("Update manually:"); - eprintln!(" npm install -g srcwalk@latest"); - eprintln!(" cargo install srcwalk --locked --force"); + print_resolution_failure(&err); process::exit(1); } } @@ -63,9 +49,232 @@ pub(crate) fn version_line() -> String { } } -type VersionFetchAttempt = (&'static str, fn() -> Result); +/// The one typed local-vs-latest decision, shared verbatim between `update`, +/// `update --check`, and compatibility `version --check` (US-074). Every +/// variant carries `latest` so the shared renderer can print it uniformly, +/// matching the pre-existing `version --check` contract for every outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum UpdateDecision { + Equal { current: String, latest: String }, + LocalNewer { current: String, latest: String }, + Newer { current: String, latest: String }, +} + +impl UpdateDecision { + fn latest(&self) -> &str { + match self { + UpdateDecision::Equal { latest, .. } + | UpdateDecision::LocalNewer { latest, .. } + | UpdateDecision::Newer { latest, .. } => latest, + } + } +} + +/// Resolves the shared local-vs-latest decision. `path_override` replaces the +/// child processes' `PATH` (tests only); production callers pass `None` so +/// resolver commands inherit the real environment. +pub(crate) fn resolve_update_decision( + path_override: Option<&OsStr>, +) -> Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + let latest = fetch_latest_version_with(path_override)?; + match compare_versions(¤t, &latest)? { + VersionOrdering::Equal => Ok(UpdateDecision::Equal { current, latest }), + VersionOrdering::LocalNewer => Ok(UpdateDecision::LocalNewer { current, latest }), + VersionOrdering::LatestNewer => Ok(UpdateDecision::Newer { current, latest }), + } +} + +/// Exact status text for `Equal`/`LocalNewer`, identical across every caller. +/// `Newer` has no single-line text here: the shared renderer prints a blank +/// line and [`UPDATE_HINT`] instead (or, for a full `update`, proceeds to a +/// channel action). +pub(crate) fn decision_status_line(decision: &UpdateDecision) -> Option { + match decision { + UpdateDecision::Equal { .. } => Some("Already up to date.".to_string()), + UpdateDecision::LocalNewer { current, latest } => Some(format!( + "Local build {current} is newer than latest release {latest}." + )), + UpdateDecision::Newer { .. } => None, + } +} + +/// The exact printed lines for a check-only result: `latest X.Y.Z` for every +/// decision (equal, local-newer, or newer alike), then either the shared +/// status line or a blank line + [`UPDATE_HINT`]. One renderer, reused by +/// `version --check` and `update --check`, so their text can never fork. +pub(crate) fn render_check_result(decision: &UpdateDecision) -> Vec { + let mut lines = vec![format!("latest {}", decision.latest())]; + match decision_status_line(decision) { + Some(line) => lines.push(line), + None => { + lines.push(String::new()); + lines.push(UPDATE_HINT.to_string()); + } + } + lines +} + +pub(crate) fn print_check_result(decision: &UpdateDecision) { + for line in render_check_result(decision) { + println!("{line}"); + } +} + +/// Shared failure rendering for a failed latest-version resolution: which +/// sources were tried, then a manual fallback. Used by both `version --check` +/// and `update`/`update --check`. +pub(crate) fn print_resolution_failure(err: &str) { + eprintln!("error: could not check latest srcwalk release."); + eprintln!(); + eprintln!("Tried:"); + for source in err.split(';') { + let label = source + .split_once(':') + .map_or(source, |(label, _)| label) + .trim(); + if !label.is_empty() { + eprintln!(" - {label}"); + } + } + eprintln!(); + eprintln!("Update manually:"); + eprintln!(" npm install -g srcwalk@latest"); + eprintln!(" cargo install srcwalk --locked --force"); +} + +pub(crate) fn command_with_path(program: &str, path_override: Option<&OsStr>) -> process::Command { + let mut cmd = process::Command::new(program); + if let Some(path) = path_override { + cmd.env("PATH", path); + } + cmd +} + +/// Bounded capture of a finished child process: at most `max_stdout` bytes of +/// stdout (exceeding it kills the child and fails), stderr capped at +/// [`MAX_STDERR_BYTES`], and a wall-clock `timeout` that kills a hung child +/// even if it never approaches the byte cap. No call site may buffer an +/// unbounded response or wait forever. +#[derive(Debug)] +pub(crate) struct BoundedOutput { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, + pub(crate) status: process::ExitStatus, +} + +pub(crate) fn run_bounded( + mut cmd: process::Command, + max_stdout: usize, + timeout: Duration, +) -> Result { + let mut child = cmd + .stdin(process::Stdio::null()) + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .spawn() + .map_err(|e| format!("could not spawn: {e}"))?; + let mut stdout_pipe = child.stdout.take().expect("stdout is piped"); + let mut stderr_pipe = child.stderr.take().expect("stderr is piped"); + let child = Arc::new(Mutex::new(child)); + + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + let watchdog_child = Arc::clone(&child); + // Stays armed until explicitly told `done` below — which only happens + // once the child has actually exited, not merely once its stdout has + // been fully read. A child can close stdout early and keep running well + // past `timeout` before actually exiting; disarming on read-completion + // alone would let that case hang forever in the wait loop below. + let watchdog = std::thread::spawn(move || -> bool { + match done_rx.recv_timeout(timeout) { + Ok(()) => false, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if let Ok(mut guard) = watchdog_child.lock() { + let _ = guard.kill(); + } + true + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => false, + } + }); + + let stderr_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = stderr_pipe + .by_ref() + .take(MAX_STDERR_BYTES as u64) + .read_to_end(&mut buf); + buf + }); + + let mut stdout_buf = Vec::new(); + let read_result = stdout_pipe + .by_ref() + .take(max_stdout as u64 + 1) + .read_to_end(&mut stdout_buf); + let exceeded = stdout_buf.len() > max_stdout; + if exceeded { + // Already known-bad; kill immediately instead of waiting out the + // watchdog's timeout for no reason. + if let Ok(mut guard) = child.lock() { + let _ = guard.kill(); + } + } + + // Poll for exit instead of a blocking `wait()`: holding the mutex across + // a blocking wait would starve the watchdog (or the exceeded-kill above) + // of the lock it needs to actually kill the child, deadlocking a hung + // child against its own timeout. Each poll only holds the lock briefly. + let status = loop { + let mut guard = child.lock().expect("child mutex poisoned"); + match guard.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => { + drop(guard); + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => break Err(format!("could not wait for child: {e}")), + } + }?; + + // The child has now actually exited (naturally, or via the exceeded-kill + // above, or via the watchdog's timeout-kill); only now is it safe to + // disarm the watchdog. + let _ = done_tx.send(()); + let timed_out = watchdog.join().unwrap_or(false); + let stderr = stderr_handle.join().unwrap_or_default(); + + read_result.map_err(|e| format!("could not read output: {e}"))?; + if timed_out { + return Err(format!("timed out after {timeout:?}")); + } + if exceeded { + return Err(format!("output exceeds {max_stdout} bytes")); + } + + Ok(BoundedOutput { + stdout: stdout_buf, + stderr, + status, + }) +} + +/// Bounded, trimmed stderr detail for a failure message — never a full or +/// unbounded body, only whatever `run_bounded` already captured within +/// [`MAX_STDERR_BYTES`], trimmed. Empty when the child wrote nothing. +pub(crate) fn stderr_detail(stderr: &[u8]) -> String { + let text = String::from_utf8_lossy(stderr); + let trimmed = text.trim(); + if trimmed.is_empty() { + String::new() + } else { + format!(": {trimmed}") + } +} + +type VersionFetchAttempt = (&'static str, fn(Option<&OsStr>) -> Result); -fn fetch_latest_version() -> Result { +pub(crate) fn fetch_latest_version_with(path_override: Option<&OsStr>) -> Result { let attempts: &[VersionFetchAttempt] = &[ ("GitHub latest via curl", fetch_github_latest_with_curl), ("GitHub latest via wget", fetch_github_latest_with_wget), @@ -76,7 +285,7 @@ fn fetch_latest_version() -> Result { let mut errors = Vec::new(); for (label, attempt) in attempts { - match attempt() { + match attempt(path_override) { Ok(version) => return Ok(version), Err(err) => errors.push(format!("{label}: {err}")), } @@ -85,96 +294,158 @@ fn fetch_latest_version() -> Result { Err(errors.join("; ")) } -fn fetch_github_latest_with_curl() -> Result { - let output = process::Command::new("curl") - .args([ - "-fsSI", - "--max-time", - "5", - "https://github.com/sting8k/srcwalk/releases/latest", - ]) - .output() - .map_err(|e| format!("could not run curl: {e}"))?; - command_stdout(output, "curl").and_then(|headers| { - parse_latest_tag_from_headers(&headers) - .ok_or_else(|| "missing latest release redirect".to_string()) - }) +/// Every candidate version must pass strict stable semver before it can be +/// accepted; a malformed candidate is a failed attempt, not a silent 0.0.0. +fn accept_candidate(raw: &str) -> Result { + parse_strict_semver(raw).map_err(|e| format!("malformed version `{raw}`: {e}"))?; + Ok(raw.to_string()) +} + +const GITHUB_LATEST_URL: &str = "https://github.com/sting8k/srcwalk/releases/latest"; +const NPM_LATEST_URL: &str = "https://registry.npmjs.org/srcwalk/latest"; + +fn curl_header_check_args() -> Vec { + vec![ + "-fsSI".to_string(), + "--proto".to_string(), + "=https".to_string(), + "--proto-redir".to_string(), + "=https".to_string(), + "--max-time".to_string(), + "5".to_string(), + GITHUB_LATEST_URL.to_string(), + ] +} + +fn wget_header_check_args() -> Vec { + vec![ + "--server-response".to_string(), + "--spider".to_string(), + "--max-redirect=0".to_string(), + "--https-only".to_string(), + "--timeout=5".to_string(), + GITHUB_LATEST_URL.to_string(), + ] +} + +fn curl_npm_json_args() -> Vec { + vec![ + "-fsSL".to_string(), + "--proto".to_string(), + "=https".to_string(), + "--proto-redir".to_string(), + "=https".to_string(), + "--max-time".to_string(), + "5".to_string(), + NPM_LATEST_URL.to_string(), + ] } -fn fetch_github_latest_with_wget() -> Result { - let output = process::Command::new("wget") - .args([ - "--server-response", - "--spider", - "--max-redirect=0", - "--timeout=5", - "https://github.com/sting8k/srcwalk/releases/latest", - ]) - .output() - .map_err(|e| format!("could not run wget: {e}"))?; +fn wget_npm_json_args() -> Vec { + vec![ + "-qO-".to_string(), + "--https-only".to_string(), + "--timeout=5".to_string(), + NPM_LATEST_URL.to_string(), + ] +} + +fn fetch_github_latest_with_curl(path_override: Option<&OsStr>) -> Result { + let mut cmd = command_with_path("curl", path_override); + cmd.args(curl_header_check_args()); + let output = run_bounded(cmd, MAX_HEADER_BYTES, RESOLVER_TIMEOUT)?; + let headers = command_stdout(&output, "curl")?; + let tag = parse_latest_tag_from_headers(&headers) + .ok_or_else(|| "missing latest release redirect".to_string())?; + accept_candidate(&tag) +} + +fn fetch_github_latest_with_wget(path_override: Option<&OsStr>) -> Result { + let mut cmd = command_with_path("wget", path_override); + cmd.args(wget_header_check_args()); + let output = run_bounded(cmd, MAX_HEADER_BYTES, RESOLVER_TIMEOUT)?; // `wget --spider` writes headers to stderr and may exit non-zero on a 302 // when redirects are disabled. Treat parseable headers as success. - let mut headers = String::new(); - headers.push_str(&String::from_utf8_lossy(&output.stdout)); + let mut headers = String::from_utf8_lossy(&output.stdout).into_owned(); headers.push_str(&String::from_utf8_lossy(&output.stderr)); - parse_latest_tag_from_headers(&headers) - .ok_or_else(|| format!("wget exited with {}", output.status)) -} - -fn fetch_npm_latest_with_curl() -> Result { - let output = process::Command::new("curl") - .args([ - "-fsSL", - "--max-time", - "5", - "https://registry.npmjs.org/srcwalk/latest", - ]) - .output() - .map_err(|e| format!("could not run curl: {e}"))?; - command_stdout(output, "curl").and_then(|json| { - parse_npm_version(&json).ok_or_else(|| "missing npm version field".to_string()) - }) + let tag = parse_latest_tag_from_headers(&headers) + .ok_or_else(|| format!("wget exited with {}", output.status))?; + accept_candidate(&tag) } -fn fetch_npm_latest_with_wget() -> Result { - let output = process::Command::new("wget") - .args([ - "-qO-", - "--timeout=5", - "https://registry.npmjs.org/srcwalk/latest", - ]) - .output() - .map_err(|e| format!("could not run wget: {e}"))?; - command_stdout(output, "wget").and_then(|json| { - parse_npm_version(&json).ok_or_else(|| "missing npm version field".to_string()) - }) +fn fetch_npm_latest_with_curl(path_override: Option<&OsStr>) -> Result { + let mut cmd = command_with_path("curl", path_override); + cmd.args(curl_npm_json_args()); + let output = run_bounded(cmd, MAX_NPM_JSON_BYTES, RESOLVER_TIMEOUT)?; + let json = command_stdout(&output, "curl")?; + let version = + parse_npm_version(&json).ok_or_else(|| "missing npm version field".to_string())?; + accept_candidate(&version) } -fn fetch_npm_latest_with_npm() -> Result { - let output = process::Command::new("npm") - .args(["view", "srcwalk", "version", "--silent"]) - .output() - .map_err(|e| format!("could not run npm: {e}"))?; - command_stdout(output, "npm").map(|s| s.trim().to_string()) +fn fetch_npm_latest_with_wget(path_override: Option<&OsStr>) -> Result { + let mut cmd = command_with_path("wget", path_override); + cmd.args(wget_npm_json_args()); + let output = run_bounded(cmd, MAX_NPM_JSON_BYTES, RESOLVER_TIMEOUT)?; + let json = command_stdout(&output, "wget")?; + let version = + parse_npm_version(&json).ok_or_else(|| "missing npm version field".to_string())?; + accept_candidate(&version) } -fn command_stdout(output: process::Output, command: &str) -> Result { +fn fetch_npm_latest_with_npm(path_override: Option<&OsStr>) -> Result { + let mut cmd = command_with_path("npm", path_override); + cmd.args(["view", "srcwalk", "version", "--silent"]); + let output = run_bounded(cmd, MAX_NPM_CLI_BYTES, RESOLVER_TIMEOUT)?; + let version = command_stdout(&output, "npm")?; + accept_candidate(version.trim()) +} + +fn command_stdout(output: &BoundedOutput, command: &str) -> Result { if !output.status.success() { - return Err(format!("{command} exited with {}", output.status)); + return Err(format!( + "{command} exited with {}{}", + output.status, + stderr_detail(&output.stderr) + )); } - String::from_utf8(output.stdout).map_err(|e| format!("invalid UTF-8 response: {e}")) + String::from_utf8(output.stdout.clone()).map_err(|e| format!("invalid UTF-8 response: {e}")) } fn parse_latest_tag_from_headers(headers: &str) -> Option { - headers.lines().find_map(|line| { + let location = headers.lines().find_map(|line| { let (name, value) = line.split_once(':')?; if !name.trim().eq_ignore_ascii_case("location") { return None; } - let tag = value.trim().rsplit('/').next()?; - Some(tag.trim_start_matches('v').to_string()) - }) + Some(value.trim().to_string()) + })?; + validated_release_tag_from_url(&location) +} + +const EXPECTED_HOST: &str = "github.com"; +const EXPECTED_PATH_PREFIX: &str = "/sting8k/srcwalk/releases/tag/v"; + +/// Accepts only an HTTPS URL whose host is exactly `github.com` and whose +/// path is exactly `/sting8k/srcwalk/releases/tag/vX.Y.Z` \u2014 no other host, +/// scheme, query string, fragment, or trailing path segment. Prevents a +/// crafted redirect from turning into an arbitrary accepted "latest" tag. +fn validated_release_tag_from_url(url: &str) -> Option { + let rest = url.strip_prefix("https://")?; + let (host, path_and_rest) = rest.split_once('/')?; + if !host.eq_ignore_ascii_case(EXPECTED_HOST) { + return None; + } + let path = format!("/{path_and_rest}"); + if path.contains('?') || path.contains('#') { + return None; + } + let tag = path.strip_prefix(EXPECTED_PATH_PREFIX)?; + if tag.is_empty() || tag.contains('/') { + return None; + } + Some(tag.to_string()) } fn parse_npm_version(json: &str) -> Option { @@ -192,26 +463,73 @@ fn parse_json_string_field(json: &str, field: &str) -> Option { Some(quoted[..end].to_string()) } -fn version_is_newer(latest: &str, current: &str) -> bool { - parse_semver(latest) > parse_semver(current) +/// Strict stable `X.Y.Z`: exactly three non-empty ASCII-decimal components, +/// no prerelease/build metadata, whitespace, or overflow. Never silently +/// defaults an invalid component to `0`. +pub(crate) fn parse_strict_semver(version: &str) -> Result<(u64, u64, u64), String> { + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() != 3 { + return Err(format!("expected exactly X.Y.Z, got `{version}`")); + } + let mut nums = [0u64; 3]; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return Err(format!("invalid version component `{part}` in `{version}`")); + } + nums[i] = part + .parse::() + .map_err(|_| format!("version component overflow in `{version}`"))?; + } + Ok((nums[0], nums[1], nums[2])) } -fn parse_semver(version: &str) -> (u64, u64, u64) { - let mut parts = version.split(['.', '-']); - let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - (major, minor, patch) +/// Three-way strict-semver ordering, distinguishing equality from local-newer +/// so dev builds never appear to need a downgrade. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VersionOrdering { + Equal, + LocalNewer, + LatestNewer, } -fn release_target() -> Option<&'static str> { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => Some("x86_64-unknown-linux-musl"), - ("linux", "aarch64") => Some("aarch64-unknown-linux-musl"), - ("macos", "x86_64") => Some("x86_64-apple-darwin"), - ("macos", "aarch64") => Some("aarch64-apple-darwin"), - _ => None, - } +pub(crate) fn compare_versions(current: &str, latest: &str) -> Result { + let c = parse_strict_semver(current)?; + let l = parse_strict_semver(latest)?; + Ok(match c.cmp(&l) { + std::cmp::Ordering::Equal => VersionOrdering::Equal, + std::cmp::Ordering::Greater => VersionOrdering::LocalNewer, + std::cmp::Ordering::Less => VersionOrdering::LatestNewer, + }) +} + +/// One release asset's target triple and expected binary name. Mirrors +/// `npm/install.js`'s `PLATFORM_MAP` exactly; the two tables are cross-checked +/// against a shared pinned fixture so they cannot silently drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ReleaseTarget { + pub(crate) triple: &'static str, + pub(crate) binary: &'static str, +} + +/// Pure OS/arch -> release-asset mapping, independent of the running host, so +/// every row (including unsupported pairs) is directly testable. +pub(crate) fn release_target_for(os: &str, arch: &str) -> Option { + let (triple, binary) = match (os, arch) { + ("linux", "x86_64") => ("x86_64-unknown-linux-musl", "srcwalk"), + ("linux", "aarch64") => ("aarch64-unknown-linux-musl", "srcwalk"), + ("macos", "x86_64") => ("x86_64-apple-darwin", "srcwalk"), + ("macos", "aarch64") => ("aarch64-apple-darwin", "srcwalk"), + ("windows", "x86_64") => ("x86_64-pc-windows-msvc", "srcwalk.exe"), + // Windows on ARM64 runs the x64 binary via OS emulation; no native + // ARM64 asset is published (mirrors npm/install.js precedent). + ("windows", "aarch64") => ("x86_64-pc-windows-msvc", "srcwalk.exe"), + _ => return None, + }; + Some(ReleaseTarget { triple, binary }) +} + +pub(crate) fn release_target() -> Option { + release_target_for(std::env::consts::OS, std::env::consts::ARCH) } #[cfg(test)] diff --git a/tests/fixtures/release-targets.json b/tests/fixtures/release-targets.json new file mode 100644 index 0000000..587211f --- /dev/null +++ b/tests/fixtures/release-targets.json @@ -0,0 +1,51 @@ +[ + { + "rustOs": "linux", + "rustArch": "x86_64", + "nodePlatform": "linux", + "nodeArch": "x64", + "target": "x86_64-unknown-linux-musl", + "binary": "srcwalk" + }, + { + "rustOs": "linux", + "rustArch": "aarch64", + "nodePlatform": "linux", + "nodeArch": "arm64", + "target": "aarch64-unknown-linux-musl", + "binary": "srcwalk" + }, + { + "rustOs": "macos", + "rustArch": "x86_64", + "nodePlatform": "darwin", + "nodeArch": "x64", + "target": "x86_64-apple-darwin", + "binary": "srcwalk" + }, + { + "rustOs": "macos", + "rustArch": "aarch64", + "nodePlatform": "darwin", + "nodeArch": "arm64", + "target": "aarch64-apple-darwin", + "binary": "srcwalk" + }, + { + "rustOs": "windows", + "rustArch": "x86_64", + "nodePlatform": "win32", + "nodeArch": "x64", + "target": "x86_64-pc-windows-msvc", + "binary": "srcwalk.exe" + }, + { + "rustOs": "windows", + "rustArch": "aarch64", + "nodePlatform": "win32", + "nodeArch": "arm64", + "target": "x86_64-pc-windows-msvc", + "binary": "srcwalk.exe", + "note": "ARM64 runs the x64 asset via OS emulation; no native asset is published." + } +] diff --git a/tests/intent_first_cli.rs b/tests/intent_first_cli.rs index 0612272..8778804 100644 --- a/tests/intent_first_cli.rs +++ b/tests/intent_first_cli.rs @@ -4548,3 +4548,1175 @@ fn explicit_as_text_and_file_are_not_intercepted() { let _ = fs::remove_dir_all(&dir); } + +/// US-072: C and C++ files enter owner-only dispatch. Inline Text OR rows carry +/// exact `[owner NAME@start-end]` evidence, the C++ owner renders `::` +/// qualification without being advertised as a canonical selector, the non-Go +/// honesty caveat is present, and the Go-only call appendix never appears for a +/// C/C++-only query. +#[test] +fn discover_text_or_c_cpp_attributes_owners_without_go_call_appendix() { + let dir = temp_repo("discover_text_or_c_cpp_owner"); + fs::write( + dir.join("util.c"), + "int add(int a, int b) {\n return a + b;\n}\n", + ) + .unwrap(); + fs::write( + dir.join("svc.cpp"), + "namespace ns {\nclass Foo {\npublic:\n void bar() {\n log();\n }\n};\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "return a + b,log()", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Exact per-file owner evidence: C plain name, C++ `::` qualified name. + assert!(stdout.contains("util.c:2 [owner add@1-3]"), "{stdout}"); + assert!( + stdout.contains("svc.cpp:5 [owner ns::Foo::bar@4-6]"), + "{stdout}" + ); + // No Go call appendix is emitted for a C/C++-only query. + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + !stdout.contains("[recv=same package-qualified receiver type"), + "{stdout}" + ); + assert!( + !stdout.contains("structural owner and mechanically filtered"), + "{stdout}" + ); + // C++ `::` display is never promoted to a copyable canonical selector. + assert!( + !stdout.contains("## Confirmed structural targets"), + "{stdout}" + ); + // Non-Go honesty caveat present, and it must not imply call analysis ran. + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + assert!( + stdout.contains("no call analysis was run for non-Go languages"), + "{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072: compact rollup renders owners for BOTH C and C++ in one query, and +/// the exact command replays byte-identically (compact ordering + ranges). +#[test] +fn discover_text_or_c_cpp_compact_rollup_replays_byte_identically() { + let dir = temp_repo("discover_c_cpp_compact_replay"); + fs::write( + dir.join("util.c"), + "int add(int a, int b) {\n return a + b;\n}\nint sub(int a, int b) {\n return a - b;\n}\n", + ) + .unwrap(); + fs::write( + dir.join("svc.cpp"), + "namespace ns {\nclass Foo {\npublic:\n void bar() {\n log();\n }\n void run() {\n bar();\n }\n};\n}\n", + ) + .unwrap(); + + let run = |d: &Path| { + srcwalk() + .args([ + "discover", + "return a + b,return a - b,log(),bar()", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(d) + .output() + .unwrap() + }; + let first = run(&dir); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + let stdout = String::from_utf8_lossy(&first.stdout); + // Compact rollup line carries both languages with `::` C++ qualification. + assert!( + stdout.contains("owners (#N=Nth query term; *K=hits)"), + "{stdout}" + ); + assert!(stdout.contains("add:1-3[#1]"), "{stdout}"); + assert!(stdout.contains("sub:4-6[#2]"), "{stdout}"); + assert!(stdout.contains("ns::Foo::bar:4-6[#3,#4]"), "{stdout}"); + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + + // Byte-identical replay. + let second = run(&dir); + assert_eq!( + first.stdout, second.stdout, + "stdout differs across identical replays" + ); + assert_eq!( + first.stderr, second.stderr, + "stderr differs across identical replays" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 §7.2: every C++ lambda is an anonymous barrier and macro-generated +/// `TEST(Foo, Bar)` bodies abstain. In compact mode the enclosing named +/// function keeps the owner for its non-lambda hits while lambda-body and +/// TEST-body hits contribute no owner evidence at all (the TEST file's rollup +/// has no owners line). +#[test] +fn discover_text_or_cpp_lambda_and_macro_lines_abstain() { + let dir = temp_repo("discover_cpp_lambda_macro_abstain"); + fs::write( + dir.join("lam.cpp"), + "void run() {\n init();\n auto l = [](int x) {\n return x + 1;\n };\n}\n", + ) + .unwrap(); + fs::write( + dir.join("tests.cpp"), + "TEST(Foo, Bar) {\n EXPECT_EQ(1, 1);\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "init(),return x + 1,EXPECT_EQ(1, 1)", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // The enclosing function keeps the owner for hits outside the lambda; the + // lambda body and TEST body contribute nothing. + let owners_lines = stdout + .lines() + .filter(|l| l.contains("owners (#N=Nth query term")) + .collect::>(); + assert_eq!(owners_lines.len(), 1, "{stdout}"); + assert!(owners_lines[0].contains("run:1-6[#1]"), "{stdout}"); + assert!(!owners_lines[0].contains("TEST"), "{stdout}"); + // No fabricated macro owner appears anywhere. + assert!(!stdout.contains("TEST@"), "{stdout}"); + assert!(!stdout.contains("[owner TEST"), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 P1 regression (Biscuit review): a type-less namespace +/// "constructor", a macro/TEST body, and an abstained (malformed) function +/// identity never gain owner evidence, and an abstained function never leaks +/// a nested function as an owner. +#[test] +fn discover_text_or_cpp_abstained_identities_never_leak_nested_owner() { + let dir = temp_repo("discover_cpp_p1_regression"); + fs::write( + dir.join("regress.cpp"), + "namespace Faux {\n\ + Faux() {\n\ + hit_one();\n\ + }\n\ + }\n\ + TEST(Foo, Bar) {\n\ + void leaked() {\n\ + hit_two();\n\ + }\n\ + }\n\ + void Foo::operator int() {\n\ + void leaked2() {\n\ + hit_three();\n\ + }\n\ + }\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "hit_one(),hit_two(),hit_three()", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // All three hits are found, but zero owner evidence: the namespace + // "constructor", the TEST body, and the malformed `operator int` all + // abstain, and their nested functions never leak an owner. + assert!( + stdout.contains("hit_one() — 1/1 matches, 1 file"), + "{stdout}" + ); + assert!( + stdout.contains("hit_two() — 1/1 matches, 1 file"), + "{stdout}" + ); + assert!( + stdout.contains("hit_three() — 1/1 matches, 1 file"), + "{stdout}" + ); + assert!(!stdout.contains("[owner"), "{stdout}"); + assert!(!stdout.contains("owners (#N"), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072: a mixed Go + C/C++ scope keeps the mechanical-call appendix +/// Go-only. C/C++ owners are attributed, but they never enter call-edge +/// analysis and no non-Go zero-edge/call claim is made. +#[test] +fn discover_text_or_go_plus_c_cpp_mixed_keeps_edges_go_only() { + let dir = temp_repo("discover_go_c_cpp_mixed"); + fs::write( + dir.join("feature.go"), + "package feature\nfunc First() { /* alpha */ }\nfunc Second() { /* beta */ First() }\n", + ) + .unwrap(); + fs::write( + dir.join("app.c"), + "int load(void) {\n return 1; /* alpha */\n}\n", + ) + .unwrap(); + fs::write( + dir.join("app.cpp"), + "namespace svc {\nclass App {\npublic:\n void handle() { /* beta */ }\n};\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // All three languages carry exact per-file owner evidence. + assert!( + stdout.contains("feature.go:2 [owner First@2-2]"), + "{stdout}" + ); + assert!(stdout.contains("app.c:2 [owner load@1-3]"), "{stdout}"); + assert!( + stdout.contains("app.cpp:4 [owner svc::App::handle@4-4]"), + "{stdout}" + ); + // The Go mechanical-call appendix exists and the single rendered edge is + // Go-only. + assert!(stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + stdout.contains("- [bare] Second calls First@feature.go:3; candidate First@:2-2"), + "{stdout}" + ); + let go_edge_rows = stdout + .lines() + .filter(|l| l.starts_with("- [") && l.contains(" calls ")) + .count(); + assert_eq!(go_edge_rows, 1, "{stdout}"); + // No non-Go zero-edge or call claim. + assert!(!stdout.contains(OWNER_LINK_ZERO_EDGE), "{stdout}"); + assert!(!stdout.contains("candidate load@"), "{stdout}"); + assert!(!stdout.contains("candidate svc::"), "{stdout}"); + // Both honesty caveats present. + assert!( + stdout.contains("structural owner and mechanically filtered"), + "{stdout}" + ); + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072: a C++ call operator (`operator()`) carries exact owner evidence, and +/// the non-Go isolation invariant holds (no call edge, no zero-edge line for a +/// C++-only hit set). +#[test] +fn discover_text_or_cpp_call_operator_owner_without_call_edges() { + let dir = temp_repo("discover_cpp_call_operator"); + fs::write( + dir.join("functor.cpp"), + "namespace svc {\nstruct Adder {\n int operator()(int x) {\n return x; /* alpha */\n }\n};\n}\nint svc::Adder::operator()(int x, int y) {\n return x + y; /* beta */\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Inline member and out-of-line definitions both name the call operator. + assert!( + stdout.contains("functor.cpp:4 [owner svc::Adder::operator()@3-5]"), + "{stdout}" + ); + assert!( + stdout.contains("functor.cpp:9 [owner svc::Adder::operator()@8-10]"), + "{stdout}" + ); + // Non-Go isolation: no mechanical call appendix, no edge, no zero-edge line. + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!(!stdout.contains(" calls "), "{stdout}"); + assert!(!stdout.contains(OWNER_LINK_ZERO_EDGE), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072: adding nonmatching C/C++ files to the scope does not change any +/// pre-existing stdout/stderr bytes (no owner evidence, no caveat, no rollup +/// line appears for files with zero hits). +#[test] +fn discover_text_or_c_cpp_nonmatching_files_do_not_change_output() { + let dir = temp_repo("discover_c_cpp_nonmatching"); + fs::write(dir.join("app.c"), "int alpha(void) {\n return 1;\n}\n").unwrap(); + fs::write(dir.join("app.cpp"), "namespace n { void beta() { } }\n").unwrap(); + + let run = |d: &Path| { + srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(d) + .output() + .unwrap() + }; + let baseline = run(&dir); + assert!( + baseline.status.success(), + "{}", + String::from_utf8_lossy(&baseline.stderr) + ); + + // Add nonmatching C/C++ files (no query-term text). + fs::write( + dir.join("unrelated.c"), + "static int unused_helper(void) {\n return 42;\n}\n", + ) + .unwrap(); + fs::write( + dir.join("unrelated.cpp"), + "namespace other { class Widget { public: void draw() {} }; }\n", + ) + .unwrap(); + + let after = run(&dir); + assert!( + after.status.success(), + "{}", + String::from_utf8_lossy(&after.stderr) + ); + assert_eq!( + baseline.stdout, after.stdout, + "stdout changed after adding nonmatching C/C++ files" + ); + assert_eq!( + baseline.stderr, after.stderr, + "stderr changed after adding nonmatching C/C++ files" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2: Ruby enters owner-only dispatch. Inline evidence renders +/// exact `#` (instance) and `.` (singleton) display with full `::` container +/// paths; no Go mechanical-call appendix, no canonical-selector promotion, and +/// the non-Go honesty caveat is present. +#[test] +fn discover_text_or_ruby_attributes_owners_without_go_call_appendix() { + let dir = temp_repo("discover_text_or_ruby_owner"); + fs::write( + dir.join("app.rb"), + "module Billing\n\ + \x20 class Invoice\n\ + \x20\x20 def paid?\n\ + \x20\x20\x20 compute_total\n\ + \x20\x20 end\n\ + \x20\x20 def self.find(id)\n\ + \x20\x20\x20 query\n\ + \x20\x20 end\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "compute_total,query", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Exact per-file owner evidence: `#` for instance, `.` for singleton. + assert!( + stdout.contains("app.rb:4 [owner Billing::Invoice#paid?@3-5]"), + "{stdout}" + ); + assert!( + stdout.contains("app.rb:7 [owner Billing::Invoice.find@6-8]"), + "{stdout}" + ); + // No Go call appendix is emitted for a Ruby-only query. + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + !stdout.contains("structural owner and mechanically filtered"), + "{stdout}" + ); + // Ruby display punctuation is never promoted to a copyable canonical + // selector. + assert!( + !stdout.contains("## Confirmed structural targets"), + "{stdout}" + ); + // Non-Go honesty caveat present, with no call-analysis claim. + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + assert!( + stdout.contains("no call analysis was run for non-Go languages"), + "{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2: compact rollup renders Ruby owners for instance/singleton +/// methods, and the exact command replays byte-identically. +#[test] +fn discover_text_or_ruby_compact_rollup_replays_byte_identically() { + let dir = temp_repo("discover_ruby_compact_replay"); + fs::write( + dir.join("app.rb"), + "class A\n\ + \x20 def run\n\ + \x20\x20 work\n\ + \x20 end\n\ + \x20 def self.find\n\ + \x20\x20 search\n\ + \x20 end\n\ + \x20 def stop\n\ + \x20\x20 halt\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + + let run = |d: &Path| { + srcwalk() + .args([ + "discover", + "work,search,halt", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(d) + .output() + .unwrap() + }; + let first = run(&dir); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + let stdout = String::from_utf8_lossy(&first.stdout); + assert!( + stdout.contains("owners (#N=Nth query term; *K=hits)"), + "{stdout}" + ); + assert!(stdout.contains("A#run:2-4[#1]"), "{stdout}"); + assert!(stdout.contains("A.find:5-7[#2]"), "{stdout}"); + assert!(stdout.contains("A#stop:8-10[#3]"), "{stdout}"); + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + + let second = run(&dir); + assert_eq!( + first.stdout, second.stdout, + "stdout differs across identical replays" + ); + assert_eq!( + first.stderr, second.stderr, + "stderr differs across identical replays" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2: ordinary blocks/procs/lambdas are transparent (hits inside +/// them inherit the enclosing method), while dynamic metaprogramming hazards +/// and their attached bodies emit zero owner evidence — including nested +/// recovered definitions. +#[test] +fn discover_text_or_ruby_blocks_transparent_metaprogramming_barriers() { + let dir = temp_repo("discover_ruby_block_hazard"); + fs::write( + dir.join("service.rb"), + "def process\n\ + \x20 items.each do |item|\n\ + \x20\x20 handle(item)\n\ + \x20 end\n\ + \x20 define_method(:dyn) { }\n\ + end\n\ + class_eval do\n\ + \x20 def leaked\n\ + \x20\x20 secret\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "handle(item),define_method(:dyn),secret", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // The transparent block inherits the enclosing method owner: the compact + // rollup attributes term #1 (handle(item), line 3 inside the block) to + // `process`, while the define_method hazard line and the class_eval body + // contribute no owner evidence. + assert!(stdout.contains("process:1-6[#1]"), "{stdout}"); + assert!(stdout.contains("owners (#N"), "{stdout}"); + assert!(!stdout.contains("service.rb:5 [owner"), "{stdout}"); + assert!(!stdout.contains("[#2"), "{stdout}"); + assert!(!stdout.contains("[#3"), "{stdout}"); + // `leaked` is never rendered as an owner (the class_eval block and its + // nested recovered definition never regain a name). + assert!(!stdout.contains("leaked"), "{stdout}"); + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2 re-review: qualified and root-qualified container names never +/// fabricate a component through the CLI. `module A; class A::B` merges on the +/// suffix witness (`A::B`, never `A::A::B`), a witness-free `module C; +/// class A::B` barriers (no `C::A::B`, no shortened owner), and `class +/// ::Rooted` is absolute inside a module (`Rooted`, never `M::Rooted`). +#[test] +fn discover_text_or_ruby_qualified_containers_never_fabricate_components() { + let dir = temp_repo("discover_ruby_qualified_containers"); + fs::write( + dir.join("merged.rb"), + "module A\n\ + \x20 class A::B\n\ + \x20\x20 def hit_method\n\ + \x20\x20\x20 duplicate_container_hit\n\ + \x20\x20 end\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + fs::write( + dir.join("barriered.rb"), + "module C\n\ + \x20 class A::B\n\ + \x20\x20 def unprovable_method\n\ + \x20\x20\x20 unprovable_container_hit\n\ + \x20\x20 end\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + fs::write( + dir.join("rooted.rb"), + "module M\n\ + \x20 class ::Rooted\n\ + \x20\x20 def rooted_method\n\ + \x20\x20\x20 rooted_container_hit\n\ + \x20\x20 end\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "duplicate_container_hit,unprovable_container_hit,rooted_container_hit", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Suffix-witness merge: the duplicated component appears exactly once. + assert!(stdout.contains("A::B#hit_method:3-5"), "{stdout}"); + assert!(!stdout.contains("A::A::B"), "{stdout}"); + // Root-qualified is absolute: no lexical prefix. + assert!(stdout.contains("Rooted#rooted_method:3-5"), "{stdout}"); + assert!(!stdout.contains("M::Rooted"), "{stdout}"); + // Witness-free qualified container: no guessed path, no shortened owner, + // and no owner evidence of any kind for that hit. + assert!(!stdout.contains("C::A::B"), "{stdout}"); + assert!(!stdout.contains("unprovable_method"), "{stdout}"); + assert!(!stdout.contains("barriered.rb:4 [owner"), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2: a mixed Go + Ruby + C/C++ scope keeps the mechanical-call +/// appendix Go-only. Ruby owners are attributed but never enter call-edge +/// analysis, and no non-Go zero-edge/call claim is made. +#[test] +fn discover_text_or_go_plus_ruby_c_cpp_mixed_keeps_edges_go_only() { + let dir = temp_repo("discover_go_ruby_c_cpp_mixed"); + fs::write( + dir.join("feature.go"), + "package feature\nfunc First() { /* alpha */ }\nfunc Second() { /* beta */ First() }\n", + ) + .unwrap(); + fs::write( + dir.join("service.rb"), + "class Service\n\ + \x20 def load\n\ + \x20\x20 alpha_hit\n\ + \x20 end\n\ + \x20 def handle\n\ + \x20\x20 beta_hit\n\ + \x20 end\n\ + end\n", + ) + .unwrap(); + fs::write( + dir.join("app.c"), + "int load_c(void) {\n return 1; /* alpha */\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // All three languages carry exact per-file owner evidence. + assert!( + stdout.contains("feature.go:2 [owner First@2-2]"), + "{stdout}" + ); + assert!( + stdout.contains("service.rb:3 [owner Service#load@2-4]"), + "{stdout}" + ); + assert!( + stdout.contains("service.rb:6 [owner Service#handle@5-7]"), + "{stdout}" + ); + assert!(stdout.contains("app.c:2 [owner load_c@1-3]"), "{stdout}"); + // The Go mechanical-call appendix exists and the single rendered edge is + // Go-only. + assert!(stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!( + stdout.contains("- [bare] Second calls First@feature.go:3; candidate First@:2-2"), + "{stdout}" + ); + let go_edge_rows = stdout + .lines() + .filter(|l| l.starts_with("- [") && l.contains(" calls ")) + .count(); + assert_eq!(go_edge_rows, 1, "{stdout}"); + // No non-Go zero-edge or call claim; Ruby/C never enter candidate sets. + assert!(!stdout.contains(OWNER_LINK_ZERO_EDGE), "{stdout}"); + assert!(!stdout.contains("candidate Service#"), "{stdout}"); + assert!(!stdout.contains("candidate load_c@"), "{stdout}"); + // Both honesty caveats present. + assert!( + stdout.contains("structural owner and mechanically filtered"), + "{stdout}" + ); + assert!( + stdout.contains("structural lexical ownership candidates"), + "{stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +/// US-072 Wave 2: adding nonmatching Ruby files to the scope does not change +/// any pre-existing stdout/stderr bytes. +#[test] +fn discover_text_or_ruby_nonmatching_files_do_not_change_output() { + let dir = temp_repo("discover_ruby_nonmatching"); + fs::write(dir.join("app.rb"), "def alpha\n hit\nend\n").unwrap(); + + let run = |d: &Path| { + srcwalk() + .args([ + "discover", "alpha", "--match", "any", "--as", "text", "--scope", + ]) + .arg(d) + .output() + .unwrap() + }; + let baseline = run(&dir); + assert!( + baseline.status.success(), + "{}", + String::from_utf8_lossy(&baseline.stderr) + ); + + // Add nonmatching Ruby files (no query-term text). + fs::write( + dir.join("unrelated.rb"), + "class Unrelated\n def helper\n work\n end\nend\n", + ) + .unwrap(); + fs::write( + dir.join("another.rb"), + "module Other\n def run; end\nend\n", + ) + .unwrap(); + + let after = run(&dir); + assert!( + after.status.success(), + "{}", + String::from_utf8_lossy(&after.stderr) + ); + assert_eq!( + baseline.stdout, after.stdout, + "stdout changed after adding nonmatching Ruby files" + ); + assert_eq!( + baseline.stderr, after.stderr, + "stderr changed after adding nonmatching Ruby files" + ); + + let _ = fs::remove_dir_all(&dir); +} + +// ---- US-073: owner abstention transparency ---- + +/// The three owner states are distinguishable in one detailed Text OR run: +/// a named owner keeps its exact tag, an analyzed-but-abstained hit is +/// summarized once per file, and an unsupported language stays silent. +#[test] +fn text_or_distinguishes_named_abstained_and_unsupported_owner_states() { + let dir = temp_repo("owner_abstain_three_states"); + fs::write( + dir.join("a.py"), + "X = 1 # alpha\ndef f():\n return 2 # alpha\n", + ) + .unwrap(); + fs::write(dir.join("notes.txt"), "alpha here\n").unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Named: existing inline owner tag, unchanged. + assert!(stdout.contains("a.py:3 [owner f@2-3]"), "{stdout}"); + // Abstained: one bounded file line naming the parser-known reason. + assert!(stdout.contains("\n## Owner abstentions\n"), "{stdout}"); + assert!(stdout.contains("a.py — top-level ×1"), "{stdout}"); + // Unsupported: no owner tag and no abstention line for the text file. + assert!(stdout.contains("notes.txt:1 — alpha here"), "{stdout}"); + assert!(!stdout.contains("notes.txt —"), "{stdout}"); + assert!(!stdout.contains("notes.txt ["), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// Compact mode: a fully abstained file states `owners: none`, a mixed file +/// keeps its exact owners line and adds only the abstention line, and the +/// abstention line sits before windows. +#[test] +fn text_or_compact_renders_all_abstained_and_mixed_owner_shapes() { + let dir = temp_repo("owner_abstain_compact"); + fs::write( + dir.join("mixed.py"), + "TOP = 1 # alpha\ndef f():\n return 2 # beta\n", + ) + .unwrap(); + fs::write(dir.join("top.py"), "A = 1 # alpha\nB = 2 # beta\n").unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta,= 1", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + // Mixed file: exact owners line preserved, abstention line added after it. + assert!( + stdout.contains( + " owners (#N=Nth query term; *K=hits): f:2-3[#2]\n owner abstentions: top-level ×2" + ), + "{stdout}" + ); + // Fully abstained file: `owners: none` shape. + assert!( + stdout.contains(" owners: none — abstained (top-level ×3)"), + "{stdout}" + ); + // Placement: the abstention line precedes windows for the mixed file. + let abstain_at = stdout.find("owner abstentions:").unwrap(); + let windows_at = stdout[abstain_at..].find("windows:").unwrap(); + assert!(windows_at > 0, "{stdout}"); + // Compact mode adds no detailed section. + assert!(!stdout.contains("## Owner abstentions"), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// Backward safety: a named-only result gains zero new lines, and identical +/// commands replay byte-identically (Unix and Windows). +#[test] +fn text_or_named_only_output_is_unchanged_and_replays_byte_identically() { + let dir = temp_repo("owner_abstain_named_only"); + fs::write( + dir.join("only.py"), + "def f():\n return 1 # alpha\n\ndef g():\n return 2 # beta\n", + ) + .unwrap(); + + let run = || { + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!(output.status.success()); + ( + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + ) + }; + let (stdout, stderr) = run(); + // Every shown hit is named, so no abstention line or section may appear. + assert!(stdout.contains("only.py:2 [owner f@1-2]"), "{stdout}"); + assert!(stdout.contains("only.py:5 [owner g@4-5]"), "{stdout}"); + assert!(!stdout.contains("Owner abstentions"), "{stdout}"); + assert!(!stdout.contains("owner abstentions"), "{stdout}"); + assert!(!stdout.contains("owners: none"), "{stdout}"); + // Deterministic replay. + let (stdout2, stderr2) = run(); + assert_eq!(stdout, stdout2); + assert_eq!(stderr, stderr2); + + let _ = fs::remove_dir_all(&dir); +} + +/// Go isolation: an abstained Go hit never creates a call appendix, an edge, or +/// a zero-edge claim, and a malformed Go file reports `parse-failed`. +#[test] +fn text_or_go_abstentions_do_not_widen_call_evidence() { + let dir = temp_repo("owner_abstain_go_isolation"); + fs::write( + dir.join("broken.go"), + "package p\nfunc F( {\n // alpha\n}\n", + ) + .unwrap(); + + let output = srcwalk() + .args([ + "discover", + "alpha,beta", + "--match", + "any", + "--as", + "text", + "--scope", + ]) + .arg(&dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("broken.go — parse-failed ×1"), "{stdout}"); + // No Go call evidence of any kind for an unparsed file. + assert!(!stdout.contains("## Mechanical Go calls"), "{stdout}"); + assert!(!stdout.contains(" calls "), "{stdout}"); + assert!(!stdout.contains(OWNER_LINK_ZERO_EDGE), "{stdout}"); + + let _ = fs::remove_dir_all(&dir); +} + +/// Every routed owner language reaches the shared decision end-to-end: each +/// fixture's top-level hit is reported as `top-level` for its own file. +#[test] +fn text_or_owner_abstentions_cover_every_routed_language() { + // The detailed section is intentionally bounded, so the 13 routed languages + // are exercised in batches that fit under that cap. + let batches: &[&[(&str, &str)]] = &[ + &[ + ("a.go", "package p\n\nvar X = 1 // alpha\n"), + ("a.py", "X = 1 # alpha\n"), + ("a.rs", "const X: u8 = 1; // alpha\n"), + ("a.js", "const X = 1; // alpha\n"), + ("a.ts", "const X: number = 1; // alpha\n"), + ("a.tsx", "const X = 1; // alpha\n"), + ("A.java", "class A {\n int x = 1; // alpha\n}\n"), + ], + &[ + ("a.kt", "val x = 1 // alpha\n"), + ("A.cs", "class A {\n int x = 1; // alpha\n}\n"), + ("a.php", " Next: read raw hit evidence"), + "budget {budget} lost its footer: {stdout}" + ); + // Any surviving abstention row is complete (`PATH — reason ×N`), never a + // dangling label. Only rows inside the section are checked; the header + // line legitimately contains the same em-dash separator. + if let Some(section) = stdout.split("## Owner abstentions").nth(1) { + for line in section + .lines() + .take_while(|line| !line.starts_with("> Next")) + .filter(|line| line.contains(" — ")) + { + assert!( + line.contains(" ×"), + "budget {budget} emitted a partial abstention row `{line}`: {stdout}" + ); + } + } + } + + let _ = fs::remove_dir_all(&dir); +} diff --git a/tests/update_command.rs b/tests/update_command.rs new file mode 100644 index 0000000..0efaae8 --- /dev/null +++ b/tests/update_command.rs @@ -0,0 +1,305 @@ +//! CLI integration tests for `srcwalk update`/`update --check` (US-074). +//! +//! Zero real network: every test either strips `PATH` of network tools +//! entirely (proving no phone-home) or replaces `PATH` with fake curl/npm +//! scripts plus the real system `tar` (proving the full pipeline runs, with +//! network calls intercepted). No test ever touches a real developer +//! binary: every mutating test operates on a disposable copy of the just- +//! built test binary under a throwaway temp directory. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn srcwalk() -> Command { + Command::new(env!("CARGO_BIN_EXE_srcwalk")) +} + +/// RAII-cleaned: the returned `TempDir` removes its directory tree on drop, +/// so a disposable binary/archive built inside it never leaks into `/tmp` +/// even if an assertion below panics partway through a test. +fn temp_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("srcwalk_update_test_{name}_")) + .tempdir() + .unwrap() +} + +#[cfg(unix)] +fn write_fake_tool(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(&path, perms).unwrap(); +} + +#[cfg(unix)] +fn disposable_copy(dir: &Path, name: &str) -> PathBuf { + let copy_path = dir.join(name); + fs::copy(env!("CARGO_BIN_EXE_srcwalk"), ©_path).unwrap(); + let mut perms = fs::metadata(©_path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(©_path, perms).unwrap(); + copy_path +} + +#[cfg(unix)] +const CURL_REDIRECT_ONLY: &str = + "echo 'HTTP/2 302'\necho 'location: https://github.com/sting8k/srcwalk/releases/tag/v9.9.9'"; + +/// Looks up this host's release target triple from the same pinned +/// `tests/fixtures/release-targets.json` used by the Rust<->npm target-map +/// cross-check, instead of a third hardcoded (OS, ARCH) match living only +/// in this integration test. +#[cfg(unix)] +fn release_target_triple_for_this_host() -> String { + let fixture = include_str!("fixtures/release-targets.json"); + let rows: Vec = serde_json::from_str(fixture).unwrap(); + rows.iter() + .find(|row| { + row["rustOs"] == std::env::consts::OS && row["rustArch"] == std::env::consts::ARCH + }) + .map(|row| row["target"].as_str().unwrap().to_string()) + .unwrap_or_else(|| { + panic!( + "unsupported test host os={} arch={}; extend tests/fixtures/release-targets.json", + std::env::consts::OS, + std::env::consts::ARCH + ) + }) +} + +// ---- 1: arg parsing — `update` accepts only `--check` ---- + +#[test] +fn update_rejects_unknown_flag() { + let output = srcwalk().args(["update", "--force"]).output().unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "clap must reject an unknown update flag before any resolver runs: {output:?}" + ); +} + +#[test] +fn update_check_rejects_unknown_flag_too() { + let output = srcwalk() + .args(["update", "--check", "--force"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{output:?}"); +} + +// ---- 2: no-phone-home for unrelated/plain commands ---- + +#[cfg(unix)] +#[test] +fn plain_version_and_unrelated_commands_never_touch_network_tools() { + let empty_path = temp_dir("empty-path"); + + let output = srcwalk() + .arg("version") + .env("PATH", empty_path.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "plain `version` must never need curl/wget/npm: {output:?}" + ); + + let output = srcwalk() + .args(["discover", "fn", "--scope", "src"]) + .env("PATH", empty_path.path()) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + assert!( + output.status.success(), + "an unrelated command must never need curl/wget/npm: {output:?}" + ); +} + +// ---- 3: `update --check` / `version --check` — shared renderer, zero mutation ---- + +#[cfg(unix)] +#[test] +fn update_check_reports_newer_without_any_mutation_tool_on_path() { + let dir = temp_dir("update-check"); + // Only curl is on PATH: no wget/npm/tar. If `--check` ever attempted a + // download, channel detection, or package-manager exec, it would fail + // here with a missing-tool error instead of succeeding. + write_fake_tool(dir.path(), "curl", CURL_REDIRECT_ONLY); + + let output = srcwalk() + .args(["update", "--check"]) + .env("PATH", dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("latest 9.9.9\n\nRun: srcwalk update\n"), + "{stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn version_check_and_update_check_render_identical_newer_text() { + let dir = temp_dir("compat-check"); + write_fake_tool(dir.path(), "curl", CURL_REDIRECT_ONLY); + + let update_out = srcwalk() + .args(["update", "--check"]) + .env("PATH", dir.path()) + .output() + .unwrap(); + let version_out = srcwalk() + .args(["version", "--check"]) + .env("PATH", dir.path()) + .output() + .unwrap(); + + assert!(update_out.status.success(), "{update_out:?}"); + assert!(version_out.status.success(), "{version_out:?}"); + let update_stdout = String::from_utf8_lossy(&update_out.stdout); + let version_stdout = String::from_utf8_lossy(&version_out.stdout); + // `version --check` additionally prints the provenance line first; the + // shared check-only render (latest + status/hint) must be identical text + // after that, one renderer, not two forks of the same wording. + let shared = "latest 9.9.9\n\nRun: srcwalk update\n"; + assert!(update_stdout.ends_with(shared), "{update_stdout}"); + assert!(version_stdout.ends_with(shared), "{version_stdout}"); +} + +// ---- 4: package-manager path — exact print + real exec, honest post-check ---- + +#[cfg(unix)] +#[test] +fn update_under_node_modules_prints_and_execs_npm_for_real() { + let root = temp_dir("npm-path"); + let node_modules_dir = root.path().join("lib/node_modules/srcwalk/bin"); + fs::create_dir_all(&node_modules_dir).unwrap(); + let copy_path = disposable_copy(&node_modules_dir, "srcwalk"); + + let tool_dir = temp_dir("npm-path-tools"); + write_fake_tool(tool_dir.path(), "curl", CURL_REDIRECT_ONLY); + write_fake_tool(tool_dir.path(), "npm", "echo 'FAKE_NPM_INVOKED'\nexit 0"); + + let output = Command::new(©_path) + .arg("update") + .env("PATH", tool_dir.path()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("npm install -g srcwalk@latest"), "{stdout}"); + assert!( + stdout.contains("FAKE_NPM_INVOKED"), + "{stdout}: npm's inherited stdio must show it was actually executed, not just printed" + ); + // The disposable copy's own `--version` still reports the original + // build (a fake npm script cannot really update a compiled binary), so + // the post-check honestly fails here. That is the correct, honest + // outcome: it proves print+exec+verify-attempt all ran for real, + // without faking success. + assert!( + !output.status.success(), + "an unverifiable npm update must not claim success: {output:?}" + ); +} + +// ---- 5: standalone path — full verified swap via fake curl + real tar ---- + +#[cfg(unix)] +#[test] +fn standalone_update_performs_full_verified_swap() { + use sha2::{Digest, Sha256}; + + let root = temp_dir("standalone-root"); + let copy_path = disposable_copy(root.path(), "srcwalk"); + + // Build the fake "new" binary: reports the fake latest version in the + // canonical `srcwalk X.Y.Z (...)` shape `parse_reported_version` expects. + let fixture_dir = temp_dir("standalone-fixture"); + let staging_dir = fixture_dir.path().join("staging"); + fs::create_dir_all(&staging_dir).unwrap(); + let fake_binary_path = staging_dir.join("srcwalk"); + fs::write( + &fake_binary_path, + "#!/bin/sh\necho 'srcwalk 9.9.9 (fake-e2e)'\n", + ) + .unwrap(); + let mut perms = fs::metadata(&fake_binary_path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(&fake_binary_path, perms).unwrap(); + + // Production always builds the archive name from the real host OS/arch; + // looked up from the same pinned `tests/fixtures/release-targets.json` + // that `release_target_matches_shared_fixture_used_by_npm_install_js` + // (src/main_version_tests.rs) and `install.test.js` cross-check, so the + // target-triple table never has a third, drifting copy. + let target_triple = release_target_triple_for_this_host(); + let archive_name = format!("srcwalk-{target_triple}.tar.gz"); + let archive_path = fixture_dir.path().join(&archive_name); + + // Real system tar builds the fixture archive (exactly one entry). + let status = Command::new("tar") + .args(["czf", archive_name.as_str(), "-C", "staging", "srcwalk"]) + .current_dir(fixture_dir.path()) + .status() + .unwrap(); + assert!(status.success(), "fixture archive build failed"); + + let archive_bytes = fs::read(&archive_path).unwrap(); + let sha256_hex: String = Sha256::digest(&archive_bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let checksum_path = fixture_dir.path().join(format!("{archive_name}.sha256")); + fs::write(&checksum_path, format!("{sha256_hex} {archive_name}\n")).unwrap(); + + let tool_dir = temp_dir("standalone-tools"); + write_fake_tool( + tool_dir.path(), + "curl", + &format!( + "case \"$*\" in\n *-fsSI*)\n echo 'HTTP/2 302'\n echo 'location: https://github.com/sting8k/srcwalk/releases/tag/v9.9.9'\n exit 0\n ;;\nesac\nfor a in \"$@\"; do url=\"$a\"; done\ncase \"$url\" in\n *.sha256) cat '{}' ;;\n *) cat '{}' ;;\nesac", + checksum_path.display(), + archive_path.display(), + ), + ); + // Real `tar` must still resolve: prepend the fake curl-only dir onto the + // inherited PATH rather than replacing it. No fake `wget`/`npm` is + // needed or present -- curl always succeeds here, so neither is ever + // reached. + let inherited_path = std::env::var("PATH").unwrap_or_default(); + let combined_path = format!("{}:{inherited_path}", tool_dir.path().display()); + + let output = Command::new(©_path) + .arg("update") + .env("PATH", combined_path) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stdout.contains("latest 9.9.9"), "{stdout}"); + assert!(stdout.contains("-> 9.9.9."), "{stdout}"); + + // The installed copy must now literally be the fake replacement. + let installed = fs::read_to_string(©_path).unwrap(); + assert!( + installed.contains("fake-e2e"), + "installed binary was not actually replaced: {installed}" + ); + + let version_output = Command::new(©_path).arg("--version").output().unwrap(); + let version_stdout = String::from_utf8_lossy(&version_output.stdout); + assert!(version_stdout.contains("9.9.9"), "{version_stdout}"); +}