-
Notifications
You must be signed in to change notification settings - Fork 0
feat(context): ship deterministic context CLI #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
495b1f3
fdc034a
0ba0dcb
fb49508
84f4c13
5b71ad6
2968888
61cd236
66cb11c
036fdb2
01fb00a
b7784bd
bef4b55
85558bd
a9d8de8
fc1fd27
4d5ba5a
1475a9d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| name: cf11g-context-proof | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - .github/workflows/cf11g-context-proof.yml | ||
| - Cargo.toml | ||
| - Cargo.lock | ||
| - crates/commandf-pkg/** | ||
| - crates/commandf-cli/** | ||
| - specs/012-cf-11g-ecosystem-context-graph/** | ||
| push: | ||
| branches: | ||
| - impl/cf11g-context-cli | ||
| paths: | ||
| - .github/workflows/cf11g-context-proof.yml | ||
| - Cargo.toml | ||
| - Cargo.lock | ||
| - crates/commandf-pkg/** | ||
| - crates/commandf-cli/** | ||
| - specs/012-cf-11g-ecosystem-context-graph/** | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| env: | ||
| CF11G_PROOF_CONTAINER: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f | ||
|
|
||
| jobs: | ||
| deterministic-context-cli: | ||
| runs-on: ubuntu-24.04 | ||
| container: | ||
| # Docker Official Image rust:1.97.1-trixie, pinned to the linux/amd64 manifest. | ||
| image: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Assert pinned execution toolchain | ||
| run: | | ||
| set -euo pipefail | ||
| rustc --version --verbose | ||
| cargo --version | ||
| test "$(rustc --version | awk '{print $2}')" = "1.97.1" | ||
|
|
||
| - name: Prove byte-identical commandf context output | ||
| run: | | ||
| set -euo pipefail | ||
| cargo test --locked -p commandf --test context_determinism_proof -- --nocapture \ | ||
| | tee /tmp/cf11g-context-proof.log | ||
| grep -oE 'CF11G_CONTEXT_SHA256=[0-9a-f]{64}' /tmp/cf11g-context-proof.log \ | ||
| | tail -n 1 \ | ||
| | tee /tmp/cf11g-context.sha256 | ||
| test -s /tmp/cf11g-context.sha256 | ||
|
|
||
| - name: Assert repository remains clean | ||
| run: | | ||
| set -euo pipefail | ||
| status="$(git -c safe.directory="$GITHUB_WORKSPACE" status --porcelain)" | ||
| test -z "$status" | ||
|
|
||
| - name: Upload context determinism evidence | ||
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | ||
| with: | ||
| name: cf11g-context-proof | ||
| path: /tmp/cf11g-context.sha256 | ||
| if-no-files-found: error | ||
| retention-days: 3 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ use std::process::{self, ExitCode}; | |
|
|
||
| use clap::{Parser, Subcommand, ValueEnum}; | ||
| use commandf_pkg::{ | ||
| build_source_mapped_check_report, build_terminology_diff_report, | ||
| build_context_graph, build_source_mapped_check_report, build_terminology_diff_report, | ||
| check_report_to_github_annotations_bytes, check_report_to_sarif_bytes, | ||
| classify_structural_diff, diff_package_archives, evaluate_compatibility_policy, | ||
| inspect_package, source_mapped_check_report_to_github_annotations_bytes, CheckDirection, | ||
|
|
@@ -48,6 +48,14 @@ enum Command { | |
| #[arg(long, value_enum, default_value = "json")] | ||
| format: OutputFormat, | ||
| }, | ||
| Context { | ||
| #[arg(long, default_value = "commandf.lock")] | ||
| lock: PathBuf, | ||
| #[arg(long, default_value = ".commandf/cache")] | ||
| cache: PathBuf, | ||
| #[arg(long, value_enum, default_value = "json")] | ||
| format: OutputFormat, | ||
| }, | ||
| Diff { | ||
| package: String, | ||
| #[arg(long)] | ||
|
|
@@ -319,6 +327,18 @@ fn run(cli: Cli) -> Result<ExitCode, Box<dyn std::error::Error>> { | |
| OutputFormat::Json => io::stdout().write_all(&inspection.to_json_bytes()?)?, | ||
| } | ||
| } | ||
| Command::Context { | ||
| lock, | ||
| cache, | ||
| format, | ||
| } => { | ||
| let lockfile = Lockfile::from_slice(&fs::read(&lock)?)?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Lockfile read is unbounded The new context command fully allocates the user-supplied lockfile with fs::read and then deserializes unconstrained vectors, maps, and strings. A large synthetic lockfile can exhaust memory and trigger substantial validation CPU before cache processing begins. Agent Prompt
|
||
| let cache = PackageCache::new(cache); | ||
| let report = build_context_graph(&lockfile, &cache)?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Duplicate filenames misattribute edges build_context_graph indexes inspected resources only by filename, so duplicate TAR entries overwrite each other while references are still extracted from every scanned entry. A package containing two different package/Foo.json entries can therefore emit references from one resource under the other resource's artifact hash, corrupting graph evidence. Agent Prompt
|
||
| match format { | ||
| OutputFormat::Json => io::stdout().write_all(&report.to_json_bytes()?)?, | ||
| } | ||
| } | ||
| Command::Diff { | ||
| package, | ||
| before_lock, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| use std::collections::BTreeMap; | ||
| use std::fs; | ||
| use std::path::{Path, PathBuf}; | ||
| use std::process::{Command, Output}; | ||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||
|
|
||
| use commandf_pkg::{LockedPackage, Lockfile, PackageCache, ResolvedDependency}; | ||
|
|
||
| const PARENT_A_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-a.tgz"); | ||
| const PARENT_B_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-b.tgz"); | ||
| const SHARED_V1_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v1.tgz"); | ||
| const SHARED_V2_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v2.tgz"); | ||
| const MALFORMED_ARCHIVE: &[u8] = include_bytes!("fixtures/malformed.tgz"); | ||
|
|
||
| fn commandf() -> Command { | ||
| Command::new(env!("CARGO_BIN_EXE_commandf")) | ||
| } | ||
|
|
||
| fn unique_temp_dir(label: &str) -> PathBuf { | ||
| let nonce = SystemTime::now() | ||
| .duration_since(UNIX_EPOCH) | ||
| .expect("system clock after epoch") | ||
| .as_nanos(); | ||
| std::env::temp_dir().join(format!( | ||
| "commandf-context-{label}-{}-{nonce}", | ||
| std::process::id() | ||
| )) | ||
| } | ||
|
|
||
| fn run_context(lock: &Path, cache: &Path) -> Output { | ||
| commandf() | ||
| .args([ | ||
| "context", | ||
| "--lock", | ||
| lock.to_str().expect("UTF-8 lock path"), | ||
| "--cache", | ||
| cache.to_str().expect("UTF-8 cache path"), | ||
| "--format", | ||
| "json", | ||
| ]) | ||
| .env("HTTP_PROXY", "http://127.0.0.1:9") | ||
| .env("HTTPS_PROXY", "http://127.0.0.1:9") | ||
| .env("NO_PROXY", "") | ||
| .output() | ||
| .expect("commandf context must execute") | ||
| } | ||
|
|
||
| #[test] | ||
| fn context_help_exposes_offline_inputs() { | ||
| let output = commandf() | ||
| .args(["context", "--help"]) | ||
| .output() | ||
| .expect("commandf context help must execute"); | ||
| assert!(output.status.success()); | ||
| let stdout = String::from_utf8(output.stdout).expect("UTF-8 help"); | ||
| for flag in ["--lock", "--cache", "--format"] { | ||
| assert!(stdout.contains(flag), "missing {flag}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn context_emits_byte_identical_multi_version_graph_evidence() { | ||
| let root = unique_temp_dir("success"); | ||
| let (lock, cache) = write_context_state(&root); | ||
|
|
||
| let first = run_context(&lock, &cache); | ||
| let second = run_context(&lock, &cache); | ||
| assert_success(&first); | ||
| assert_success(&second); | ||
| assert_eq!(first.stdout, second.stdout); | ||
|
|
||
| let json = String::from_utf8(first.stdout).expect("UTF-8 context JSON"); | ||
| for evidence in [ | ||
| "\"lock_schema\": 2", | ||
| "\"name\": \"acme.shared\"", | ||
| "\"version\": \"1.0.0\"", | ||
| "\"version\": \"2.0.0\"", | ||
| "\"id\": \"extension\"", | ||
| "\"resource_type\": \"Patient\"", | ||
| "\"resolution\": \"resolved\"", | ||
| "\"resolution\": \"external\"", | ||
| "\"resolution\": \"ambiguous\"", | ||
| "\"relation\": \"structure_base_definition\"", | ||
| "\"relation\": \"structure_type_profile\"", | ||
| "\"relation\": \"structure_type_target_profile\"", | ||
| "\"relation\": \"structure_binding_value_set\"", | ||
| "\"relation\": \"value_set_include_system\"", | ||
| "\"relation\": \"value_set_include_value_set\"", | ||
| "\"relation\": \"value_set_exclude_system\"", | ||
| "\"relation\": \"code_system_supplements\"", | ||
| "\"unsupported_source_resource_types\": [\n \"Patient\"\n ]", | ||
| ] { | ||
| assert!(json.contains(evidence), "missing evidence: {evidence}"); | ||
| } | ||
|
|
||
| let _ = fs::remove_dir_all(root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn context_rejects_schema_v1_with_stable_migration_diagnostic() { | ||
| let root = unique_temp_dir("schema-v1"); | ||
| let lock = root.join("commandf.lock"); | ||
| let cache = root.join("cache"); | ||
| fs::create_dir_all(&cache).unwrap(); | ||
| fs::write( | ||
| &lock, | ||
| Lockfile::new(Vec::new(), Vec::new()).to_bytes().unwrap(), | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| let output = run_context(&lock, &cache); | ||
| assert_eq!(output.status.code(), Some(1)); | ||
| assert!(output.stdout.is_empty()); | ||
| assert!(String::from_utf8_lossy(&output.stderr) | ||
| .contains("commandf context requires commandf.lock schema 2; found schema 1")); | ||
| let _ = fs::remove_dir_all(root); | ||
| } | ||
|
|
||
| #[test] | ||
| fn context_fails_closed_on_missing_corrupt_and_malformed_inputs() { | ||
| let missing_root = unique_temp_dir("missing"); | ||
| fs::create_dir_all(&missing_root).unwrap(); | ||
| let missing_lock = missing_root.join("commandf.lock"); | ||
| let missing_cache = missing_root.join("cache"); | ||
| let missing = Lockfile::new_v2( | ||
| vec!["acme.root@1.0.0".to_owned()], | ||
| vec![locked_package( | ||
| "acme.root", | ||
| "1.0.0", | ||
| &"a".repeat(64), | ||
| BTreeMap::new(), | ||
| )], | ||
| vec![], | ||
| ); | ||
| fs::write(&missing_lock, missing.to_bytes().unwrap()).unwrap(); | ||
| let output = run_context(&missing_lock, &missing_cache); | ||
| assert_eq!(output.status.code(), Some(1)); | ||
| assert!(output.stdout.is_empty()); | ||
|
Comment on lines
+137
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Cache failures lack diagnostics The missing-archive and corrupt-digest CLI cases assert only exit code and empty stdout, without checking the emitted error type or message. This allows these distinct failure branches to regress to an incorrect or misleading diagnostic unnoticed. Agent Prompt
|
||
| let _ = fs::remove_dir_all(missing_root); | ||
|
|
||
| let corrupt_root = unique_temp_dir("corrupt"); | ||
| let (corrupt_lock, corrupt_cache) = write_context_state(&corrupt_root); | ||
| let lockfile = Lockfile::from_slice(&fs::read(&corrupt_lock).unwrap()).unwrap(); | ||
| let digest = &lockfile.packages[0].sha256; | ||
| fs::write( | ||
| corrupt_cache.join("sha256").join(format!("{digest}.tgz")), | ||
| b"corrupted", | ||
| ) | ||
| .unwrap(); | ||
| let output = run_context(&corrupt_lock, &corrupt_cache); | ||
| assert_eq!(output.status.code(), Some(1)); | ||
| assert!(output.stdout.is_empty()); | ||
| let _ = fs::remove_dir_all(corrupt_root); | ||
|
|
||
| let malformed_root = unique_temp_dir("malformed"); | ||
| let malformed_cache = malformed_root.join("cache"); | ||
| let malformed_lock = malformed_root.join("commandf.lock"); | ||
| fs::create_dir_all(&malformed_root).unwrap(); | ||
| let cache = PackageCache::new(&malformed_cache); | ||
| let digest = cache.put(MALFORMED_ARCHIVE).unwrap(); | ||
| let lock = Lockfile::new_v2( | ||
| vec!["acme.bad@1.0.0".to_owned()], | ||
| vec![locked_package( | ||
| "acme.bad", | ||
| "1.0.0", | ||
| &digest, | ||
| BTreeMap::new(), | ||
| )], | ||
| vec![], | ||
| ); | ||
| fs::write(&malformed_lock, lock.to_bytes().unwrap()).unwrap(); | ||
| let output = run_context(&malformed_lock, &malformed_cache); | ||
| assert_eq!(output.status.code(), Some(1)); | ||
| assert!(output.stdout.is_empty()); | ||
| assert!(String::from_utf8_lossy(&output.stderr).contains("must be an array")); | ||
| let _ = fs::remove_dir_all(malformed_root); | ||
| } | ||
|
|
||
| fn write_context_state(root: &Path) -> (PathBuf, PathBuf) { | ||
| fs::create_dir_all(root).unwrap(); | ||
| let cache_path = root.join("cache"); | ||
| let lock_path = root.join("commandf.lock"); | ||
| let cache = PackageCache::new(&cache_path); | ||
|
|
||
| let parent_a_sha = cache.put(PARENT_A_ARCHIVE).unwrap(); | ||
| let parent_b_sha = cache.put(PARENT_B_ARCHIVE).unwrap(); | ||
| let shared_v1_sha = cache.put(SHARED_V1_ARCHIVE).unwrap(); | ||
| let shared_v2_sha = cache.put(SHARED_V2_ARCHIVE).unwrap(); | ||
|
|
||
| let mut parent_a_dependencies = BTreeMap::new(); | ||
| parent_a_dependencies.insert("acme.shared".to_owned(), "1.0.0".to_owned()); | ||
| let mut parent_b_dependencies = BTreeMap::new(); | ||
| parent_b_dependencies.insert("acme.shared".to_owned(), "2.0.0".to_owned()); | ||
|
|
||
| let lock = Lockfile::new_v2( | ||
| vec![ | ||
| "acme.parentb@1.0.0".to_owned(), | ||
| "acme.parenta@1.0.0".to_owned(), | ||
| ], | ||
| vec![ | ||
| locked_package( | ||
| "acme.parenta", | ||
| "1.0.0", | ||
| &parent_a_sha, | ||
| parent_a_dependencies, | ||
| ), | ||
| locked_package( | ||
| "acme.parentb", | ||
| "1.0.0", | ||
| &parent_b_sha, | ||
| parent_b_dependencies, | ||
| ), | ||
| locked_package("acme.shared", "1.0.0", &shared_v1_sha, BTreeMap::new()), | ||
| locked_package("acme.shared", "2.0.0", &shared_v2_sha, BTreeMap::new()), | ||
| ], | ||
| vec![ | ||
| ResolvedDependency { | ||
| from_name: "acme.parenta".to_owned(), | ||
| from_version: "1.0.0".to_owned(), | ||
| to_name: "acme.shared".to_owned(), | ||
| to_version: "1.0.0".to_owned(), | ||
| declared_constraint: "1.0.0".to_owned(), | ||
| }, | ||
| ResolvedDependency { | ||
| from_name: "acme.parentb".to_owned(), | ||
| from_version: "1.0.0".to_owned(), | ||
| to_name: "acme.shared".to_owned(), | ||
| to_version: "2.0.0".to_owned(), | ||
| declared_constraint: "2.0.0".to_owned(), | ||
| }, | ||
| ], | ||
| ); | ||
| fs::write(&lock_path, lock.to_bytes().unwrap()).unwrap(); | ||
| (lock_path, cache_path) | ||
| } | ||
|
|
||
| fn assert_success(output: &Output) { | ||
| assert!( | ||
| output.status.success(), | ||
| "stderr: {}", | ||
| String::from_utf8_lossy(&output.stderr) | ||
| ); | ||
| assert!(output.stderr.is_empty()); | ||
| } | ||
|
|
||
| fn locked_package( | ||
| name: &str, | ||
| version: &str, | ||
| sha256: &str, | ||
| dependencies: BTreeMap<String, String>, | ||
| ) -> LockedPackage { | ||
| LockedPackage { | ||
| name: name.to_owned(), | ||
| version: version.to_owned(), | ||
| sha256: sha256.to_owned(), | ||
| source: "synthetic-context-test".to_owned(), | ||
| dependencies, | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Lock parse failures untested
📘 Rule violation▣ TestabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools