diff --git a/ROADMAP.md b/ROADMAP.md index c49f06d6..102be48f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6386,7 +6386,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 436. **DONE — `claw init` scaffolds safe project settings and reports partial/deferred artifacts** — fixed 2026-06-04 in `fix: scaffold safe init settings`. The starter `.claw.json` and new `.claw/settings.json` template now both use `permissions.defaultMode:"acceptEdits"` instead of unsafe `dontAsk`. Fresh init materializes `.claw/settings.json`, keeps `.claw/sessions/` deferred until the first successful session save, and emits per-artifact entries for `.claw/`, `.claw/settings.json`, `.claw/sessions/`, `.claw.json`, `.gitignore`, and `CLAUDE.md`. When `.claw/` already exists but its settings template is missing, init creates `.claw/settings.json` without overwriting existing files and reports `.claw/` as `partial` rather than `skipped`; idempotent reruns keep existing artifacts skipped and session storage deferred. JSON init output now includes `partial[]` and `deferred[]` alongside `created[]`, `updated[]`, and `skipped[]`, and init help/USAGE document the artifact statuses. Regression coverage: `initialize_repo_creates_expected_files_and_gitignore_entries`, `initialize_repo_is_idempotent_and_preserves_existing_files`, `artifacts_with_status_partitions_fresh_and_idempotent_runs`, and `init_json_envelope_has_hint_and_already_initialized_783`. -437. **`version --output-format json` omits build provenance fields — no `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`; `git_sha` is truncated to 7 chars instead of full 40-char hash; sibling: `executable_path` leaks the build host's path (`/tmp/claw-dog-0530/...`) into runtime output** — dogfooded 2026-05-11 by Jobdori on `8cf628a5` in response to Clawhip pinpoint nudge at `1503320791582900344`. Reproduction: `claw version --output-format json` returns `{"build_date":"2026-05-11","executable_path":"/tmp/claw-dog-0530/rust/target/release/claw","git_sha":"b98b9a7","kind":"version","message":"Claw Code\n Version 0.1.0\n Git SHA b98b9a7\n Target aarch64-apple-darwin\n Build date 2026-05-11","target":"aarch64-apple-darwin","version":"0.1.0"}`. Critical provenance fields missing: (a) **`is_dirty`** — was the working tree clean at build time? Automation that pins on build provenance cannot tell if the binary was built from a clean commit or includes uncommitted changes; (b) **`branch`** — was this built from `main`, `dev/rust`, a release tag, or a feature branch? The `git_sha` alone doesn't reveal the integration point; (c) **`commit_date` / `commit_timestamp`** — only `build_date` (when the binary was compiled) is exposed; the commit itself might be days/weeks older if the build happened later. Reproducibility audits need both; (d) **`rustc_version`** — what Rust compiler version produced this binary? Critical for security advisories (e.g., known regressions in specific rustc versions); (e) **`git_sha` truncated to 7 chars** ("b98b9a7" instead of full "b98b9a71..."): 7-char shas have known collision rates in large repos and prevent unambiguous git rev-parse round-trip. **Sibling: `executable_path` leaks build-host path.** The `executable_path` field returns `/tmp/claw-dog-0530/rust/target/release/claw` — the directory where the binary was compiled, embedded into the binary metadata. For a binary copied/installed/symlinked to a different location, this field still reports the build path, not the actual invocation path. Either the field should reflect the runtime path via `std::env::current_exe()` at runtime (not compile-time), or it should be dropped to avoid leaking compile-host filesystem layout. **Sibling: prose `message` field duplicates structured data.** The `message` field still contains the entire text-mode prose version block (`"Claw Code\n Version 0.1.0\n Git SHA b98b9a7\n..."`) — every field present as structured JSON (`version`, `git_sha`, `target`, `build_date`) is also embedded in the prose. Same issue as #391 (`version json includes prose message field`) which was closed as "fixed" — the prose remains. **Required fix shape:** (a) add `is_dirty:bool`, `branch:string|null`, `commit_date:string` (ISO-8601), `commit_timestamp:int` (Unix epoch), `rustc_version:string` to the JSON envelope; (b) preserve full 40-char `git_sha` and add `git_sha_short:string` as a derived field if 7-char form is needed for UX; (c) `executable_path` should be `std::env::current_exe()` at runtime, not the compile-time path; (d) drop the prose `message` field from JSON or rename it `human_readable:string` and make it explicitly secondary to the structured fields; (e) re-verify #391 closure — the prose `message` is still present, the fix didn't fully land. **Why this matters:** version surface is the canonical provenance probe for security audits, build reproducibility, and bug-report metadata. Missing `is_dirty` means automated triage cannot distinguish "issue against a clean main commit" from "issue against a developer's uncommitted hack". Truncated `git_sha` blocks unambiguous git lookup. Leaked `executable_path` exposes build-host layout. Cross-references #391 (version prose duplication — apparently not fully fixed), #334 (version json omits build_date — fixed, but partial scope), #100 (commit identity audit). Source: Jobdori live dogfood, `8cf628a5`, 2026-05-11. +437. **DONE — `version --output-format json` exposes complete build provenance without duplicating prose** — fixed 2026-06-04 in `fix: expose complete version provenance`. Build metadata now records the full 40-character `git_sha`, separate derived `git_sha_short`, `is_dirty`, `branch`, ISO-8601 `commit_date`, Unix `commit_timestamp`, `rustc_version`, target, and build date. Version JSON exposes those fields at top level and mirrors them under `binary_provenance`; `workspace_git_sha` is also a full SHA and `workspace_match` now compares full commit identities. `executable_path` is resolved at runtime with `std::env::current_exe()` instead of reporting a compile-host path. The prose report is no longer duplicated in JSON as `message`; JSON callers get the secondary text block as `human_readable`. Docs in `USAGE.md` and `rust/README.md` describe the provenance contract. Regression coverage: `version_emits_json_when_requested`, `version_status_doctor_include_binary_provenance_797`, `resumed_version_and_init_emit_structured_json_when_requested`, and `resumed_version_command_emits_structured_json`. 438. **Memory file discovery only recognizes `CLAUDE.md` — `AGENTS.md` (industry convention used by OpenCode/Codex/Aider/Cursor) and `CLAW.md` (project's own brand name) are silently ignored despite being present in the workspace** — dogfooded 2026-05-11 by Jobdori on `d3a982dd` in response to Clawhip pinpoint nudge at `1503328341422244012`. Reproduction (fresh empty dir, isolated `CLAW_CONFIG_HOME`): create three files in cwd — `CLAUDE.md` (marker `MARKER-FROM-CLAUDE-MD`), `AGENTS.md` (marker `MARKER-FROM-AGENTS-MD`), `CLAW.md` (marker `MARKER-FROM-CLAW-MD`). Run `claw status --output-format json` → `workspace.memory_file_count: 1`. Run `claw system-prompt --output-format json` and search the `message` field for each marker: only `MARKER-FROM-CLAUDE-MD` is found; `MARKER-FROM-AGENTS-MD` and `MARKER-FROM-CLAW-MD` are absent. `claw-code` exclusively recognizes the Claude-branded filename inherited from upstream Claude Code; the project's own `CLAW.md` brand name and the cross-tool industry convention `AGENTS.md` are both silently dropped. **Three sibling implications:** (a) **brand-consistency gap**: a project rebranded from Claude Code to Claw Code that introduces `CLAUDE.md` as its only memory file is internally inconsistent. Users naturally expect `claw ` to read `CLAW.md`. (b) **industry-convention gap**: `AGENTS.md` is the convergent convention for OpenCode (oh-my-opencode/sisyphus), OpenAI Codex CLI, Aider, Cursor, Continue.dev, and most ACP harnesses. Users with mixed-tool workflows maintain a shared `AGENTS.md` and expect every AI coding tool to honor it. (c) **silent failure mode**: there is no warning when `AGENTS.md` or `CLAW.md` exist but are not loaded. Users who copy-paste `AGENTS.md` from another tool's docs see `memory_file_count` stay at 0 or 1 and have to guess why their instructions aren't applied. **Required fix shape:** (a) discover and load **`CLAUDE.md`, `CLAW.md`, `AGENTS.md`** in that priority order (existing config-precedence pattern); (b) all three contribute to `memory_file_count` with `memory_files:[{path, source:"claude_md"|"claw_md"|"agents_md", chars}]` array exposed in `status --output-format json`; (c) when multiple files exist, merge or document the precedence: project-specific `CLAUDE.md`/`CLAW.md` overrides industry-shared `AGENTS.md`; (d) `claw doctor --output-format json` adds a `memory` check that warns when `AGENTS.md` exists but is not the loaded variant (alerting users that they may be relying on the wrong file); (e) regression test: workspace with all three files results in `memory_file_count >= 1` and the system prompt contains markers from at least the highest-precedence file. **Why this matters:** `AGENTS.md` is the lingua-franca instruction file for cross-tool AI coding workflows. A team using OpenCode for one project and Claw Code for another keeps their conventions in a shared `AGENTS.md`. Forcing them to also maintain a `CLAUDE.md` for claw-code (with identical content) is friction that breaks the value proposition of a fork. Cross-references #438 itself (the multi-file convention), and AGENTS.md ecosystem references in oh-my-opencode/sisyphus docs. Source: Jobdori live dogfood, `d3a982dd`, 2026-05-11. diff --git a/USAGE.md b/USAGE.md index 859c1de7..e44237c9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -51,6 +51,7 @@ cd rust ``` **Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch. +`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. ### Initialize a repository diff --git a/rust/README.md b/rust/README.md index b7b64236..6c837e63 100644 --- a/rust/README.md +++ b/rust/README.md @@ -148,6 +148,7 @@ Top-level commands: `claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`. `--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs. +`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field. Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options. `claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory. diff --git a/rust/crates/rusty-claude-cli/build.rs b/rust/crates/rusty-claude-cli/build.rs index 551408ce..fc4e3c45 100644 --- a/rust/crates/rusty-claude-cli/build.rs +++ b/rust/crates/rusty-claude-cli/build.rs @@ -1,10 +1,9 @@ use std::env; use std::process::Command; -fn main() { - // Get git SHA (short hash) - let git_sha = Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) +fn command_output(program: &str, args: &[&str]) -> Option { + Command::new(program) + .args(args) .output() .ok() .and_then(|output| { @@ -14,11 +13,37 @@ fn main() { None } }) - .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()); + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn main() { + let git_sha = + command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); + let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"]) + .or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + let git_dirty = command_output("git", &["status", "--porcelain"]) + .map(|status| (!status.trim().is_empty()).to_string()) + .unwrap_or_else(|| "false".to_string()); + let git_branch = command_output("git", &["branch", "--show-current"]) + .unwrap_or_else(|| "unknown".to_string()); + let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"]) + .unwrap_or_else(|| "unknown".to_string()); + let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"]) + .unwrap_or_else(|| "unknown".to_string()); + let rustc_version = + command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string()); println!("cargo:rustc-env=GIT_SHA={git_sha}"); + println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}"); + println!("cargo:rustc-env=GIT_DIRTY={git_dirty}"); + println!("cargo:rustc-env=GIT_BRANCH={git_branch}"); + println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}"); + println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}"); + println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}"); - // TARGET is always set by Cargo during build + // TARGET is always set by Cargo during build. let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); println!("cargo:rustc-env=TARGET={target}"); @@ -35,23 +60,12 @@ fn main() { }) .or_else(|| std::env::var("BUILD_DATE").ok()) .unwrap_or_else(|| { - // Fall back to current date via `date` command - Command::new("date") - .args(["+%Y-%m-%d"]) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - String::from_utf8(o.stdout).ok() - } else { - None - } - }) - .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()) + command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string()) }); println!("cargo:rustc-env=BUILD_DATE={build_date}"); - // Rerun if git state changes - println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs"); + // Rerun if git state changes. Paths are relative to this package root. + println!("cargo:rerun-if-changed=../../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../../.git/refs"); + println!("cargo:rerun-if-changed=../../../.git/index"); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 07433f9a..79d922f7 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -268,6 +268,12 @@ const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +const GIT_SHA_SHORT: Option<&str> = option_env!("GIT_SHA_SHORT"); +const GIT_DIRTY: Option<&str> = option_env!("GIT_DIRTY"); +const GIT_BRANCH: Option<&str> = option_env!("GIT_BRANCH"); +const GIT_COMMIT_DATE: Option<&str> = option_env!("GIT_COMMIT_DATE"); +const GIT_COMMIT_TIMESTAMP: Option<&str> = option_env!("GIT_COMMIT_TIMESTAMP"); +const RUSTC_VERSION: Option<&str> = option_env!("RUSTC_VERSION"); const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; @@ -4452,9 +4458,15 @@ fn version_json_value() -> serde_json::Value { "kind": "version", "action": "show", "status": "ok", - "message": render_version_report(), + "human_readable": render_version_report(), "version": VERSION, "git_sha": binary_provenance.git_sha, + "git_sha_short": binary_provenance.git_sha_short, + "is_dirty": binary_provenance.is_dirty, + "branch": binary_provenance.branch, + "commit_date": binary_provenance.commit_date, + "commit_timestamp": binary_provenance.commit_timestamp, + "rustc_version": binary_provenance.rustc_version, "target": binary_provenance.target, "build_date": binary_provenance.build_date, "executable_path": binary_provenance.executable_path, @@ -4693,6 +4705,12 @@ struct StatusContext { #[derive(Debug, Clone, PartialEq, Eq)] struct BinaryProvenance { git_sha: Option, + git_sha_short: Option, + is_dirty: bool, + branch: Option, + commit_date: String, + commit_timestamp: i64, + rustc_version: String, target: Option, build_date: String, executable_path: Option, @@ -4714,6 +4732,12 @@ impl BinaryProvenance { json!({ "status": self.status(), "git_sha": self.git_sha, + "git_sha_short": self.git_sha_short, + "is_dirty": self.is_dirty, + "branch": self.branch, + "commit_date": self.commit_date, + "commit_timestamp": self.commit_timestamp, + "rustc_version": self.rustc_version, "target": self.target, "build_date": self.build_date, "executable_path": self.executable_path, @@ -4733,18 +4757,35 @@ fn known_build_metadata(value: Option<&str>) -> Option { } } +fn parse_build_bool(value: Option<&str>) -> bool { + value + .map(str::trim) + .is_some_and(|value| value.eq_ignore_ascii_case("true") || value == "1") +} + +fn parse_build_timestamp(value: Option<&str>) -> i64 { + value + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) +} + fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance { let git_sha = known_build_metadata(GIT_SHA); + let git_sha_short = known_build_metadata(GIT_SHA_SHORT).or_else(|| { + git_sha + .as_ref() + .map(|sha| sha.chars().take(12).collect::()) + }); let target = known_build_metadata(BUILD_TARGET); let workspace_git_sha = cwd.and_then(|cwd| { - run_git_capture_in(cwd, &["rev-parse", "--short", "HEAD"]) + run_git_capture_in(cwd, &["rev-parse", "HEAD"]) .map(|sha| sha.trim().to_string()) .filter(|sha| !sha.is_empty()) }); let workspace_match = git_sha .as_deref() .zip(workspace_git_sha.as_deref()) - .map(|(binary, workspace)| binary.starts_with(workspace) || workspace.starts_with(binary)); + .map(|(binary, workspace)| binary == workspace); let hint = if git_sha.is_none() { Some( "Build metadata did not include a git SHA; rebuild from a git checkout before filing provenance-sensitive dogfood reports." @@ -4760,6 +4801,12 @@ fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance { }; BinaryProvenance { git_sha, + git_sha_short, + is_dirty: parse_build_bool(GIT_DIRTY), + branch: known_build_metadata(GIT_BRANCH), + commit_date: known_build_metadata(GIT_COMMIT_DATE).unwrap_or_else(|| "unknown".to_string()), + commit_timestamp: parse_build_timestamp(GIT_COMMIT_TIMESTAMP), + rustc_version: known_build_metadata(RUSTC_VERSION).unwrap_or_else(|| "unknown".to_string()), target, build_date: DEFAULT_DATE.to_string(), executable_path: env::current_exe() @@ -10285,10 +10332,12 @@ fn parse_titled_body(value: &str) -> Option<(String, String)> { } fn render_version_report() -> String { - let git_sha = GIT_SHA.unwrap_or("unknown"); + let git_sha = GIT_SHA_SHORT.or(GIT_SHA).unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown"); + let branch = GIT_BRANCH.unwrap_or("unknown"); + let dirty = GIT_DIRTY.unwrap_or("unknown"); format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Branch {branch}\n Dirty {dirty}\n Target {target}\n Build date {DEFAULT_DATE}" ) } diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 1ac7c7ef..5a3f1c6b 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -270,29 +270,88 @@ fn version_emits_json_when_requested() { "version JSON must have action:show (#711)" ); assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); - // Provenance fields must be present for binary identification (#507). + // Provenance fields must be present for binary identification (#507/#437). + assert!( + parsed.get("message").is_none(), + "version JSON should not duplicate the text report in legacy message; use human_readable instead: {parsed}" + ); + assert!( + parsed["human_readable"] + .as_str() + .is_some_and(|text| text.contains("Claw Code")), + "version JSON should keep text output only in human_readable: {parsed}" + ); + let git_sha = parsed["git_sha"] + .as_str() + .expect("git_sha must be the full build commit SHA in version JSON"); + assert_eq!(git_sha.len(), 40, "git_sha must not be truncated: {parsed}"); + assert!( + git_sha.chars().all(|ch| ch.is_ascii_hexdigit()), + "git_sha must be a hex commit id: {parsed}" + ); + let git_sha_short = parsed["git_sha_short"] + .as_str() + .expect("version JSON should expose the short SHA as a separate derived field"); + assert!( + git_sha.starts_with(git_sha_short), + "git_sha_short should derive from git_sha: {parsed}" + ); + assert!( + parsed["is_dirty"].is_boolean(), + "is_dirty should be boolean: {parsed}" + ); + assert!( + parsed["branch"].is_string() || parsed["branch"].is_null(), + "branch should be string|null: {parsed}" + ); + assert!( + parsed["commit_date"] + .as_str() + .is_some_and(|date| date != "unknown" && date.contains('T')), + "commit_date should be an ISO-8601 commit timestamp string: {parsed}" + ); + assert!( + parsed["commit_timestamp"].as_i64().is_some_and(|ts| ts > 0), + "commit_timestamp should be a positive Unix timestamp: {parsed}" + ); + assert!( + parsed["rustc_version"] + .as_str() + .is_some_and(|version| version.starts_with("rustc ")), + "rustc_version should identify the compiler: {parsed}" + ); assert!( parsed["build_date"].is_string(), "build_date must be a string in version JSON" ); assert!( - parsed["executable_path"].is_string(), - "executable_path must be a string in version JSON so callers can identify which binary is running" + parsed["executable_path"].as_str().is_some_and(|path| !path.is_empty()), + "executable_path must be a runtime path string so callers can identify which binary is running" ); let binary_provenance = parsed["binary_provenance"] .as_object() - .expect("version JSON must include binary_provenance object (#797)"); + .expect("version JSON must include binary_provenance object (#797/#437)"); assert!(matches!( binary_provenance["status"].as_str(), Some("known" | "unknown") )); - assert_eq!(binary_provenance["git_sha"], parsed["git_sha"]); - assert_eq!(binary_provenance["target"], parsed["target"]); - assert_eq!(binary_provenance["build_date"], parsed["build_date"]); - assert_eq!( - binary_provenance["executable_path"], - parsed["executable_path"] - ); + for key in [ + "git_sha", + "git_sha_short", + "is_dirty", + "branch", + "commit_date", + "commit_timestamp", + "rustc_version", + "target", + "build_date", + "executable_path", + ] { + assert_eq!( + binary_provenance[key], parsed[key], + "binary_provenance.{key} should mirror top-level version field" + ); + } assert!( binary_provenance["hint"].is_string() || binary_provenance["hint"].is_null(), "binary provenance must classify missing/stale lineage with a structured hint field" @@ -334,6 +393,14 @@ fn version_status_doctor_include_binary_provenance_797() { version["binary_provenance"]["workspace_match"].is_boolean() || version["binary_provenance"]["workspace_match"].is_null() ); + let workspace_git_sha = version["binary_provenance"]["workspace_git_sha"] + .as_str() + .expect("workspace git sha should be a string"); + assert_eq!( + workspace_git_sha.len(), + 40, + "workspace_git_sha should be a full SHA, not a truncated prefix: {version}" + ); let status = assert_json_command(&root, &["--output-format", "json", "status"]); assert_eq!(status["kind"], "status"); @@ -1518,6 +1585,11 @@ fn resumed_version_and_init_emit_structured_json_when_requested() { ); assert_eq!(version["kind"], "version"); assert_eq!(version["version"], env!("CARGO_PKG_VERSION")); + assert!( + version.get("message").is_none(), + "resumed /version JSON should not include legacy prose message: {version}" + ); + assert!(version["human_readable"].as_str().is_some()); let init = assert_json_command( &root, diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index ddaf6e08..dd5ae8e5 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -463,6 +463,9 @@ fn resumed_version_command_emits_structured_json() { assert!(parsed["version"].as_str().is_some()); assert!(parsed["git_sha"].as_str().is_some()); assert!(parsed["target"].as_str().is_some()); + assert!(parsed["git_sha_short"].as_str().is_some()); + assert!(parsed.get("message").is_none()); + assert!(parsed["human_readable"].as_str().is_some()); } #[test]