From 249f1006cee42b5ef09b44caacbede2a1c24daa8 Mon Sep 17 00:00:00 2001 From: Marius Arvinte Date: Tue, 22 Sep 2026 14:12:13 -0700 Subject: [PATCH 1/2] Release 2026.9 (#1297) # :tada: Major Updates - **Codex backend for core translation tasks**: IDEAS now supports and defaults to launching a [`codex exec`](https://learn.chatgpt.com/docs/non-interactive-mode) session for symbol translation and C FFI compatibility function generation. IDEAS retains its guarantees that this cannot edit past translations. - **Revised core prompts**: This release comes with major prompt simplifications for both translation and C FFI compatibility function generation. The new prompts are more focused on _what_ a model dispatch should achieve instead of prescribing _how_ Rust should be implemented. - **Improved test generation**: Significantly reduced the prompt size for test generation agents and added portability verification (tests are accepted only if they survive being moved to a random on-disk location). - **C interoperability traits**: IDEAS now instructs the C FFI compatibility LLM call to populate prescribed Rust traits for ahead-of-time `struct` conversions and roundtrip tests. LLM responses are verified against the templates. # :balloon: Minor Updates - All IDEAS subtasks run by default in Docker. - Switched Rust AST parsing backend to the [`tree-sitter-rust-orchard`](https://codeberg.org/grammar-orchard/tree-sitter-rust-orchard) grammar. # :construction: Bug Fixes - Fixed more `ideas.ast` and `ideas.consolidate` corner cases. --------- Co-authored-by: Cory Cornelius --- .dockerignore | 9 + .gitignore | 3 +- .pre-commit-config.yaml | 6 +- IDEAS.mk | 243 +- INSTALL.mk | 74 + Makefile | 233 +- README.md | 233 +- VARIABLES.mk | 38 +- docker/DOCKER.mk | 103 + docker/ideas.Dockerfile | 74 +- docker/run.sh | 281 +++ pyproject.toml | 7 +- src/ideas/__init__.py | 4 + src/ideas/agents/generate_io_tests.py | 719 +++--- src/ideas/agents/templates/Cargo.toml | 26 + src/ideas/agents/templates/src/lib.rs | 1 + src/ideas/agents/templates/src/main.rs | 1 + .../agents/templates/tests/bin_assert.rs | 120 + .../agents/templates/tests/bin_collect.rs | 135 ++ .../agents/templates/tests/lib_assert.rs | 52 + .../agents/templates/tests/lib_collect.rs | 62 + src/ideas/agents/utils.py | 553 +++-- src/ideas/agents/verifiers.py | 313 +++ src/ideas/ast.py | 587 +++-- src/ideas/ast_rust.py | 89 +- src/ideas/bear.py | 300 ++- src/ideas/codex.py | 951 ++++++++ src/ideas/consolidate.py | 214 +- src/ideas/convert_tests.py | 18 +- src/ideas/hybrid.py | 330 +++ src/ideas/model.py | 147 +- src/ideas/oracle.py | 404 ++++ src/ideas/refine.py | 159 ++ src/ideas/tools.py | 205 +- src/ideas/translate.py | 208 +- src/ideas/translate_context.py | 283 +++ src/ideas/translate_recurrent.py | 1267 +++++------ src/ideas/translate_snippet.py | 305 ++- src/ideas/wrapper.py | 992 ++++++--- test/integration/conftest.py | 128 +- test/integration/test_final.py | 91 +- test/integration/test_init.py | 95 +- test/integration/test_symbol_table.py | 547 +++++ test/integration/test_verification.py | 467 ++++ test/test_ast_rust.py | 295 +++ test/test_bear.py | 327 ++- test/test_clang.py | 366 +++- test/test_consolidate.py | 235 +- test/test_oracle.py | 284 +++ test/test_translate_recurrent.py | 1947 +++++++++++++++-- test/test_wrapper.py | 774 ++++++- tools/cando2/src/runners/lib_runner.patch | 25 + tools/rust_tests/lib_testing.rs | 84 +- uv.lock | 61 +- 54 files changed, 12595 insertions(+), 2880 deletions(-) create mode 100644 .dockerignore create mode 100644 INSTALL.mk create mode 100644 docker/DOCKER.mk create mode 100755 docker/run.sh create mode 100644 src/ideas/agents/templates/Cargo.toml create mode 100644 src/ideas/agents/templates/src/lib.rs create mode 100644 src/ideas/agents/templates/src/main.rs create mode 100644 src/ideas/agents/templates/tests/bin_assert.rs create mode 100644 src/ideas/agents/templates/tests/bin_collect.rs create mode 100644 src/ideas/agents/templates/tests/lib_assert.rs create mode 100644 src/ideas/agents/templates/tests/lib_collect.rs create mode 100644 src/ideas/agents/verifiers.py create mode 100644 src/ideas/codex.py create mode 100644 src/ideas/hybrid.py create mode 100644 src/ideas/oracle.py create mode 100644 src/ideas/refine.py create mode 100644 src/ideas/translate_context.py create mode 100644 test/integration/test_symbol_table.py create mode 100644 test/integration/test_verification.py create mode 100644 test/test_ast_rust.py create mode 100644 test/test_oracle.py create mode 100644 tools/cando2/src/runners/lib_runner.patch diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b88ce50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# Ignore everything +* + +# Allowlist +!pyproject.toml +!uv.lock +!INSTALL.mk +!docker/ideas.Dockerfile +!docker/ideas.bashrc diff --git a/.gitignore b/.gitignore index a7a114f..988be38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# Ignore examples +# IDEAS specific examples/ +docker/build.log .DS_Store *.*~ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5683dd0..cd6ba6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.11 + python: python3.13 exclude: ^(examples/|lib/|tools/|test/fixtures/) @@ -23,7 +23,7 @@ repos: - id: pretty-format-json - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.13.0 hooks: # Run the linter. - id: ruff @@ -38,6 +38,6 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.7.12 + rev: 0.11.13 hooks: - id: uv-lock diff --git a/IDEAS.mk b/IDEAS.mk index c407314..71ae746 100644 --- a/IDEAS.mk +++ b/IDEAS.mk @@ -7,27 +7,28 @@ .NOTPARALLEL: MAKEFILE_DIR := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST))))) include ${MAKEFILE_DIR}/VARIABLES.mk +include ${MAKEFILE_DIR}/INSTALL.mk +include ${MAKEFILE_DIR}/docker/DOCKER.mk -TEST_FILES := $(wildcard test_vectors/*.json) GIT := @git -C ${TRANSLATION_DIR} +CMAKE := cmake CARGO := cargo -q PYTHON := uv run python ifeq (${TARGETS},) -ifeq ($(filter bear clean,$(MAKECMDGOALS)),) +ifeq ($(filter bear clean reset,$(MAKECMDGOALS)),) $(error No TARGETS found! You need to run bear!) endif endif -# TRANSLATION_TEST=null (or empty) disables translation-time testing: no test file is -# required from the -sys crate and RecurrentTranslator only wraps global symbols. Empty -# is normalized to null because hydra parses a bare `tests=` as "" rather than None. +TEST_FILES := $(wildcard test_vectors/*.json) ifeq ($(filter-out null,$(strip ${TRANSLATION_TEST})),) override TRANSLATION_TEST := null +TRANSLATION_TEST_ARG := null TRANSLATION_TEST_DEP := else -# Recursive `=` so `%` stays literal until the pattern rule substitutes the stem -TRANSLATION_TEST_DEP = ${TRANSLATION_DIR}/%-sys/tests/${TRANSLATION_TEST}.rs +TRANSLATION_TEST_ARG = ${TESTGEN_CACHE}/$*-sys/tests/${TRANSLATION_TEST}.rs +TRANSLATION_TEST_DEP = ${TESTGEN_CACHE}/%-sys/tests/${TRANSLATION_TEST}.rs endif # bear @@ -35,24 +36,44 @@ endif bear: build-ninja/events.jsonl @[ -s build-ninja/events.jsonl ] || (echo "BROKEN ${CURDIR}/${TRANSLATION_DIR}") @: # empty rule to suppress "Nothing to be done for" - +.DELETE_ON_ERROR: build-ninja/events.jsonl ifneq ($(wildcard CMakePresets.json),) build-ninja/events.jsonl: CMakeLists.txt CMakePresets.json - rm -rf $(@D) - cmake -S $( $@ +else +build-ninja/baseline.json: + RUSTUP_TOOLCHAIN="${RUNNER_TOOLCHAIN}" CARGO_TARGET_DIR=build-ninja/cargo \ + ${CARGO} run --manifest-path ${MAKEFILE_DIR}/tools/cando2/Cargo.toml --release --bin cando_binrunner \ + -- \ + --log-level quiet bin --name ${TARGETS_BIN} > $@ endif # workspace ${TRANSLATION_DIR}/.git/config: @mkdir -p ${TRANSLATION_DIR} - ${GIT} init --initial-branch=main --quiet + ${GIT} init --initial-branch=${TRANSLATION_BRANCH} --quiet + ${GIT} config core.excludesFile /dev/null define WORKSPACE_CARGO_TOML [workspace] @@ -63,16 +84,24 @@ libc = "0.2.185" openssl = "0.10.79" regex = "1" flate2 = "1" +insta = { version = "1.48.0", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" assert_cmd = "2.0.17" predicates = "3.1.3" +walkdir = "2" +endef +define WORKSPACE_GITIGNORE +cache.db* +target/ +*.log endef .PRECIOUS: ${TRANSLATION_DIR}/Cargo.toml ${TRANSLATION_DIR}/Cargo.toml: | ${TRANSLATION_DIR}/.git/config $(file >$@,${WORKSPACE_CARGO_TOML}) - ${GIT} add $(@F) + $(file >$(@D)/.gitignore,${WORKSPACE_GITIGNORE}) + ${GIT} add Cargo.toml .gitignore ${GIT} commit -qm "Created cargo workspace" @echo "" @@ -83,8 +112,7 @@ ${TRANSLATION_DIR}/Cargo.toml: | ${TRANSLATION_DIR}/.git/config init: $(patsubst %,${TRANSLATION_DIR}/%-sys/Cargo.toml,${TARGETS}) touch $(patsubst %,${TRANSLATION_DIR}/%-sys/Cargo.toml,${TARGETS}) \ $(patsubst %,${TRANSLATION_DIR}/%-sys/src/lib.c,${TARGETS}) -${TRANSLATION_DIR}/%-sys/src/lib.c \ -${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-ninja/%.so.d/compile_commands.json +${TRANSLATION_DIR}/%-sys/src/lib.c ${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-ninja/%.so.d/compile_commands.json ${PYTHON} -m ideas.consolidate cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ template=lib \ compile_commands=build-ninja/$*.so.d/compile_commands.json \ @@ -95,8 +123,7 @@ ${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-nin hydra.run.dir=${TRANSLATION_DIR}/$*-sys @touch ${TRANSLATION_DIR}/$*-sys/Cargo.toml ${TRANSLATION_DIR}/$*-sys/src/lib.c @echo "" -${TRANSLATION_DIR}/%-sys/src/lib.c \ -${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-ninja/%.d/compile_commands.json +${TRANSLATION_DIR}/%-sys/src/lib.c ${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-ninja/%.d/compile_commands.json ${PYTHON} -m ideas.consolidate cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ compile_commands=build-ninja/$*.d/compile_commands.json \ links=build-ninja/$*.d/links.json \ @@ -111,34 +138,43 @@ ${TRANSLATION_DIR}/%-sys/Cargo.toml &: | ${TRANSLATION_DIR}/Cargo.toml build-nin # translate .PHONY: translate .PRECIOUS: ${TRANSLATION_DIR}/%/Cargo.toml ${TRANSLATION_DIR}/%/src/lib.rs ${TRANSLATION_DIR}/%/src/main.rs +SELECTED_TARGETS_LIB := $(filter ${TARGETS},${TARGETS_LIB}) +SELECTED_TARGETS_BIN := $(filter ${TARGETS},${TARGETS_BIN}) +SELECTED_TARGET_OUTPUTS := $(patsubst %,${TRANSLATION_DIR}/%/src/lib.rs,${SELECTED_TARGETS_LIB}) +SELECTED_TARGET_OUTPUTS += $(patsubst %,${TRANSLATION_DIR}/%/src/main.rs,${SELECTED_TARGETS_BIN}) translate: $(patsubst %,${TRANSLATION_DIR}/%/Cargo.toml,${TARGETS}) - touch $(patsubst %,${TRANSLATION_DIR}/%/Cargo.toml,${TARGETS}) \ - $(patsubst %,${TRANSLATION_DIR}/%/src/lib.rs,${TARGETS_LIB}) $(patsubst %,${TRANSLATION_DIR}/%/src/main.rs,${TARGETS_BIN}) -${TRANSLATION_DIR}/%/src/lib.rs \ -${TRANSLATION_DIR}/%/Cargo.toml &: ${TRANSLATION_DIR}/%-sys/src/lib.c | ${TRANSLATION_DIR}/%-sys/Cargo.toml ${TRANSLATION_TEST_DEP} build-ninja/%.so.d/compile_commands.json - -${PYTHON} -m ideas.translate cargo_toml=${TRANSLATION_DIR}/$*/Cargo.toml \ - bindings_cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ - template=lib \ - vcs=${VCS} \ - tests=${TRANSLATION_TEST} \ - 'deps=[libc,openssl,flate2,regex]' \ - model.name=${PROVIDER}/${MODEL} \ - hydra.output_subdir=.translate \ - hydra.job.name=translate \ - hydra.run.dir=${TRANSLATION_DIR}/$* ${TRANSLATE_ARGS} + @touch $(patsubst %,${TRANSLATION_DIR}/%/Cargo.toml,${TARGETS}) ${SELECTED_TARGET_OUTPUTS} +${TRANSLATION_DIR}/%/src/lib.rs ${TRANSLATION_DIR}/%/src/main.rs ${TRANSLATION_DIR}/%/Cargo.toml: private RUN_ARGS += --writable "${CURDIR}/${TRANSLATION_DIR}" +ifneq ($(filter-out null,$(strip ${TRANSLATION_TEST})),) +ifneq ($(abspath ${TESTGEN_CACHE}),$(abspath ${TRANSLATION_DIR})) +${TRANSLATION_DIR}/%/src/lib.rs ${TRANSLATION_DIR}/%/src/main.rs ${TRANSLATION_DIR}/%/Cargo.toml: private RUN_ARGS += --readable "${CURDIR}/${TESTGEN_CACHE}" +endif +endif +${TRANSLATION_DIR}/%/src/lib.rs ${TRANSLATION_DIR}/%/Cargo.toml &: ${TRANSLATION_DIR}/%-sys/src/lib.c | ${TRANSLATION_DIR}/%-sys/Cargo.toml ${TRANSLATION_TEST_DEP} build-ninja/%.so.d/compile_commands.json + -${DOCKER_RUN} ${PYTHON} -m ideas.translate cargo_toml=${TRANSLATION_DIR}/$*/Cargo.toml \ + bindings_cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ + template=lib \ + vcs=${VCS} \ + tests=${TRANSLATION_TEST_ARG} \ + 'deps=[openssl,flate2,regex]' \ + model.name=${PROVIDER}/${MODEL} \ + model.reasoning_effort=${REASONING_EFFORT} \ + hydra.output_subdir=.translate \ + hydra.job.name=translate \ + hydra.run.dir=${TRANSLATION_DIR}/$* ${TRANSLATE_ARGS} @touch ${TRANSLATION_DIR}/$*/Cargo.toml ${TRANSLATION_DIR}/$*/src/lib.rs @echo "" -${TRANSLATION_DIR}/%/src/main.rs \ -${TRANSLATION_DIR}/%/Cargo.toml &: ${TRANSLATION_DIR}/%-sys/src/lib.c | ${TRANSLATION_DIR}/%-sys/Cargo.toml ${TRANSLATION_TEST_DEP} build-ninja/%.d/compile_commands.json - -${PYTHON} -m ideas.translate cargo_toml=${TRANSLATION_DIR}/$*/Cargo.toml \ - bindings_cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ - vcs=${VCS} \ - tests=${TRANSLATION_TEST} \ - 'deps=[libc,openssl,flate2,regex]' \ - model.name=${PROVIDER}/${MODEL} \ - hydra.output_subdir=.translate \ - hydra.job.name=translate \ - hydra.run.dir=${TRANSLATION_DIR}/$* ${TRANSLATE_ARGS} +${TRANSLATION_DIR}/%/src/main.rs ${TRANSLATION_DIR}/%/Cargo.toml &: ${TRANSLATION_DIR}/%-sys/src/lib.c | ${TRANSLATION_DIR}/%-sys/Cargo.toml ${TRANSLATION_TEST_DEP} build-ninja/%.d/compile_commands.json + -${DOCKER_RUN} ${PYTHON} -m ideas.translate cargo_toml=${TRANSLATION_DIR}/$*/Cargo.toml \ + bindings_cargo_toml=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ + vcs=${VCS} \ + tests=${TRANSLATION_TEST_ARG} \ + 'deps=[openssl,flate2,regex]' \ + model.name=${PROVIDER}/${MODEL} \ + model.reasoning_effort=${REASONING_EFFORT} \ + hydra.output_subdir=.translate \ + hydra.job.name=translate \ + hydra.run.dir=${TRANSLATION_DIR}/$* ${TRANSLATE_ARGS} @touch ${TRANSLATION_DIR}/$*/Cargo.toml ${TRANSLATION_DIR}/$*/src/main.rs @echo "" @@ -146,17 +182,9 @@ ${TRANSLATION_DIR}/%/Cargo.toml &: ${TRANSLATION_DIR}/%-sys/src/lib.c | ${TRANSL # cost .PHONY: cost cost: ${TRANSLATION_DIR}/cost.tsv -ifneq (${VERBOSE},0) - @echo "# ${CURDIR}/${TRANSLATION_DIR}" - @cat $^ | tr -d '$$,' | datamash -g1 sum 3 sum 4 sum 5 sum 6 | awk '{printf "%28s $$%10.4f %12\047d tok (%12\047d in / %12\047d out)\n",$$1,$$2,$$3,$$4,$$5}' - @echo "" -else - @: -endif - + @: # empty rule to suppress "Nothing to be done for" ${TRANSLATION_DIR}/cost.tsv: $(patsubst %,${TRANSLATION_DIR}/%/cost.tsv,${TARGETS}) @cat $^ | sort -k1 > $@ - ${TRANSLATION_DIR}/%/cost.tsv: ${TRANSLATION_DIR}/%/translate.log @cat $^ | awk 'match($$0,/^\[[^]]+\]\[([^]]+)\]\[[^]]+\].*`([^`]+)`[^$$]*([$$][0-9.]+), ([0-9,]+) tok \(([0-9,]+) in \/ ([0-9,]+) out\)/,m){printf "%s\t%s\t%s\t%s\t%s\t%s\n",m[1],m[2],m[3],m[4],m[5],m[6]}' | sort -k1,2 > $@ @@ -165,11 +193,11 @@ ${TRANSLATION_DIR}/%/cost.tsv: ${TRANSLATION_DIR}/%/translate.log .PHONY: build .PRECIOUS: ${TRANSLATION_DIR}/build.log ${TRANSLATION_DIR}/%/build.log build: ${TRANSLATION_DIR}/build.log - @: -${TRANSLATION_DIR}/build.log: $(patsubst %,${TRANSLATION_DIR}/%/build.log,${TARGETS}) ; + @: # empty rule to suppress "Nothing to be done for" +${TRANSLATION_DIR}/build.log: $(patsubst %,${TRANSLATION_DIR}/%/build.log,${TARGETS}) @cat $^ > $@ ${TRANSLATION_DIR}/%/build.log: | ${TRANSLATION_DIR}/%/Cargo.toml - -export RUSTFLAGS=${RUSTFLAGS} && cd ${TRANSLATION_DIR} && ${CARGO} build -p $* 2> $*/build.log + -${CARGO} build --manifest-path ${TRANSLATION_DIR}/Cargo.toml -p $* 2> ${TRANSLATION_DIR}/$*/build.log @cat ${TRANSLATION_DIR}/$*/build.log @@ -178,19 +206,23 @@ ${TRANSLATION_DIR}/%/build.log: | ${TRANSLATION_DIR}/%/Cargo.toml .PRECIOUS: ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.log ${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.log .PRECIOUS: ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl ${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.jsonl test: ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.log ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl ${TRANSLATION_DIR}/build.log - @: + @: # empty rule to suppress "Nothing to be done for" ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.log: $(patsubst %,${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.log,${TARGETS}) @cat $^ > $@ ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl: $(patsubst %,${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.jsonl,${TARGETS}) @cat $^ > $@ -${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.log \ -${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.jsonl &: ${TRANSLATION_DIR}/%/build.log ${TRANSLATION_DIR}/%/tests/${EVALUATION_TEST}.rs ${TRANSLATION_DIR}/%-sys/tests/${EVALUATION_TEST}.rs - -export RUSTFLAGS=${RUSTFLAGS} NEXTEST_EXPERIMENTAL_LIBTEST_JSON=1 && \ - cd ${TRANSLATION_DIR} && ${CARGO} nextest run -p $* --test ${EVALUATION_TEST} --message-format=libtest-json --no-fail-fast --cargo-quiet --color=always --success-output=never --failure-output=never --status-level=none --final-status-level=all --no-tests=pass > $*/cargo_${EVALUATION_TEST}.jsonl 2> $*/cargo_${EVALUATION_TEST}.log +${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.log ${TRANSLATION_DIR}/%/cargo_${EVALUATION_TEST}.jsonl &: ${TRANSLATION_DIR}/%/build.log ${TRANSLATION_DIR}/%/tests/${EVALUATION_TEST}.rs + -${CARGO} nextest run --manifest-path ${TRANSLATION_DIR}/Cargo.toml -p $* --test ${EVALUATION_TEST} \ + --message-format=libtest-json \ + --no-fail-fast --no-tests=pass \ + --color=always \ + --success-output=never --failure-output=never \ + --status-level=none --final-status-level=all \ + > ${TRANSLATION_DIR}/$*/cargo_${EVALUATION_TEST}.jsonl \ + 2> ${TRANSLATION_DIR}/$*/cargo_${EVALUATION_TEST}.log # convert cando tests -# FIXME: We should just copy tests and deps from the -sys crate! -.PRECIOUS: ${TRANSLATION_DIR}/%/tests/test_cases.rs ${TRANSLATION_DIR}/%-sys/tests/test_cases.rs +.PRECIOUS: ${TRANSLATION_DIR}/%/tests/test_cases.rs ${TRANSLATION_DIR}/%/tests/test_cases.rs: | ${TEST_FILES} ${TRANSLATION_DIR}/%/Cargo.toml runner/Cargo.toml build-ninja/%.so.d/compile_commands.json ${PYTHON} -m ideas.convert_tests runner_manifest=runner/Cargo.toml \ vcs=${VCS} \ @@ -205,55 +237,62 @@ ${TRANSLATION_DIR}/%/tests/test_cases.rs: | ${TEST_FILES} ${TRANSLATION_DIR}/%/C 'test_vectors=[$(shell echo "$(TEST_FILES)" | tr ' ' ',')]' \ hydra.output_subdir=.convert_tests \ hydra.run.dir=${TRANSLATION_DIR}/$* -${TRANSLATION_DIR}/%-sys/tests/test_cases.rs: | ${TEST_FILES} ${TRANSLATION_DIR}/%-sys/Cargo.toml runner/Cargo.toml build-ninja/%.so.d/compile_commands.json - ${PYTHON} -m ideas.convert_tests runner_manifest=runner/Cargo.toml \ - vcs=${VCS} \ - template=${MAKEFILE_DIR}/tools/rust_tests/lib_testing.rs \ - output=tests/test_cases.rs \ - 'test_vectors=[$(shell echo "$(TEST_FILES)" | tr ' ' ',')]' \ - hydra.output_subdir=.convert_tests \ - hydra.run.dir=${TRANSLATION_DIR}/$*-sys -${TRANSLATION_DIR}/%-sys/tests/test_cases.rs: | ${TEST_FILES} ${TRANSLATION_DIR}/%-sys/Cargo.toml build-ninja/%.d/compile_commands.json - ${PYTHON} -m ideas.convert_tests vcs=${VCS} \ - output=tests/test_cases.rs \ - 'test_vectors=[$(shell echo "$(TEST_FILES)" | tr ' ' ',')]' \ - hydra.output_subdir=.convert_tests \ - hydra.run.dir=${TRANSLATION_DIR}/$*-sys - - -# can't rely on test vectors without explicit targets -.PRECIOUS: test_vectors/%.json -test_vectors/%.json: - $(error $@ not found) -# generate I/O equivalence tests +# generate and run I/O equivalence tests .PHONY: testgen +.PRECIOUS: ${TRANSLATION_DIR}/cargo_io.jsonl +.PRECIOUS: ${TRANSLATION_DIR}/cargo_io.log +testgen: ${TRANSLATION_DIR}/cargo_io.jsonl ${TRANSLATION_DIR}/cargo_io.log + @: # empty rule to suppress "Nothing to be done for" +${TRANSLATION_DIR}/cargo_io.jsonl: $(patsubst %,${TRANSLATION_DIR}/%-sys/cargo_io.jsonl,${TARGETS}) + @cat $^ > $@ +${TRANSLATION_DIR}/cargo_io.log: $(patsubst %,${TRANSLATION_DIR}/%-sys/cargo_io.log,${TARGETS}) + @cat $^ > $@ .PRECIOUS: ${TRANSLATION_DIR}/%-sys/tests/io.rs -${TRANSLATION_DIR}/%-sys/tests/io.rs: -testgen: $(patsubst %,${TRANSLATION_DIR}/%-sys/tests/io.rs,${TARGETS}) - touch $(patsubst %,${TRANSLATION_DIR}/%-sys/tests/io.rs,${TARGETS}) - +${TRANSLATION_DIR}/%-sys/tests/io.rs: private RUN_ARGS += --writable "${CURDIR}/${TRANSLATION_DIR}" ${TRANSLATION_DIR}/%-sys/tests/io.rs: | ${TRANSLATION_DIR}/%-sys/Cargo.toml - ${PYTHON} -m ideas.agents.generate_io_tests model=${PROVIDER}/${MODEL} \ - manifest=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ - budget=${TESTGEN_BUDGET} \ - coverage=${TESTGEN_COVERAGE} \ - hydra.output_subdir=.generate_io_tests \ - hydra.job.name=generate_io_tests \ - hydra.run.dir=${TRANSLATION_DIR}/$*-sys - + ${DOCKER_RUN} ${PYTHON} -m ideas.agents.generate_io_tests model=${PROVIDER}/${MODEL} \ + manifest=${TRANSLATION_DIR}/$*-sys/Cargo.toml \ + budget=${TESTGEN_BUDGET} \ + steps=${TESTGEN_STEPS} \ + coverage=${TESTGEN_COVERAGE} \ + hydra.output_subdir=.generate_io_tests \ + hydra.job.name=generate_io_tests \ + hydra.run.dir=${TRANSLATION_DIR}/$*-sys +.PRECIOUS: ${TRANSLATION_DIR}/%-sys/cargo_io.jsonl ${TRANSLATION_DIR}/%-sys/cargo_io.log +${TRANSLATION_DIR}/%-sys/cargo_io.jsonl ${TRANSLATION_DIR}/%-sys/cargo_io.log &: ${TRANSLATION_DIR}/%-sys/Cargo.toml ${TRANSLATION_DIR}/%-sys/tests/io.rs + -${CARGO} nextest run --manifest-path $< --test io \ + --message-format=libtest-json \ + --no-fail-fast \ + --test-threads 1 \ + > ${TRANSLATION_DIR}/$*-sys/cargo_io.jsonl \ + 2> ${TRANSLATION_DIR}/$*-sys/cargo_io.log # write smoke tests -.PRECIOUS: ${TRANSLATION_DIR}/%-sys/tests/smoke.rs -${TRANSLATION_DIR}/%-sys/tests/smoke.rs: +.PRECIOUS: ${TESTGEN_CACHE}/%-sys/tests/smoke.rs +${TESTGEN_CACHE}/%-sys/tests/smoke.rs: @mkdir -p $(@D) @printf '#[test]\nfn smoke() {\n assert_eq!(1, 1);\n}\n' > $@ - ${GIT} add $*-sys/tests/smoke.rs - ${GIT} commit -qm "Generated smoke tests" + +# reset +.PHONY: reset +ifneq ($(wildcard ${TRANSLATION_DIR}/.git),) +reset: + ${GIT} add -A + -${GIT} add -f -- '*.log' 2>/dev/null + ${GIT} commit -q --allow-empty -m "Checkpoint before reset to ${TRANSLATION_BRANCH}" + ${GIT} switch --orphan ${TRANSLATION_BRANCH} + ${GIT} clean -xdfq -e '/cache.db*' +else +reset: + @echo "Skipped reset of ${TRANSLATION_DIR}" +endif # clean .PHONY: clean clean: rm -rf build-ninja - find . -name Cargo.toml -exec cargo clean --quiet --manifest-path {} \; +ifneq ($(wildcard ${TRANSLATION_DIR}/Cargo.toml),) + ${CARGO} clean --manifest-path ${TRANSLATION_DIR}/Cargo.toml +endif diff --git a/INSTALL.mk b/INSTALL.mk new file mode 100644 index 0000000..2062759 --- /dev/null +++ b/INSTALL.mk @@ -0,0 +1,74 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +BEAR_VERSION = 4.1.5## bear version to install +CMAKE_VERSION = 4.1.2## Cmake version to install +LLVM_VERSION = 21## LLVM version to install +TRANSLATION_TOOLCHAIN = 1.88.0## Rust toolchain to use for IDEAS translation +RUNNER_TOOLCHAIN = 1.94.1## Rust toolchain for building cando runners +CODEX_RELEASE = 0.148.0## Codex version to install +UV_VERSION = 0.11.13## uv version to install +export RUNNER_TOOLCHAIN # Generated tests read it from the environment + +.PHONY: install +install:## Install all dependencies +install: | install-sys install-user + +.PHONY: install-sys +install-sys:## Install system-wide packages +install-sys: | install-cc install-sys-deps +install-sys-deps: | install-cc + +.PHONY: install-user +install-user:## Install user toolchains +install-user: | install-rust install-bear install-uv install-codex +install-bear: | install-rust + +.PHONY: install-cc +install-cc:## Install C tooling + wget https://apt.llvm.org/llvm.sh && \ + chmod +x llvm.sh && \ + ./llvm.sh ${LLVM_VERSION} all && \ + rm ./llvm.sh + wget https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.sh -O /tmp/cmake-install.sh && \ + chmod +x /tmp/cmake-install.sh && \ + /tmp/cmake-install.sh --skip-license --prefix=/usr/local && \ + rm /tmp/cmake-install.sh + apt-get install -y ninja-build + ln -sf /usr/bin/clang-${LLVM_VERSION} /usr/bin/clang + ln -sf /usr/bin/ld.lld-${LLVM_VERSION} /usr/bin/ld.lld + +.PHONY: install-rust +install-rust:## Install Rust toolchains and CLI tools + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain ${TRANSLATION_TOOLCHAIN} -y + rustup default ${TRANSLATION_TOOLCHAIN} + rustup toolchain install ${RUNNER_TOOLCHAIN} + rustup component add rustfmt + rustup component add llvm-tools-preview --toolchain ${TRANSLATION_TOOLCHAIN}-x86_64-unknown-linux-gnu + cargo install bindgen-cli --version 0.72.1 + cargo install cargo-llvm-cov --version 0.8.6 + cargo install cargo-nextest --version 0.9.114 --locked + cargo install cargo-insta --version 1.48.0 --locked + cargo install ast-grep --version 0.45.0 --locked + +.PHONY: install-sys-deps +install-sys-deps:## Install miscellaneous dependencies + apt-get install -y libssl-dev zlib1g-dev libpcre3-dev libpcre2-dev tcl-dev + apt-get install -y datamash jq ripgrep + +.PHONY: install-bear +install-bear:## Install bear ${BEAR_VERSION} from source (requires Rust) + git clone --branch ${BEAR_VERSION} --depth 1 https://github.com/rizsotto/Bear /tmp/bear + cd /tmp/bear && cargo build --release && ./scripts/install.sh + rm -rf /tmp/bear + +.PHONY: install-uv +install-uv:## Install uv@${UV_VERSION} + curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | sh + +.PHONY: install-codex +install-codex:## Install codex@${CODEX_RELEASE} + curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_RELEASE=${CODEX_RELEASE} CODEX_NON_INTERACTIVE=1 sh diff --git a/Makefile b/Makefile index 82bf5e4..44863b3 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ # -# Copyright (C) 2025 Intel Corporation +# Copyright (C) 2026 Intel Corporation # # SPDX-License-Identifier: Apache-2.0 # MAKEFILE_DIR := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +IDEAS_MAKEFILE := ${MAKEFILE_DIR}/IDEAS.mk include ${MAKEFILE_DIR}/VARIABLES.mk -BEAR_VERSION = 4.1.5## bear version to install +# Arguments for the inner Make invocation from an example target +IDEAS_MAKE_ARGS = --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) HF_TOKEN = ## Hugging Face token (optional, recommended for faster download speed) HF_CACHE = ${HOME}/.cache/huggingface## Hugging Face cache dir on host @@ -25,8 +27,9 @@ VLLM_RECIPE = zai-org/GLM-5.2-FP8 \ --reasoning-parser glm45 \ --max-model-len auto## See https://recipes.vllm.ai/ -EXAMPLES_DIR := examples -ALL_EXAMPLES := $(sort $(patsubst %/test_case,%,$(shell find ${EXAMPLES_DIR} -maxdepth 3 -name test_case -type d))) +EXAMPLES_DIR := examples/Test-Corpus/Public-Tests +EXAMPLES_BATTERY := B02_ +ALL_EXAMPLES := $(sort $(patsubst %/test_case,%,$(shell find ${EXAMPLES_DIR} -maxdepth 3 -name test_case -type d | grep -E '(${EXAMPLES_BATTERY})'))) EXAMPLES ?= ${ALL_EXAMPLES}## List of examples to run on ifeq ($(EXAMPLES),) @@ -36,44 +39,10 @@ endif all: help ; -.PHONY: docker/build -docker/build:## Build translation Docker image -docker/build: docker/docker_build.log - -.PRECIOUS: docker/docker_build.log -docker/docker_build.log: docker/ideas.Dockerfile uv.lock pyproject.toml - cp uv.lock pyproject.toml docker/ - cd docker && docker build --build-arg USER_UID=$(shell id -u) \ - --build-arg USER_GID=$(shell id -g) \ - -f ideas.Dockerfile -t ideas-$(shell id -u) . - rm docker/uv.lock docker/pyproject.toml - docker images --quiet ideas-$(shell id -u):latest > $@ - -.PHONY: examples/docker -examples/docker:## Mount all examples to the translation Docker image -examples/docker: docker/docker_build.log - mkdir -p $(foreach ex,${EXAMPLES},${MAKEFILE_DIR}/${ex}/${TRANSLATION_DIR}) - ${DOCKER_RUN} \ - --mount type=tmpfs,dst=${MAKEFILE_DIR}/examples \ - $(foreach ex,${EXAMPLES},\ - --mount type=tmpfs,dst=${MAKEFILE_DIR}/${ex} \ - --mount type=bind,src=${MAKEFILE_DIR}/${ex}/test_case,dst=${MAKEFILE_DIR}/${ex}/test_case \ - --mount type=bind,src=${MAKEFILE_DIR}/${ex}/${TRANSLATION_DIR},dst=${MAKEFILE_DIR}/${ex}/${TRANSLATION_DIR}) \ - --env TRANSLATION_DIR \ - --env "EXAMPLES=${EXAMPLES}" \ - -it ${DOCKER_IMAGE} bash - -examples/%/docker:##Mount specific example to translation Docker image -examples/%/docker: docker/docker_build.log - mkdir -p ${MAKEFILE_DIR}/$(@D)/${TRANSLATION_DIR} - ${DOCKER_RUN} \ - --mount type=tmpfs,dst=${MAKEFILE_DIR}/examples \ - --mount type=tmpfs,dst=${MAKEFILE_DIR}/$(@D) \ - --mount type=bind,src=${MAKEFILE_DIR}/$(@D)/test_case,dst=${MAKEFILE_DIR}/$(@D)/test_case \ - --mount type=bind,src=${MAKEFILE_DIR}/$(@D)/${TRANSLATION_DIR},dst=${MAKEFILE_DIR}/$(@D)/${TRANSLATION_DIR} \ - --env TRANSLATION_DIR \ - --env EXAMPLES=$(@D) \ - -it ${DOCKER_IMAGE} bash +include ${MAKEFILE_DIR}/INSTALL.mk +INCLUDE_DOCKER_RULES := 1 +unexport INCLUDE_DOCKER_RULES +include ${MAKEFILE_DIR}/docker/DOCKER.mk .PHONY: vllm/serve vllm/serve:## Start vLLM server @@ -83,40 +52,6 @@ vllm/serve:## Start vLLM server vllm/kill:## Gracefully stop the running vLLM server docker stop ${VLLM_NAME} -.PHONY: install -install: install-uv install-rust ## Install uv and Rust - -.PHONY: install-uv -install-uv:## Install uv@0.11.13 - curl -LsSf https://astral.sh/uv/0.11.13/install.sh | sh - -.PHONY: install-rust -install-rust:## Install Rust@1.88.0 and tools - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain 1.88.0 - rustup component add rustfmt - rustup component add llvm-tools-preview --toolchain 1.88.0-x86_64-unknown-linux-gnu - cargo install bindgen-cli --version 0.72.1 - cargo install cargo-llvm-cov --version 0.8.6 - cargo install cargo-nextest --version 0.9.114 --locked - -.PHONY: install-clang -install-clang:## Install Clang-21, must be sudo - wget https://apt.llvm.org/llvm.sh - chmod +x llvm.sh - -./llvm.sh 21 all - rm ./llvm.sh - -.PHONY: install-sys-deps -install-sys-deps:## Install system dependencies, must be sudo - apt install libpcre3-dev libpcre2-dev - -.PHONY: install-bear -install-bear:## Install bear ${BEAR_VERSION} from source (requires Rust) - git clone --branch ${BEAR_VERSION} --depth 1 https://github.com/rizsotto/Bear /tmp/bear - cd /tmp/bear && cargo build --release && ./scripts/install.sh - rm -rf /tmp/bear - - .PHONY: FORCE FORCE: @@ -126,55 +61,84 @@ examples: $(addsuffix /print,${EXAMPLES}) ; examples/%/print: FORCE @if [ -d "$(@D)" ]; then echo "$(@D)"; fi -.PHONY: examples/init -examples/init:## Initialize all examples -examples/init: $(addsuffix /init,${EXAMPLES}) ; - @echo "# ${TRANSLATION_DIR}" -examples/%/init:## Initialize specific example -examples/%/init: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) init - - .PHONY: examples/bear examples/bear:## Use bear to intercept compile and linker commands -examples/bear: $(addsuffix /bear,${EXAMPLES}) ; +examples/bear: $(addsuffix /.bear,${EXAMPLES}) ; ifneq (${VERBOSE},0) @echo "" endif @echo "--- Bear ---" @find ${EXAMPLES} -maxdepth 2 -path "*/build-ninja/events.jsonl" \( -size +0 -printf 'SUCCEEDED\n' -o -printf 'FAILED\n' \) | sort -r | uniq -c +examples/%/.bear: examples/%/test_case/CMakeLists.txt FORCE ${DOCKER_READY} + -@$(MAKE) ${IDEAS_MAKE_ARGS} bear examples/%/bear:## Bear intercept and build specific example examples/%/bear: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear + -@$(MAKE) --no-print-directory examples/bear EXAMPLES="$(@D)" +examples/libgit2_noconfig_notests/test_case/CMakeLists.txt: # libgit2 (no config, no tests) + rsync -rvhz /raid/datasets/P02/libgit2_noconfig_notests/ examples/libgit2_noconfig_notests/ +.PHONY: examples/init +examples/init:## Initialize all examples +examples/init: $(addsuffix /.init,${EXAMPLES}) ; + @echo "# ${TRANSLATION_DIR}" +examples/%/.init: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} init +examples/%/init:## Initialize specific example +examples/%/init: FORCE + -@$(MAKE) --no-print-directory examples/init EXAMPLES="$(@D)" .PHONY: examples/testgen -examples/testgen:## Generate I/O test vectors for all targets in all C examples with an agent -examples/testgen: $(addsuffix /testgen,${EXAMPLES}) -examples/%/testgen:## Generate I/O test vectors for all targets in a specific C example with an agent +examples/testgen:## Generate I/O tests and check that they pass for all examples +examples/testgen: $(addsuffix /.testgen,${EXAMPLES}) +ifneq (${VERBOSE},0) + @echo "" +endif + @echo "--- Project Completion Count for ${TESTGEN_CACHE}/cargo_io.jsonl ---" + @find ${EXAMPLES} -maxdepth 2 -path '*/${TESTGEN_CACHE}/cargo_io.jsonl' -exec ./scripts/test_log_stats.sh {} + | cut -d" " -f1 | sort | uniq -c +ifneq (${VERBOSE},0) + @echo "" + @find ${EXAMPLES} -maxdepth 2 -path '*/${TESTGEN_CACHE}/cargo_io.jsonl' -exec ./scripts/test_log_stats.sh {} + | egrep -v "^complete" | sort | sed 's|/${TESTGEN_CACHE}/cargo_io.jsonl$$||' +endif + @echo "" + @echo "--- Aggregated Test Count for ${TESTGEN_CACHE}/cargo_io.jsonl ---" + @find ${EXAMPLES} -maxdepth 2 -path '*/${TESTGEN_CACHE}/cargo_io.jsonl' -exec cat {} + \ + | jq -r 'select(.type=="test" and (.event=="ok" or .event=="failed")) | if .event == "ok" then "ok" elif .reason == "time limit exceeded" then "TIMEOUT" else "FAILED" end' \ + | LC_ALL=C sort -r | uniq -c +examples/%/.testgen: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} testgen TRANSLATION_DIR=${TESTGEN_CACHE} +examples/%/testgen:## Generate I/O tests and check that they pass for a specific example examples/%/testgen: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) testgen + -@$(MAKE) --no-print-directory examples/testgen EXAMPLES="$(@D)" .PHONY: examples/translate examples/translate:## Translate all examples -examples/translate: $(addsuffix /translate,${EXAMPLES}) +examples/translate: $(addsuffix /.translate,${EXAMPLES}) ifneq (${VERBOSE},0) @echo "" endif @echo "--- Translation Count for ${TRANSLATION_DIR} ---" - @find ${EXAMPLES} -maxdepth 2 -path "*/${TRANSLATION_DIR}/translate.log" | wc -l + @find ${EXAMPLES} -maxdepth 3 -path "*/${TRANSLATION_DIR}/*/translate.log" -exec grep -H '\[ideas.translate\]' {} + | \ + sed -En -e 's#^(.+)/[^/]+/[^/]+/translate\.log:.* - Translated .* `([^`]+)`.*:.*#translated \1 \2#p' \ + -e 's#^(.+)/[^/]+/[^/]+/translate\.log:.* - Failed .* `([^`]+)`.*:.*#FAILED \1 \2#p' | \ + cut -d" " -f1 | sort | uniq -c +ifneq (${VERBOSE},0) + @echo "" + @find ${EXAMPLES} -maxdepth 3 -path "*/${TRANSLATION_DIR}/*/translate.log" -exec grep -H '\[ideas.translate\]' {} + | \ + sed -En -e 's#^(.+)/[^/]+/[^/]+/translate\.log:.* - Translated .* `([^`]+)`.*:.*#translated \1 \2#p' \ + -e 's#^(.+)/[^/]+/[^/]+/translate\.log:.* - Failed .* `([^`]+)`.*:.*#FAILED \1 \2#p' | \ + grep -v "^translated" | sort +endif +examples/%/.translate: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} translate examples/%/translate:## Translate specific example examples/%/translate: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) translate + -@$(MAKE) --no-print-directory examples/translate EXAMPLES="$(@D)" .PHONY: examples/build examples/build:## Build all translated examples -examples/build: $(addsuffix /build,${EXAMPLES}) +examples/build: $(addsuffix /.build,${EXAMPLES}) ifneq (${VERBOSE},0) @echo "" endif @@ -184,15 +148,43 @@ ifneq (${VERBOSE},0) @echo "" @find ${EXAMPLES} -maxdepth 2 -path "*/${TRANSLATION_DIR}/build.log" -size +0 -printf 'BROKEN %h\n' | sed 's|/${TRANSLATION_DIR}$$||' | sort endif +examples/%/.build: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} build examples/%/build:## Build specific translated example examples/%/build: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) build + -@$(MAKE) --no-print-directory examples/build EXAMPLES="$(@D)" +.PHONY: examples/baseline +examples/baseline:## Run the test vectors of all examples against their C build +examples/baseline: $(addsuffix /.baseline,${EXAMPLES}) +ifneq (${VERBOSE},0) + @echo "" +endif + @echo "--- Project Completion Count for build-ninja/baseline.json ---" + @find ${EXAMPLES} -maxdepth 2 -path '*/build-ninja/baseline.json' \( -size +0 -printf 'complete\n' -o -printf 'EMPTY\n' \) | LC_ALL=C sort -r | uniq -c +ifneq (${VERBOSE},0) + @echo "" + @find ${EXAMPLES} -maxdepth 2 -path '*/build-ninja/baseline.json' -size 0 -printf 'EMPTY %h\n' | sort +endif + @echo "" + @echo "--- Aggregated Test Count for build-ninja/baseline.json ---" + @find ${EXAMPLES} -maxdepth 2 -path '*/build-ninja/baseline.json' -exec cat {} + \ + | jq -r '.[].result' | sort | uniq -c +ifneq (${VERBOSE},0) + @echo "" + @find ${EXAMPLES} -maxdepth 2 -path '*/build-ninja/baseline.json' -size +0 -print0 \ + | xargs -0 -r jq -r 'to_entries[] | select(.value.result != "Pass" and .value.result != "Skip") | "\(.value.result) \(input_filename) \(.key)"' \ + | sed 's|/build-ninja/baseline.json | |' | sort +endif +examples/%/.baseline: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} baseline +examples/%/baseline:## Run the test vectors of a specific example against its C build +examples/%/baseline: FORCE + -@$(MAKE) --no-print-directory examples/baseline EXAMPLES="$(@D)" .PHONY: examples/test examples/test:## Test all translated examples -examples/test: $(addsuffix /test,${EXAMPLES}) +examples/test: $(addsuffix /.test,${EXAMPLES}) ifneq (${VERBOSE},0) @echo "" endif @@ -200,31 +192,35 @@ endif @find ${EXAMPLES} -maxdepth 2 -path '*/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl' -exec ./scripts/test_log_stats.sh {} + | cut -d" " -f1 | sort | uniq -c ifneq (${VERBOSE},0) @echo "" - @find ${EXAMPLES} -maxdepth 2 -path '*/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl' -exec ./scripts/test_log_stats.sh {} + | egrep -v "^complete" | sort | sed 's|/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl$$||' + @find ${EXAMPLES} -maxdepth 2 -path '*/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl' -exec ./scripts/test_log_stats.sh {} + | egrep -v "^complete" | sort | sed 's|/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl$$||' | sort endif @echo "" @echo "--- Aggregated Test Count for ${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl ---" @find ${EXAMPLES} -maxdepth 2 -path '*/${TRANSLATION_DIR}/cargo_${EVALUATION_TEST}.jsonl' -exec cat {} + \ - | jq -r 'select(.type=="test" and (.event=="ok" or .event=="failed")) | .event' \ - | sed 's/failed/FAILED/' | sort -r | uniq -c + | jq -r 'select(.type=="test" and (.event=="ok" or .event=="failed")) | if .event == "ok" then "ok" elif .reason == "time limit exceeded" then "TIMEOUT" else "FAILED" end' \ + | LC_ALL=C sort -r | uniq -c +examples/%/.test: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} test examples/%/test:## Test specific translated example examples/%/test: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) test + -@$(MAKE) --no-print-directory examples/test EXAMPLES="$(@D)" .PHONY: examples/cost examples/cost:## Print cost of all translated examples -examples/cost: $(addsuffix /cost,${EXAMPLES}) +examples/cost: $(addsuffix /.cost,${EXAMPLES}) @echo "--- Aggregated Cost for ${TRANSLATION_DIR} ---" @find ${EXAMPLES} -maxdepth 2 -path "*/${TRANSLATION_DIR}/cost.tsv" -exec cat {} + | sort -k1 | tr -d '$$,' | datamash -g1 sum 3 sum 4 sum 5 sum 6 | awk '{printf "%28s $$%10.4f %12\047d tok ( %12\047d in / %12\047d out)\n",$$1,$$2,$$3,$$4,$$5}' +examples/%/.cost: examples/%/.bear FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} cost examples/%/cost:## Print cost for specific translated example examples/%/cost: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) bear - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) cost + -@$(MAKE) --no-print-directory examples/cost EXAMPLES="$(@D)" .PHONY: examples/stats examples/stats:## Print translation stats for all examples examples/stats: + -@$(MAKE) --no-print-directory examples/translate + @echo "" -@$(MAKE) --no-print-directory examples/build @echo "" -@$(MAKE) --no-print-directory examples/test @@ -232,24 +228,27 @@ examples/stats: -@$(MAKE) --no-print-directory examples/cost VERBOSE=0 examples/%/stats:##Print translation stats for specific example - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) build VERBOSE=1 - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) test VERBOSE=1 - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) cost VERBOSE=1 - +examples/%/stats: FORCE + -@$(MAKE) --no-print-directory examples/stats EXAMPLES="$(@D)" +.PHONY: examples/reset +examples/reset:## Start a fresh run in ${TRANSLATION_DIR} of all examples, keeping the old one on a branch +examples/reset: $(addsuffix /reset,${EXAMPLES}) +examples/%/reset:## Start a fresh run in ${TRANSLATION_DIR} of specific example, keeping the old one on a branch +examples/%/reset: FORCE + -@$(MAKE) ${IDEAS_MAKE_ARGS} reset .PHONY: examples/clean examples/clean:## Clean all examples examples/clean: $(addsuffix /clean,${EXAMPLES}) examples/%/clean:## Clean specific example examples/%/clean: FORCE - -@$(MAKE) --no-print-directory -f $(IDEAS_MAKEFILE) -C $(@D) clean + -@$(MAKE) ${IDEAS_MAKE_ARGS} clean # Global clean +clean:## Clean Docker and Rust artifacts clean: - rm -rf docker/docker_build.log - rm -rf examples - git checkout HEAD examples + find examples -type d -exec test -e '{}/Cargo.toml' \; -prune -exec cargo clean --manifest-path '{}/Cargo.toml' \; # help RESET := \033[0;0m diff --git a/README.md b/README.md index b6e3c43..8a3a2ba 100644 --- a/README.md +++ b/README.md @@ -2,49 +2,52 @@ ![GitHub License](https://img.shields.io/github/license/IntelLabs/IDEAS) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/IntelLabs/IDEAS/badge)](https://scorecard.dev/viewer/?uri=github.com/IntelLabs/IDEAS) -> [!NOTE] -> IDEAS is a framework under active development which may go through major changes with each release. -> If you encounter any issues or have questions about how to run the framework, please do not hesitate to [open a GitHub issue](https://github.com/IntelLabs/IDEAS/issues/new). +
-## Controlling translation costs on large-scale projects -> [!NOTE] -> The default C FFI wrapper generation context does not include already-generated C FFI wrappers. -> This can reduce the performance of the translator on projects with, e.g., function pointers, where a C FFI wrapper _must_ call others (without major logic duplication). +[Docker image](#docker-image) ⏐ +[Quickstart](#quickstart) ⏐ +[Expected C project structure](#expected-c-project-structure) ⏐ +[Translated Rust structure](#translated-rust-structure) -> To enable full C FFI wrapper generation context, set the `REDUCED_CONTEXT=0` environment variable: -```bash -REDUCED_CONTEXT=0 make examples/C-project-name/translate -``` -> [!CAUTION] -> This is currently experimental and can lead to prohibitive costs and exceeding input context limits for large-scale, fragmented C projects (20k+ LoC). +[OpenRouter models](#use-with-openrouter-models) ⏐ +[OpenAI models](#use-with-openai-models) ⏐ +[Locally-hosted models](#use-with-locally-hosted-models) +[C/Rust equivalence test generation](#crust-equivalence-test-generation) ⏐ +[Citation](#citation) ⏐ +[Acknowledgments](#acknowledgments) -# Requirements -Developed and tested on Ubuntu 24.04. +
-IDEAS requires specific versions of the `clang` and Rust toolchains to translate C to Rust. -A Docker image with the user-specific name `ideas-${UID}` can be built using: -```bash -make docker/build -``` +> [!NOTE] +> IDEAS is under active development and may go through major changes with each release. +> If you encounter any obstacles, please do not hesitate to [open a GitHub issue](https://github.com/IntelLabs/IDEAS/issues/new). -We strongly recommend launching all runs in the Docker image. -> [!NOTE] -> Setting the `TRANSLATION_DIR` environment is **mandatory** when mounting examples to the Docker image. -> If the `OPENROUTER_API_KEY` or `OPENAI_API_KEY` environment variables are set on the host, they will be automatically passed to the interactive session. +# Docker image +Run `make docker/build` to build the IDEAS Docker image. +All translation steps run inside this image. -# Quickstart -To translate a single C project to a Rust workspace, ensure it uses CMake as a build system, place it in the `examples` folder, and generate an [OpenRouter API key](https://openrouter.ai/workspaces/default/keys). +## Local development +For local development users must first ensure [`rustup`](https://rustup.rs/) is installed on their machine and available on `PATH`. -Then, build and mount your project and API key in an interactive Docker session: +Run ```bash -TRANSLATION_DIR="translation.demo" OPENROUTER_API_KEY="your-key" make examples/C-project-name/docker +sudo make install-sys +make install-user ``` -And trigger end-to-end translation: +To install all other required depedencies. Developed and tested on Ubuntu 24.04. + +# Quickstart +To translate a C project to a Rust workspace: +- Ensure it uses CMake as a build system. +- Place its top-level `CMakeLists.txt` in the `examples//test_case` folder. +- Obtain an [OpenRouter API key](https://openrouter.ai/workspaces/default/keys). + +And trigger end-to-end translation using the Docker image: ```bash -make examples/C-project-name/translate +make examples//translate OPENROUTER_API_KEY="your-key" ``` # Expected C project structure @@ -54,172 +57,130 @@ IDEAS requires the [official DARPA TRACTOR folder structure](https://github.com/ 📦IDEAS ┣ 📂src/ideas # Core library ┗ 📂examples # Project folders go here - ┣ 📂C-project-name + ┣ 📂your-project-name ┃ ┣ 📂test_case - ┃ ┃ ┣ 📂include - ┃ ┃ ┣ 📂src + ┃ ┃ ┣ 📂some-folder + ┃ ┃ ┣ 📄some-file ┃ ┃ ┗ 📄CMakeLists.txt # Must be correct and complete - ┃ ┗ 📂test_vectors - ┃ ┣ 📄some-name.json - ┃ ┗ 📄other-name.json - ┗ 📂other-C-project-name + ┃ ┗ 📂test_vectors # Optional, must follow the TRACTOR schema + ┃ ┣ 📄some-test.json + ┃ ┗ 📄other-test.json + ┗ 📂other-project-name ``` -See the [`examples/templates`](examples/templates/) folder for minimal examples. +See the [`examples/templates`](examples/templates/) folder for minimal examples on how to organize your project. # Translated Rust structure -The translation tool identifies each CMake target (library or binary) and currently translates it to _three_ separate, self-contained Rust [crates](https://doc.rust-lang.org/book/ch07-01-packages-and-crates.html#packages-and-crates): +The translation tool identifies each CMake target (library or executable) and translates it to _three_ separate Rust [crates](https://doc.rust-lang.org/book/ch07-01-packages-and-crates.html#packages-and-crates): -- a `*` crate that holds C FFI compatibility wrappers. -- a `*-rs` crate holding the guaranteed-safe Rust translation. -- a `*-sys` crate that links the original C library. +- a `` crate that holds the C FFI compatibility layer for all safe Rust symbols. +- a `-rs` crate that holds the `#![forbid(unsafe_code)]` Rust translation. +- a `-sys` crate that links the original C library through `rust-bindgen` and depletes it as translation progresses. -All crates are organized under a Rust [workspace](https://doc.rust-lang.org/cargo/reference/workspaces.html) in the folder given by the `TRANSLATION_DIR` environment variable, alongside the original C `test_case` folder. +All crates are organized as a Rust [workspace](https://doc.rust-lang.org/cargo/reference/workspaces.html) in the folder given by the `TRANSLATION_DIR` environment variable. For example, running ```bash -TRANSLATION_DIR="translation.demo" OPENROUTER_API_KEY="sk-..." make examples/docker -make examples/templates/hello_world_lib/translate +make docker +TRANSLATION_DIR="translated_rust" make examples/templates/hello_world_lib/translate OPENROUTER_API_KEY="sk-..." ``` -Should produce the following translated folder structure: +Will produce the following translated folder structure: ``` 📂examples/templates/hello_world_lib - ┣ 📂test_case - ┗ 📂translation.demo - ┣ 📂libhello_world_lib # Safe Rust + C FFI-compatible wrappers + ┣ 📂test_case # The original C code is not modified + ┗ 📂translated_rust + ┣ 📂libhello_world_lib # C FFI compatibility layer ┃ ┣ 📂src - ┃ ┃ ┣ 📄lib.rs - ┃ ┃ ┗ 📄wrap_hello_print.rs # `wrap_{name}`: C FFI compatibility wrapper - ┃ ┣ 📂tests - ┃ ┃ ┗ 📄smoke.rs # Always-passing tests by default + ┃ ┃ ┗ 📄lib.rs ┃ ┗ 📄Cargo.toml - ┣ 📂libhello_world_lib-rs # Guaranteed safe Rust translation + ┣ 📂libhello_world_lib-rs # Safe Rust translation ┃ ┣ 📂src ┃ ┃ ┗ 📄lib.rs ┃ ┗ 📄Cargo.toml - ┣ 📂libhello_world_lib-sys # Links C/Rust along the translation trajectory + ┣ 📂libhello_world_lib-sys # Rust bindings to the original C code ┃ ┣ 📂src ┃ ┃ ┣ 📄lib.c ┃ ┃ ┗ 📄lib.rs - ┃ ┣ 📂tests - ┃ ┃ ┗ 📄smoke.rs # Always-passing tests by default - ┃ ┣ 📄build.rs # Hybrid build script + ┃ ┣ 📄build.rs ┃ ┗ 📄Cargo.toml ┣ 🗄️cache.db # Resumable translation cache ┣ 📄Cargo.lock # Workspace lockfile ┗ 📄Cargo.toml # Workspace manifest ``` -For a library target named `hello_world_lib`, the three generated crates play the following roles: - -- **`libhello_world_lib`** — the C FFI-compatible translated crate. By default, it contains a (possibly unsafe) wrapper `wrap_{name}` for every symbol (including private ones) that wraps the safe Rust translation and restores C FFI compatibility. -- **`libhello_world_lib-rs`** — the guaranteed-safe Rust translation of the original C target. It enforces `#![forbid(unsafe_code)]` at the top of each module. -- **`libhello_world_lib-sys`** — the consolidated C code visible through the Rust C FFI. As symbols are translated, they are progressively depleted from the source file until it can be dropped entirely on complete translation. - Binary targets have a similar expected crate structure, with a `main.rs` present in all crates. > [!NOTE] -> If translation fails and exits early, the crates are not guaranteed to be in a valid state, but are still useful for debugging and contain exact `git` logs. +> If translation fails and exits early, the crates are not guaranteed to be in a valid state, but are still useful for debugging and contain `git` logs that can be inspected. IDEAS is capable of testing Rust translations with the DARPA TRACTOR evaluation schema. See [here](https://github.com/DARPA-TRACTOR-Program/PUBLIC-Test-Corpus?tab=readme-ov-file#test-vector-schema-json) for more details and the exact specification for writing test vectors and `cando2` runners. -# Usage with OpenRouter API -Our translation framework treats [OpenRouter](https://openrouter.ai/) as the default provider, allowing easy switching between models. +# Use with OpenRouter models +Our translation framework treats [OpenRouter](https://openrouter.ai/) as the default provider. The `MODEL` environment variable controls which LLM will be used, and should be the model's name on OpenRouter. -To run LLM-based memory-safe translation of a single project and save the translated Rust workspace in a newly created `TRANSLATION_DIR` sub-folder, run: -```bash -TRANSLATION_DIR="translation.demo" OPENROUTER_API_KEY="sk-..." make examples/C-project-name/docker -make examples/C-project-name/translate -``` - -If a project (library or executable) was not already found under `TRANSLATION_DIR`, our dependency chain will first trigger its memory-safe translation, followed by C FFI wrappers (only for libraries). - -# Usage with Anthropic API -IDEAS can be used with any Anthropic model by setting the `PROVIDER`, `MODEL`, and `ANTHROPIC_API_KEY` variables: -```bash -TRANSLATION_DIR="translation.demo" ANTHROPIC_API_KEY="sk-..." make examples/C-project-name/docker -make examples/C-project-name/translate \ - PROVIDER="anthropic" \ - MODEL="claude-sonnet-4.6" -``` -Note the `anthropic` prefix is missing from `MODEL` and is instead set as the `PROVIDER`. - -# Usage with OpenAI API +# Use with OpenAI models IDEAS can be used with any OpenAI model by setting the `PROVIDER`, `MODEL`, and `OPENAI_API_KEY` variables: ```bash -TRANSLATION_DIR="translation.demo" OPENAI_API_KEY="sk-..." make examples/C-project-name/docker -make examples/C-project-name/translate \ +TRANSLATION_DIR="translated_rust" make examples//translate \ + OPENAI_API_KEY="sk-..." \ PROVIDER="openai" \ - MODEL="gpt-5.4" + MODEL="gpt-6-astra" ``` -Note the `openai` prefix is missing from `MODEL` and is instead set as the `PROVIDER`. - -# Usage with other APIs -IDEAS relies on [`litellm`](https://github.com/BerriAI/litellm), which supports many other model providers (e.g., Google Vertex, MS Azure, etc.). - -The instructions at https://docs.litellm.ai/docs/providers indicate which parameters should be set in `litellm`; IDEAS passes them through the [`dspy.LM`](https://dspy.ai/api/models/LM/) instance. - -Developers can inspect [how a `dspy.LM` is instantiated by IDEAS](https://github.com/IntelLabs/IDEAS/blob/main/src/ideas/model.py) and infer any additional parameters that need to be passed to `litellm`. - -# Usage with locally-hosted models -We support a single-command launch of locally-hosted models using the [official `vllm` Docker image](https://docs.vllm.ai/en/stable/deployment/docker/#pre-built-images), pinned to an exact version. - -Running -```bash -make vllm/serve -``` - -will serve GLM-5.2 on your machine using the default recipe for single-instance, 8-way GPU inference. -Consult the [official `vllm` recipes](https://recipes.vllm.ai/) to identify a model suitable for your platform. -For example, to use the [Qwen3.6-35B-A3B](https://recipes.vllm.ai/Qwen/Qwen3.6-35B-A3B) model, set the `VLLM_RECIPE` Makefile variable to its recipe and directly serve the model: +# Use with locally-hosted models +To use with a locally-hosted `vllm` endpoint, simply set `PROVIDER=hosted_vllm` and use the expected model name, for example: +Then, run IDEAS using: ```bash -make VLLM_RECIPE=vllm serve Qwen/Qwen3.6-35B-A3B \ - --trust-remote-code \ - --tensor-parallel-size 1 \ - --enable-auto-tool-choice \ - --tool-call-parser qwen3_xml \ - --reasoning-parser qwen3 \ - --mm-encoder-tp-mode data vllm/serve -``` - -Then, run IDEAS on host using: -```bash -TRANSLATION_DIR="translation.demo" make examples/C-project-name/translate \ +TRANSLATION_DIR="translated_rust" make examples//translate \ PROVIDER="hosted_vllm" \ MODEL="Qwen/Qwen3.6-35B-A3B" ``` -# (Experimental) C/Rust equivalence test generation -IDEAS has a submodule that automatically generates portable Rust C FFI tests for libraries and binary targets. -`TRANSLATION_DIR` is overloaded to also hold the test generation results, so the same directory is used whether or not a translation was already produced in it. - -First, mount the project into the Docker image: +## Serving locally-hosted models +We offer a single-command launch of locally-hosted models using their [official `vllm` Docker images](https://docs.vllm.ai/en/stable/deployment/docker/#pre-built-images), pinned to an exact version.\ +Running: ```bash -TRANSLATION_DIR="translation.demo" OPENROUTER_API_KEY="sk-..." make examples/templates/hello_world_lib/docker +make vllm/serve ``` -This mounts the repository read-only and bind-mounts only `test_case` and `TRANSLATION_DIR` as writable, so the agent cannot reach the rest of the host. -Use `make examples/docker` instead to mount every project in `EXAMPLES` at once. +Will serve GLM-5.2 on your local machine using the default recipe for single-instance, 8-way GPU inference.\ +Consult the [official `vllm` recipes](https://recipes.vllm.ai/) to identify a model suitable for your local machine. -Then, from the interactive session, launch the test generation agent: + +# C/Rust equivalence test generation +Launch the test generation agent using: ```bash -make examples/templates/hello_world_lib/testgen \ +TRANSLATION_DIR=test_crates make examples//testgen \ TESTGEN_BUDGET="4.0" \ - TESTGEN_COVERAGE="100" + TESTGEN_STEPS=100 ``` Note the `anthropic` prefix is missing from `MODEL` and is instead set as the `PROVIDER`. -This launches a self-sufficient [KISS](https://github.com/ksenxx/kiss_ai) agent that is tasked with generating unit and integration tests with high branch coverage. +This is a monolithic (single-session) agent tasked with generating unit and integration tests with high branch coverage. You can consult the agent prompt [here](src/ideas/agents/generate_io_tests.py). -For every target, the agent writes two test files to the `*-sys` crate: `tests/collect.rs`, which records the behavior of the C code into `json/`, and `tests/io.rs`, which asserts that recorded behavior through the C FFI. +For every target, the agent writes two test files to the `*-sys` crate: +- `tests/collect.rs`, which snapshots inputs and matching outputs from the C code into `json` files. +- `tests/io.rs`, which asserts recorded snapshots using Rust's `assert!` and `assert_eq!` macros. + The full agent trajectory is logged next to them as `testgen-*.log`. -> [!NOTE] -> This behavior is disabled by default and may not be reliable on large codebases and/or with weaker LLMs. +# Citation +If you find this repository useful, please cite the following work: +``` +@ARTICLE{11635938, + author={Cornelius, Cory and Melara, Marcela S. and Xu, Weilin and Arvinte, Marius and Momeu, Marius and He, Jingxuan and Sen, Koushik and Song, Dawn}, + journal={IEEE Security & Privacy}, + title={IDEAS: C-to-Rust Translation Using Improved Large Language Model Decoding and Automated Equivalence Testing}, + year={2026}, + volume={}, + number={}, + pages={2-11}, + keywords={Testing;Translation;Codes;Memory;Modeling;Symbols;Feedback;Large language models;Security;Safety}, + doi={10.1109/MSEC.2026.3710126}} +``` ## Acknowledgments This material is based upon work supported by the Defense Advanced Research Projects Agency (DARPA) Translating All C To Rust (TRACTOR) program under Agreement No. HR00112590134. diff --git a/VARIABLES.mk b/VARIABLES.mk index 8825fde..a601766 100644 --- a/VARIABLES.mk +++ b/VARIABLES.mk @@ -1,45 +1,47 @@ -MAKEFILE_DIR := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST))))) -IDEAS_MAKEFILE := $(MAKEFILE_DIR)/IDEAS.mk +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# -PROVIDER = openai## Provider to use with DSPy/LiteLLM -MODEL = gpt-5.6-sol## Model to use to translate +PROVIDER = openrouter## Provider to use with DSPy/LiteLLM +MODEL = openai/gpt-5.6-luna## Model to use to translate and generate wrappers +REASONING_EFFORT = medium## Reasoning effort to translate and generate wrappers HOST = localhost PORT = 8000## Port to use for vLLM BASE_URL = http://${HOST}:${PORT}/v1## Base URL of vLLM server GIT_HEAD := $(shell git --git-dir=${MAKEFILE_DIR}/.git rev-parse HEAD 2>/dev/null || echo "ideas") TRANSLATION_DIR ?= translation.${GIT_HEAD}## Directory to put IDEAS translation +TRANSLATION_TIME := $(shell date -u +%Y%m%dT%H%M%SZ) +TRANSLATION_BRANCH ?= ${GIT_HEAD}.${TRANSLATION_TIME}## Branch name for translation CARGO_NET_OFFLINE = true## Cargo offline mode +DEBUGINFOD_URLS = ## Debuginfod servers RUSTFLAGS = -Awarnings## Flags to build Rust translation +RUSTUP_TOOLCHAIN ?= ${TRANSLATION_TOOLCHAIN} +NEXTEST_EXPERIMENTAL_LIBTEST_JSON ?= 1 CC = clang CFLAGS = -w## Ignore C compiler warnings TRANSLATION_TEST = null## Translation test directory/name to run (empty/null: wrap globals only, no tests) EVALUATION_TEST = test_cases## Evaluation test directory/name to run VCS = git## Whether to use version control during translation. Options: ['git', 'none'] -GIT_AUTHOR_NAME = ideas.${MODEL} -GIT_AUTHOR_EMAIL = ${MODEL}@${PROVIDER} +GIT_AUTHOR_NAME = ${MODEL}/${REASONING_EFFORT} +GIT_AUTHOR_EMAIL = ideas@${PROVIDER} +GIT_COMMITTER_NAME = ${GIT_AUTHOR_NAME} +GIT_COMMITTER_EMAIL = ${GIT_AUTHOR_EMAIL} TRANSLATE_ARGS = ## Args to pass to IDEAS translation +TESTGEN_CACHE ?= test_crates## Where to fetch tests from TESTGEN_BUDGET = 4.0## Budget [USD] for IDEAS test generation +TESTGEN_STEPS = 100## Number of steps for IDEAS test generation TESTGEN_COVERAGE = 100## Requested branch coverage [%] for IDEAS test generation VERBOSE = 0## Whether to output failed/partial projects in summaries -DOCKER_RUN = mkdir -p ${MAKEFILE_DIR}/docker/venv && docker run --rm \ - --init \ - --mount type=bind,src=${MAKEFILE_DIR},dst=${MAKEFILE_DIR},readonly \ - --mount type=bind,src=${MAKEFILE_DIR}/docker/venv,dst=${MAKEFILE_DIR}/.venv \ - -w ${CURDIR} \ - -e OPENROUTER_API_KEY -e ANTHROPIC_API_KEY -e OPENAI_API_KEY \ - -e RUSTFLAGS \ - -e GIT_AUTHOR_NAME -e GIT_AUTHOR_EMAIL -USER_UID := $(shell id -u) -DOCKER_IMAGE = $(if ${DOCKER_RUN},ideas-${USER_UID},) - ifeq (${PROVIDER},hosted_vllm) override TRANSLATE_ARGS += model.base_url=${BASE_URL} override TRANSLATE_ARGS += generate.timeout=5400 endif # Directories reserved for IDEAS -TRANSLATION_DIR_RESERVED := test_case build-ninja test_vectors runner +TRANSLATION_DIR_RESERVED := test_case build-ninja test_vectors runner translated_rust ifeq ($(strip ${TRANSLATION_DIR}),) $(error TRANSLATION_DIR must not be empty) endif diff --git a/docker/DOCKER.mk b/docker/DOCKER.mk new file mode 100644 index 0000000..326dca5 --- /dev/null +++ b/docker/DOCKER.mk @@ -0,0 +1,103 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +RUN_ARGS = +ENV_ARGS = +# Keep wrapper command traces visible when tool stderr is redirected to a log +DOCKER_RUN = @3>&2 ${MAKEFILE_DIR}/docker/run.sh ${ENV_ARGS} ${RUN_ARGS} -- + +ifdef OPENAI_BASE_URL +ENV_ARGS += OPENAI_BASE_URL="${OPENAI_BASE_URL}" +endif +ifdef MAX_DEPENDENT_CHARS +ENV_ARGS += MAX_DEPENDENT_CHARS="${MAX_DEPENDENT_CHARS}" +endif +ifdef REDUCED_CONTEXT +ENV_ARGS += REDUCED_CONTEXT="${REDUCED_CONTEXT}" +endif +ifdef TESTGEN_BOTTOM_UP +ENV_ARGS += TESTGEN_BOTTOM_UP="${TESTGEN_BOTTOM_UP}" +endif +ifdef TESTGEN_FINISH_EARLY +ENV_ARGS += TESTGEN_FINISH_EARLY="${TESTGEN_FINISH_EARLY}" +endif + +ENV_ARGS += CC="${CC}" +ENV_ARGS += CFLAGS="${CFLAGS}" +ENV_ARGS += CARGO_NET_OFFLINE="${CARGO_NET_OFFLINE}" +ENV_ARGS += DEBUGINFOD_URLS="${DEBUGINFOD_URLS}" +ENV_ARGS += GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME}" +ENV_ARGS += GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL}" +ENV_ARGS += GIT_COMMITTER_NAME="${GIT_COMMITTER_NAME}" +ENV_ARGS += GIT_COMMITTER_EMAIL="${GIT_COMMITTER_EMAIL}" +ENV_ARGS += RUSTFLAGS="${RUSTFLAGS}" + +IDEAS_DOCKER_IMAGE ?= ideas-$(shell id -u)## Docker image used for translation tooling; empty for native execution +IDEAS_DOCKER_IMAGE := $(IDEAS_DOCKER_IMAGE) +DOCKER_READY := + +ifneq ($(strip ${IDEAS_DOCKER_IMAGE}),) +DOCKER_READY := docker/venv/.ready + +RUN_ARGS += --image "${IDEAS_DOCKER_IMAGE}" +RUN_ARGS += --ideas-dir "${MAKEFILE_DIR}" +RUN_ARGS += --workdir "${CURDIR}" +ifeq (${DOCKER_INSPECT},1) +RUN_ARGS += --inspect +endif # DOCKER_INSPECT + +ifeq (${INCLUDE_DOCKER_RULES},1) +.PHONY: docker +docker: private RUN_ARGS += --tty +docker:## Open an interactive Docker shell +docker: ${DOCKER_READY} + ${DOCKER_RUN} bash + +.PRECIOUS: docker/build.log +docker/build.log: docker/ideas.Dockerfile uv.lock pyproject.toml INSTALL.mk + docker build --build-arg USER_UID=$(shell id -u) \ + --build-arg USER_GID=$(shell id -g) \ + -f docker/ideas.Dockerfile -t ${IDEAS_DOCKER_IMAGE} . + docker images --quiet ${IDEAS_DOCKER_IMAGE}:latest > $@ + +.PHONY: docker/build +docker/build:## Build the Docker image +docker/build: docker/build.log + +docker/venv/.ready: docker/build.log uv.lock pyproject.toml + ${DOCKER_RUN} uv sync --frozen + @touch $@ + +.PHONY: docker-clean +docker-clean: + rm -f docker/build.log +clean: docker-clean + +.PHONY: examples/docker +examples/docker:## Open an interactive Docker shell with all EXAMPLES ready to translate +examples/docker: private RUN_ARGS += --tty +examples/docker: private ENV_ARGS += TRANSLATION_DIR="${TRANSLATION_DIR}" +examples/docker: private RUN_ARGS += $(foreach example,${EXAMPLES},--readable "${MAKEFILE_DIR}/${example}/test_case") +examples/docker: private RUN_ARGS += $(foreach example,${EXAMPLES},$(if $(wildcard ${MAKEFILE_DIR}/${example}/CMakePresets.json),--readable "${MAKEFILE_DIR}/${example}/CMakePresets.json")) +examples/docker: private RUN_ARGS += $(foreach example,${EXAMPLES},$(if $(wildcard ${MAKEFILE_DIR}/${example}/CMakeLists.txt),--readable "${MAKEFILE_DIR}/${example}/CMakeLists.txt")) +examples/docker: private RUN_ARGS += $(foreach example,${EXAMPLES},--writable "${MAKEFILE_DIR}/${example}/build-ninja") +examples/docker: private RUN_ARGS += $(foreach example,${EXAMPLES},--writable "${MAKEFILE_DIR}/${example}/${TRANSLATION_DIR}") +examples/docker: ${DOCKER_READY} FORCE + @mkdir -p $(foreach example,${EXAMPLES},"${example}/build-ninja" "${example}/${TRANSLATION_DIR}") + ${DOCKER_RUN} bash +examples/%/docker:## Open an interactive Docker shell for a specific example ready to translate +examples/%/docker: private RUN_ARGS += --tty +examples/%/docker: private ENV_ARGS += TRANSLATION_DIR="${TRANSLATION_DIR}" +examples/%/docker: private RUN_ARGS += --readable "${MAKEFILE_DIR}/$(@D)/test_case" +examples/%/docker: private RUN_ARGS += $(if $(wildcard ${MAKEFILE_DIR}/$(@D)/CMakePresets.json),--readable "${MAKEFILE_DIR}/$(@D)/CMakePresets.json") +examples/%/docker: private RUN_ARGS += $(if $(wildcard ${MAKEFILE_DIR}/$(@D)/CMakeLists.txt),--readable "${MAKEFILE_DIR}/$(@D)/CMakeLists.txt") +examples/%/docker: private RUN_ARGS += --writable "${MAKEFILE_DIR}/$(@D)/build-ninja" +examples/%/docker: private RUN_ARGS += --writable "${MAKEFILE_DIR}/$(@D)/${TRANSLATION_DIR}" +examples/%/docker: ${DOCKER_READY} FORCE + @mkdir -p "$(@D)/build-ninja" "$(@D)/${TRANSLATION_DIR}" + ${DOCKER_RUN} bash +endif # INCLUDE_DOCKER_RULES +endif # IDEAS_DOCKER_IMAGE diff --git a/docker/ideas.Dockerfile b/docker/ideas.Dockerfile index 3cdd2cf..7893c68 100644 --- a/docker/ideas.Dockerfile +++ b/docker/ideas.Dockerfile @@ -11,58 +11,26 @@ RUN apt-get update && apt-get install -y \ build-essential \ checkinstall \ pkg-config \ - jq \ git \ lsb-release \ software-properties-common \ gnupg \ - vim \ - datamash + vim -RUN wget https://apt.llvm.org/llvm.sh && \ - chmod +x llvm.sh && \ - ./llvm.sh 21 all && \ - rm ./llvm.sh - -# Install ninja-build -RUN apt-get install -y ninja-build - -# Install possible translation dependencies -RUN apt-get install -y \ - zlib1g-dev \ - libssl-dev \ - libpcre3-dev - -# Install specific version of cmake from binary -ARG CMAKE_VERSION=4.1.2 -RUN wget https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.sh -O /tmp/cmake-install.sh && \ - chmod +x /tmp/cmake-install.sh && \ - /tmp/cmake-install.sh --skip-license --prefix=/usr/local && \ - rm /tmp/cmake-install.sh - -# Symlink /usr/bin/clang and set it as default compiler -RUN ln -s /usr/bin/clang-21 /usr/bin/clang +# Configuration ENV CC=clang -# Symlink /usr/bin/ld.lld because Bear's intercept-preload needs lld -RUN ln -s /usr/bin/ld.lld-21 /usr/bin/ld.lld - -# Install uv ENV UV_INSTALL_DIR="/usr/local/bin" -RUN curl -LsSf https://astral.sh/uv/0.11.13/install.sh | sh +ENV CODEX_HOME=/opt/codex +ENV CODEX_INSTALL_DIR=/usr/local/bin -# Install Rust toolchain non-interactively -RUN rustup default 1.88.0 -RUN rustup component add rustfmt -RUN rustup component add llvm-tools-preview --toolchain 1.88.0-x86_64-unknown-linux-gnu -RUN cargo install bindgen-cli --version 0.72.1 -RUN cargo install cargo-llvm-cov --version 0.8.6 -RUN cargo install cargo-nextest --version 0.9.114 --locked +# Install all dependencies +COPY INSTALL.mk ./ +RUN make -f INSTALL.mk install +RUN rm INSTALL.mk -# Install Bear (Build EAR) for capturing build commands -ARG BEAR_VERSION=4.1.5 -RUN git clone --branch ${BEAR_VERSION} --depth 1 https://github.com/rizsotto/Bear /tmp/bear -RUN cd /tmp/bear && cargo build --release && ./scripts/install.sh -RUN rm -rf /tmp/bear +# Ensure cargo is in the PATH for all users, including non-interactive shells +RUN printf '. "%s/env"\n' "${CARGO_HOME:-/usr/local/cargo}" \ + > /etc/profile.d/cargo.sh && chmod 0644 /etc/profile.d/cargo.sh # Non-root user ARG USER_UID=1000 @@ -71,11 +39,15 @@ RUN sed -i 's/UID_MAX.*/UID_MAX 20000000/' /etc/login.defs RUN groupadd -g ${USER_GID} ideas && \ useradd -m -u ${USER_UID} -g ${USER_GID} user && \ chown -R user:ideas /home/user && \ + chown -R user:ideas /opt/codex && \ chown -R user:ideas /usr/local/cargo USER user RUN mkdir -p /home/user/IDEAS WORKDIR /home/user/IDEAS +# No nested docker runs +ENV IDEAS_DOCKER_IMAGE="" + # Set up a basic git identity ENV GIT_AUTHOR_NAME="ideas" ENV GIT_AUTHOR_EMAIL="ideas@ideas.local" @@ -83,7 +55,10 @@ ENV GIT_COMMITTER_NAME="ideas" ENV GIT_COMMITTER_EMAIL="ideas@ideas.local" # Shell quality-of-life for interactive use -COPY --chown=user:ideas ideas.bashrc /home/user/.bashrc +COPY --chown=user:ideas docker/ideas.bashrc /home/user/.bashrc + +# Codex runs inside Docker, so let it use the container's full filesystem access. +ENV IDEAS_CODEX_SANDBOX="danger-full-access" # Cache Python dependencies ENV PYTHONDONTWRITEBYTECODE=1 @@ -101,13 +76,14 @@ RUN cargo init --lib cargo_deps && \ cargo add --dev \ assert_cmd@2.0.17 \ predicates@3.1.3 \ - once_cell@1.21.3 \ - test-cdylib@1.1.0 \ serde_json@1 \ - tempfile@3 && \ + tempfile@3 \ + walkdir@2 && \ cargo add --dev \ --features derive serde@1 && \ + cargo add --dev \ + --features json insta@1.48.0 && \ cargo add --build \ cc@1.2.53 && \ - CARGO_NET_OFFLINE=false cargo metadata \ - --manifest-path /home/user/IDEAS/cargo_deps/Cargo.toml + CARGO_NET_OFFLINE=false cargo fetch --manifest-path /home/user/IDEAS/cargo_deps/Cargo.toml --locked && \ + rm -rf /home/user/IDEAS/cargo_deps diff --git a/docker/run.sh b/docker/run.sh new file mode 100755 index 0000000..172a0e2 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 + +set -eu + +usage() { + echo "usage: $0 [--image IMAGE --ideas-dir DIR] [options] [NAME=value ...] -- command..." >&2 + exit 2 +} + +die() { + echo "docker/run.sh: $*" >&2 + exit 2 +} + +add_env_spec() { + local env_spec=$1 + local env_name=${env_spec%%=*} + case "$env_spec" in + *=*) ;; + *) die "environment assignment must contain '=': $env_spec" ;; + esac + case "$env_name" in + ""|[!A-Za-z_]*|*[!A-Za-z0-9_]*) die "invalid environment variable name: $env_name" ;; + esac + env_specs+=("$env_spec") +} + +image= +ideas_dir= +inspect=false +tty=false +mount_specs=() +env_specs=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --image) + [ "$#" -ge 2 ] || usage + image=$2 + shift 2 + ;; + --ideas-dir) + [ "$#" -ge 2 ] || usage + ideas_dir=$2 + shift 2 + ;; + --workdir) + [ "$#" -ge 2 ] || usage + workdir=$2 + shift 2 + ;; + --writable|--readable) + [ "$#" -ge 2 ] || usage + case "$1" in + --writable) mount_kind=writable ;; + --readable) mount_kind=readonly ;; + esac + mount_specs+=("$mount_kind:$2") + shift 2 + ;; + --env) + [ "$#" -ge 2 ] || usage + env_specs+=("$2") + shift 2 + ;; + *=*) + add_env_spec "$1" + shift + ;; + --inspect) + inspect=true + tty=true + shift + ;; + --tty) + tty=true + shift + ;; + --) + shift + break + ;; + *) + usage + ;; + esac +done + +[ "$#" -gt 0 ] || usage + +echo_command=true +case "$1" in + @*) + command_name=${1#@} + [ -n "$command_name" ] || die "command name must not be empty" + shift + set -- "$command_name" "$@" + [ "$inspect" = true ] || echo_command=false + ;; +esac + +if [ -z "$image" ]; then + if [ -n "${workdir:-}" ]; then + workdir=$(CDPATH= cd -- "$workdir" && pwd -P) || die "working directory does not exist: $workdir" + cd "$workdir" + fi + if [ "$echo_command" = true ]; then + echo_fd=2 + if ( : >&3 ) 2>/dev/null; then + echo_fd=3 + fi + if [ -t "$echo_fd" ] && [ -z "${NO_COLOR:-}" ]; then + printf '\033[1;36m[NATIVE]\033[0m ' >&"$echo_fd" + printf '\033[90m' >&"$echo_fd" + printf '%q ' env "${env_specs[@]}" >&"$echo_fd" + printf '\033[0m' >&"$echo_fd" + printf '%q ' "$@" >&"$echo_fd" + else + printf '[NATIVE] ' >&"$echo_fd" + printf '%q ' env "${env_specs[@]}" "$@" >&"$echo_fd" + fi + printf '\n' >&"$echo_fd" + fi + exec env "${env_specs[@]}" "$@" +fi + +[ -n "$image" ] || usage +[ -n "$ideas_dir" ] || usage +ideas_dir=$(CDPATH= cd -- "$ideas_dir" && pwd -P) || die "IDEAS directory does not exist: $ideas_dir" +if [ -n "${workdir:-}" ]; then + workdir=$(CDPATH= cd -- "$workdir" && pwd -P) || die "working directory does not exist: $workdir" +else + workdir=$ideas_dir +fi + +venv_dir=$ideas_dir/docker/venv +mkdir -p "$venv_dir" + +resolve_mount_path() { + local mount_path=$1 + local resolved_path + local candidate + case "$mount_path" in + ""|*:* ) die "mount path must not be empty or contain ':': $mount_path" ;; + esac + case "$mount_path" in + /*) + candidate=$mount_path + ;; + .|..|../*|*/../*|*/..|./*|*/./*) + die "relative mount path contains an unsafe path: $mount_path" + ;; + *) + candidate=$ideas_dir/$mount_path + ;; + esac + if [ -d "$candidate" ]; then + resolved_path=$(CDPATH= cd -- "$candidate" && pwd -P) \ + || die "mount path does not exist: $mount_path" + elif [ -f "$candidate" ]; then + resolved_path=$(realpath -e -- "$candidate") \ + || die "mount path does not exist: $mount_path" + else + die "mount path does not exist: $mount_path" + fi + case "$mount_path" in + /*) ;; + *) + case "$resolved_path" in + "$ideas_dir"/*) ;; + *) die "relative mount path must stay inside ideas directory: $mount_path" ;; + esac + ;; + esac + printf '%s\n' "$resolved_path" +} + +resolved_mount_specs=() +for mount_spec in "${mount_specs[@]}"; do + mount_kind=${mount_spec%%:*} + mount_path=$(resolve_mount_path "${mount_spec#*:}") + duplicate=false + for resolved_mount_spec in "${resolved_mount_specs[@]}"; do + resolved_mount_kind=${resolved_mount_spec%%:*} + resolved_mount_path=${resolved_mount_spec#*:} + if [ "$resolved_mount_path" = "$mount_path" ]; then + if [ "$resolved_mount_kind" != "$mount_kind" ]; then + die "mount directory has conflicting modes: $mount_path" + fi + duplicate=true + break + fi + done + if [ "$duplicate" = false ]; then + resolved_mount_specs+=("$mount_kind:$mount_path") + fi +done + +workdir_has_mount=false +for mount_spec in "${resolved_mount_specs[@]}"; do + if [ "${mount_spec#*:}" = "$workdir" ]; then + workdir_has_mount=true + break + fi +done + +run_docker() { + local -a docker_args=( + --rm --init + --env no_proxy --env https_proxy --env http_proxy + --env NO_PROXY --env HTTPS_PROXY --env HTTP_PROXY + --env OPENROUTER_API_KEY --env OPENAI_API_KEY --env ANTHROPIC_API_KEY --env VLLM_API_KEY + --env UV_NO_SYNC=1 --env TINI_KILL_PROCESS_GROUP=1 + --workdir "$workdir" + --mount "type=bind,src=$ideas_dir,dst=$ideas_dir,readonly" + --mount "type=bind,src=$venv_dir,dst=$ideas_dir/.venv" + --mount "type=tmpfs,dst=$ideas_dir/examples" + ) + for env_spec in "${env_specs[@]}"; do + docker_args+=(--env "$env_spec") + done + if [ "$workdir" != "$ideas_dir" ] && [ "$workdir_has_mount" = false ]; then + docker_args+=(--mount "type=tmpfs,dst=$workdir") + fi + for mount_spec in "${resolved_mount_specs[@]}"; do + mount_kind=${mount_spec%%:*} + mount_path=${mount_spec#*:} + case "$mount_kind" in + readonly) + docker_args+=(--mount "type=bind,src=$mount_path,dst=$mount_path,readonly") + ;; + writable) + docker_args+=(--mount "type=bind,src=$mount_path,dst=$mount_path") + ;; + esac + done + if [ "$tty" = true ]; then + docker_args+=(-it) + fi + docker_run_arg_count=${#docker_args[@]} + if [ "$inspect" = true ]; then + docker_args+=("$image" bash -c 'bash -i; exec "$@"' -- "$@") + else + docker_args+=("$image" "$@") + fi + docker_run_arg_count=$((docker_run_arg_count + 1)) + if [ "$echo_command" = true ]; then + echo_fd=2 + if ( : >&3 ) 2>/dev/null; then + echo_fd=3 + fi + if [ -t "$echo_fd" ] && [ -z "${NO_COLOR:-}" ]; then + printf '\033[1;36m[DOCKER %s]\033[0m ' "$image" >&"$echo_fd" + else + printf '[DOCKER %s] ' "$image" >&"$echo_fd" + fi + if [ "$inspect" = true ]; then + printf '%q ' "$@" >&"$echo_fd" + elif [ "${VERBOSE:-0}" = 1 ]; then + if [ -t "$echo_fd" ] && [ -z "${NO_COLOR:-}" ]; then + printf '\033[90mdocker run ' >&"$echo_fd" + printf '%q ' "${docker_args[@]:0:docker_run_arg_count}" >&"$echo_fd" + printf '\033[0m' >&"$echo_fd" + printf '%q ' "${docker_args[@]:docker_run_arg_count}" >&"$echo_fd" + else + printf 'docker run ' >&"$echo_fd" + printf '%q ' "${docker_args[@]}" >&"$echo_fd" + fi + else + printf '%q ' "$@" >&"$echo_fd" + fi + printf '\n' >&"$echo_fd" + fi + exec docker run "${docker_args[@]}" +} + +run_docker "$@" diff --git a/pyproject.toml b/pyproject.toml index c347a54..e9f04ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,11 @@ dependencies = [ "clang==21.1.7", "dspy==3.1.2", "kiss-agent-framework[core]", - "hydra-core==1.3.2", + "hydra-core==1.3.4", "networkx==3.6.1", "tomlkit>=0.14.0", "tree-sitter==0.25.2", - "tree-sitter-rust==0.24.0", + "tree-sitter-rust-orchard==0.16.8", ] [dependency-groups] @@ -22,6 +22,7 @@ dev = [ "basedpyright==1.29.4", "pre-commit==4.2.0", "pytest==9.0.3", + "pytest-xdist==3.8.0", "ruff==0.13.0", "vulture==2.16", ] @@ -50,4 +51,4 @@ line-length = 96 build-constraint-dependencies = ["setuptools==81.0.0"] [tool.uv.sources] -kiss-agent-framework = { git = "https://github.com/mariusarvinte/kiss_ai", rev = "f4f6cc1ef1fc5e2962aec6da71677be6e057f194" } +kiss-agent-framework = { git = "https://github.com/mariusarvinte/kiss_ai", rev = "edb12535c0097bdf9d05174b3afa94a99b44bf17" } diff --git a/src/ideas/__init__.py b/src/ideas/__init__.py index 2c2bc86..ae608df 100644 --- a/src/ideas/__init__.py +++ b/src/ideas/__init__.py @@ -9,6 +9,7 @@ from .translate_recurrent import RecurrentTranslator from .translate_snippet import SnippetTranslator from .wrapper import WrapperGenerator +from .codex import CodexConfig, CodexSnippetTranslator, CodexWrapperGenerator from clang.cindex import Config __all__ = [ @@ -20,6 +21,9 @@ "RecurrentTranslator", "SnippetTranslator", "WrapperGenerator", + "CodexConfig", + "CodexSnippetTranslator", + "CodexWrapperGenerator", ] # NOTE: .so is *nix specific diff --git a/src/ideas/agents/generate_io_tests.py b/src/ideas/agents/generate_io_tests.py index 11e0c0f..5edaf06 100644 --- a/src/ideas/agents/generate_io_tests.py +++ b/src/ideas/agents/generate_io_tests.py @@ -5,37 +5,28 @@ # -import sys -import os import logging +import shutil import tempfile import textwrap import time -import shutil -from pathlib import Path from dataclasses import dataclass +from pathlib import Path import hydra from omegaconf import MISSING from hydra.core.config_store import ConfigStore from hydra.core.hydra_config import HydraConfig -from ideas import create_translation_unit, extract_info_c from ideas.agents.printer import ConsoleTee, LoggingConsolePrinter -from ideas.agents.utils import ( - RESTRICT_COVERAGE, - strip_line_directives, - write_instrumentation_script, - write_assert_script, - write_collect_script, - write_profile_list, -) -from ideas.consolidate import get_symbols_and_dependencies, is_system_symbol +from ideas.agents.utils import finalize_tests, strip_line_directives, write_test_templates +from ideas.agents.utils import TESTGEN_BOTTOM_UP, TESTGEN_FINISH_EARLY +from ideas.agents.verifiers import get_fresh_copy, verification_service from ideas.tools import Crate from kiss.agents.sorcar.useful_tools import UsefulTools from kiss.core.kiss_agent import KISSAgent -from kiss.core.kiss_error import KISSError +from kiss.core.kiss_error import KISSError, BudgetExceededError logger = logging.getLogger("ideas.agents.generate_io_tests") @@ -51,377 +42,289 @@ class TestgenConfig: budget: float = 4.0 # USD steps: int = 100 + vcs: str = "none" -@dataclass -class TestgenInstructions: - overview: str = textwrap.dedent( - """ - # Overview - The working directory is {work_dir}. All paths are relative to the working directory. - You must work **strictly** inside {work_dir} and never read, write, or list anything outside it. - This directory is self-contained and holds everything you need. - Never use absolute paths that leave it, never use `..` to climb above it, and never `cd` out of it. - - {work_dir} contains a Rust crate that links C code through the Rust C FFI. - Everything you need to know must be derived from the C source in `src/lib.c`. - The C source is amalgamated and has no `#include` directives. - Every declaration and definition you need is in the file. - Do not test or look for the definition of any functions with an `extern` declaration. - - Do not edit the `src/lib.c` file. - """ - ) - analyze_library: str = textwrap.dedent( - """ - Public (exported) functions have pre-generated FFI bindings using `bindgen` in `src/lib.rs`. - The bindings and build files are correct and must not be changed. - - Understand the C source and, for each public function, determine which parameters are input-only, - which are output-only (written by the callee) and which are modified in place. - This tells you how to set up inputs and where to capture outputs. - - Note any logic that can loop forever and the conditions that trigger it. - Identify any input that would cause undefined behavior so you can avoid it. - The code can only be driven through calling public functions with their arguments set up, - environment variables (if any are used), and any input files it reads. - You cannot call private functions or edit the program to reach more code. - """ - ) +class TestgenAgent(KISSAgent): + """Give the model its step count without token or budget telemetry.""" - analyze_binary: str = textwrap.dedent( - """ - The C `main` function (correctly placed in `src/lib.c` and linked in) - is the entry point of the final executable. - The build files are correct and must not be changed. - - Understand the C source and determine how the program uses `argc`/`argv`, whether it reads from stdin, - what it prints to stdout and stderr, and which exit codes it returns. - - Decide whether the program is batch (runs and exits) or interactive (loops on stdin). - If interactive, find its exit condition. - Note any input that can loop forever, and any input that would cause undefined behavior so you can avoid it. - The code can only be driven through the `main` function: command-line arguments, stdin, environment - variables, and any input files it reads. - You cannot call any other function directly or edit the program to reach more code. - """ - ) + def _get_usage_info_string(self) -> str: + return f"Steps: {self.step_count}/{self.max_steps}" - analyze_instrumentation: str = textwrap.dedent( - """ - # Instrumentation - The `{instrument_path}` script instruments the tests for coverage and sanitizers. - It should not be modified and should always be executed to get accurate coverage results. - It takes a single argument selecting which test file to run, either `collect` or `io`: - ```bash - {instrument_path} collect - {instrument_path} io - ``` + def _can_finish(self) -> bool: + return self.step_count >= self._finish_step_threshold() or TESTGEN_FINISH_EARLY + + def _finish_tool_description(self) -> str: + # The base description advertises the step gate that TESTGEN_FINISH_EARLY lifts. + if not TESTGEN_FINISH_EARLY: + return super()._finish_tool_description() + return ( + "Finish with the final answer. Call this only once the inventory is exhausted: every " + "public function or mode is tested and verified, the coverage target is met, and every " + "remaining branch has been proven unreachable. Difficulty, slow progress, a long " + "trajectory, and the number of passing tests never justify finishing." + ) - Carefully read and understand this script, it is **critical** for correct measurements. - - Coverage is measured only if every test passes under every sanitizer build; if anything - fails, the script exits early and none of the coverage outputs below are generated. - {coverage_scope} - - `{coverage_dir}/coverage_summary.log`: the aggregate per-file and TOTAL coverage - summary table. Also printed to stdout under `Coverage summary:`. - - `{coverage_dir}/coverage_report.log`: the full per-line annotated coverage report, - including branch coverage details. - - `{coverage_dir}/uncovered_branches.log`: just the uncovered branches (a branch - whose True or False count is zero), or `none` if fully covered. Also printed to - stdout under `Uncovered branches:`. - - Running this script and inspecting its logs is the **only** allowed way to run the tests and get metrics. - You must never invoke `cargo test`, `cargo nextest`, `cargo llvm-cov`, or any other test runner directly, - and you must never derive coverage or output data by any other means. - Every test run must go through `{instrument_path} collect` or `{instrument_path} io`. - No ad-hoc or manual run can replace this script. - - A sanitizer `FAIL` only means the test failed under that sanitizer build. - It does not necessarily mean the test exercises undefined behavior. - The script does not tell the two apart, so read `{sanitizer_dir}/.log`: - - A sanitizer diagnostic (`ERROR: AddressSanitizer:`, `runtime error:`, - `SUMMARY: UndefinedBehaviorSanitizer:`, or a stack trace into the C source) means the - input drives the C code into UB. Change that input, or drop the case. - - A Rust panic (`assertion ... failed`, `panicked at tests/...`) with no sanitizer - diagnostic is a normal test failure. Your expected value is wrong, so fix the test. - - A test killed for running too long is a hang, usually a program waiting on stdin. - Give it the input it waits for, or close its stdin. - - Read the whole report, not just the last error: a sanitizer diagnostic often appears inside - an assertion message. The same test failing under several features is one problem, not many. - - The sanitizers are configured by this script. - Do not set or override `ASAN_OPTIONS`, `UBSAN_OPTIONS`, `LSAN_OPTIONS`, or any other sanitizer - variable, whether in a test, in the environment, or in a config file. - Never mark a test `#[ignore]`, comment it out, or otherwise skip it to get past a failure. - Silencing a check invalidates the whole result. - """ - ) - goal_library: str = textwrap.dedent( +@dataclass +class TestgenInstructions: + introduction: str = textwrap.dedent( """ # Goal - Your goal is to write input/output C FFI tests to `{test_path}` that call public C functions - directly and verify their outputs with `assert!` or `assert_eq!`. + Work **strictly** inside `{work_dir}`; all paths below are relative to it. + + Your goal is to generate input/output tests in `{test_path}` and + reach at least {target_coverage}%% branch coverage without Undefined Behavior (UB). + + The amalgamated `src/lib.c` is the source of truth for the implementation-under-test. + Never edit it, its Rust bindings, the build files, `{instrument_path}`, or the supplied helpers. + Do not test or search for definitions of functions declared `extern`. Do not run `git``; + repository metadata is not provided, so inspect and compare files directly. + + Identify and avoid all code paths that lead to infinite loops or hang execution waiting for + interactive inputs or network requests. """ ) - goal_binary: str = textwrap.dedent( + library_top_down: str = textwrap.dedent( """ - # Goal - Your goal is to write input/output tests to `{test_path}` that run the binary - and verify its stdout, stderr, and exit code. + Start generating tests from the highest-level public functions: use implementation call + relationships to identify entry points and orchestration APIs that exercise substantial project + behavior. Give each one its smallest focused real-work success case, then work downward through + lower-level public primitives without skipping any inventory item or combining unrelated APIs. """ ) - goal_common: str = textwrap.dedent( + library_bottom_up: str = textwrap.dedent( """ - You must achieve a branch coverage of at least {target_coverage}%. - You can **never** exercise UB (undefined behavior) in any test. - If you can no longer improve branch coverage (e.g., unreachable code without UB), you may early stop. - - Reach this goal in three steps: - - 1. Collect input/output pairs in `{collect_path}`. - Use the `{instrument_path} collect` command to instrument the tests for coverage and sanitizers. - - 2. Improve branch coverage by appending more collection tests to `{collect_path}`. - Any data collection attempt that exercises UB will be detected by the instrumentation and rejected. - - 3. Write pure assertion I/O tests to `{test_path}` from the collected data. - Use the `{instrument_path} io` command to run a final verification of the coverage and sanitizers. - The coverage should exactly match the outcome of `{instrument_path} collect` and all tests should pass without exercising UB. + Start generating tests from the lowest-level public functions: use implementation call + relationships to identify leaf APIs and foundational primitives used throughout the project. + Give each one its smallest focused real-work success case, then work upward through higher-level + entry points and orchestration APIs without skipping any inventory item or combining unrelated APIs. """ ) - collect_common: str = textwrap.dedent( + library_scope: str = textwrap.dedent( """ - ## Step 1: Collect input/output pairs - - Collect input/output pairs of data by writing tests to `{collect_path}`. - - These tests should not assert outputs, but serialize them using `serde` for later conversion - to expected value tests. - - Build up complexity gradually, in two passes: + Do not change `src/lib.rs` or any build file. Inventory every public function by subsystem, + including inputs, outputs, mutations, return paths, errors, and file or environment effects. Read + each implementation and avoid inputs that hang or cause UB. - 1a. Start with the simplest possible tests: one isolated invocation each, with straightforward inputs. - Cover the common path of every entry point this way before doing anything more elaborate. - 1b. Only once the simple tests are collected and passing, add chained tests that perform several - invocations in sequence, where earlier invocations set up the state for later ones. + {library_breadth_order} - Prefer the simplest test that reaches a given behavior. Reach for a chained test only when the - behavior genuinely cannot be reached by a single invocation. - - Keep tests short and contained, if possible: one behavior per test, named after that behavior. - Never grow an existing test to cover something new. Many small tests are better than a few large ones. + Drive the code only through public FFI bindings, environment variables, and input files. Never + call private functions or edit the program to reach code. Assert outputs, mutations, return values, + and file-system effects. """ + ).replace( + "{library_breadth_order}", + (library_bottom_up if TESTGEN_BOTTOM_UP else library_top_down).strip(), ) - collect_library: str = textwrap.dedent( + binary_scope: str = textwrap.dedent( """ - The `{collect_path}` file is pre-populated and imports every - FFI binding for the C library: all functions and all data structures. - Use them exactly as imported; do not redeclare or wrap them. - - It also provides a helper that must be used and must not be changed: - - `save_case(name, case)` serializes any `Serialize` value to `{json_dir}/.json`. - - Append each new test below the - `// ==== Add collection tests below this line ====` marker. - - Call each C function through its FFI binding, set up all of its inputs, and capture the resulting state. - For pointer parameters, allocate the pointed-to data as a local variable and pass a raw pointer to it; - never use hard-coded addresses. NUL-terminate any C strings, or the data will be silently corrupted. - - Start with one test per public function, calling that function exactly once. Only after those exist - should you write chained tests that call several functions in sequence on shared state (for example an - init/update/finalize sequence, or a function whose output feeds the next function's input). + Do not change any build file. Read C `main` completely and follow every dispatch entry and handler. + Understand every mode or subcommand with its flags, arguments, stdin, stdout, stderr, exit codes, + and file effects. Derive exit conditions and dangerous inputs from implementations, dispatch + tables, option parsers, and usage strings. - In a chained test, capture the intermediate state after every call, - not just the final one, so each step can be asserted later. + Drive the program through `main` using arguments, stdin, environment, and files, and reach what + `main` cannot by calling public functions directly through the `{lib_name}` FFI bindings already + imported by the test files. **Never** edit the program to reach code. - If a function reads or writes files, run it inside a fresh temporary directory unique to the test. - Create any input files there first, then capture the files the function creates or modifies - (their paths and contents) as part of the output state. Never touch shared or absolute system paths, - so the collection and I/O tests stay isolated and reproducible. - - Each collection test should finish with a single `save_case("", &case)`, writing one JSON - file to `{json_dir}/.json` per collection test. - Write both inputs and outputs using the same data structure across all collection tests. - This data structure should contain the input and output state of all inputs - (in case functions modify data in-place) and any return value. + When calling `main`, assert stdout, stderr, exit codes, return values, mutations, and file-system effects. """ ) - collect_binary: str = textwrap.dedent( + portability: str = textwrap.dedent( """ - The `{collect_path}` file is pre-populated with two helpers that must be used and must not be changed: - - `run(args, stdin) -> Call` runs the binary once and returns its `stdout`, `stderr`, - and `exit_code`. - - `save_case(name, calls)` serializes an ordered slice of calls to `{json_dir}/.json`. - - Push every `Call` into a local `Vec` in the order it was made, and finish each test with a single - `save_case("", &calls)`, passing the test's own function name. - - Write one `#[test]` per collection test. - Append each new test to the end of the file, below the `// ==== Add collection tests below this line ====` - marker. Leave the helpers above that marker unchanged. - - Start with tests that call `run` exactly once, covering each subcommand or mode on its own with simple - arguments and stdin. Only after those exist should you write chained tests that call `run` several times - in sequence, where earlier invocations set up state (files, configuration) for later ones. In a chained - test, push every call, so each intermediate invocation can be asserted later. - - For programs with multiple subcommands or modes, cover each one, and cover sequential invocations - of subcommands in any relevant combination. - - If the program reads or writes files, set up a fresh temporary directory unique to the test. - Create any input files there first, then capture the files the program creates or modifies - (their paths and contents) as part of the collected output. Never touch shared or absolute system paths, - so tests stay isolated and reproducible. - - Each collection test produces one file, `{json_dir}/.json`, holding the test `name` - and its ordered `calls`. Each call has its `args` and `stdin` inputs and its `stdout`, `stderr`, - and `exit_code` outputs. The script empties `{json_dir}` before every collection run, so the - files left there always match the tests in `{collect_path}`. + # Linux portability and isolation + Generated tests must behave identically in any fresh compatible Linux container; assume only the + supplied harness and project-declared dependencies. + Documented Linux kernel and `libc` contracts are allowed. + + Every expected value and asserted file must trace to project code, a literal, an explicit test-owned input, + or such an OS contract; sandbox containment does not make an ambient value portable. + Generated tests may invoke only the target through supplied FFI or helpers. Neither a test nor its result may + depend, directly or indirectly, on unrelated executables, installed package data, daemons, network or DNS, + system or user configuration, caches, templates, account databases, or their presence, version, or contents. + + Assume every environment value not fixed by the harness or test may be absent or arbitrary. Never use inherited + identity, account details, hostnames, paths, locale, timezone, proxy or tool settings, or other host values + to select a case, construct input, or form an exact expectation; assert only stable structure if exposed. + + When the target searches defaults or imports external data, supply literal sandbox-owned data through its + public interface; never copy ambient files or discover host programs for fixtures. + A portability obstacle never permits skipping an item: replace the ambient input or assert a stable, + host-independent invariant, verify it, and continue the inventory. """ ) - improvement: str = textwrap.dedent( + workflow: str = textwrap.dedent( """ - # Step 2: Improve branch coverage - - After initial data collection, focus on adding more tests to increase branch coverage. - Ensure that additions do not introduce undefined behavior (UB). - - To review the current coverage status, execute: - ```bash - cat {coverage_dir}/coverage_summary.log - ``` - - To review a summary of the current uncovered code branches, execute: + If `src/lib.c` is too large for your context, never sample it or read it front to back. Locate + what you need with pinpointed searches: `rg` or `grep -n` for text such as strings, dispatch + tables, call sites, enums, and macros, and `ast-grep` for structure such as definitions, + signatures, and call patterns. Turn each match's line number into a bounded read with + `sed -n 'START,ENDp'`, widening the range only until the definition is complete. Record the + symbols and line ranges you resolve so you can return to them without rescanning. + + Build a complete inventory and track every item as untested, basic, deep, or temporarily blocked. + Complete a test generation pass across the entire program before spending many attempts on one branch, + then return item by item. + + **Never** give up early, test only a convenient subset, or leave a + large subsystem unexamined. Source size, complexity, slow coverage growth, and repeated failures + never justify narrowing the scope. + + Call a branch unreachable only after reading its guard, tracing its public input path, and trying + the simplest safe input that satisfies it. + + # Workflow + Read `{instrument_path}` once before editing so you understand every check, log, and reported + result; never edit it. Run tests only through: ```bash - cat {coverage_dir}/uncovered_branches.log - ``` - - To review the current uncovered code branches in detail and the complete report, execute: - ```bash - cat {coverage_dir}/coverage_report.log + {instrument_path} collect + {instrument_path} io ``` - - Carefully reason about code paths and behavior to identify program states that may lead to uncovered branches. - - Keep preferring the simplest test that covers a branch: first try new inputs to a single invocation, - and only chain invocations when a branch depends on state left behind by an earlier one. + A successful `collect` prints only JSON values that changed in that run; all case files remain + under `{json_dir}` if you need to inspect one again. + + On failure, read the complete relevant + logs under `{sanitizer_dir}` and `{coverage_dir}`, classify the cause as UB, a wrong expectation, + or a hang, and fix the input or test. Count one test failing in several builds as one problem. + Never set sanitizer environment variables or assert sanitizer failure as expected. + Keep each test's body, inputs, calls, and expectations identical in every build; + never branch on Cargo features or sanitizer state. + + Work in batches of generated tests: + 1. Append collection cases to `{collect_path}` and run `{instrument_path} collect`. + 2. Mirror them in `{test_path}` and run `{instrument_path} io`. + + Run both steps before adding another batch. In `{collect_path}`, serialize outputs for assertion + tests; do not assert them. Append rather than rewriting the file or accumulating an unverified + batch. Target one public operation or mode that covers as many code branches as possible, + with only necessary setup and cleanup. + + Add the minimum state needed for behavior direct calls cannot reach; + then add interactions only when one call must create state for another. + Help, usage, version, and immediate validation failures do not count as basic + coverage. Give every test fresh state and one named behavior. + Prefer one call over a short chain and a short chain over a long one. + + ## Test readability + **Always** prefer more shorter and simpler tests over fewer, longer, and more complex ones, + and prefer tests that use short, self-contained files over files containing an amalgamation of + tested behaviors. """ ) - assert_common: str = textwrap.dedent( + collection: str = textwrap.dedent( """ - # Step 3: Write pure assertion I/O tests - - Once branch coverage is satisfactory, write the pure assertion I/O tests to `{test_path}` - using the data collected in the `{json_dir}` JSON files. + In `{collect_path}`, append below the marker and keep `use {lib_name}::*;` exactly as written. Call + public C functions through FFI. Record return values, output parameters, and mutations. Allocate + pointed-to data locally by idiomatically populating Rust `struct`s where needed. + Use NUL-terminated C strings. Do not hard-code addresses. + + Give every file test its own `sandbox("")` and keep all paths inside it. Use `snapshot` + when the complete tree matters; otherwise record the relevant relative paths, existence or removal, + and stable bytes directly. Do not substitute hashes or sizes for contents. Finish each test with + one `save_case("", &case)` containing every input and observed output. + """ + ) - Write one I/O test per collection test, reading the expected values from the matching JSON file. - These tests must be pure: hard-code the expected values as plain Rust literals and do not depend on - `serde`, `serde_json`, the `{json_dir}` files, or the `{collect_path}` file in any way. Each test must - set up its own inputs and assert every recorded output, so it still passes if moved to another crate. + binary_collection: str = textwrap.dedent( + """ + Give every test that calls `main` its own `sandbox("")` and invoke it only with + `run(&dir, ...)`. The helper uses that sandbox for both the working directory and `HOME` and + records its file effects. Keep every path inside the sandbox and preserve the helper's normalized + `` placeholder. - An I/O test must mirror the structure of the collection test it came from. For a chained collection test, - replay the same sequence in the same order and assert the recorded intermediate state after every step, - not only the final result. An intermediate step that is not asserted is a missing assertion. + Record every program invocation in order. When file contents matter, read the relevant sandbox + files after the call and serialize that observation alongside the calls. + """ + ) - Do not skip any assertion, and do not weaken one just to make a test pass. + assertion: str = textwrap.dedent( + """ + In `{test_path}`, create exactly one pure assertion test per collection test. Hard-code expected + values as Rust literals; never read `{json_dir}` or `{collect_path}` or use `serde` or `serde_json`. + Replay calls in order and assert every return value, output, mutation, and file effect after each + step. Assert unrelated values separately and do not omit or weaken an assertion to make a test pass. + + Append below the marker and keep the statement `use {lib_name}::*;` **exactly** as written. + Reach C only through imported library items; never add raw `extern` declarations. + Compare integers and booleans exactly, floats with a small relative epsilon, + and pointed-to values rather than addresses. + + Give file tests their own sandbox. Assert a complete `snapshot` when the whole tree is behaviorally + relevant; otherwise assert relevant relative paths, existence or removal, and stable bytes directly. + Assert in-place state after every call in a chain. """ ) - assert_library: str = textwrap.dedent( + binary_assertion: str = textwrap.dedent( """ - The `{test_path}` file is pre-populated with `use {lib_name}::*;`, the same FFI bindings - `{collect_path}` imports. Append each new test to the end of the file, below the - `// ==== Add assertion tests below this line ====` marker. - - For each JSON file, reconstruct the recorded input state as Rust literals, call the C function through - its FFI binding, and assert that every output field matches the recorded output state: - - use `assert_eq!` for integer and boolean fields; - - for floating-point fields, compare with a small relative epsilon rather than exact equality; - - for pointer outputs, dereference the pointer and compare the pointed-to value, not the address; - - for files the function created or modified, recreate the recorded inputs in a fresh temporary - directory and assert the resulting file paths and contents. - - For a chained case, call the same functions in the same order and assert the recorded state after each - call, including any state modified in place by an earlier call. + After every `main` call, assert stdout, stderr, exit code, changed and removed paths, + and no disk change where none was recorded. """ ) - assert_binary: str = textwrap.dedent( + completion_gated: str = textwrap.dedent( """ - The `{test_path}` file is pre-populated with a helper that must be used and must not be changed: - - `run(args, stdin) -> Call` runs the binary once and returns its `stdout`, `stderr`, - and `exit_code`. - - This is the only way you may run the binary. It uses `stdbuf -e0 -o0`, exactly like the - collection helper, so it sees the same unbuffered output the recorded values came from. - Never use `Command::cargo_bin` or `std::process::Command` in this file. - It is the collection helper without the recording, so a collection test body carries over as is: - drop the `Vec` and the `save_case` call, and assert each `Call` instead. - - Append each new test to the end of the file, below the - `// ==== Add assertion tests below this line ====` marker, and leave the helper above it unchanged. - - For each JSON file, replay its `calls` in order and assert each one against literals: - - ```rust - let call = run(&["-E", "-"], Some("int x;\\n")); - assert_eq!(call.exit_code, 0); - assert_eq!(call.stdout, "int x;\\n"); - assert_eq!(call.stderr, ""); - /// TODO: File state assertions, if any - ``` + Before the explicit `Only N steps left` warning, you can never call `finish`. + Difficulty, slow progress, and the number of passing tests do not change this gate. - When a value changes between runs (temporary paths, PIDs), assert the stable part with - `predicates::str::contains`. For files the program created or changed, recreate the recorded - inputs in a fresh temporary directory and assert the resulting paths and contents. + Once the warning appears, make `{test_path}` mirror `{collect_path}` exactly, + run `{instrument_path} io`, and call `finish` with a brief report. + """ + ) - Assert every call in the sequence, not just the last one. + completion_early: str = textwrap.dedent( + """ + You may call `finish` only once the inventory is exhausted: every item is tested and verified, + the coverage target is met, and every branch you left uncovered has been proven unreachable by + reading its guard and tracing its public input path. Difficulty, slow progress, a long + trajectory, repeated failures, and the number of passing tests never justify finishing; keep + working item by item while any untested item or untried input remains. + + Before finishing, make `{test_path}` mirror `{collect_path}` exactly, + run `{instrument_path} io`, and call `finish` with a brief report. + If the `Only N steps left` warning arrives first, wrap up as instructed and finish then. """ ) - finish: str = textwrap.dedent( + completion: str = textwrap.dedent( """ - Once the target coverage is achieved and all I/O tests are written, exit immediately. - Do not generate extensive reports or perform redundant sanity checks. + # Completion + A passing instrumentation or pristine verification run proves only that the current batch is + valid; it never means the task is complete. + {completion_gate} """ + ).replace( + "{completion_gate}", + (completion_early if TESTGEN_FINISH_EARLY else completion_gated).strip(), ) library: str = ( - overview - + analyze_library - + analyze_instrumentation - + goal_library - + goal_common - + collect_common - + collect_library - + improvement - + assert_common - + assert_library - + finish + introduction + + library_scope + + portability + + workflow + + collection + + assertion + + completion ) binary: str = ( - overview - + analyze_binary - + analyze_instrumentation - + goal_binary - + goal_common - + collect_common - + collect_binary - + improvement - + assert_common - + assert_binary - + finish + introduction + + binary_scope + + portability + + workflow + + collection + + binary_collection + + assertion + + binary_assertion + + completion ) @@ -429,20 +332,18 @@ class TestgenInstructions: cs.store(name="generate_io_tests", node=TestgenConfig) -def get_tools(): - useful_tools = UsefulTools() +def get_tools(work_dir: Path): + useful_tools = UsefulTools(work_dir=str(work_dir)) return [useful_tools.Bash, useful_tools.Read, useful_tools.Edit, useful_tools.Write] def _wrapup_notice(collect_path: Path, test_path: Path) -> str: return ( - "Stop starting new work and consolidate what you already have: finish any " - f"test you left half-written, and make `{test_path}` mirror `{collect_path}` " - "exactly, one assertion test per collected case. Running out of steps is not " - "a reason to cut corners: keep every assertion, do not weaken or drop one to " - "make a test pass, do not mark tests `#[ignore]`, and do not touch the " - "sanitizer configuration. If a case cannot be finished properly, remove it " - "from both files rather than leaving a broken version of it behind." + "Stop adding cases. Finish the current batch and make " + f"`{test_path}` mirror `{collect_path}` exactly, with one assertion test per " + "collected case and every output and file effect asserted. Never weaken, " + "ignore, or change sanitizer behavior to pass. Remove any unfinished case " + "from both files, run `./instrument.sh io`, and exit." ) @@ -459,122 +360,81 @@ def _main(cfg: TestgenConfig) -> None: logger_trajectory.addHandler(fh) # Simultaneous print and log to file printer = LoggingConsolePrinter(logger=logger_trajectory) - agent = KISSAgent(name="C code reviewer") + agent = TestgenAgent(name="C code reviewer") # -sys crate setup - sys_crate = Crate(cfg.manifest) + sys_crate = Crate(cfg.manifest, vcs=cfg.vcs) # type: ignore[reportArgumentType] sys_root = sys_crate.cargo_toml.parent sys_test_dir = sys_root / "tests" - sys_json_dir = sys_root / "json" sys_test_dir.mkdir(parents=True, exist_ok=True) - sys_json_dir.mkdir(parents=True, exist_ok=True) - - # Analyze consolidated code to find all reachable symbols - template = "bin" if len(sys_crate.bin_targets) > 0 else "lib" - assert sys_crate.lib_src_path is not None, "Expected lib.rs to exist in -sys crate!" - c_src_path = sys_crate.lib_src_path.with_suffix(".c") - tu = create_translation_unit(c_src_path) - asts = [extract_info_c(tu)] - symbols, _ = get_symbols_and_dependencies( - asts, external_symbol_names=["c:@F@main"] if template == "bin" else None - ) - profile_list = None - if RESTRICT_COVERAGE: - profile_functions = [ - s.spelling - for s in symbols.values() - if s.is_function and s.is_definition and not is_system_symbol(s) - ] - if profile_functions: - logger.info(f"Restricting coverage to {len(profile_functions)} functions") - profile_list = write_profile_list(sys_root / "profile.lst", profile_functions) - else: - logger.warning("No instrumentable functions found, coverage stays unrestricted!") - - # Add testing dependencies - if template == "bin": - sys_crate.cargo_add(dep="assert_cmd@2.0.17", section="dev") - sys_crate.cargo_add(dep="predicates@3.1.3", section="dev") - sys_crate.cargo_add(dep="libc@0.2", section="dev") - sys_crate.cargo_add(dep="serde@1", section="dev", features=["derive"]) - sys_crate.cargo_add(dep="serde_json@1", section="dev") - sys_crate.invalidate_metadata() # Write instrumentation and test scripts - write_collect_script( - sys_test_dir / "collect.rs", - template=template, - lib_name=sys_crate.lib_name if template == "lib" else None, - ) - write_assert_script( - sys_test_dir / "io.rs", - template=template, - lib_name=sys_crate.lib_name if template == "lib" else None, - ) - write_instrumentation_script( - sys_root / "instrument.sh", features=["cc_asan", "cc_ubsan"], profile_list=profile_list - ) + template = "bin" if len(sys_crate.bin_targets) > 0 else "lib" + lib_name = sys_crate.lib_name + write_test_templates(sys_test_dir, template=template, lib_name=lib_name) # Isolate the crate work_dir = Path(tempfile.mkdtemp()) / sys_root.name shutil.copytree( - sys_root, work_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns("*.log", ".*") + sys_root, + work_dir, + dirs_exist_ok=True, + ignore=lambda _directory, entries: { + entry + for entry in entries + if entry.endswith(".log") or (entry.startswith(".") and entry != ".config") + }, ) + work_crate = Crate(work_dir / "Cargo.toml") + + # Copy the crate pre-agent for verification under a random package and target name + fresh_copy_parent = Path(tempfile.mkdtemp()) + fresh_crate = get_fresh_copy(sys_crate, fresh_copy_parent) # Strip line directives for c_file in (work_dir / "src").glob("*.c"): strip_line_directives(c_file) - # If this is a binary, remove the `lib.rs` file and simplify `main.rs` - if template == "bin": - lib_rs = work_dir / "src" / "lib.rs" - assert lib_rs.exists(), "Expected to find lib.rs in -sys crate" - lib_rs.unlink() - main_rs = work_dir / "src" / "main.rs" - main_rs.write_text("#![no_main]\n") - # Build the task prompt task_description = ( - TestgenInstructions.library if template == "lib" else TestgenInstructions.binary + TestgenInstructions.binary if template == "bin" else TestgenInstructions.library ).strip() collect_path = (sys_test_dir / "collect.rs").relative_to(sys_root) test_path = (sys_test_dir / "io.rs").relative_to(sys_root) - coverage_scope = ( - f"Coverage is restricted to the program's own functions through `{profile_list.name}`;" - " libc and system code is never counted, so do not try to cover it, and never modify" - " that file:" - if profile_list is not None - else "The coverage outputs are:" - ) task_description = task_description.format( work_dir=work_dir, - instrument_path="instrument.sh", + instrument_path="./instrument.sh", target_coverage=cfg.coverage, collect_path=collect_path, test_path=test_path, - json_dir=sys_json_dir.relative_to(sys_root), + json_dir="json", sanitizer_dir="sanitizer_logs", coverage_dir="coverage_logs", - coverage_scope=coverage_scope, - lib_name=sys_crate.lib_name or "", + lib_name=lib_name or "", ) - # Run the agent - os.chdir(work_dir) - agent.wrapup_steps = 10 + # Expose the direct pristine verifier only while the agent is running. + agent.wrapup_steps = cfg.steps // 10 agent.wrapup_notice = _wrapup_notice(collect_path, test_path) - try: - agent.run( - model_name=cfg.model, - prompt_template=task_description, - max_steps=cfg.steps, - max_budget=cfg.budget, - tools=get_tools(), - printer=printer, - verbose=True, - ) - except KISSError as e: - logger.warning(f"Agent claims it failed with error: {e}") + with verification_service(work_crate, fresh_crate): + try: + agent.run( + model_name=cfg.model, + prompt_template=task_description, + max_steps=cfg.steps, + max_budget=cfg.budget, + tools=get_tools(work_dir), + printer=printer, + verbose=True, + ) + except BudgetExceededError as error: + message = f"Budget of ${cfg.budget} exhausted, keeping tests so far: {error}" + logger.warning(message) + except KISSError as error: + message = ( + f"Agent {agent.name} completed {cfg.steps} steps without finishing: {error}" + ) + logger.warning(message) # Copy the generated tests back work_test_dir = work_dir / "tests" @@ -584,33 +444,20 @@ def _main(cfg: TestgenConfig) -> None: if src.exists(): shutil.copy2(src, sys_test_dir / name) - # Copy the collected JSON data back - work_json_dir = work_dir / "json" - if work_json_dir.is_dir(): - shutil.copytree(work_json_dir, sys_json_dir, dirs_exist_ok=True) - else: - logger.warning("The agent removed the JSON directory!") - - # Guarantee tests are generated - placeholder = "#[test]\nfn placeholder() {\n assert_eq!(1, 1);\n}\n" - for name in ("collect.rs", "io.rs"): - test_file = sys_test_dir / name - if not test_file.exists(): - test_file.write_text(placeholder) - logger.warning( - f"{name} not generated by the agent, writing always-pass placeholder!" - ) - sys_crate.vcs.add(sys_test_dir / "collect.rs", sys_test_dir / "io.rs", sys_json_dir) + sys_crate.vcs.init(force_init=True) + sys_crate.vcs.add(sys_test_dir / "collect.rs", sys_test_dir / "io.rs") sys_crate.vcs.commit("Generated I/O equivalence tests") + # Run post-agent verification on the copied tests + finalize_tests( + sys_crate, fresh_crate, ["collect", "io"], ["cc_asan", "cc_ubsan", "cc_coverage"] + ) + shutil.rmtree(fresh_copy_parent) + @hydra.main(version_base=None, config_name="generate_io_tests") def main(cfg: TestgenConfig) -> None: - try: - _main(cfg) - except Exception as e: - logger.exception(e) - sys.exit(1) + _main(cfg) if __name__ == "__main__": diff --git a/src/ideas/agents/templates/Cargo.toml b/src/ideas/agents/templates/Cargo.toml new file mode 100644 index 0000000..2525acc --- /dev/null +++ b/src/ideas/agents/templates/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "testgen-templates" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "__LIB_NAME__" + +[[bin]] +name = "testgen-templates" +path = "src/main.rs" + +[features] +cc_asan = [] +cc_ubsan = [] +cc_coverage = [] + +[dev-dependencies] +assert_cmd = "2.0.17" +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +walkdir = "2" + +[workspace] diff --git a/src/ideas/agents/templates/src/lib.rs b/src/ideas/agents/templates/src/lib.rs new file mode 100644 index 0000000..3b9c860 --- /dev/null +++ b/src/ideas/agents/templates/src/lib.rs @@ -0,0 +1 @@ +// This target gives the library templates a real crate to import during template checks. diff --git a/src/ideas/agents/templates/src/main.rs b/src/ideas/agents/templates/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/src/ideas/agents/templates/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/src/ideas/agents/templates/tests/bin_assert.rs b/src/ideas/agents/templates/tests/bin_assert.rs new file mode 100644 index 0000000..6961481 --- /dev/null +++ b/src/ideas/agents/templates/tests/bin_assert.rs @@ -0,0 +1,120 @@ +use __LIB_NAME__::*; + +use assert_cmd::Command; +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +const PROGRAM_PATH_PLACEHOLDER: &str = ""; +const SANITIZER_EXIT_CODE: i32 = 86; + +/// What one binary invocation produced. +struct Call { + stdout: String, + stderr: String, + exit_code: i32, + changed: Vec, + removed: Vec, +} + +/// Create a fresh directory reserved for one test. +fn sandbox(name: &str) -> PathBuf { + let mut components = Path::new(name).components(); + assert!( + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(), + "sandbox name must be one ordinary path component", + ); + let mut id = std::collections::hash_map::DefaultHasher::new(); + env!("CARGO_MANIFEST_DIR").hash(&mut id); + let dir = std::env::temp_dir() + .join("ideas-testgen") + .join(format!("{:016x}", id.finish())) + .join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +type FileTree = BTreeMap>>; + +fn snapshot(dir: &Path) -> FileTree { + WalkDir::new(dir) + .min_depth(1) + .sort_by_file_name() + .into_iter() + .map(|entry| { + let entry = entry.unwrap(); + let path = entry + .path() + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let contents = entry + .file_type() + .is_file() + .then(|| std::fs::read(entry.path()).unwrap()); + (path, contents) + }) + .collect() +} + +fn diff(before: &FileTree, after: &FileTree) -> (Vec, Vec) { + let changed = after + .iter() + .filter_map(|(path, state)| (before.get(path) != Some(state)).then(|| path.clone())) + .collect(); + let removed = before + .keys() + .filter_map(|path| (!after.contains_key(path)).then(|| path.clone())) + .collect(); + (changed, removed) +} + +fn output_text(bytes: &[u8], bin_path: &str) -> String { + String::from_utf8_lossy(bytes).replace(bin_path, PROGRAM_PATH_PLACEHOLDER) +} + +/// Run once and record changed and removed paths below dir. +fn run(dir: &Path, args: &[&str], stdin: Option<&str>) -> Call { + let bin_path = assert_cmd::cargo::cargo_bin(assert_cmd::pkg_name!()); + let bin_path_str = bin_path.to_str().unwrap(); + let before = snapshot(dir); + + let mut cmd = Command::new("stdbuf"); + cmd.args(["-e0", "-o0", bin_path_str]) + .args(args) + .current_dir(dir) + .env("HOME", dir) + .env("TZ", "UTC") + .env("LC_ALL", "C"); + if let Some(input) = stdin { + cmd.write_stdin(input); + } + let output = cmd.output().expect("failed to execute process"); + let signal = output.status.signal(); + let sanitizer_failed = cfg!(any(feature = "cc_asan", feature = "cc_ubsan")) + && (output.status.code() == Some(SANITIZER_EXIT_CODE) || signal.is_some()); + if sanitizer_failed || matches!(signal, Some(libc::SIGILL) | Some(libc::SIGABRT)) { + panic!( + "Sanitizer detected an error while running the binary ({}).\n{}", + output.status, + output_text(&output.stderr, bin_path_str), + ); + } + + let after = snapshot(dir); + let (changed, removed) = diff(&before, &after); + Call { + stdout: output_text(&output.stdout, bin_path_str), + stderr: output_text(&output.stderr, bin_path_str), + exit_code: output.status.code().unwrap_or(-1), + changed, + removed, + } +} + +// ==== Add assertion tests below this line ==== diff --git a/src/ideas/agents/templates/tests/bin_collect.rs b/src/ideas/agents/templates/tests/bin_collect.rs new file mode 100644 index 0000000..f0cd2ce --- /dev/null +++ b/src/ideas/agents/templates/tests/bin_collect.rs @@ -0,0 +1,135 @@ +use __LIB_NAME__::*; + +use assert_cmd::Command; +use serde::Serialize; +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +const PROGRAM_PATH_PLACEHOLDER: &str = ""; +const SANITIZER_EXIT_CODE: i32 = 86; + +/// One binary invocation and everything it produced. +#[derive(Debug, Serialize)] +struct Call { + args: Vec, + stdin: Option, + stdout: String, + stderr: String, + exit_code: i32, + changed: Vec, + removed: Vec, +} + +/// Create a fresh directory reserved for one test. +fn sandbox(name: &str) -> PathBuf { + let mut components = Path::new(name).components(); + assert!( + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(), + "sandbox name must be one ordinary path component", + ); + let mut id = std::collections::hash_map::DefaultHasher::new(); + env!("CARGO_MANIFEST_DIR").hash(&mut id); + let dir = std::env::temp_dir() + .join("ideas-testgen") + .join(format!("{:016x}", id.finish())) + .join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +type FileTree = BTreeMap>>; + +fn snapshot(dir: &Path) -> FileTree { + WalkDir::new(dir) + .min_depth(1) + .sort_by_file_name() + .into_iter() + .map(|entry| { + let entry = entry.unwrap(); + let path = entry + .path() + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let contents = entry + .file_type() + .is_file() + .then(|| std::fs::read(entry.path()).unwrap()); + (path, contents) + }) + .collect() +} + +fn diff(before: &FileTree, after: &FileTree) -> (Vec, Vec) { + let changed = after + .iter() + .filter_map(|(path, state)| (before.get(path) != Some(state)).then(|| path.clone())) + .collect(); + let removed = before + .keys() + .filter_map(|path| (!after.contains_key(path)).then(|| path.clone())) + .collect(); + (changed, removed) +} + +fn output_text(bytes: &[u8], bin_path: &str) -> String { + String::from_utf8_lossy(bytes).replace(bin_path, PROGRAM_PATH_PLACEHOLDER) +} + +/// Run once and record changed and removed paths below dir. +fn run(dir: &Path, args: &[&str], stdin: Option<&str>) -> Call { + let bin_path = assert_cmd::cargo::cargo_bin(assert_cmd::pkg_name!()); + let bin_path_str = bin_path.to_str().unwrap(); + let before = snapshot(dir); + + let mut cmd = Command::new("stdbuf"); + cmd.args(["-e0", "-o0", bin_path_str]) + .args(args) + .current_dir(dir) + .env("HOME", dir) + .env("TZ", "UTC") + .env("LC_ALL", "C"); + if let Some(input) = stdin { + cmd.write_stdin(input); + } + let output = cmd.output().expect("failed to execute process"); + let signal = output.status.signal(); + let sanitizer_failed = cfg!(any(feature = "cc_asan", feature = "cc_ubsan")) + && (output.status.code() == Some(SANITIZER_EXIT_CODE) || signal.is_some()); + if sanitizer_failed || matches!(signal, Some(libc::SIGILL) | Some(libc::SIGABRT)) { + panic!( + "Sanitizer detected an error while running the binary ({}).\n{}", + output.status, + output_text(&output.stderr, bin_path_str), + ); + } + + let after = snapshot(dir); + let (changed, removed) = diff(&before, &after); + Call { + args: args.iter().map(|value| value.to_string()).collect(), + stdin: stdin.map(str::to_string), + stdout: output_text(&output.stdout, bin_path_str), + stderr: output_text(&output.stderr, bin_path_str), + exit_code: output.status.code().unwrap_or(-1), + changed, + removed, + } +} + +fn save_case(name: &str, case: &T) { + std::fs::create_dir_all("json").unwrap(); + std::fs::write( + format!("json/{name}.json"), + serde_json::to_string_pretty(case).unwrap(), + ) + .unwrap(); +} + +// ==== Add collection tests below this line ==== diff --git a/src/ideas/agents/templates/tests/lib_assert.rs b/src/ideas/agents/templates/tests/lib_assert.rs new file mode 100644 index 0000000..968c210 --- /dev/null +++ b/src/ideas/agents/templates/tests/lib_assert.rs @@ -0,0 +1,52 @@ +use __LIB_NAME__::*; + +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// Create a fresh directory reserved for one test. +fn sandbox(name: &str) -> PathBuf { + let mut components = Path::new(name).components(); + assert!( + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(), + "sandbox name must be one ordinary path component", + ); + let mut id = std::collections::hash_map::DefaultHasher::new(); + env!("CARGO_MANIFEST_DIR").hash(&mut id); + let dir = std::env::temp_dir() + .join("ideas-testgen") + .join(format!("{:016x}", id.finish())) + .join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +type FileTree = BTreeMap>>; + +/// Capture relative paths and exact file contents below a sandbox. +fn snapshot(dir: &Path) -> FileTree { + WalkDir::new(dir) + .min_depth(1) + .sort_by_file_name() + .into_iter() + .map(|entry| { + let entry = entry.unwrap(); + let path = entry + .path() + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let contents = entry + .file_type() + .is_file() + .then(|| std::fs::read(entry.path()).unwrap()); + (path, contents) + }) + .collect() +} + +// ==== Add assertion tests below this line ==== diff --git a/src/ideas/agents/templates/tests/lib_collect.rs b/src/ideas/agents/templates/tests/lib_collect.rs new file mode 100644 index 0000000..60ec7f1 --- /dev/null +++ b/src/ideas/agents/templates/tests/lib_collect.rs @@ -0,0 +1,62 @@ +use __LIB_NAME__::*; + +use serde::Serialize; +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// Create a fresh directory reserved for one test. +fn sandbox(name: &str) -> PathBuf { + let mut components = Path::new(name).components(); + assert!( + matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(), + "sandbox name must be one ordinary path component", + ); + let mut id = std::collections::hash_map::DefaultHasher::new(); + env!("CARGO_MANIFEST_DIR").hash(&mut id); + let dir = std::env::temp_dir() + .join("ideas-testgen") + .join(format!("{:016x}", id.finish())) + .join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +type FileTree = BTreeMap>>; + +/// Capture relative paths and exact file contents below a sandbox. +fn snapshot(dir: &Path) -> FileTree { + WalkDir::new(dir) + .min_depth(1) + .sort_by_file_name() + .into_iter() + .map(|entry| { + let entry = entry.unwrap(); + let path = entry + .path() + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let contents = entry + .file_type() + .is_file() + .then(|| std::fs::read(entry.path()).unwrap()); + (path, contents) + }) + .collect() +} + +fn save_case(name: &str, case: &T) { + std::fs::create_dir_all("json").unwrap(); + std::fs::write( + format!("json/{name}.json"), + serde_json::to_string_pretty(case).unwrap(), + ) + .unwrap(); +} + +// ==== Add collection tests below this line ==== diff --git a/src/ideas/agents/utils.py b/src/ideas/agents/utils.py index de88340..2013da7 100644 --- a/src/ideas/agents/utils.py +++ b/src/ideas/agents/utils.py @@ -4,74 +4,322 @@ # SPDX-License-Identifier: Apache-2.0 # +import logging import os -import textwrap import stat +import textwrap from pathlib import Path -from ideas.tools import run_subprocess +from ideas.agents.verifiers import ( + VERIFIER_COMMAND_ENV, + VERIFIER_SOCKET_ENV, + VerificationError, + verify_tests_are_portable, +) +from ideas.ast_rust import get_root +from ideas.tools import Crate, run_subprocess + +logger = logging.getLogger("ideas.agents.utils") + +_TEMPLATE_DIR = Path(__file__).with_name("templates") / "tests" +_ASAN_OPTIONS = ( + "detect_leaks=0" # This is assumed a non-issue for short-lived instrumentation + ":allocator_may_return_null=1" # https://github.com/google/sanitizers/wiki/SanitizerCommonFlags + ":detect_invalid_pointer_pairs=2" # https://github.com/llvm/llvm-project/blob/03fdf3e62c9c647649168a2edb50b0ebb55cdb47/compiler-rt/lib/asan/asan_flags.inc#L149 + ":exitcode=86" +) +_UBSAN_OPTIONS = ( + "halt_on_error=1" # UBSan doesn't halt by default + ":print_stacktrace=1" # https://releases.llvm.org/21.1.0/tools/clang/docs/UndefinedBehaviorSanitizer.html#stack-traces-and-report-symbolization + ":exitcode=86" +) +_PLACEHOLDER = "#[test]\nfn placeholder() {\n assert_eq!(1, 1);\n}\n" + + +def _cargo_test_with_features( + crate: Crate, + test: str, + features: list[str], + name: str | None = None, +) -> bool: + cmd = [ + "cargo", + "nextest", + "run", + "--color=never", + "--cargo-quiet", + f"--manifest-path={crate.cargo_toml}", + "--no-fail-fast", + "--test", + test, + "--test-threads=1", + "--features", + ",".join(features), + ] + if name is not None: + cmd.extend(["-E", f"test(={name})"]) + env = { + **os.environ, + "ASAN_OPTIONS": _ASAN_OPTIONS, + "UBSAN_OPTIONS": _UBSAN_OPTIONS, + "LSAN_OPTIONS": "", + } + return run_subprocess(cmd, env=env, cwd=crate.cargo_toml.parent)[0] + + +def find_failing_tests(crate: Crate, test: str, features: list[str] | None = None) -> list[str]: + if features: + passes = _cargo_test_with_features(crate, test, features) + else: + passes = crate.cargo_test(test)[0] + if passes: + return [] + + names = crate.cargo_test_list(test, features) + return [ + name + for name in names + if not ( + _cargo_test_with_features(crate, test, features, name) + if features + else crate.cargo_test(test, skip=[other for other in names if other != name])[0] + ) + ] + + +def remove_tests(path: Path, names: list[str]) -> list[str]: + unique_names = set(names) + if not unique_names: + return [] + + source = path.read_bytes() + spans: list[tuple[int, int]] = [] + removed: list[str] = [] + for node in get_root(source).children: + name_node = node.child_by_field_name("name") if node.type == "function_item" else None + if name_node is None or name_node.text is None: + continue + if (name := name_node.text.decode()) not in unique_names: + continue + + start, end = node.start_byte, node.end_byte + sibling = node.prev_sibling + while sibling is not None and sibling.type == "attribute_item": + start = sibling.start_byte + sibling = sibling.prev_sibling + while end < len(source) and source[end : end + 1].isspace(): + end += 1 + + spans.append((start, end)) + removed.append(name) + + for start, end in sorted(spans, reverse=True): + source = source[:start] + source[end:] + path.write_bytes(source) + return removed + + +def prune_failing_tests( + crate: Crate, test: str, features: list[str] | None = None +) -> list[str]: + label = f"`{test}`" + (f" with {'+'.join(features)}" if features else "") + failures = find_failing_tests(crate, test, features) + if not failures: + return [] + + logger.warning(f"{len(failures)} test(s) of {label} fail: {', '.join(failures)}") + removed = remove_tests(crate.cargo_toml.parent / "tests" / f"{test}.rs", failures) + if missing := sorted(set(failures) - set(removed)): + logger.warning(f"Failing test(s) {missing} of {label} are not defined in {test}.rs!") + try: + listed = crate.cargo_test_list(test) + if survivors := sorted(set(removed) & set(listed)): + logger.warning(f"Removed test(s) {survivors} of {label} are still listed!") + except RuntimeError as e: + logger.warning(f"Cannot list the tests of `{test}` after removing {removed}: {e}") + return removed + + +def clear_generated_test_artifacts(crate: Crate) -> None: + for pattern in ("json/*.json", "coverage_logs/*.log", "sanitizer_logs/*.log"): + for artifact in crate.cargo_toml.parent.glob(pattern): + artifact.unlink() + + +def write_placeholder_tests(crate: Crate, fresh: Crate, tests: list[str], message: str) -> None: + test_dir = crate.cargo_toml.parent / "tests" + for test in tests: + (test_dir / f"{test}.rs").write_text(_PLACEHOLDER) + crate.vcs.add(test_dir) + crate.vcs.commit(message) + try: + verify_tests_are_portable(crate, fresh, tests) + except VerificationError as error: + # Critical failure + raise VerificationError("A placeholder integration test does not build!") from error + + +def finalize_tests(crate: Crate, fresh: Crate, tests: list[str], features: list[str]) -> None: + crate.vcs.init(force_init=True) + crate_dir = crate.cargo_toml.parent + test_dir = crate_dir / "tests" + clear_generated_test_artifacts(crate) + + for test in tests: + if not (test_dir / f"{test}.rs").exists(): + logger.warning(f"{test}.rs was not generated by the agent!") + (test_dir / f"{test}.rs").write_text(_PLACEHOLDER) + + # Prune uninstrumented first, then once per sanitizer feature + pruned: dict[str, list[str]] = {} + unbuildable: set[str] = set() + for test in tests: + removed: set[str] = set() + try: + for feature in [None, *features]: + removed.update(prune_failing_tests(crate, test, [feature] if feature else None)) + except RuntimeError: + logger.warning( + f"Cannot list the tests of `{test}`; replacing the suites with placeholders" + ) + unbuildable.add(test) + pruned[test] = sorted(removed) + + if any(pruned.values()): + clear_generated_test_artifacts(crate) + + # A test file that lists nothing either holds no test or does not build + empty = list(unbuildable) + for test in tests: + if test in unbuildable: + continue + try: + listed = crate.cargo_test_list(test) + except RuntimeError as e: + logger.warning(f"Cannot list the tests of `{test}`: {e}") + listed = [] + if not listed: + empty.append(test) + + if empty: + message = "No test survived. Only placeholders left" + logger.warning(message) + write_placeholder_tests(crate, fresh, tests, message) + return + + # Recollect data if any test was pruned + if pruned["collect"]: + for stale in (crate_dir / "json").glob("*.json"): + stale.unlink() + crate.cargo_test("collect") + + crate.vcs.add(test_dir) + crate.vcs.commit(f"Removed the failing tests: {pruned}") + + # Portability verification after pruning + try: + verify_tests_are_portable(crate, fresh, tests) + except VerificationError as e: + message = f"The pruned tests did not pass verification: {e}" + logger.warning(message) + write_placeholder_tests(crate, fresh, tests, message) def strip_line_directives(path: Path) -> None: success, output, error, _ = run_subprocess( - ["clang", "--preprocess", "--no-line-commands", str(path)] + ["clang", "--preprocess", "--no-line-commands", str(path), "-o", "-"] ) if not success: raise RuntimeError(f"Failed to strip line directives from {path}!{output + error}") + path.write_text(output) -def write_profile_list(path: Path, functions: list[str]) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("".join(f"fun:{name}\n" for name in sorted(set(functions)))) - return path - - -def write_instrumentation_script( - path: Path, features: list[str], profile_list: Path | None = None -) -> Path: +def write_instrumentation_script(path: Path, features: list[str]) -> Path: path.parent.mkdir(parents=True, exist_ok=True) - # Resolve the list relative to the script so the crate stays relocatable. - profile_list_export = ( - "# Restrict C instrumentation to the program's own functions.\n " - 'script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)\n ' - f'export CFLAGS="${{CFLAGS:-}} -fprofile-list=$script_dir/' - f'{profile_list.resolve().relative_to(path.parent.resolve())}"\n ' - if profile_list is not None - else "" - ) contents = textwrap.dedent( f""" #!/usr/bin/env bash ## This script is generated automatically and should not be modified! ## set -euo pipefail - export ASAN_OPTIONS="detect_leaks=0" - export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1" - export LSAN_OPTIONS= - - # Discard raw profiles from previous runs so coverage reflects only this run's tests. - cargo llvm-cov clean --profraw-only - test_name="${{1:-}}" if [ "$test_name" != "collect" ] && [ "$test_name" != "io" ]; then echo "Usage: $0 " >&2 exit 2 fi + # Keep the harness callback out of the environment observed by generated tests. + test_verifier="${{{VERIFIER_COMMAND_ENV}:-}}" + verifier_socket="${{{VERIFIER_SOCKET_ENV}:-}}" + unset {VERIFIER_COMMAND_ENV} {VERIFIER_SOCKET_ENV} + log_dir="coverage_logs" mkdir -p "$log_dir" + coverage_report="$log_dir/coverage_report.log" + coverage_summary="$log_dir/coverage_summary.log" + coverage_log="$log_dir/cc_coverage.log" + uncovered_branches="$log_dir/uncovered_branches.log" + rm -f "$coverage_report" "$coverage_summary" "$coverage_log" "$uncovered_branches" sanitizer_dir="sanitizer_logs" + mkdir -p "$sanitizer_dir" + rm -f "$sanitizer_dir"/*.log features="{" ".join(features)}" sanitizer_status=0 + + json_snapshot="" + previous_json="" + restore_json() {{ + local snapshot="$json_snapshot" + json_snapshot="" + if [ -n "$snapshot" ]; then + mkdir -p json + rm -f json/*.json + cp -a "$snapshot"/. json/ + rm -rf "$snapshot" + fi + }} + cleanup_json() {{ + restore_json + if [ -n "$previous_json" ]; then + rm -rf "$previous_json" + previous_json="" + fi + }} + trap cleanup_json EXIT + if [ "$test_name" = "collect" ]; then + json_snapshot=$(mktemp -d) + previous_json=$(mktemp -d) + if [ -d json ]; then + find json -maxdepth 1 -type f -name '*.json' -exec cp -a -t "$json_snapshot" {{}} + + find json -maxdepth 1 -type f -name '*.json' -exec cp -a -t "$previous_json" {{}} + + fi rm -f json/*.json fi + # Capture expected values from the ordinary, uninstrumented crate + cargo nextest run --cargo-quiet --status-level fail --final-status-level fail \\ + --test "$test_name" --no-fail-fast --test-threads 1 + if [ "$test_name" = "collect" ]; then + rm -f "$json_snapshot"/*.json + if [ -d json ]; then + find json -maxdepth 1 -type f -name '*.json' -exec cp -a -t "$json_snapshot" {{}} + + fi + fi + + export ASAN_OPTIONS="{_ASAN_OPTIONS}" + export UBSAN_OPTIONS="{_UBSAN_OPTIONS}" + export LSAN_OPTIONS= + export DEBUGINFOD_URLS= + # Run each sanitizer: print basic diagnostics to stdout and, only on # failure, save a detailed per-sanitizer log (including stderr diagnostics). for feature in $features; do err_file=$(mktemp) - if ! output=$(NEXTEST_EXPERIMENTAL_LIBTEST_JSON=1 cargo nextest run --features "$feature" --test "$test_name" --no-fail-fast --test-threads 1 --message-format libtest-json 2>"$err_file"); then + if ! output=$(NEXTEST_EXPERIMENTAL_LIBTEST_JSON=1 cargo nextest run \\ + --cargo-quiet --status-level fail --final-status-level fail \\ + --features "$feature" --test "$test_name" --no-fail-fast \\ + --test-threads 1 --message-format libtest-json 2>"$err_file"); then sanitizer_status=1 mkdir -p "$sanitizer_dir" log_file="$sanitizer_dir/$feature.log" @@ -82,8 +330,6 @@ def write_instrumentation_script( | "\\(.passed + .failed) tests run: \\(.passed) passed, \\(.failed) failed"' printf '%s\\n' "$output" | jq -r 'select(.type == "test" and .event == "failed") | "FAIL \\(.name)"' - echo " detailed log: $log_file" - # Detailed diagnostics -> per-sanitizer log file {{ echo "=== $feature ===" @@ -92,6 +338,8 @@ def write_instrumentation_script( echo "--- stderr (sanitizer diagnostics) ---" cat "$err_file" }} > "$log_file" + sed -n '1,80p' "$log_file" + echo " detailed log: $log_file" fi rm -f "$err_file" done @@ -101,174 +349,111 @@ def write_instrumentation_script( fi echo "All sanitizer checks passed" - {profile_list_export} - cargo llvm-cov nextest --features cc_coverage --include-ffi --no-report --test "$test_name" --no-fail-fast --test-threads 1 > /dev/null 2>&1 - cargo llvm-cov report --include-ffi --text > "$log_dir/coverage_report.log" + coverage_failed() {{ + local stage="$1" + local status="$2" + echo "=== cc_coverage ===" + echo "Coverage $stage failed with exit code $status" + tail -n 40 "$coverage_log" + echo " detailed log: $coverage_log" + exit "$status" + }} + + if cargo llvm-cov clean --profraw-only >> "$coverage_log" 2>&1; then + : + else + status=$? + coverage_failed "cleanup" "$status" + fi + if cargo llvm-cov nextest --features cc_coverage --include-ffi --no-report --test "$test_name" --no-fail-fast --test-threads 1 >> "$coverage_log" 2>&1; then + : + else + status=$? + coverage_failed "test run" "$status" + fi + if cargo llvm-cov report --include-ffi --text > "$coverage_report" 2>> "$coverage_log"; then + : + else + status=$? + coverage_failed "text report" "$status" + fi # Coverage summary table -> stdout and log file. - echo "Coverage summary:" - cargo llvm-cov report --include-ffi --summary-only | tee "$log_dir/coverage_summary.log" + echo "Coverage summary for $test_name:" + if cargo llvm-cov report --include-ffi --summary-only 2>> "$coverage_log" | tee "$coverage_summary" | tee -a "$coverage_log"; then + : + else + status=$? + coverage_failed "summary report" "$status" + fi # Emit only uncovered branches (a branch whose True or False count is zero). echo "Uncovered branches:" - {{ grep -E 'Branch \\(.*(True: 0,|False: 0\\])' "$log_dir/coverage_report.log" || echo " none"; }} | tee "$log_dir/uncovered_branches.log" + {{ grep -E 'Branch \\(.*(True: 0,|False: 0\\])' "$coverage_report" || echo " none"; }} | tee "$uncovered_branches" + + # The test-generation harness verifies the generated test against a pristine crate. + if [ -n "$test_verifier" ]; then + "$test_verifier" -m ideas.agents.verifiers "$verifier_socket" "$test_name" + echo "Verified tests/$test_name.rs against a pristine crate." + fi + + if [ "$test_name" = "collect" ]; then + restore_json + echo "JSON values changed by this collect run:" + changed_json=0 + total_json=0 + for json_file in json/*.json; do + [ -e "$json_file" ] || continue + total_json=$((total_json + 1)) + prior="$previous_json/${{json_file#json/}}" + if [ -e "$prior" ] && cmp -s "$prior" "$json_file"; then + continue + fi + changed_json=$((changed_json + 1)) + echo "--- $json_file" + jq -c . "$json_file" 2>/dev/null || cat "$json_file" + done + if [ "$changed_json" -eq 0 ]; then + echo " none ($total_json JSON files remain under json/)" + fi + fi + + branch_coverage=$(awk '$1 == "TOTAL" {{ print $NF; exit }}' "$coverage_summary") + if [ -z "$branch_coverage" ]; then + coverage_failed "branch coverage extraction" 1 + fi + echo "Branch coverage: $branch_coverage" """ ).strip() path.unlink(missing_ok=True) path.write_text(contents + "\n") + # Read + execute for the current user path.chmod(stat.S_IRUSR | stat.S_IXUSR) return path -def write_collect_script(path: Path, template: str, lib_name: str | None = None): - path.parent.mkdir(parents=True, exist_ok=True) - if template == "bin": - collect_stub = textwrap.dedent( - """ - #![allow(unused_imports, dead_code)] - use std::os::unix::process::ExitStatusExt; - use assert_cmd::Command; - use serde::Serialize; - use serde_json; - - /// A single invocation of the binary and everything it produced. - #[derive(Serialize)] - struct Call { - args: Vec, - stdin: Option, - stdout: String, - stderr: String, - exit_code: i32, - } - - /// One collection test and the calls it made, in order. - #[derive(Serialize)] - struct Case<'a> { - name: &'a str, - calls: &'a [Call], - } - - fn run(args: &[&str], stdin: Option<&str>) -> Call { - let bin_path = assert_cmd::cargo::cargo_bin(assert_cmd::pkg_name!()); - let bin_path_str = bin_path.to_str().unwrap(); - - let mut cmd = Command::new("stdbuf"); - cmd.args(&["-e0", "-o0", bin_path_str]); - if !args.is_empty() { - cmd.args(args); - } - if let Some(input) = stdin { - cmd.write_stdin(input); - } - let output = cmd.output().expect("failed to execute process"); - if matches!(output.status.signal(), Some(libc::SIGILL) | Some(libc::SIGABRT)) { - panic!("Sanitizer detected an error during collection!"); - } - Call { - args: args.iter().map(|s| s.to_string()).collect(), - stdin: stdin.map(|s| s.to_string()), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - exit_code: output.status.code().unwrap_or(-1), - } - } - - /// Serialize one collection test's ordered calls to `json/.json`. - fn save_case(name: &str, calls: &[Call]) { - std::fs::create_dir_all("json").unwrap(); - let case = Case { name, calls }; - std::fs::write( - format!("json/{name}.json"), - serde_json::to_string_pretty(&case).unwrap(), - ) - .unwrap(); - } - - // ==== Add collection tests below this line ==== - """ - ).strip() - else: - if lib_name is None: - raise ValueError("lib_name is required for the library collect script") - - collect_stub = textwrap.dedent( - f""" - #![allow(unused_imports, dead_code)] - use {lib_name}::*; - use serde::Serialize; - use serde_json; - - /// Serialize one collection test's input/output state to `json/.json`. - fn save_case(name: &str, case: &T) {{ - std::fs::create_dir_all("json").unwrap(); - let path = format!("json/{{name}}.json"); - std::fs::write(path, serde_json::to_string_pretty(case).unwrap()).unwrap(); - }} - - // ==== Add collection tests below this line ==== - """ - ).strip() - path.write_text(collect_stub + "\n") - - -def write_assert_script(path: Path, template: str, lib_name: str | None = None) -> Path: +def _write_test_template(path: Path, template: str, kind: str, lib_name: str | None) -> Path: + if template not in {"bin", "lib"}: + raise ValueError(f"Unknown test template: {template}") + if lib_name is None: + raise ValueError("lib_name is required for a test template") + path.parent.mkdir(parents=True, exist_ok=True) - if template == "bin": - assert_stub = textwrap.dedent( - """ - #![allow(unused_imports, dead_code)] - use std::os::unix::process::ExitStatusExt; - use assert_cmd::Command; - use predicates::prelude::*; - - /// What a single invocation of the binary produced. - struct Call { - stdout: String, - stderr: String, - exit_code: i32, - } - - fn run(args: &[&str], stdin: Option<&str>) -> Call { - let bin_path = assert_cmd::cargo::cargo_bin(assert_cmd::pkg_name!()); - let bin_path_str = bin_path.to_str().unwrap(); - - let mut cmd = Command::new("stdbuf"); - cmd.args(&["-e0", "-o0", bin_path_str]); - if !args.is_empty() { - cmd.args(args); - } - if let Some(input) = stdin { - cmd.write_stdin(input); - } - let output = cmd.output().expect("failed to execute process"); - if matches!(output.status.signal(), Some(libc::SIGILL) | Some(libc::SIGABRT)) { - panic!("Sanitizer detected an error while running the binary!"); - } - Call { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - exit_code: output.status.code().unwrap_or(-1), - } - } - - // ==== Add assertion tests below this line ==== - """ - ).strip() - else: - if lib_name is None: - raise ValueError("lib_name is required for the library assert script") - - assert_stub = textwrap.dedent( - f""" - #![allow(unused_imports, dead_code)] - use {lib_name}::*; - - // ==== Add assertion tests below this line ==== - """ - ).strip() - path.write_text(assert_stub + "\n") + source = _TEMPLATE_DIR / f"{template}_{kind}.rs" + path.write_text(source.read_text().replace("__LIB_NAME__", lib_name)) return path -RESTRICT_COVERAGE = os.environ.get("RESTRICT_COVERAGE", "0") not in ("0", "", "false", "False") +def write_test_templates(path: Path, template: str, lib_name: str | None = None) -> None: + _write_test_template(path / "collect.rs", template, "collect", lib_name) + _write_test_template(path / "io.rs", template, "assert", lib_name) + + +TESTGEN_BOTTOM_UP = os.environ.get("TESTGEN_BOTTOM_UP", "0") not in ("0", "", "false", "False") +TESTGEN_FINISH_EARLY = os.environ.get("TESTGEN_FINISH_EARLY", "1") not in ( + "0", + "", + "false", + "False", +) diff --git a/src/ideas/agents/verifiers.py b/src/ideas/agents/verifiers.py new file mode 100644 index 0000000..84eb665 --- /dev/null +++ b/src/ideas/agents/verifiers.py @@ -0,0 +1,313 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import contextlib +import filecmp +import logging +import os +import secrets +import shutil +import socket +import sys +import tempfile +import threading +from collections.abc import Generator +from pathlib import Path + +import tomlkit + +from ideas.tools import Crate + +VERIFIER_COMMAND_ENV = "IDEAS_TEST_VERIFIER" +VERIFIER_SOCKET_ENV = "IDEAS_TEST_VERIFIER_SOCKET" + +_VERIFICATION_TESTS = ("collect", "io") +_ARTIFACT_DIRS = ("coverage_logs", "json", "sanitizer_logs") +_ignore_artifacts = shutil.ignore_patterns(".git", "target", *_ARTIFACT_DIRS) + +logger = logging.getLogger("ideas.agents.verifiers") + + +class VerificationError(Exception): + """A generated test is not portable.""" + + +def _restore(path: Path, snapshot: tuple[bytes, int]) -> bool: + try: + if ( + not path.is_symlink() + and ( + path.read_bytes(), + path.stat().st_mode & 0o7777, + ) + == snapshot + ): + return False + except OSError: + pass + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + path.write_bytes(snapshot[0]) + path.chmod(snapshot[1]) + return True + + +def get_fresh_copy(crate: Crate, destination: Path) -> Crate: + random_name = f"crate-{secrets.token_hex(8)}".replace("-", "_") + fresh_dir = destination / random_name + shutil.copytree(crate.cargo_toml.parent, fresh_dir, ignore=_ignore_artifacts) + + manifest_path = fresh_dir / "Cargo.toml" + manifest = tomlkit.loads(manifest_path.read_text()) + if (package := manifest.get("package")) is None: + raise RuntimeError(f"Expected a package manifest at {manifest_path}!") + package["name"] = random_name + + if (library := manifest.get("lib")) is not None: + library["name"] = random_name + if (binaries := manifest.get("bin")) is not None: + if len(binaries) > 1: + raise RuntimeError( + f"Expected at most one binary in the manifest at {manifest_path}!" + ) + binaries[0]["name"] = random_name + manifest_path.write_text(tomlkit.dumps(manifest)) + + if crate.main_src_path is not None and crate.lib_name is not None: + main = fresh_dir / crate.main_src_path.relative_to(crate.cargo_toml.parent) + main.write_text( + main.read_text().replace(f"use {crate.lib_name}::*;", f"use {random_name}::*;", 1) + ) + return Crate(manifest_path) + + +def verify_tests_are_portable(source: Crate, fresh: Crate, tests: list[str]) -> None: + if not tests: + raise VerificationError("No generated test was submitted for verification!") + if unknown := [test for test in tests if test not in _VERIFICATION_TESTS]: + raise VerificationError(f"Unknown generated test `{unknown[0]}`!") + + source_script = source.cargo_toml.parent / "instrument.sh" + fresh_script = fresh.cargo_toml.parent / "instrument.sh" + pristine_script = fresh_script.read_bytes(), fresh_script.stat().st_mode & 0o7777 + try: + script_was_modified = _restore(source_script, pristine_script) + except OSError as error: + raise VerificationError( + "The original `instrument.sh` could not be restored!" + ) from error + if script_was_modified: + raise VerificationError( + "The provided `instrument.sh` was modified and has been reverted." + ) + + source_dir, fresh_dir = source.cargo_toml.parent, fresh.cargo_toml.parent + (fresh_dir / "tests").mkdir(parents=True, exist_ok=True) + for test in tests: + try: + contents = (source_dir / "tests" / f"{test}.rs").read_text() + contents = contents.replace( + f"use {source.lib_name}::*;", f"use {fresh.lib_name}::*;", 1 + ) + (fresh_dir / "tests" / f"{test}.rs").write_text(contents) + except OSError as error: + raise VerificationError( + f"Cannot copy the generated `{test}.rs` test to another crate!" + ) from error + + for test in tests: + passes, output, error, _ = fresh.cargo_test(test) + if any([_restore(script, pristine_script) for script in (source_script, fresh_script)]): + raise VerificationError( + "Re-running tests in a fresh environment unexpectedly modified " + "`instrument.sh` in-place; it was reverted!" + ) + if not passes: + raise VerificationError( + f"The generated `{test}.rs` is not portable to a fresh crate copy " + "after editing the top-level imports!\n" + output + error + ) + + +def _directory_difference(comparison: filecmp.dircmp, relative: Path = Path()) -> Path | None: + changed = sorted( + comparison.left_only + + comparison.right_only + + comparison.diff_files + + comparison.common_funny + + comparison.funny_files + ) + if changed: + return relative / changed[0] + + for name in comparison.common: + left, right = Path(comparison.left) / name, Path(comparison.right) / name + if ( + left.is_symlink() != right.is_symlink() + or left.lstat().st_mode & 0o7777 != right.lstat().st_mode & 0o7777 + or left.is_symlink() + and left.readlink() != right.readlink() + or name in comparison.common_files + and not filecmp.cmp(left, right, shallow=False) + ): + return relative / name + for name, child in sorted(comparison.subdirs.items()): + if difference := _directory_difference(child, relative / name): + return difference + return None + + +def verify_instrumentation( + candidate_manifest: Path, + workspace: Path, + fresh_manifest: Path, + tests: str | list[str], +) -> None: + """Run instrumentation-loop checks in a temporary pristine workspace.""" + if isinstance(tests, str): + tests = [tests] + with tempfile.TemporaryDirectory(prefix="ideas-verifier-") as scratch: + staged_root = Path(scratch) / "workspace" + shutil.copytree(workspace, staged_root, ignore=_ignore_artifacts) + fresh = Crate(staged_root / fresh_manifest) + pristine_workspace = fresh.workspace_root + try: + verify_tests_are_portable(Crate(candidate_manifest), fresh, tests) + shutil.rmtree(pristine_workspace / "target", ignore_errors=True) + for name in _ARTIFACT_DIRS: + shutil.rmtree(fresh.cargo_toml.parent / name, ignore_errors=True) + + relative = fresh.cargo_toml.parent.relative_to(pristine_workspace) + for test in tests: + pristine_test = workspace / relative / "tests" / f"{test}.rs" + staged_test = fresh.cargo_toml.parent / "tests" / f"{test}.rs" + if pristine_test.exists(): + _restore( + staged_test, + (pristine_test.read_bytes(), pristine_test.stat().st_mode & 0o7777), + ) + else: + staged_test.unlink(missing_ok=True) + with contextlib.suppress(OSError): + staged_test.parent.rmdir() + + comparison = filecmp.dircmp(workspace, staged_root) + if difference := _directory_difference(comparison): + raise VerificationError( + "Running tests in a fresh crate changed an unexpected workspace path: " + f"`{difference}`!" + ) + except VerificationError as error: + message = str(error).replace(scratch, "") + raise VerificationError(message) from None + + +def run_cli(arguments: list[str]) -> tuple[int, str | None]: + if len(arguments) != 2: + return 2, "Usage: verifier " + try: + with socket.socket(socket.AF_UNIX) as connection: + connection.connect(arguments[0]) + connection.sendall((arguments[1] + "\n").encode()) + response = connection.makefile("rb").read().decode() + status, _, message = response.partition("\n") + return int(status), message or None + except (OSError, UnicodeError, ValueError): + return 1, "The test verifier failed unexpectedly!" + + +@contextlib.contextmanager +def verification_service(candidate: Crate, crate: Crate) -> Generator[None]: + """Verify script runs and retain the latest successful generated-test pair.""" + workspace = crate.workspace_root.resolve() + fresh_manifest = crate.cargo_toml.relative_to(workspace) + latest_pair: dict[str, tuple[bytes, int]] = {} + completed = False + + with ( + tempfile.TemporaryDirectory(prefix="ideas-verifier-") as temporary, + socket.socket(socket.AF_UNIX) as listener, + ): + socket_path = Path(temporary) / "verifier.sock" + listener.bind(str(socket_path)) + listener.listen() + + def serve() -> None: + successful: dict[str, tuple[bytes, int]] = {} + while True: + try: + connection, _ = listener.accept() + except OSError: + return + with connection: + test = connection.recv(64).decode(errors="replace").strip() + try: + verify_instrumentation( + candidate.cargo_toml, workspace, fresh_manifest, [test] + ) + path = candidate.cargo_toml.parent / "tests" / f"{test}.rs" + successful[test] = path.read_bytes(), path.stat().st_mode & 0o7777 + if all(name in successful for name in _VERIFICATION_TESTS): + latest_pair.update(successful) + status, message = 0, None + except VerificationError as error: + status, message = 1, str(error) + except Exception: + status, message = 1, "The test verifier failed unexpectedly!" + connection.sendall(f"{status}\n{message or ''}".encode()) + + server = threading.Thread(target=serve, daemon=True) + server.start() + + values = { + VERIFIER_COMMAND_ENV: sys.executable, + VERIFIER_SOCKET_ENV: str(socket_path), + } + previous = {name: os.environ.get(name) for name in values} + os.environ.update(values) + try: + yield + completed = True + finally: + for name, value in previous.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + with contextlib.suppress(OSError): + listener.shutdown(socket.SHUT_RDWR) + server.join() + + if not completed: + return + try: + verify_instrumentation( + candidate.cargo_toml, workspace, fresh_manifest, list(_VERIFICATION_TESTS) + ) + except VerificationError as error: + message = str(error).splitlines()[0] + if len(latest_pair) == len(_VERIFICATION_TESTS): + for test, (contents, mode) in latest_pair.items(): + path = candidate.cargo_toml.parent / "tests" / f"{test}.rs" + _restore(path, (contents, mode)) + logger.warning( + "Final verification failed (%s); restored the last verified pair", message + ) + else: + logger.warning("Final verification failed and no verified pair exists: %s", message) + + +def main() -> None: + status, message = run_cli(sys.argv[1:]) + if message is not None: + print(message, file=sys.stderr) + raise SystemExit(status) + + +if __name__ == "__main__": + main() diff --git a/src/ideas/ast.py b/src/ideas/ast.py index 3488b8c..7766300 100644 --- a/src/ideas/ast.py +++ b/src/ideas/ast.py @@ -4,26 +4,30 @@ # SPDX-License-Identifier: Apache-2.0 # +import os import logging from pathlib import Path from functools import cmp_to_key from collections import defaultdict from collections.abc import Iterable -from dataclasses import astuple, dataclass, field, fields, replace +from dataclasses import astuple, dataclass, field, fields from typing import get_args from clang.cindex import TranslationUnit, TranslationUnitLoadError, Diagnostic -from clang.cindex import Cursor, CursorKind, SourceRange, TokenKind, Type, TypeKind +from clang.cindex import Cursor, CursorKind, SourceRange, Token, TokenKind, Type, TypeKind from clang.cindex import PrintingPolicy, PrintingPolicyProperty, LinkageKind, StorageClass -from clang.cindex import conf, SourceLocation, _CXString +from clang.cindex import conf, SourceLocation, File, _CXString, c_object_p from ctypes import byref, pointer, c_size_t, c_char_p, c_uint from .adapters import Code +from .ast_rust import BindgenName, mangle as rust_mangle logger = logging.getLogger("ideas.ast") FILENAME = "file.c" CodeC = Code["c"] +_VA_LIST_DESUGARED = "struct __va_list_tag *" + # Cursor kinds that become symbols, ranked by order in which they should be translated _KIND_RANK = { CursorKind.ENUM_DECL: 0, @@ -35,6 +39,15 @@ CursorKind.FUNCTION_DECL: 3, } +# C keeps tags in a namespace of their own, so only the keyword tells `struct parser` apart +# from a typedef `parser`. Every other kind in `_KIND_RANK` is an ordinary identifier. +_TAG_PREFIX = { + CursorKind.STRUCT_DECL: "struct ", + CursorKind.UNION_DECL: "union ", + CursorKind.ENUM_DECL: "enum ", +} +_TAG_KEYWORDS = frozenset(prefix.strip() for prefix in _TAG_PREFIX.values()) + @dataclass(frozen=True) class TypeShape: @@ -136,14 +149,13 @@ class Symbol: kind: CursorKind # Rendered C snippets - llm_context_declaration: str - declaration: CodeC | None + forward_declaration: CodeC | None code: CodeC # Symbol semantics is_definition: bool - is_global: bool - is_system: bool + is_externally_visible: bool + in_system_header: bool is_top_level: bool storage_class: StorageClass @@ -152,11 +164,18 @@ class Symbol: presumed_path: Path | None tu_preorder_index: int line_directive: CodeC | None - declaration_line_directive: CodeC | None # Structural summary of the type, empty for symbols that are not types type_shape: TypeShape = field(default_factory=TypeShape) + is_struct: bool = False + + # `typedef struct Foo Foo;` — a second name for a tag that already has it + is_alias_typedef: bool = False + + # The name bindgen gives this symbol, or None when bindgen's name is unpredictable + bindgen_name: BindgenName | None = None + @property def difficulty(self) -> tuple[int, ...]: # A sort key that puts the easiest symbol first: the symbol kind decides the @@ -169,7 +188,6 @@ def from_cursor( name: str, cursor: Cursor, parent: Cursor | None = None, - decl: Cursor | None = None, tu_preorder_index: int = -1, ) -> "Symbol": parent_or_cursor = parent or cursor @@ -179,28 +197,38 @@ def from_cursor( name=name, spelling=cursor.spelling, kind=cursor.kind, - llm_context_declaration=_synthesize_llm_context_declaration( - cursor, fallback_code=code - ), - declaration=get_cursor_code(decl, pretty_print=True) if decl else None, + forward_declaration=_synthesize_forward_declaration(cursor), code=code, is_definition=cursor.is_definition(), - is_global=cursor.linkage == LinkageKind.EXTERNAL, - is_system=cursor.location.is_in_system_header, + is_externally_visible=_is_externally_visible(cursor), + in_system_header=cursor.location.is_in_system_header, tu_path=Path(cursor.translation_unit.spelling).resolve(), presumed_path=Path(presumed_location[0]) if presumed_location is not None else None, tu_preorder_index=tu_preorder_index, line_directive=_line_directive_for(parent_or_cursor), - declaration_line_directive=_line_directive_for(decl), storage_class=cursor.storage_class, is_top_level=parent is None, type_shape=TypeShape.from_cursor(parent_or_cursor), + is_struct=cursor.kind == CursorKind.STRUCT_DECL + or _inline_struct(cursor) is not None, + is_alias_typedef=_is_alias_typedef(cursor), + bindgen_name=_bindgen_name(cursor), ) @property def is_variable(self) -> bool: return self.kind == CursorKind.VAR_DECL + @property + def is_system(self) -> bool: + # A symbol whose presumed path shares nothing but "/" with its translation unit comes + # from outside the project even when clang does not flag it as a system header + if self.in_system_header: + return True + if self.presumed_path is None: + return False + return os.path.commonpath([self.presumed_path, self.tu_path]) == "/" + @property def is_function(self) -> bool: return self.kind == CursorKind.FUNCTION_DECL @@ -210,19 +238,13 @@ def is_type(self) -> bool: return self.kind in ( CursorKind.STRUCT_DECL, CursorKind.UNION_DECL, + CursorKind.ENUM_DECL, CursorKind.TYPEDEF_DECL, ) @property - def is_struct(self) -> bool: - return self.kind == CursorKind.STRUCT_DECL - - def with_declaration(self, decl_symbol: "Symbol") -> "Symbol": - return replace( - self, - declaration=decl_symbol.code, - declaration_line_directive=decl_symbol.line_directive, - ) + def qualified_spelling(self) -> str: + return _TAG_PREFIX.get(self.kind, "") + self.spelling def __getstate__(self) -> dict[str, object]: state = dict(self.__dict__) @@ -250,34 +272,137 @@ class TreeResult: local_names: frozenset[str] = field(default_factory=frozenset) -def _synthesize_llm_context_declaration(cursor: Cursor, fallback_code: CodeC) -> str: - # Synthesize forward declaration from cursor - if cursor.kind == CursorKind.FUNCTION_DECL: - result_type = cursor.result_type.spelling if cursor.result_type else "void" - params = ", ".join( - p.type.spelling + (" " + p.spelling if p.spelling else "") # type: ignore[reportOptionalMemberAccess] - for p in cursor.get_arguments() - ) - return f"{result_type} {cursor.spelling}({params});" - elif cursor.kind in ( +def _bindgen_name(cursor: Cursor) -> BindgenName | None: + # bindgen names a record after its tag and renders the typedef as a separate alias, so + # `typedef struct Tag { ... } Alias;` is wrapped as `Tag`. + if (inline := _inline_struct(cursor)) is not None and not inline.is_anonymous(): + cursor = inline + + # bindgen names an anonymous record `parent__bindgen_ty_N`, where `N` counts across the + # whole run, so neither it nor anything nested inside it has a predictable name + if cursor.is_anonymous(): + return None + + # Walk the lexical parents: C puts every tag in a flat namespace, so the semantic parent + # of a nested record is the translation unit, but the record is still written inside + # another record, and bindgen names it after that whole ancestor chain, so + # `struct record` inside `struct histindex` becomes `histindex_record`. + parts = [cursor.spelling] + parent = cursor.lexical_parent + while parent is not None and parent.kind in ( CursorKind.STRUCT_DECL, CursorKind.UNION_DECL, - CursorKind.ENUM_DECL, ): - kind_name = { - CursorKind.STRUCT_DECL: "struct", - CursorKind.UNION_DECL: "union", - CursorKind.ENUM_DECL: "enum", - }[cursor.kind] - return f"{kind_name} {cursor.spelling};" - elif cursor.kind == CursorKind.TYPEDEF_DECL: - underlying = cursor.underlying_typedef_type.spelling - return f"typedef {underlying} {cursor.spelling};" - elif cursor.kind == CursorKind.VAR_DECL: - return f"{cursor.type.spelling} {cursor.spelling};" - - # Fallback: return full code - return str(fallback_code) + if parent.is_anonymous(): + return None + parts.append(parent.spelling) + parent = parent.lexical_parent + + # Mangled here so the value is always usable as the Rust identifier it names + return BindgenName(rust_mangle("_".join(reversed(parts)))) + + +def _is_externally_visible(cursor: Cursor) -> bool: + # A typedef name has no linkage of its own, so clang reports NO_LINKAGE unless the + # typedef supplies the name for an otherwise unnamed tag. Inherit from the type it + # denotes instead, so `typedef struct Tag {...} alias;` is as visible as `struct Tag`. + if cursor.kind == CursorKind.TYPEDEF_DECL: + decl = cursor.underlying_typedef_type.get_canonical().get_declaration() + if decl.kind != CursorKind.NO_DECL_FOUND: + cursor = decl + return cursor.linkage == LinkageKind.EXTERNAL + + +def _inline_struct(cursor: Cursor) -> Cursor | None: + if cursor.kind != CursorKind.TYPEDEF_DECL: + return None + + # Only a typedef that writes the body inline owns a struct. A typedef that merely + # aliases one (`typedef struct Tag Alias;`) has a `TYPE_REF` child instead, which also + # keeps out aliases of aliases and opaque types, neither of which has fields to convert. + return next( + ( + child + for child in cursor.get_children() + if child.kind == CursorKind.STRUCT_DECL and child.is_definition() + ), + None, + ) + + +def _is_alias_typedef(cursor: Cursor) -> bool: + if cursor.kind != CursorKind.TYPEDEF_DECL: + return False + + # A typedef that writes the body inline declares the tag; only one that refers to an + # existing tag, and so has nothing but `TYPE_REF` children, can be redundant. + if any(child.kind != CursorKind.TYPE_REF for child in cursor.get_children()): + return False + + # `typedef struct Tag Alias;` still has to become `pub type Alias = Tag;` + decl = cursor.underlying_typedef_type.get_declaration() + return ( + decl.kind in (CursorKind.STRUCT_DECL, CursorKind.UNION_DECL, CursorKind.ENUM_DECL) + and decl.spelling == cursor.spelling + ) + + +def _synthesize_forward_declaration(cursor: Cursor) -> CodeC | None: + # A name sits inside its declarator, so `static int (*callbacks[])(void)` cannot be built + # by appending a name to a type spelling. Let clang print the declarator instead. + if cursor.kind in (CursorKind.FUNCTION_DECL, CursorKind.VAR_DECL): + inline_tags = [ + child + for child in cursor.get_children() + if child.kind in _TAG_PREFIX and child.is_definition() + ] + # `struct { int x; } v;` has no type name to declare `v` with, and clang prints the + # placeholder `struct (unnamed struct at f.c:1:1)` rather than failing. + if any(tag.is_anonymous() for tag in inline_tags): + return None + # `static` keeps the declaration a tentative definition, which needs a complete type, + # and a tag written inside this declarator is completed nowhere else. + if ( + inline_tags + and cursor.kind == CursorKind.VAR_DECL + and cursor.storage_class == StorageClass.STATIC + ): + return None + + policy = PrintingPolicy.create(cursor) + policy.set_property(PrintingPolicyProperty.TerseOutput, 1) + policy.set_property(PrintingPolicyProperty.SuppressInitializers, 1) + # `()` is an unprototyped declaration, a different declaration before C23 + policy.set_property(PrintingPolicyProperty.UseVoidForZeroParams, 1) + policy.set_property(PrintingPolicyProperty.IncludeTagDefinition, 0) + policy.set_property(PrintingPolicyProperty.Bool, 0) + declaration = ( + cursor.pretty_printed(policy).replace(_VA_LIST_DESUGARED, "va_list").strip() + ) + if not declaration: + return None + # Without `extern` a variable declaration is a tentative definition; `static` already + # says the same thing and has to be kept anyway to preserve internal linkage. + if cursor.kind == CursorKind.VAR_DECL and cursor.storage_class not in ( + StorageClass.STATIC, + StorageClass.EXTERN, + ): + declaration = f"extern {declaration}" + return CodeC(f"{declaration};") + + # An anonymous record has no tag, so there is nothing to refer to it by. clang lends a + # typedef's name to an unnamed tag and both anonymity predicates only cover C11 + # anonymous members, so the written tokens are what decide. + if cursor.kind in (CursorKind.STRUCT_DECL, CursorKind.UNION_DECL): + tokens = [token.spelling for token in cursor.get_tokens()][:2] + if not cursor.spelling or tokens[1:] == ["{"]: + return None + kind_name = "struct" if cursor.kind == CursorKind.STRUCT_DECL else "union" + return CodeC(f"{kind_name} {cursor.spelling};") + + # No other kind has a forward declaration form: `enum Tag;` is not valid ISO C and a + # typedef cannot be redeclared before C11. + return None def _line_directive_for(cursor: Cursor | None) -> CodeC | None: @@ -411,7 +536,7 @@ def _extract_symbol_info_c( reference_nodes[child_name] = child_refs[child_name] elif not symbols[child_name].is_definition and child_symbol.is_definition: # Previous symbol was a declaration so replace it with new definitional symbol - symbols[child_name] = child_symbol.with_declaration(symbols[child_name]) + symbols[child_name] = child_symbol reference_nodes[child_name] = child_refs[child_name] elif symbols[child_name].is_definition and not child_symbol.is_definition: if not symbols[child_name].is_system or not child_symbol.is_system: @@ -482,7 +607,9 @@ def get_cursor_prettyprinted(cursor: Cursor) -> CodeC: policy.set_property(PrintingPolicyProperty.IncludeTagDefinition, include_tag_definition) # Emit C99 builtin spelling to avoid dependence on stdbool.h macro context. policy.set_property(PrintingPolicyProperty.Bool, 0) - return CodeC(cursor.pretty_printed(policy)) + # Clang desugars array-typed va_list parameters to a prototype-scoped struct tag, + # making otherwise compatible callbacks appear to use distinct types. + return CodeC(cursor.pretty_printed(policy).replace(_VA_LIST_DESUGARED, "va_list")) def get_cursor_code(cursor: Cursor, pretty_print: bool = False) -> CodeC: @@ -510,6 +637,7 @@ def clang_rename( for cursor in tu.cursor.walk_preorder(): target_usr = cursor.get_usr() target_spelling = cursor.spelling + target_is_tag = cursor.kind in _TAG_PREFIX # If the cursor itself is not a symbol we want to rename, check if it's a reference to one. if target_usr not in renames: @@ -518,15 +646,45 @@ def clang_rename( continue target_usr = referenced.get_usr() target_spelling = referenced.spelling + target_is_tag = referenced.kind in _TAG_PREFIX if target_usr not in renames: continue if not target_spelling: continue + # Cursor tokens point at the expansion site for macros; the spelling location + # points at the referenced identifier in the macro body. + spelling_file, spelling_offset = c_object_p(), c_uint() + conf.lib.clang_getSpellingLocation( + cursor.location, byref(spelling_file), None, None, byref(spelling_offset) + ) + if spelling_file: + file = File(spelling_file) + location = SourceLocation.from_offset(tu, file, spelling_offset.value) + spelling = target_spelling.encode() + end = SourceLocation.from_offset(tu, file, spelling_offset.value + len(spelling)) + extent = SourceRange.from_locations(location, end) + if ( + not location.is_in_system_header + and get_code_from_tu_range(tu, extent).code.rstrip() == target_spelling + ): + file_path = Path(file.name).resolve() + offsets = ( + spelling_offset.value, + spelling_offset.value + len(spelling), + ) + edits_by_file.setdefault(file_path, {})[offsets] = renames[target_usr].encode() + # Record edits for all tokens that match the symbol's spelling and are not in system headers - for token in _get_tokens(cursor): + tokens = list(_get_tokens(cursor)) + for index, token in enumerate(tokens): if token.spelling != target_spelling or token.location.is_in_system_header: continue + # Tags live in a namespace of their own, so `typedef struct s s;` spells two + # entities the same way and only the keyword before the token tells them apart. + # A token that opens the extent has no keyword to read, so leave it alone. + if index > 0 and (tokens[index - 1].spelling in _TAG_KEYWORDS) != target_is_tag: + continue file_path = Path(token.location.file.name).resolve() extent = (token.extent.start.offset, token.extent.end.offset) edits_by_file.setdefault(file_path, {})[extent] = renames[target_usr].encode() @@ -534,15 +692,78 @@ def clang_rename( return edits_by_file +def clang_rename_(path: Path, renames: dict[str, str]): + tu = create_translation_unit(path) + tu_path = Path(tu.spelling).resolve() + + usr_renames: dict[str, str] = {} + missing: list[str] = [] + for spelling, new_spelling in renames.items(): + found = False + for cursor in _find_cursors(tu, spelling): + if usr := cursor.get_usr(): + usr_renames[usr] = new_spelling + found = True + if not found: + missing.append(spelling) + + if missing: + raise ValueError(f"Unable to find symbol(s) {missing} in {tu_path}!") + + edits_by_file = clang_rename(tu, usr_renames) + if outside := sorted(p for p in edits_by_file if p != tu_path): + raise NotImplementedError( + f"Renaming {sorted(renames)} would edit {outside}, not {tu_path}!" + ) + + if edits := edits_by_file.get(tu_path): + _apply_edits(path, edits) + + +def clang_function_arity(path: Path, spelling: str) -> int: + tu = create_translation_unit(path) + + for cursor in _find_cursors(tu, spelling): + if cursor.kind != CursorKind.FUNCTION_DECL or not cursor.is_definition(): + continue + return len(list(cursor.get_arguments())) + + raise ValueError(f"Unable to find a definition of function `{spelling}`") + + DEFINITION_START_TOKEN = {CursorKind.FUNCTION_DECL: "{", CursorKind.VAR_DECL: "="} +WEAK_ATTRIBUTE = b"__attribute__((weak))" -def clang_make_global_(path: Path, spelling: str): +def _find_weak_attribute(tokens: list[Token]) -> tuple[int, int] | None: + # Locates `__attribute__((weak))` as the half-open token index range [start, end) + for i, token in enumerate(tokens): + if token.kind != TokenKind.KEYWORD or token.spelling != "__attribute__": + continue + depth = 0 + for j in range(i + 1, len(tokens)): + if tokens[j].spelling == "(": + depth += 1 + elif tokens[j].spelling == ")": + depth -= 1 + if depth == 0: + if any(token.spelling == "weak" for token in tokens[i + 2 : j]): + return i, j + 1 + # Another attribute may still carry `weak` + break + return None + + +def clang_make_weak_(path: Path, spelling: str): tu = create_translation_unit(path) tu_path = Path(tu.spelling).resolve() edits: dict[tuple[int, int], bytes] = {} for cursor in _find_cursors(tu, spelling): + # Weakness only has to be attached to the definition to take effect + if not cursor.is_definition(): + continue + # We don't handle cursors not in the provided translation unit or anything without a definition if ( cursor.location.file is None @@ -550,101 +771,194 @@ def clang_make_global_(path: Path, spelling: str): or Path(cursor.extent.start.file.name).resolve() != tu_path or Path(cursor.extent.end.file.name).resolve() != tu_path ): - raise NotImplementedError(f"Found `{spelling}` cursor {cursor}` not in {tu_path}!") + raise NotImplementedError(f"Found `{spelling}` cursor `{cursor}` not in {tu_path}!") if cursor.kind not in DEFINITION_START_TOKEN: raise ValueError(f"Unhandled cursor kind {cursor.kind}!") tokens = list(_get_tokens(cursor)) assert len(tokens) > 0 + if _find_weak_attribute(tokens) is not None: + continue - for i, token in enumerate(tokens): - # Remove storage specifiers from declaration while preserving offsets - if token.kind == TokenKind.KEYWORD and token.spelling in ("static", "inline"): - assert i + 1 < len(tokens), "storage specifier should always come before name" - start_offset = token.extent.start.offset - # Use start of next token as end offset to remove any whitespace - end_offset = tokens[i + 1].extent.start.offset - edits[(start_offset, end_offset)] = b"" - - # Don't change anything after definition start - elif ( - token.kind == TokenKind.PUNCTUATION - and token.spelling == DEFINITION_START_TOKEN[cursor.kind] - ): - break + insert_pos = cursor.extent.start.offset + edits[(insert_pos, insert_pos)] = WEAK_ATTRIBUTE + b" " if edits: _apply_edits(path, edits) -def clang_make_extern_(path: Path, spelling: str): +def _as_spellings(spellings: str | Iterable[str]) -> list[str]: + return [spellings] if isinstance(spellings, str) else list(dict.fromkeys(spellings)) + + +def clang_make_global_(path: Path, spellings: str | Iterable[str]): tu = create_translation_unit(path) tu_path = Path(tu.spelling).resolve() edits: dict[tuple[int, int], bytes] = {} - for cursor in _find_cursors(tu, spelling): - # We don't handle cursors not in the provided translation unit or anything without a definition - if ( - cursor.location.file is None - or Path(cursor.location.file.name).resolve() != tu_path - or Path(cursor.extent.start.file.name).resolve() != tu_path - or Path(cursor.extent.end.file.name).resolve() != tu_path - ): - raise NotImplementedError(f"Found `{spelling}` cursor `{cursor}` not in {tu_path}!") - if cursor.kind not in DEFINITION_START_TOKEN: - raise ValueError(f"Unhandled cursor kind {cursor.kind}!") + for spelling, cursors in _find_cursors_by_spelling(tu, _as_spellings(spellings)).items(): + for cursor in cursors: + # We don't handle cursors not in the provided translation unit or anything without a definition + if ( + cursor.location.file is None + or Path(cursor.location.file.name).resolve() != tu_path + or Path(cursor.extent.start.file.name).resolve() != tu_path + or Path(cursor.extent.end.file.name).resolve() != tu_path + ): + raise NotImplementedError( + f"Found `{spelling}` cursor {cursor}` not in {tu_path}!" + ) + if cursor.kind not in DEFINITION_START_TOKEN: + raise ValueError(f"Unhandled cursor kind {cursor.kind}!") + + tokens = list(_get_tokens(cursor)) + assert len(tokens) > 0 + + for i, token in enumerate(tokens): + # Remove storage specifiers from declaration while preserving offsets + if token.kind == TokenKind.KEYWORD and token.spelling in ("static", "inline"): + assert i + 1 < len(tokens), ( + "storage specifier should always come before name" + ) + start_offset = token.extent.start.offset + # Use start of next token as end offset to remove any whitespace + end_offset = tokens[i + 1].extent.start.offset + edits[(start_offset, end_offset)] = b"" + + # Don't change anything after definition start + elif ( + token.kind == TokenKind.PUNCTUATION + and token.spelling == DEFINITION_START_TOKEN[cursor.kind] + ): + break - tokens = list(_get_tokens(cursor)) - assert len(tokens) > 0 + if edits: + _apply_edits(path, edits) - is_extern = False - definition_start_token_idx = None - for i, token in enumerate(tokens): - # Remove storage specifiers from declaration while preserving offsets - if token.kind == TokenKind.KEYWORD and token.spelling in ("static", "inline"): - assert i + 1 < len(tokens), "storage specifier should always come before name" - start_offset = token.extent.start.offset - # Use start of next token as end offset to remove any whitespace - end_offset = tokens[i + 1].extent.start.offset - edits[(start_offset, end_offset)] = b"" +def clang_function_definitions(path: Path) -> list[str]: + tu = create_translation_unit(path) + tu_path = Path(tu.spelling).resolve() - # Check if extern keyword already present - elif token.kind == TokenKind.KEYWORD and token.spelling == "extern": - is_extern = True + spellings: list[str] = [] + assert tu.cursor is not None + for cursor in tu.cursor.walk_preorder(): + if cursor.kind != CursorKind.FUNCTION_DECL or not cursor.is_definition(): + continue + if cursor.semantic_parent is None: + continue + if cursor.semantic_parent.kind != CursorKind.TRANSLATION_UNIT: + continue + if cursor.location.file is None: + continue + if Path(cursor.location.file.name).resolve() != tu_path: + continue + spellings.append(cursor.spelling) - # Record the first definition-opening token. - elif ( - definition_start_token_idx is None - and token.kind == TokenKind.PUNCTUATION - and token.spelling == DEFINITION_START_TOKEN[cursor.kind] + return list(dict.fromkeys(spellings)) + + +def clang_make_extern_(path: Path, spellings: str | Iterable[str]): + tu = create_translation_unit(path) + tu_path = Path(tu.spelling).resolve() + edits: dict[tuple[int, int], bytes] = {} + + for spelling, cursors in _find_cursors_by_spelling(tu, _as_spellings(spellings)).items(): + for cursor in cursors: + # We don't handle cursors not in the provided translation unit or anything without a definition + if ( + cursor.location.file is None + or Path(cursor.location.file.name).resolve() != tu_path + or Path(cursor.extent.start.file.name).resolve() != tu_path + or Path(cursor.extent.end.file.name).resolve() != tu_path ): - definition_start_token_idx = i - break + raise NotImplementedError( + f"Found `{spelling}` cursor `{cursor}` not in {tu_path}!" + ) + if cursor.kind not in DEFINITION_START_TOKEN: + raise ValueError(f"Unhandled cursor kind {cursor.kind}!") - # Replace definition portion with ';' - if definition_start_token_idx is not None: - assert definition_start_token_idx > 0 - # Use end of prior token as end offset to remove any whitespace - start_pos = tokens[definition_start_token_idx - 1].extent.end.offset - end_pos = cursor.extent.end.offset - edits[(start_pos, end_pos)] = b";" + tokens = list(_get_tokens(cursor)) + assert len(tokens) > 0 - # Add 'extern ' prefix if not already present - if not is_extern: + # Dropping the body would otherwise turn a weak definition into a weak *reference*, + # which resolves to NULL instead of erroring when nothing else defines the symbol extern_insert_pos = cursor.extent.start.offset - edits[(extern_insert_pos, extern_insert_pos)] = b"extern " + weak_span = _find_weak_attribute(tokens) + if weak_span is not None: + weak_start = tokens[weak_span[0]].extent.start.offset + weak_end_idx = weak_span[1] + weak_end = ( + tokens[weak_end_idx].extent.start.offset + if weak_end_idx < len(tokens) + else tokens[-1].extent.end.offset + ) + edits[(weak_start, weak_end)] = b"" + if weak_start == cursor.extent.start.offset: + extern_insert_pos = weak_end + is_extern = False + definition_start_token_idx = None + + for i, token in enumerate(tokens): + # Remove storage specifiers from declaration while preserving offsets + if token.kind == TokenKind.KEYWORD and token.spelling in ("static", "inline"): + assert i + 1 < len(tokens), ( + "storage specifier should always come before name" + ) + start_offset = token.extent.start.offset + # Use start of next token as end offset to remove any whitespace + end_offset = tokens[i + 1].extent.start.offset + edits[(start_offset, end_offset)] = b"" + + # Check if extern keyword already present + elif token.kind == TokenKind.KEYWORD and token.spelling == "extern": + is_extern = True + + # Record the first definition-opening token. + elif ( + definition_start_token_idx is None + and token.kind == TokenKind.PUNCTUATION + and token.spelling == DEFINITION_START_TOKEN[cursor.kind] + ): + definition_start_token_idx = i + break + + # Replace definition portion with ';' + if definition_start_token_idx is not None: + assert definition_start_token_idx > 0 + # Use end of prior token as end offset to remove any whitespace + start_pos = tokens[definition_start_token_idx - 1].extent.end.offset + end_pos = cursor.extent.end.offset + edits[(start_pos, end_pos)] = b";" + + # Add 'extern ' prefix if not already present + if not is_extern: + edits[(extern_insert_pos, extern_insert_pos)] = b"extern " if edits: _apply_edits(path, edits) -def clang_make_bindable_(path: Path, spelling: str): +def clang_make_bindable_(path: Path, spellings: str | Iterable[str]): source = path.read_bytes() tu = create_translation_unit(path) tu_path = Path(tu.spelling).resolve() edits: dict[tuple[int, int], bytes] = {} - cursors = _find_cursors(tu, spelling) + + for spelling, cursors in _find_cursors_by_spelling(tu, _as_spellings(spellings)).items(): + _collect_bindable_edits(spelling, cursors, source, tu_path, edits) + + if edits: + _apply_edits(path, edits) + + +def _collect_bindable_edits( + spelling: str, + cursors: list[Cursor], + source: bytes, + tu_path: Path, + edits: dict[tuple[int, int], bytes], +) -> None: has_variable_initializer_definition = any( cursor.kind == CursorKind.VAR_DECL and any( @@ -715,15 +1029,34 @@ def clang_make_bindable_(path: Path, spelling: str): assert cursor.kind == CursorKind.VAR_DECL declaration_end_idx = None + has_inline_body = False + depth = 0 for i, token in enumerate(tokens): if token.kind != TokenKind.PUNCTUATION: continue - if token.spelling in ("=", ";"): + # An inline record or enum body brings its own `;` and `=`, so only a + # terminator outside every brace ends the declarator + if token.spelling == "{": + depth += 1 + has_inline_body = True + elif token.spelling == "}": + depth -= 1 + elif depth == 0 and token.spelling in ("=", ";"): declaration_end_idx = i break if declaration_end_idx is None: declaration_end_idx = len(tokens) + # An inline record body makes the variable an aggregate, which bindgen already emits + # as a `pub static`, and an anonymous body has no spelling a declaration could use + if has_inline_body: + if cursor.type.get_canonical().kind == TypeKind.ENUM: + logger.warning( + f"Variable `{spelling}` has an inline enum body, so bindgen may emit it " + "as a `pub const` with no linkage" + ) + continue + declaration_tokens = [ token for token in tokens[:declaration_end_idx] @@ -753,9 +1086,6 @@ def clang_make_bindable_(path: Path, spelling: str): extern_insert_pos = cursor.extent.start.offset edits[(extern_insert_pos, extern_insert_pos)] = extern_decl - if edits: - _apply_edits(path, edits) - def _get_tokens(cursor: Cursor): # Use get_tokens if it actually returns a non-empty list @@ -780,13 +1110,19 @@ def _get_tokens(cursor: Cursor): def _find_cursors(tu: TranslationUnit, spelling: str) -> list[Cursor]: - candidates: list[Cursor] = [] + return _find_cursors_by_spelling(tu, [spelling])[spelling] + + +def _find_cursors_by_spelling( + tu: TranslationUnit, spellings: Iterable[str] +) -> dict[str, list[Cursor]]: + candidates: dict[str, list[Cursor]] = {s: [] for s in spellings} assert tu.cursor is not None for cursor in tu.cursor.walk_preorder(): if cursor.kind not in (CursorKind.FUNCTION_DECL, CursorKind.VAR_DECL): continue - if cursor.spelling != spelling: + if cursor.spelling not in candidates: continue if cursor.semantic_parent is None: continue @@ -794,8 +1130,12 @@ def _find_cursors(tu: TranslationUnit, spelling: str) -> list[Cursor]: continue if cursor.location.is_in_system_header: continue - candidates.append(cursor) + candidates[cursor.spelling].append(cursor) + + return {s: _disambiguate_cursors(s, c) for s, c in candidates.items()} + +def _disambiguate_cursors(spelling: str, candidates: list[Cursor]) -> list[Cursor]: if len(candidates) == 0: raise ValueError(f"Unable to find function or variable with spelling `{spelling}`") @@ -845,6 +1185,7 @@ def mangle(name: str) -> str: return name +# FIXME: rename to `SymbolKey`; these are clang USRs (e.g. `c:@F@main`), not spellings SymbolName = str SymbolGroup = tuple[SymbolName, ...] diff --git a/src/ideas/ast_rust.py b/src/ideas/ast_rust.py index dad7691..488c30c 100644 --- a/src/ideas/ast_rust.py +++ b/src/ideas/ast_rust.py @@ -4,19 +4,31 @@ # SPDX-License-Identifier: Apache-2.0 # +from textwrap import indent from collections import OrderedDict from tree_sitter import Language, Parser, Node, Query, QueryCursor -import tree_sitter_rust +import tree_sitter_rust_orchard from .adapters import Code # Initialize the Rust language once -RUST_LANGUAGE = Language(tree_sitter_rust.language()) +RUST_LANGUAGE = Language(tree_sitter_rust_orchard.language()) RUST_PARSER = Parser(RUST_LANGUAGE) CodeRust = Code["rust"] +# The name bindgen gives an item, already mangled into a usable Rust identifier +class BindgenName(str): + __slots__ = () + + def __new__(cls, value: str) -> "BindgenName": + # An unnamed item reads as "", which bindgen would take as a regex matching everything + if not value: + raise ValueError("A bindgen name cannot be empty!") + return super().__new__(cls, value) + + class RustFnSignature: def __init__(self, node: Node): if not node.type == "function_item": @@ -66,6 +78,21 @@ def get_nodes(node: Node, node_type: str | None = None) -> list[Node]: return nodes +def get_node_text(node: Node | None) -> str: + if node is None or node.text is None: + return "" + return node.text.decode() + + +_TYPE_IDENTIFIER_QUERY = Query(RUST_LANGUAGE, "(type_identifier) @name") + + +def get_referenced_type_names(node: Node) -> set[BindgenName]: + # Every type named anywhere under `node`, e.g. the field types of a struct item + captures = QueryCursor(_TYPE_IDENTIFIER_QUERY).captures(node) + return {BindgenName(text) for n in captures.get("name", []) if (text := get_node_text(n))} + + def get_ancestor_nodes(node: Node, node_type: str | None = None) -> list[Node]: ancestors = [] # Excluding self @@ -99,6 +126,50 @@ def get_macro_nodes(root: Node, placeholder: str) -> list[Node]: return list(ancestors) +_CATCH_UNWIND_QUERY = Query( + RUST_LANGUAGE, + """ + (scoped_identifier + path: (_) @path + name: (identifier) @name + (#eq? @name "catch_unwind")) + + (scoped_use_list + path: (_) @path + list: (use_list (identifier) @name) + (#eq? @name "catch_unwind")) + """, +) + + +def _path_tail(node: Node) -> str: + name = node.child_by_field_name("name") if node.type == "scoped_identifier" else node + return get_node_text(name) + + +def uses_catch_unwind(code: CodeRust) -> bool: + matches = QueryCursor(_CATCH_UNWIND_QUERY).matches(get_root(str(code))) + return any(_path_tail(caps["path"][0]) == "panic" for _, caps in matches) + + +_UNIMPLEMENTED_QUERY = Query( + RUST_LANGUAGE, + """ + (macro_invocation + macro: [ + (identifier) @name + (scoped_identifier name: (identifier) @name) + ] + (#eq? @name "unimplemented")) + """, +) + + +def is_unimplemented(code: CodeRust) -> bool: + captures = QueryCursor(_UNIMPLEMENTED_QUERY).captures(get_root(str(code))) + return bool(captures.get("name")) + + def validate_changes(code: CodeRust, template: CodeRust) -> OrderedDict[str, str]: code_root = get_root(str(code)) template_root = get_root(str(template)) @@ -189,6 +260,20 @@ def _rust_node_signature(node: Node, source: bytes, delete: bool = False) -> str sig = source[node.start_byte : body.start_byte].rstrip() return sig.decode() + ";" + # Recurse into impl/trait/mod bodies so nested functions are also stripped/deleted + if ntype in ("impl_item", "trait_item", "mod_item"): + body = node.child_by_field_name("body") + if body is not None: + header = source[node.start_byte : body.start_byte].decode() + parts = [ + sig + for child in body.named_children + if (sig := _rust_node_signature(child, source, delete=delete)) is not None + ] + if not parts: + return header + "{}" + return header + "{\n" + indent("\n".join(parts), " " * 4) + "\n}" + # Keep everything else as-is return source[node.start_byte : node.end_byte].decode() diff --git a/src/ideas/bear.py b/src/ideas/bear.py index 48665a0..f789c52 100644 --- a/src/ideas/bear.py +++ b/src/ideas/bear.py @@ -81,12 +81,7 @@ class LinkCommand: def _is_link_command(arguments: list[str]) -> bool: if not arguments: return False - executable_basename = Path(arguments[0]).name - # Match exact names and versioned variants e.g. clang-21, gcc-13 - if not any( - executable_basename == d or executable_basename.startswith(d + "-") - for d in _LINKERS - ): + if not _matches_tool(Path(arguments[0]).name, _LINKERS): return False # Must not be a compile-only step if "-c" in arguments: @@ -94,6 +89,11 @@ def _is_link_command(arguments: list[str]) -> bool: # Must have at least one object file input to distinguish from non-linker calls return any(arg.endswith(".o") for arg in arguments[1:]) + @cached_property + def is_driver(self) -> bool: + # gcc/clang rather than the ld they exec + return _matches_tool(Path(self.arguments[0]).name, _COMPILERS) + @classmethod def from_arguments(cls, arguments: list[str], working_dir: Path) -> "LinkCommand | None": if not cls._is_link_command(arguments): @@ -104,84 +104,135 @@ def from_arguments(cls, arguments: list[str], working_dir: Path) -> "LinkCommand return None return cmd + @cached_property + def _arg_pairs(self) -> list[tuple[str | None, str]]: + # argv normalized to (flag, operand) pairs; the glued `-L/opt` and separated + # `-L /opt` forms collapse to the same pair, and inputs come back as (None, arg) + pairs: list[tuple[str | None, str]] = [] + it = iter(self.arguments[1:]) # skip argv[0] + for arg in it: + if arg in _IGNORED_FLAGS_WITH_ARG: + next(it, None) + elif flag := next((f for f in _LINK_FLAGS_WITH_ARG if arg.startswith(f)), None): + if value := arg[len(flag) :] or next(it, ""): + pairs.append((flag, value)) + elif not arg.startswith("-"): + pairs.append((None, arg)) + return pairs + + @cached_property + def _output_arg(self) -> str | None: + outputs = [v for flag, v in self._arg_pairs if flag == "-o"] + # No build generator emits two -o, so this means flags were injected from elsewhere + if len(outputs) > 1: + logger.warning( + "Linker invocation has %d -o flags (%s); using the last one as gcc/clang do", + len(outputs), + ", ".join(outputs), + ) + return outputs[-1] if outputs else None + @cached_property def target(self) -> TargetName | None: - for i, arg in enumerate(self.arguments): - if arg == "-o" and i + 1 < len(self.arguments): - name = Path(self.arguments[i + 1]).name - # Strip version suffix from shared libraries: libfoo.so.1.2.3 -> libfoo.so - return re.sub(r"\.so(\.\d+)+$", ".so", name) - return None + if self._output_arg is None: + return None + # Strip version suffix from shared libraries: libfoo.so.1.2.3 -> libfoo.so + return re.sub(r"\.so(\.\d+)+$", ".so", Path(self._output_arg).name) @cached_property def output_path(self) -> Path | None: - for i, arg in enumerate(self.arguments): - if arg == "-o" and i + 1 < len(self.arguments): - p = Path(self.arguments[i + 1]) - return (p if p.is_absolute() else self.working_dir / p).resolve() - return None + return None if self._output_arg is None else self._resolve(self._output_arg) - def _input_args(self) -> list[str]: - result = [] - it = iter(self.arguments[1:]) # skip argv[0] - for arg in it: - if arg == "-o": - next(it, None) # consume and discard the output path - elif not arg.startswith("-"): - result.append(arg) - return result + def _resolve(self, path: str) -> Path: + p = Path(path) + return (p if p.is_absolute() else self.working_dir / p).resolve() @cached_property def object_files(self) -> list[Path]: - objects: list[Path] = [] - for arg in self._input_args(): - p = Path(arg) - if p.suffix == ".o": - resolved = p if p.is_absolute() else self.working_dir / p - objects.append(resolved.resolve()) - return objects + return [ + self._resolve(v) for flag, v in self._arg_pairs if flag is None and v.endswith(".o") + ] @cached_property - def linked_binary_inputs(self) -> list[Path]: - binaries: list[Path] = [] - for arg in self._input_args(): - p = Path(arg) - if (".so" in p.name and p.suffix != ".o") or p.suffix == ".a": - resolved = p if p.is_absolute() else self.working_dir / p - binaries.append(resolved.resolve()) - return binaries + def link_inputs(self) -> list[str | Path]: + # `-l` names (str) and library path inputs (Path) interleaved in command-line order: + # a library only resolves against inputs to its left, so the order is significant. + inputs: list[str | Path] = [] + for flag, value in self._arg_pairs: + if flag == "-l": + inputs.append(value) + elif flag is None and is_library_file(Path(value)): + inputs.append(self._resolve(value)) + return inputs @cached_property - def link_libs(self) -> list[str]: - return [arg[2:] for arg in self.arguments if arg.startswith("-l") and arg[2:]] + def link_search_dirs(self) -> list[Path]: + return [self._resolve(v) for flag, v in self._arg_pairs if flag == "-L"] _SOURCE_EXTS = {".c", ".cpp", ".cxx", ".cc", ".C", ".s", ".S", ".m"} -_COMPILERS = {"gcc", "clang", "cc", "g++", "c++", "clang++"} -_LINKERS = { - "gcc", - "clang", - "cc", - "g++", - "c++", - "clang++", - "ld", - "ld.bfd", - "ld.lld", - "ld.gold", - "lld", -} +_COMPILERS = frozenset({"gcc", "clang", "cc", "g++", "c++", "clang++"}) +_LINKERS = _COMPILERS | frozenset({"ld", "ld.bfd", "ld.lld", "ld.gold", "lld"}) # CMake injects these dependency-tracking flags into every compile command. # They cause libclang to try writing .d files at relative paths that don't # exist during analysis, which fails TranslationUnit parsing. _DEP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MG"}) _DEP_FLAGS_WITH_ARG = frozenset({"-MF", "-MT", "-MQ"}) +# Linker flags whose operand may be glued to the flag or a separate argv entry +_LINK_FLAGS_WITH_ARG = frozenset({"-o", "-L", "-l"}) +# Flags that likewise take a separate operand, but one we have no use for. Unlike the set +# above these are matched exactly, never by prefix: the glued spellings that extend them +# (-Bstatic, -fPIC, -znow) take no operand and would swallow the input that follows. +_IGNORED_FLAGS_WITH_ARG = frozenset( + { + # file operands + "-plugin", + "-dynamic-linker", + "--dynamic-linker", + "-T", + "--script", + "-R", + "--just-symbols", + "-Map", + "--version-script", + "--dynamic-list", + "--retain-symbols-file", + "--out-implib", + "--exclude-libs", + "-F", + "--filter", + "-f", + "--auxiliary", + "--sysroot", + "-rpath", + "--rpath", + "-rpath-link", + "--rpath-link", + # name, symbol and value operands + "-h", + "-soname", + "--soname", + "-e", + "--entry", + "-u", + "--undefined", + "-y", + "--trace-symbol", + "--wrap", + "--defsym", + "-m", + "-z", + "-Xlinker", + "-B", + } +) @dataclass class TargetOutputs: entries: list[CompileCommand] - link_libs: list[str] + link_inputs: list[str | Path] # `-l` names and library paths, in link order + link_search_dirs: list[Path] # the build's own -L flags @dataclass @@ -221,72 +272,84 @@ def _collect_transitive_entries( self, link_cmd: "LinkCommand", obj_map: dict[Path, CompileCommand], - binary_source_map: dict[Path, list[Path]], - ) -> list[CompileCommand]: + link_map: dict[Path, "LinkCommand"], + ) -> TargetOutputs: entries: list[CompileCommand] = [] + link_inputs: list[str | Path] = [] + search_dirs: list[Path] = [] seen_sources: set[Path] = set() seen_binaries: set[Path] = set() def _collect(binary: Path) -> None: - if binary in seen_binaries: + if binary in seen_binaries or (producer := link_map.get(binary)) is None: return seen_binaries.add(binary) - for inp in binary_source_map.get(binary, []): - if (entry := obj_map.get(inp)) is not None: - # inp is a .o — add its compilation entry (dedup by source path) + # A flattened target inherits the link requirements of every library it absorbs + search_dirs.extend(producer.link_search_dirs) + for obj in producer.object_files: + if (entry := obj_map.get(obj)) is not None: + # dedup by source path if entry.source not in seen_sources: seen_sources.add(entry.source) entries.append(entry) - elif inp in binary_source_map: - # inp is a .so/.a built in this project — recurse + elif obj.is_relative_to(Path("/usr")): + # System CRT objects (crti.o, crtbeginS.o, ...) have no compile command + logger.debug( + "Skipping system object file: %s (linked into %s)", + obj, + link_cmd.target, + ) + else: + logger.warning( + "No compile command found for object file: %s (linked into %s)", + obj, + link_cmd.target, + ) + for inp in producer.link_inputs: + if isinstance(inp, Path) and inp in link_map: + # a .so/.a built in this project — recurse, splicing its own link + # requirements in at the position it occupied on the link line _collect(inp) - elif inp.suffix == ".o": - # No compile command for this object file. System CRT objects - # (crti.o, crtbeginS.o, etc.) under /usr are expected — log at - # debug. Any other gap is unexpected and logged as a warning. - if inp.is_relative_to(Path("/usr")): - logger.debug( - "Skipping system object file: %s (linked into %s)", - inp, - link_cmd.target, - ) - else: - logger.warning( - "No compile command found for object file: %s (linked into %s)", - inp, - link_cmd.target, - ) else: - # External .so/.a not built in this project — skip but log so - # the user can verify it is intentionally external. - logger.debug("Skipping external binary input: %s", inp) + # an external library: keep it, path and all + link_inputs.append(inp) if link_cmd.output_path: _collect(link_cmd.output_path) - return entries - - def resolve_targets(self) -> dict[TargetName, TargetOutputs]: - obj_map: dict[Path, CompileCommand] = {cmd.output: cmd for cmd in self.compile_commands} - binary_source_map: dict[Path, list[Path]] = {} + return TargetOutputs( + entries=entries, + link_inputs=list(dict.fromkeys(link_inputs)), + link_search_dirs=list(dict.fromkeys(search_dirs)), + ) + + def _primary_link_commands(self) -> dict[Path, LinkCommand]: + # A driver execs ld, so bear captures two commands for one link. Keep the driver's since + # ld's line additionally carries CRT objects and the toolchain -l/-L the driver adds. + primary: dict[Path, LinkCommand] = {} for lc in self.link_commands: if lc.output_path is None: continue - inputs = lc.object_files + lc.linked_binary_inputs - binary_source_map[lc.output_path] = inputs + prev = primary.get(lc.output_path) + if prev is None or lc.is_driver or not prev.is_driver: + primary[lc.output_path] = lc + return primary + + def resolve_targets(self) -> dict[TargetName, TargetOutputs]: + obj_map: dict[Path, CompileCommand] = {cmd.output: cmd for cmd in self.compile_commands} + primary = self._primary_link_commands() + link_map: dict[Path, LinkCommand] = dict(primary) + for output_path, lc in primary.items(): # Also index by the unversioned name (libfoo.so.1.2.3 → libfoo.so) so that # consumers referencing the symlink name are resolved transitively. - normalized = lc.output_path.parent / re.sub( - r"\.so(\.\d+)+$", ".so", lc.output_path.name - ) - if normalized != lc.output_path: - binary_source_map[normalized] = inputs + normalized = output_path.parent / re.sub(r"\.so(\.\d+)+$", ".so", output_path.name) + if normalized != output_path: + link_map[normalized] = lc result: dict[TargetName, TargetOutputs] = {} - for link_cmd in self.link_commands: + for link_cmd in primary.values(): assert link_cmd.target is not None - entries = self._collect_transitive_entries(link_cmd, obj_map, binary_source_map) - result[link_cmd.target] = TargetOutputs( - entries=entries, link_libs=link_cmd.link_libs - ) + outputs = self._collect_transitive_entries(link_cmd, obj_map, link_map) + _warn_ambiguous_lib_files(link_cmd.target, outputs) + result[link_cmd.target] = outputs return result def write_outputs(self, output_dir: Path) -> None: @@ -300,12 +363,43 @@ def write_outputs(self, output_dir: Path) -> None: json.dumps([e.to_dict() for e in outputs.entries], indent=2) ) link_entries: list[dict] = [{"source": str(e.source)} for e in outputs.entries] - link_entries += [{"lib": lib} for lib in outputs.link_libs] + link_entries += [{"search_dir": str(d)} for d in outputs.link_search_dirs] + link_entries += [ + {"lib_path": str(i)} if isinstance(i, Path) else {"lib": i} + for i in outputs.link_inputs + ] (target_dir / "links.json").write_text( json.dumps({"entries": link_entries}, indent=2) ) +def is_library_file(path: Path) -> bool: + # libfoo.a, libfoo.so, versioned libfoo.so.1.2.13, and the unprefixed spelling + # (foo.so) that a CMake MODULE library or a plugin gets + return re.fullmatch(r".+\.(?:so(?:\.\d+)*|a)", path.name) is not None + + +def _matches_tool(basename: str, tools: frozenset[str]) -> bool: + # exact names and versioned variants e.g. clang-21, gcc-13 + return any(basename == t or basename.startswith(t + "-") for t in tools) + + +def _warn_ambiguous_lib_files(target: TargetName, outputs: TargetOutputs) -> None: + # rustc can only re-link a path-named library as `-l:` against a search path, so + # the exact directory is lost; warn when the filename is not unique across those dirs + lib_paths = [i for i in outputs.link_inputs if isinstance(i, Path)] + dirs = list(dict.fromkeys(outputs.link_search_dirs + [p.parent for p in lib_paths])) + for path in lib_paths: + matches = [d for d in dirs if (d / path.name).exists()] + if len(matches) > 1: + logger.warning( + "%s: %s exists in %s — the linker resolves it to the first match", + target, + path.name, + ", ".join(str(d) for d in matches), + ) + + def _main(build_command: list[str], output_dir: Path): output_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/ideas/codex.py b/src/ideas/codex.py new file mode 100644 index 0000000..51e8f20 --- /dev/null +++ b/src/ideas/codex.py @@ -0,0 +1,951 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import os +import json +import shutil +import logging +import tempfile +from typing import Any +from pathlib import Path +from functools import cache +from dataclasses import dataclass, field + +import dspy +from litellm import get_model_info +from json_repair import loads as json_repair_loads +from dspy.utils.exceptions import AdapterParseError +from dspy.dsp.utils.settings import settings +from hydra.core.config_store import ConfigStore + +from .ast import CodeC, Symbol +from .ast_rust import CodeRust, mangle +from .adapters import Code +from .model import ModelConfig +from .tools import run_subprocess +from .translate_snippet import SnippetTranslator +from .wrapper import WrapperGenerator + +logger = logging.getLogger("ideas.codex") + + +@dataclass +class CodexConfig: + sandbox: str = os.environ.get("IDEAS_CODEX_SANDBOX", "workspace-write") + timeout: int = 900 + ignore_user_config: bool = True + ignore_rules: bool = True + extra_config: list[str] = field(default_factory=list) + extra_env: list[str] = field(default_factory=list) + + +cs = ConfigStore.instance() +cs.store(name="codex", node=CodexConfig) + + +class _CodexOutput(dspy.Signature): + reasoning: str = dspy.OutputField() + output: str = dspy.OutputField() + + +@dataclass +class CodexResult: + final_message: str + usage: dict[str, Any] + reasoning: str + raw: str + empty_output: str = "" + + def rust(self) -> tuple[CodeRust, str]: + try: + parsed = json_repair_loads(self.final_message.strip()) + if isinstance(parsed, dict) and isinstance(parsed.get("output"), str): + summary = parsed.get("reasoning") + code = CodeRust(parsed["output"]) + return code, summary if isinstance(summary, str) else "" + except Exception: + pass + + logger.error(f"Unparseable Codex message: {self.final_message.strip() or ''}") + logger.debug(f"Codex event stream:\n{self.raw.strip() or ''}") + raise AdapterParseError( + adapter_name="CodexAdapter", + signature=_CodexOutput, # type: ignore[reportArgumentType] + lm_response=self.final_message, + ) + + def track(self, pred: dspy.Prediction | None, model_name: str) -> None: + if pred is not None: + pred.set_lm_usage({model_name: self.usage}) + if (parent := settings.usage_tracker) is not None: + parent.add_usage(model_name, dict(self.usage)) + + def prompt_files(self, prompt: str) -> dict[str, str]: + files = {"_prompt.md": prompt, "_codex.jsonl": self.raw} + return {name: text.replace("\x00", "\\0") for name, text in files.items()} + + def as_prediction(self, name: str, prompt: str, model_name: str) -> dspy.Prediction: + try: + code, summary = self.rust() + except AdapterParseError: + # A turn that failed or returned garbage still burned tokens + self.track(None, model_name) + raise + + # The model's own account of the turn, falling back to the raw reasoning items + pred = dspy.Prediction(reasoning=summary or self.reasoning, **{name: code}) + self.track(pred, model_name) + pred.was_generated = True + pred.prompt_files = self.prompt_files(prompt) + return pred + + +SNIPPET_INSTRUCTIONS = """\ +Translate exactly `snippet` into idiomatic, memory-safe Rust with the same observable +behavior as the original C. Return only this symbol group and still-missing types it +requires; do not translate unrelated functions. Treat `feedback`, `build_feedback`, and +`scope_feedback` as evidence about the prior attempt and fix their root causes. + +The existing Rust file prefix (`reference_code` in the translator) is immutable and was +already accepted for earlier symbols. `prior_translation`, when nonempty, is the complete +rejected candidate for this symbol group: replace it as a whole. Do not treat it as +another immutable prefix, append a second implementation after it, or return a patch. +The new output must contain exactly one complete definition of each requested symbol. + +# Establish the program contract + +The snippet is not self-contained. Search the supplied C and Rust files before writing. + +1. Find its declarations, callers, callees, constructors, mutators, iterators, callback + sites, and destructors. A type definition must be designed for its entire lifecycle. +2. For each pointer and `void *`, determine its concrete meaning, nullability, owner, + aliases, identity uses, lifetime, live extent, capacity, and allocator. +3. Reuse the exact representation already established in Rust. Earlier items are + immutable. If this is the first relevant type, choose one representation that can + support every related operation you found. +4. Privately map each C state location to its Rust owner. For this operation, account for + every change to storage, links, root/head/tail, size, capacity, iterator state, + allocation ownership, out-parameters, and rollback. + +# Representation rules + +- The owner of per-instance state must be reachable from that Rust instance. Do not use + a global or thread-local arena, address registry, leaked object, or fabricated + `'static` lifetime unless the C state itself is global. +- Use one owner plus stable handles for cyclic or aliased graphs. Keep null, sentinel, + vacant, removed, and live states distinct. Preserve pointer identity and shallow-copy + aliases; do not clone identity-bearing objects or renumber surviving handles. +- A handle denotes identity, not payload. Compare handles only where C compares + pointers. A comparator, predicate, lookup, printer, or destructor that dereferences a + C pointer must resolve the handle and inspect the same pointee fields as C. +- Give `void *` a concrete type whenever its uses establish one. Use type erasure only + for genuinely heterogeneous data, with one agreed concrete erased representation for + every producer and consumer. +- Keep logical storage separate from observable custom-allocation tokens. Retain each + successful token in its owner until the C lifetime ends, then release it exactly once + through the same allocator family. Preserve allocator call order, count, requested + byte sizes, failure timing, and rollback. The layout of an idiomatic Rust replacement + is not the layout of the C struct it represents. +- Callbacks are optional until invoked and may be reentrant. Do not hold a borrow or lock + across a callback. If C rereads state afterward, observe callback mutations instead of + continuing from a cached snapshot. A comparator may even change the root, collection, + or global being traversed, so reacquire all affected state before the next C read. + +# Translate the behavior + +- Follow C control flow, sequencing, and short-circuit evaluation. Fetch conditional + operands lazily. Check left and right children independently; one missing child must + not suppress the other. Distinguish live length from allocated capacity and validate + both slice bounds immediately before slicing. `Vec::with_capacity` reserves memory but + creates no initialized, indexable elements; materialize safe storage for every slot C + may legally read or write without claiming dead slots are live values. +- Preserve all defined return values, mutations, allocation effects, partial failures, + output parameters, output bytes, and early returns, including strange behavior. + Updating only a counter is not enough when C also inserts, removes, or relinks data. +- Public state may be externally modified between calls. Do not assume stronger + invariants than C, and do not panic for an input on which C has a defined path. Avoid + unchecked indexing, `unwrap`, `expect`, and caller-reachable assertions. +- Preserve integer width, signedness, promotions, truncation, wrapping, comparison + direction, and operator grouping. Use checked arithmetic only where C checks it. +- Preserve C `sizeof`, alignment, and element-count results. Never apply `size_of` to an + idiomatic Rust replacement and assume it has the C layout; derive the C quantity from + the original fields and target primitive layouts when no C-layout type is available. +- C strings end at the first NUL; length-delimited data does not. Match parsing, + delimiter consumption, stdout/stderr selection, newlines, and exit status exactly. +- Translate `main` as a zero-argument Rust entry point that gathers arguments and exits + with the C status. + +# Required output + +- It must compile with the existing `#![forbid(unsafe_code)]`. Emit no `unsafe`, raw + pointer representation shortcuts, `#[repr(C)]`, derives, `impl` blocks, crate + attributes, placeholders, stubs, or knowingly partial behavior. +- Every emitted top-level item and struct field must be `pub`. Use existing names and + types exactly and never duplicate an existing item. +- Emit the exact requested symbol name. Fix current-turn code instead of adding a + `_translation`, `_safe`, `_impl`, or other renamed substitute. +- If an immutable earlier representation makes fidelity impossible, do not hide the + mismatch behind shadow state or false success. Leave this translation incomplete and + identify the incompatible representation in `reasoning`. + +Before returning, compare the result statement by statement with C and repeat the +ownership/mutation audit across construction, use, failure, and destruction. Use only +the existing dependencies; `openssl`, `flate2`, and `regex` are the only additional +crates permitted when required. +""" + + +class CodexSnippetTranslator(SnippetTranslator): + def __init__( + self, + cache: Path | None = None, + *, + codex: CodexConfig, + model: ModelConfig, + workdir: Path, + rust_src: Path, + c_src: Path, + max_iters: int = 1, + ): + super().__init__(dspy.Predict, max_iters, cache) + self._codex = codex + self._model = model + self._workdir = workdir + self._rust_src = rust_src + self._c_src = c_src + + def translate( + self, + reference_code: CodeRust, + snippet: CodeC, + dependent_code: CodeC, + prior_translation: CodeRust | None, + feedback: str, + build_feedback: str, + scope_feedback: str, + translation: CodeRust | None, + ) -> dspy.Prediction: + if translation is not None: + pred = dspy.Prediction(translation=translation, reasoning="") + pred.set_lm_usage({}) + pred.was_generated = False + return pred + + inputs = { + "snippet": snippet, + "prior_translation": prior_translation or CodeRust(), + "feedback": feedback, + "build_feedback": build_feedback, + "scope_feedback": scope_feedback, + } + # Codex runs with `--cd workdir`, so the prompt names files relative to it + rust_src = str(self._rust_src.relative_to(self._workdir)) + c_src = str(self._c_src.relative_to(self._workdir)) + files = { + rust_src: ( + "the Rust crate translated so far. Search it for the items your " + "translation references; your output is appended to this file and must " + "compile against it." + ), + c_src: ( + "the original C. Search it for the callers of the snippet and for any " + "definition the snippet references. A definition already translated " + "appears here as an extern declaration; read that one from the Rust crate." + ), + } + prompt = build_prompt( + SNIPPET_INSTRUCTIONS, + inputs, + files, + rust_src, + output="an idiomatic, memory-safe Rust translation of `snippet`", + ) + restore = [self._rust_src, self._c_src] + result = run_codex( + prompt, self._codex, self._model, self._workdir, restore, self._rust_src + ) + return result.as_prediction("translation", prompt, self._model.name) + + +def build_prompt( + instructions: str, + inputs: dict[str, Code | str], + files: dict[str, str], + append_to: str, + build_cmd: str = "cargo build", + output: str = "the Rust source these instructions ask for", +) -> str: + if "--no-run" in build_cmd: + no_tests = ( + "Never execute the tests: no `cargo test` without `--no-run`, no " + "`cargo nextest run`, and nothing else that runs them. " + ) + else: + no_tests = "Never run `cargo test`, `cargo nextest`, or any other test command. " + + parts = [ + instructions, + "# Files\n\n" + "Consult these rather than waiting to be given the inputs they stand in for:\n\n" + + "\n".join(f"- `{path}` — {what}" for path, what in files.items()) + + f"\n\nBefore you answer, append your work to `{append_to}` and run `{build_cmd}`. " + "That exact command and its final exit status are authoritative: a narrower package " + "build does not count, and a linker or test-target compilation failure is not " + "unrelated merely because the appended crate alone builds. After every code change, " + "rerun the exact command. Do not return code you have not compiled with it: read the " + "errors, fix them, and rebuild until it builds clean. If you run out of time before " + "it does, return your latest " + "version anyway and say in your reasoning what is still broken. Preserve every " + "item that existed before this invocation and modify no other file except where " + "these instructions say otherwise. You may revise or remove only text that you " + "yourself appended during this invocation; use that freedom to fix build errors " + "instead of adding renamed, duplicate, or shadow implementations. The final file " + "must consist of the untouched original prefix followed by exactly the final code " + f"returned in `output`. {no_tests}Your edits are discarded once you finish, " + "so the value you return must stand on its own.", + ] + for name, value in inputs.items(): + language = getattr(value, "language", "") + parts.append(f"# {name}\n\n```{language}\n{str(value).strip()}\n```") + + parts.append( + '# Output\n\nReturn `{"reasoning": "", "output": ""}}` and nothing else.\n\n- `output` is {output}: exactly the text you appended ' + f"for your last `{build_cmd}`, never a variant you did not build, and never a " + "diff, a summary, or a path to a file.\n" + "- `reasoning` argues that `output` is that, and not merely something that " + "compiled: name the choices that make it so, the places where the C forced your " + "hand, and anything still wrong or unfinished." + ) + return "\n\n".join(parts) + + +def run_codex( + prompt: str, + cfg: CodexConfig, + model: ModelConfig, + workdir: Path, + restore: list[Path], + append_to: Path, +) -> CodexResult: + manifests = [workdir / "Cargo.toml", workdir / "Cargo.lock"] + manifests += [path.parent.parent / "Cargo.toml" for path in restore] + saved = [ + (path, path.read_bytes() if path.exists() else None) + for path in dict.fromkeys(restore + manifests) + ] + try: + result = _run_codex(prompt, cfg, model, workdir) + if not result.final_message.strip(): + # Work left on disk by a killed turn outranks a deliberate empty `output`, + # which is the real answer only when the turn appended nothing + salvaged = _salvage(append_to, dict(saved).get(append_to)) + result.final_message = salvaged or result.empty_output + return result + finally: + for path, data in saved: + _restore(path, data) + + +def _salvage(path: Path, original: bytes | None) -> str: + # A killed or failed turn still left its work on disk; the appended tail is the answer + # it never got to return + try: + current = path.read_bytes() + except OSError: + return "" + + prefix = original or b"" + if not current.startswith(prefix) or len(current) == len(prefix): + return "" + + tail = current[len(prefix) :].decode("utf-8", "replace").strip() + logger.warning( + f"Recovered {len(tail):,} characters appended to {path} by an unfinished run" + ) + return json.dumps( + { + "reasoning": "SALVAGED FROM AN UNFINISHED RUN", + "output": tail, + } + ) + + +def _restore(path: Path, data: bytes | None) -> None: + try: + if (path.read_bytes() if path.exists() else None) == data: + return + if data is None: + path.unlink() + else: + path.write_bytes(data) + except OSError as e: + # Never mask the outcome of the run itself + logger.error(f"Failed to restore {path}: {e}") + + +def _run_codex(prompt: str, cfg: CodexConfig, model: ModelConfig, workdir: Path) -> CodexResult: + if shutil.which("codex") is None: + raise RuntimeError( + "`codex` was not found on PATH; install the Codex CLI or select another " + "`translator`/`wrapper`" + ) + + with tempfile.TemporaryDirectory() as tmp: + schema = Path(tmp) / "schema.json" + schema.write_text( + json.dumps( + { + "type": "object", + # Reasoning comes first so it is generated before the code it explains + "properties": { + "reasoning": {"type": "string"}, + "output": {"type": "string"}, + }, + "required": ["reasoning", "output"], + "additionalProperties": False, + } + ) + ) + codex_cmd = _codex_cmd(cfg, model, workdir, schema) + logger.debug(f"Running {' '.join(codex_cmd)}") + env = _env(cfg, workdir) + _codex_login(env) + try: + ok, stdout, stderr, code = run_subprocess( + codex_cmd, + input=prompt, + timeout=cfg.timeout, + env=env, + ) + except OSError as e: + raise RuntimeError(f"Failed to run `{' '.join(codex_cmd)}`: {e}") from e + + if not ok: + logger.error(f"`codex exec` exited with {code}: {stderr.strip()}") + return _parse_events(stdout, model.name) + + +def _codex_cmd(cfg: CodexConfig, model: ModelConfig, workdir: Path, schema: Path) -> list[str]: + model_name = model.name.split("/", 1)[-1] + provider_config: list[str] = [] + output_schema = ("--output-schema", str(schema)) + if model.name.startswith("openrouter/"): + model_name = model.name.removeprefix("openrouter/") + provider_config = [ + 'model_provider="openrouter"', + 'model_providers.openrouter.name="OpenRouter"', + 'model_providers.openrouter.base_url="https://openrouter.ai/api/v1"', + 'model_providers.openrouter.env_key="OPENROUTER_API_KEY"', + 'model_providers.openrouter.wire_api="responses"', + ] + elif model.name.startswith("hosted_vllm/"): + model_name = model.name.removeprefix("hosted_vllm/") + provider_config = [ + 'model_provider="vllm"', + 'model_providers.vllm.name="vLLM"', + f'model_providers.vllm.base_url="{model.base_url}"', + 'model_providers.vllm.wire_api="responses"', + ] + if os.environ.get("VLLM_API_KEY"): + provider_config.append('model_providers.vllm.env_key="VLLM_API_KEY"') + # vLLM's schema is applied to every message, not just the final one like openai + output_schema = () + cmd = [ + "codex", + "exec", + "-", # Read the prompt from stdin + "--json", + "--ephemeral", + "--skip-git-repo-check", + *("--sandbox", cfg.sandbox), + *output_schema, + *("--model", model_name), + *("-c", f'model_reasoning_effort="{model.reasoning_effort}"'), + *("--cd", str(workdir)), + *("-c", 'web_search="disabled"'), + *("-c", "features.view_image=false"), + *("-c", "agents.enabled=false"), + ] + if cfg.ignore_user_config: + cmd.append("--ignore-user-config") + if cfg.ignore_rules: + cmd.append("--ignore-rules") + for override in provider_config: + cmd += ["-c", override] + for override in cfg.extra_config: + cmd += ["-c", override] + return cmd + + +def _codex_login(env: dict[str, str]) -> None: + key = env.get("OPENAI_API_KEY") + if not key: + return + + logged_in, *_ = run_subprocess(["codex", "login", "status"], timeout=60, env=env) + if logged_in: + return + + ok, _, stderr, _ = run_subprocess( + ["codex", "login", "--with-api-key"], input=key, timeout=60, env=env + ) + if not ok: + raise RuntimeError(f"`codex login --with-api-key` failed: {stderr.strip()}") + + +def _env(cfg: CodexConfig, workdir: Path) -> dict[str, str]: + keep = ( + "PATH", + "HOME", + "USER", + "TERM", + "LANG", + "CARGO_HOME", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENROUTER_API_KEY", + "VLLM_API_KEY", + *("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"), + *("http_proxy", "https_proxy", "no_proxy"), + *cfg.extra_env, + ) + prefixes = ("LC_", "XDG_", "CODEX_") + env = { + name: value + for name, value in os.environ.items() + if name in keep or name.startswith(prefixes) + } + env["SHELL"] = "/bin/bash" + tmpdir = workdir / "target" / "codex" / "tmp" + tmpdir.mkdir(parents=True, exist_ok=True) + env["TMPDIR"] = str(tmpdir) + codex_home = workdir / "target" / "codex" / "home" + codex_home.mkdir(parents=True, exist_ok=True) + env["CODEX_HOME"] = str(codex_home) + env.setdefault("RUSTFLAGS", "-Awarnings") + env.setdefault("CARGO_TARGET_DIR", str(workdir / "target" / "codex")) + return env + + +def _parse_events(stdout: str, model_name: str) -> CodexResult: + messages: list[str] = [] + reasoning: list[str] = [] + tokens: dict[str, int] = dict.fromkeys( + ( + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "reasoning_output_tokens", + ), + 0, + ) + + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + match event.get("type"): + case "item.completed": + item = event.get("item") or {} + match item.get("type"): + case "agent_message": + if text := item.get("text"): + messages.append(text) + case "reasoning": + if text := item.get("text"): + reasoning.append(text) + case "turn.completed": + turn_usage = event.get("usage") or {} + for key in tokens: + tokens[key] += turn_usage.get(key) or 0 + case "turn.failed": + error = (event.get("error") or {}).get("message", "") + logger.error(f"Codex turn failed: {error}") + case "error": + logger.error(f"Codex error: {event.get('message', '')}") + + usage: dict[str, Any] = { + "prompt_tokens": tokens["input_tokens"], + "completion_tokens": tokens["output_tokens"], + "total_tokens": tokens["input_tokens"] + tokens["output_tokens"], + "cached_input_tokens": tokens["cached_input_tokens"], + "cache_write_input_tokens": tokens["cache_write_input_tokens"], + "reasoning_output_tokens": tokens["reasoning_output_tokens"], + } + # `codex exec` reports no per-call cost, so derive one from the token counts + if (cost := _cost(model_name, tokens)) is None: + usage["cost_missing"] = 1 + else: + usage["cost"] = cost + + final, empty_output = _final_message(messages) + return CodexResult(final, usage, "\n\n".join(reasoning), stdout, empty_output) + + +def _final_message(messages: list[str]) -> tuple[str, str]: + # `--output-schema` constrains every agent message, so the status updates the agent + # emits mid-turn parse too, carrying an empty `output` + empty = "" + for text in reversed(messages): + try: + parsed = json_repair_loads(text.strip()) + except Exception: + continue + if isinstance(parsed, dict) and isinstance(out := parsed.get("output"), str): + if out.strip(): + return text, "" + empty = empty or text + + # Nothing carried code; returning "" lets the caller salvage the work left on disk + return "", empty + + +def _cost(model_name: str, tokens: dict[str, int]) -> float | None: + if (rates := _rates(model_name)) is None: + return None + + # Codex reports cached and cache-write tokens as subsets of `input_tokens`, and a + # multi-turn session resends its whole context, so the cached share dominates + cached = tokens["cached_input_tokens"] + written = tokens["cache_write_input_tokens"] + fresh = max(tokens["input_tokens"] - cached - written, 0) + return ( + fresh * rates["input"] + + cached * rates["cache_read"] + + written * rates["cache_write"] + + tokens["output_tokens"] * rates["output"] + ) + + +@cache +def _rates(model_name: str) -> dict[str, float] | None: + try: + info = get_model_info(model_name) + except Exception: + info = {} + + input_cost = info.get("input_cost_per_token") or 0.0 + output_cost = info.get("output_cost_per_token") or 0.0 + if not input_cost and not output_cost: + logger.warning(f"litellm has no pricing for `{model_name}`; spend is unreported") + return None + + return { + "input": input_cost, + "output": output_cost, + # Providers that do not price these separately bill them as ordinary input + "cache_read": info.get("cache_read_input_token_cost") or input_cost, + "cache_write": info.get("cache_creation_input_token_cost") or input_cost, + } + + +FUNCTION_WRAPPER_INSTRUCTIONS = """\ +Implement only the C ABI adapter for `{wrapped_crate}::{symbol_name}` by completing +`example_wrapper`. Preserve its attributes, exported name, signature, types, and module +structure exactly. Use feedback about prior attempts to fix the conversion rather than +the C algorithm. + +For a function template, replace only the existing `unimplemented!()` body. Emit the +same single top-level function as `example_wrapper`: add no top-level `use`, helper, +static, thread-local, module, attribute, or second export. Any helper function or scoped +callback context must be declared inside that body. `prior_wrapper` is a rejected +candidate to replace, not text to combine with the template. + +# Boundary contract + +Search the C definition, safe Rust function, relevant types, and existing conversions. +Privately map every C argument, reachable mutable object/global, result, and +out-parameter to its Rust counterpart and back. Record null/sentinel meaning, ownership, +aliases, pointer identity, live length versus capacity, callback ABI, allocator family, +and the point at which C reads or writes each value. Reuse a correct existing `CInterop` +policy; do not invent a competing conversion for the same value. + +# Delegate, do not translate again + +- The semantic operation must be one fully qualified call to + `{wrapped_crate}::{symbol_name}`. Convert inputs, make that call, then synchronize all + C-visible effects. An early return before it is valid only where C takes the same early + return before the operation. +- Do not implement searching, parsing, sorting, balancing, insertion, deletion, or + business logic in the wrapper. Traversal is allowed only to map an object graph or + synchronize the safe result, and must not independently decide the result. +- If the exact safe function is absent, do not call a renamed helper, another hybrid + export, or a wrapper-side reimplementation, and do not edit the safe crate. Return an + empty `output` and identify the missing safe symbol in `reasoning`; deliberate wrapper + rejection is required so the translation can be regenerated with the exact entry + point. + +# Convert safely and faithfully + +- Respect C sequencing: check null and bounds only when C does, never after an invalid + dereference or eager slice. Read live elements unless C actually reads initialized + capacity storage; do not touch unrelated dead slots on write-back. +- Inbound C strings end at their first NUL. Any pointer returned to C for string use must + refer to storage with an explicit trailing NUL and a lifetime covering every C use; + `str::as_ptr()` and an unterminated byte slice are not C strings. +- For a graph, build one address-to-handle map and reuse it for every edge. Preserve + cycles, aliases, null, sentinels, removed nodes, and addresses of surviving objects. + Update existing C objects in place rather than replacing equal contents at new + addresses. +- Never use `Box::from_raw`, `Vec::from_raw_parts`, or an owning slice merely to borrow + C-owned memory. Transfer ownership only where C transfers it. Preserve opaque `void *` + identity without dereferencing unless its concrete pointee is proven. +- Bridge C callbacks with an explicit scoped trampoline; never transmute ABIs. Support + nesting and restore prior context. A callback may reenter and mutate the current graph + or globals: expose current state before it, import its mutations afterward, hold no + borrow or lock across it, and never overwrite it from an older snapshot. +- Use the object's configured allocator for the same calls, sizes, ordering, failures, + and rollback as C. Carry logical data and allocation tokens separately. Do not expose + Rust allocation storage as C-owned or substitute `libc` for a callback allocator. +- A new or resized C buffer is committed only after it is allocated, filled with every + live translated element, and ready to publish. Then update its pointer and metadata + together and release the old allocation only where C does. Never report success after + discarding a Rust mutation. + +# Synchronize and audit + +Treat mutable pointers as in/out. Write back every changed scalar, buffer element, link, +root/head/tail, size, capacity, iterator cursor, global, and out-parameter. Preserve C +string termination and actual writable bounds. Use global synchronization helpers +without recursively reacquiring their locks. Return the safe function's exact +C-equivalent result; do not mask panics with invented statuses. + +For the temporary build of a nonempty wrapper, change the definition of `{symbol_name}` +in `{c_src}` into an extern declaration with the same prototype; change no other C +definition. This edit must not appear in the returned Rust. If the exact safe symbol is +missing and `output` is empty, leave the C definition intact so the unchanged hybrid can +still be built before the wrapper is deliberately rejected. + +Before returning, search the final body and verify the exact safe call is present, every +boundary-map entry is synchronized, all allocation ownership is balanced, and no branch +implements or discards part of the semantic operation. +""" + + +TYPE_WRAPPER_INSTRUCTIONS = """\ +Implement only the `CInterop` conversion between C-layout `{type_name}` and its existing +safe representation in `{wrapped_crate}`. Complete the exact `example_wrapper`, including +its tests. Conversion maps representation; it never performs the data structure's +semantic algorithms. + +Preserve every top-level item, signature, test-module name, and test-function name from +`example_wrapper`. Replace `type Rust = ()` before implementing anything; a unit type, +no-op conversion, or tests that merely prove the C object was never touched are not a +conversion. If no translated safe type can represent the valid C states, report that +representation mismatch rather than fabricating one in the wrapper. + +# One conversion contract + +Search all C and Rust uses of the type. For every field, identify whether it is scalar, +owner, alias, opaque identity, callback, allocator, live length, capacity, union tag, +null, sentinel, vacant slot, or stable handle. Then use one policy in both directions: + +- `to_rust` reads only C state that is live and defined and produces the canonical safe + value. +- `sync_to_c` updates the caller's existing C objects and live storage without changing + unrelated bytes, aliases, or identity. + +# Safety, ownership, and identity + +- Never dereference null or form a slice before validating both bounds. Zero live length + with a null pointer is empty/absent. Capacity, dead slots, removed nodes, and inactive + union members are not readable unless C explicitly reads them. +- Use one graph map for cycles and aliases. Distinguish null, sentinel, vacant, removed, + and live handles. Write surviving objects in place; do not clone, free, or recreate + them merely to simplify conversion. +- Per-instance state must remain in or be reachable from that instance. Do not use + global/thread-local arenas, permanent address registries, leaks, or fabricated + `'static` references. +- Borrowed C storage remains borrowed. Do not use `Box::from_raw`, owning slices, or + `Vec::from_raw_parts` unless ownership really transfers. Preserve an opaque pointer as + identity rather than casting it to a dummy pointee type. +- A Rust string written back for C string use needs explicit NUL-terminated storage. A + Rust vector's capacity is not initialized C storage, and publishing a new or resized C + buffer requires copying every live Rust element before pointer and capacity metadata + are committed. +- Keep logical contents separate from custom-allocation tokens. Preserve allocator and + callback nullability and identity; never transmute callback ABIs or treat C storage as + Rust-owned. Converting a borrow must not invent an owning token, and synchronizing must + copy live contents rather than drop or forget a token. + +# Non-vacuous tests + +For any valid C value, `to_rust` followed immediately by `sync_to_c` must preserve every +C-visible field, live pointee, and retained address unless C semantics require +reallocation. + +- `round_trip_zeroed` must use the simplest valid empty value, not zero bytes if those + are invalid. +- `round_trip_nontrivial` must include relevant nonzero lengths, pointers, links, + aliases, callbacks, and sentinel state. Include poisoned dead storage when a field + controls liveness and prove it was neither read nor changed. +- Compare fields, pointees, and addresses directly; do not normalize expected and actual + through the same conversion. Exercise two independent owning instances to detect + accidental shared global storage. + +Use no placeholders, `catch_unwind`, or vacuous self-round-trip assertions. Conversion +code must not use `unwrap`, `expect`, or assertions for states accepted by C. +""" + + +VARIABLE_WRAPPER_INSTRUCTIONS = """\ +Implement the exact `example_wrapper` template that synchronizes C global +`{variable_name}` with translated global `{wrapped_crate}::{rust_variable_name}`. They +are two representations of one logical object. + +Preserve the template's complete top-level shape and names. `prior_wrapper` is a rejected +candidate to replace, not another definition to retain or extend. + +# Establish the contract + +Search every C and Rust initializer, reader, writer, and wrapper for this global. Record +its initial value and mutability, which direction must be synchronized around each call, +whether pointers refer to other globals by identity, and which Rust lock or +interior-mutability mechanism is authoritative. + +# Synchronize the real globals + +- Preserve the template's names and module structure and implement only its requested + synchronization functions. +- C-to-Rust synchronization must update the existing Rust global in place. Do not + replace an identity-bearing object with a clone, initializer call, leaked value, + shadow global, or private registry entry. +- Rust-to-C synchronization must update the actual C symbol and every live pointee or + buffer C can observe. +- Reuse the type's `CInterop` conversion. Preserve null and sentinel states, pointer + identity and aliases, logical length versus capacity, callbacks, and allocator + ownership; do not invent a second representation policy here. +- Hold each Rust lock once for the complete local operation, then release it before any + helper or callback that could reacquire it. A callback mutation must remain + authoritative rather than being overwritten by a stale pre-call snapshot. +- Never transmute callbacks, fabricate ownership of C memory, or reinterpret C storage + as a Rust-owned container. + +# Prove both directions + +`initial_value_matches` must independently compare the C compiler's initializer with +the translated initializer before synchronization runs; read `{wrapped_crate}` directly +and do not compare the C value with itself. + +For round trips, write a nontrivial value on one side, synchronize once, and inspect the +other side before reversing direction. Check live pointees and observable addresses, not +just scalar metadata. If callbacks can mutate the global, test that a later Rust read +sees the callback's change. + +Remove every placeholder. Do not use `catch_unwind`, self-comparisons, or expected and +actual values produced by the same conversion path. +""" + + +class CodexWrapperGenerator(WrapperGenerator): + def __init__( + self, + cache: Path | None = None, + *, + codex: CodexConfig, + model: ModelConfig, + workdir: Path, + rust_src: Path, + c_src: Path, + hybrid_src: Path, + max_iters: int = 1, + ) -> None: + super().__init__(dspy.Predict, max_iters, cache) + self._codex = codex + self._model = model + self._workdir = workdir + self._rust_src = rust_src + self._c_src = c_src + self._hybrid_src = hybrid_src + + def generate( + self, + generate_wrapper: dspy.Module, + crate: CodeRust, + wrapped_crate_code: CodeRust, + support_code: CodeC | None, + example_wrapper: CodeRust, + prior_wrapper: CodeRust | None, + feedback: str, + build_feedback: str, + scope_feedback: str, + wrapper: CodeRust | None, + symbol: Symbol, + wrapped_crate: str, + ) -> dspy.Prediction: + if wrapper is not None: + pred = dspy.Prediction(wrapper=wrapper, reasoning="") + pred.set_lm_usage({}) + pred.was_generated = False + return pred + + inputs = { + "example_wrapper": example_wrapper, + "prior_wrapper": prior_wrapper or CodeRust(), + "feedback": feedback, + "build_feedback": build_feedback, + "scope_feedback": scope_feedback, + } + if symbol.is_type: + template = TYPE_WRAPPER_INSTRUCTIONS + elif symbol.is_function: + template = FUNCTION_WRAPPER_INSTRUCTIONS + elif symbol.is_variable: + template = VARIABLE_WRAPPER_INSTRUCTIONS + else: + raise ValueError(f"No Codex wrapper prompt for `{symbol.kind}` `{symbol.name}`") + + # Codex runs with `--cd workdir`, so the prompt names files relative to it + rust_src = str(self._rust_src.relative_to(self._workdir)) + c_src = str(self._c_src.relative_to(self._workdir)) + hybrid_src = str(self._hybrid_src.relative_to(self._workdir)) + instructions = template.format( + symbol_name=symbol.spelling, + type_name=symbol.bindgen_name, + variable_name=symbol.spelling, + rust_variable_name=mangle(symbol.spelling), + wrapped_crate=wrapped_crate, + c_src=c_src, + ) + files = { + hybrid_src: ( + "the hybrid crate: the bindgen C-layout types, the `CInterop` trait, the " + "`__c_globals` externs, and the wrappers written so far. It can be very " + "large, so search it for the specific declarations you need rather than " + "reading it end to end; your output is appended to this file and must " + "compile against it." + ), + rust_src: "the safe Rust crate being wrapped.", + c_src: ( + "the C that goes into the build. It still defines the symbol you are " + "wrapping; a symbol wrapped earlier appears as an extern declaration, and " + "its translation is in the Rust crate above." + ), + } + prompt = build_prompt( + instructions, + inputs, + files, + hybrid_src, + "cargo test --no-run --lib", + output="the wrapper these instructions describe", + ) + restore = [self._hybrid_src, self._rust_src, self._c_src] + result = run_codex( + prompt, self._codex, self._model, self._workdir, restore, self._hybrid_src + ) + return result.as_prediction("wrapper", prompt, self._model.name) diff --git a/src/ideas/consolidate.py b/src/ideas/consolidate.py index 152c41a..321638a 100644 --- a/src/ideas/consolidate.py +++ b/src/ideas/consolidate.py @@ -25,7 +25,8 @@ from ideas.ast_rust import mangle as mangle_rs from ideas.ast import extract_info_c, TreeResult, Symbol, clang_rename, mangle, CodeC from ideas.ast import SymbolName, SymbolGroup, create_symbol_lexical_key_fn -from ideas.ast import create_symbol_ordering_key_fn +from ideas.ast import create_symbol_ordering_key_fn, clang_make_weak_ +from ideas.agents.utils import write_instrumentation_script logger = logging.getLogger("ideas.consolidate") @@ -69,18 +70,6 @@ def analyze( return symbols, sorted_symbol_groups -def is_system_symbol(symbol: Symbol) -> bool: - if symbol.is_system: - logger.debug(f"Ignoring system symbol `{symbol.name}`") - return True - if symbol.presumed_path is None: - return False - if os.path.commonpath([symbol.presumed_path, symbol.tu_path]) == "/": - logger.debug(f"Ignoring system symbol {symbol.name}") - return True - return False - - def get_symbols_and_dependencies( asts: list[TreeResult], external_symbol_names: list[SymbolName] | None = None, @@ -90,7 +79,7 @@ def get_symbols_and_dependencies( list_of_symbols: list[dict[SymbolName, Symbol]] = [ast.symbols for ast in asts] if filter_system_symbols: list_of_symbols = [ - {name: symbol for name, symbol in symbols.items() if not is_system_symbol(symbol)} + {name: symbol for name, symbol in symbols.items() if not symbol.is_system} for symbols in list_of_symbols ] @@ -111,9 +100,9 @@ def get_symbols_and_dependencies( external_symbol_names = [ name for name, symbol in symbols.items() - if symbol.is_global + if symbol.is_externally_visible and (symbol.is_variable or (symbol.is_function and symbol.is_definition)) - and not is_system_symbol(symbol) + and not symbol.is_system ] if external_symbol_names: paths = nx.multi_source_dijkstra_path(project_dependencies, external_symbol_names) @@ -300,42 +289,102 @@ def _get_ast( return tree +def _agreeing_types( + symbols_with_spelling: dict[SymbolSpelling, list[Symbol]], + type_references: dict[SymbolSpelling, set[SymbolSpelling]], +) -> set[SymbolSpelling]: + # A tag spelling carries its keyword, so only a typedef can share a key with a function + # or a variable. Judging that key by anything but its type symbols would let an + # unrelated function decide what the typedef denotes. + types_with_spelling = { + spelling: type_symbols + for spelling, symbols in symbols_with_spelling.items() + if (type_symbols := [sym for sym in symbols if sym.is_type]) + } + # Start from the types whose text agrees across TUs, then withdraw any that is written + # in terms of one that does not, until nothing changes: disagreement travels up the + # chain, so `typedef struct parser parser;` stops naming one type the moment the two + # `struct parser` completions differ. Only definitions are compared, because a TU that + # leaves a tag incomplete says nothing about what it is. + agreeing = { + spelling + for spelling, symbols in types_with_spelling.items() + if len({str(sym.code) for sym in symbols if sym.is_definition}) <= 1 + } + # A spelling the corpus completes at most once has a single reading whatever it is + # written in terms of, so there is no second reading for disagreement to travel to. + settled = { + spelling + for spelling, symbols in types_with_spelling.items() + if sum(sym.is_definition for sym in symbols) <= 1 + } + while withdrawn := { + s for s in agreeing - settled if not type_references.get(s, set()) <= agreeing + }: + agreeing -= withdrawn + return agreeing + + +def _denotes_one_entity(definitions: list[Symbol], agreeing_types: set[SymbolSpelling]) -> bool: + # Identical text is never proof on its own, because text carries no context: what it + # means depends on what the names in it resolve to, and that is decided per TU. + if len({str(sym.code) for sym in definitions}) > 1: + return False + + symbol = definitions[0] + + # A type has no linkage to appeal to, so its identity is structural + if symbol.is_type: + return symbol.qualified_spelling in agreeing_types + + # Only linkage joins separately written functions and variables into one entity, and + # internal linkage means each TU owns its own copy + if symbol.kind in (CursorKind.FUNCTION_DECL, CursorKind.VAR_DECL): + return all(sym.is_externally_visible and sym.is_top_level for sym in definitions) + + return True + + def _get_conflicting_symbols( asts: list[TreeResult], ) -> dict[Path, dict[SymbolName, SymbolSpelling]]: # Gather best representative symbol per spelling per AST into a single dict symbols_with_spelling: dict[SymbolSpelling, list[Symbol]] = {} + # Pooled across TUs, because a type only has to disagree in one of them to be two types + type_references: dict[SymbolSpelling, set[SymbolSpelling]] = {} for ast in asts: seen: dict[SymbolSpelling, Symbol] = {} - for symbol in ast.symbols.values(): - spelling = symbol.spelling - if not spelling: + for usr, symbol in ast.symbols.items(): + if not symbol.spelling: continue + spelling = symbol.qualified_spelling - if symbol.kind == CursorKind.STRUCT_DECL: - spelling = "struct " + spelling - if symbol.kind == CursorKind.UNION_DECL: - spelling = "union " + spelling - if symbol.kind == CursorKind.ENUM_DECL: - spelling = "enum " + spelling - - # Save this symbol if we haven't seen it before - if spelling not in seen: - seen[spelling] = symbol - # Or replace it if it's a definition and existing symbol is a declaration - elif symbol.is_definition and not seen[spelling].is_definition: + # Save this symbol if we haven't seen it before, or replace it if it's a + # definition and the existing symbol is a declaration + if spelling not in seen or ( + symbol.is_definition and not seen[spelling].is_definition + ): seen[spelling] = symbol + + if symbol.is_type: + type_references.setdefault(spelling, set()).update( + ast.symbols[dep].qualified_spelling + for dep in ast.complete_graph.get(usr, ()) + if ast.symbols[dep].is_type + ) for spelling, sym in seen.items(): symbols_with_spelling.setdefault(spelling, []).append(sym) + agreeing_types = _agreeing_types(symbols_with_spelling, type_references) + # Find symbols with common spelling but different definitions across ASTs. # Group definitions by code to avoid O(n^2) pairwise comparison. tu_renames: dict[Path, dict[SymbolName, SymbolSpelling]] = {} - used_spellings = set(symbols_with_spelling.keys()) + used_spellings = {sym.spelling for syms in symbols_with_spelling.values() for sym in syms} for ast in asts: used_spellings.update(ast.local_names) new_spellings: dict[tuple[Path, SymbolSpelling], SymbolSpelling] = {} - for spelling, symbols in symbols_with_spelling.items(): + for symbols in symbols_with_spelling.values(): # Only definitions and variables can conflict definitions = [s for s in symbols if s.is_definition or s.is_variable] if len(definitions) <= 1: @@ -345,27 +394,17 @@ def _get_conflicting_symbols( if len({sym.presumed_path or sym.tu_path for sym in definitions}) <= 1: continue - # Byte-identical definitions are one entity redeclared, not a conflict - if len({str(sym.code) for sym in definitions}) <= 1: + # One entity redeclared in several TUs is not a conflict + if _denotes_one_entity(definitions, agreeing_types): continue - # Multiple distinct definitions exist - rename any symbol that can safely be - # renamed. Only true linker symbols (global functions and global variables) must - # preserve their spelling across TUs. Struct/union/enum tags and typedefs have - # no linker visibility in C, so they can differ freely between TUs. However, - # clang reports EXTERNAL linkage for all of these — including anonymous tags that - # inherit the name of their enclosing typedef — so we cannot rely on is_global - # to filter them out and must check the cursor kind explicitly. - NON_LINKED_KINDS = ( - CursorKind.STRUCT_DECL, - CursorKind.UNION_DECL, - CursorKind.ENUM_DECL, - CursorKind.TYPEDEF_DECL, - ) + # Distinct entities share a spelling, so rename the ones that can be renamed. A + # global function or variable is a linker symbol and has to keep its spelling; a + # type has no linker presence, so each TU may spell it however it likes. for sym in definitions: - if sym.is_system: + if sym.in_system_header: continue - if sym.is_global and sym.is_top_level and sym.kind not in NON_LINKED_KINDS: + if sym.is_externally_visible and sym.is_top_level and not sym.is_type: continue path = sym.presumed_path or sym.tu_path @@ -458,16 +497,15 @@ def consolidate( for group in symbol_order: # Add forward declarations if more than one symbol in group if len(group) > 1: + # A synthesized declaration has no source line of its own to point at. for name in group: symbol = symbols[name] - declaration = symbol.declaration + declaration = symbol.forward_declaration if declaration is None: continue if declaration in sources: continue sources[declaration] = None - if include_line_directives and symbol.declaration_line_directive is not None: - sources[declaration] = str(symbol.declaration_line_directive) # Add symbol code for name in group: @@ -485,7 +523,7 @@ def consolidate( ) -def _generate_build_rs(extra_link_libs: list[str]) -> str: +def _generate_build_rs(link_inputs: list[tuple[str, str]], link_search_dirs: list[str]) -> str: body_lines = [ 'println!("cargo:rerun-if-changed=src/lib.c");', 'let ubsan = std::env::var("CARGO_FEATURE_CC_UBSAN").is_ok();', @@ -506,7 +544,8 @@ def _generate_build_rs(extra_link_libs: list[str]) -> str: "}", "", "if asan {", - ' build.flag("-fsanitize=address")', + ' build.flag("-fsanitize=address,pointer-compare")', + ' .flag("-ftrivial-auto-var-init=pattern")', ' .flag("-fno-sanitize-recover=all");', "}", "", @@ -525,8 +564,16 @@ def _generate_build_rs(extra_link_libs: list[str]) -> str: ' println!("cargo:rustc-link-lib=static=clang_rt.asan-x86_64");', "}", ] - for lib in extra_link_libs: - body_lines.append(f'println!("cargo:rustc-link-lib=dylib={lib}");') + # rustc-link-lib cannot express a path, so a library the C build named by absolute path + # has to be re-resolved as `-l:` against a search dir added for its own directory + search_dirs = link_search_dirs + [ + str(Path(value).parent) for kind, value in link_inputs if kind == "lib_path" + ] + for search_dir in dict.fromkeys(search_dirs): + body_lines.append(f'println!("cargo:rustc-link-search=native={search_dir}");') + for kind, value in link_inputs: + spec = f"dylib:+verbatim={Path(value).name}" if kind == "lib_path" else f"dylib={value}" + body_lines.append(f'println!("cargo:rustc-link-lib={spec}");') body = textwrap.indent("\n".join(body_lines), " ") return f"fn main() {{\n{body}\n}}\n" @@ -535,12 +582,12 @@ def _generate_bindings(symbols: dict[SymbolName, Symbol], c_src_path: Path) -> s allowed_functions = [ mangle_rs(s.spelling) for s in symbols.values() - if s.is_global and s.is_function and s.is_definition and not is_system_symbol(s) + if s.is_externally_visible and s.is_function and s.is_definition and not s.is_system ] allowed_variables = [ mangle_rs(s.spelling) for s in symbols.values() - if s.is_global and s.is_variable and not is_system_symbol(s) + if s.is_externally_visible and s.is_variable and not s.is_system ] return _bindgen( c_src_path, @@ -586,12 +633,17 @@ def _main(cfg: ConsolidateConfig): # Read source priority and link libs from the links file. links_data = json.loads(cfg.links.read_text()) source_priority: list[Path] = [] - extra_link_libs: list[str] = [] + link_inputs: list[tuple[str, str]] = [] + link_search_dirs: list[str] = [] for entry in links_data.get("entries", []): if "source" in entry: source_priority.append(Path(entry["source"]).resolve()) elif "lib" in entry: - extra_link_libs.append(entry["lib"]) + link_inputs.append(("lib", entry["lib"])) + elif "lib_path" in entry: + link_inputs.append(("lib_path", entry["lib_path"])) + elif "search_dir" in entry: + link_search_dirs.append(entry["search_dir"]) symbols, symbol_order = analyze(cfg.compile_commands, source_priority) logger.info( @@ -601,13 +653,37 @@ def _main(cfg: ConsolidateConfig): assert crate.lib_src_path is not None, "Expected lib.rs to exist in -sys crate!" c_src_path = crate.lib_src_path.with_suffix(".c") - # Consolidate and write C code to disk + # Consolidate and write C code to disk, then check that it compiles c_src = consolidate(symbols, symbol_order, cfg.include_line_directives) c_src_path.parent.mkdir(exist_ok=True, parents=True) c_src_path.write_text(str(c_src)) + ok, _, error, _ = run_subprocess(["clang", "-fsyntax-only", "-w", str(c_src_path)]) + if not ok: + raise ValueError(f"Consolidated C in {c_src_path} does not compile!\n{error}") + + if cfg.template == "bin": + # The -sys bin target is `#![no_main]`, so C must own the entrypoint, and until `main` + # is wrapped it has to stay weak to avoid colliding with the libtest harness `main` + assert any(sym.spelling == "main" and sym.is_definition for sym in symbols.values()), ( + f"Expected a `main` definition in {cfg.compile_commands}!" + ) + clang_make_weak_(c_src_path, "main") + + # Add dev dependencies for driving the binary through its entrypoint + crate.cargo_add(dep="assert_cmd@2.0.17", section="dev") + crate.cargo_add(dep="predicates@3.1.3", section="dev") + + # Add dev dependencies common to every -sys crate + crate.cargo_add(dep="libc@0.2", section="dev") + crate.cargo_add(dep="insta@1.48.0", section="dev", features=["json"]) + crate.cargo_add(dep="serde@1", section="dev", features=["derive"]) + crate.cargo_add(dep="serde_json@1", section="dev") + crate.cargo_add(dep="walkdir@2", section="dev") # Write build.rs to disk with dependencies and the sanitizer/coverage config - (crate.cargo_toml.parent / "build.rs").write_text(_generate_build_rs(extra_link_libs)) + (crate.cargo_toml.parent / "build.rs").write_text( + _generate_build_rs(link_inputs, link_search_dirs) + ) crate.cargo_add("cc@1.2.53", section="build") crate.cargo_feature(cc_ubsan=[], cc_asan=[], cc_coverage=[]) @@ -616,17 +692,29 @@ def _main(cfg: ConsolidateConfig): if crate.main_src_path is not None: assert crate.lib_name is not None, "Expected a library target in the -sys crate!" crate.main_src_path.write_text(f"#![no_main]\nuse {crate.lib_name}::*;\n") + # `#![no_main]` suppresses the libtest harness `main` too, so a `--test` build of this + # bin target is just the C entrypoint; nextest would then choke on `--list` + crate.configure_target("bin", name=crate.name, test=False, doctest=False) # Write cargo configurations to disk # NOTE: We configure nextest for both the crate and the workspace such that the -sys crate is standalone crate.cargo_nextest_config(crate.cargo_toml.parent / ".config" / "nextest.toml") crate.cargo_nextest_config(crate.workspace_root / ".config" / "nextest.toml") + crate.vcs.add(crate.workspace_root / ".config") + + # Write instrumentation script + write_instrumentation_script( + crate.cargo_toml.parent / "instrument.sh", features=["cc_asan", "cc_ubsan"] + ) # Commit the crate crate.vcs.add(crate.cargo_toml.parent) workspace_cargo_toml = crate.workspace_root / "Cargo.toml" if workspace_cargo_toml != crate.cargo_toml and workspace_cargo_toml.exists(): crate.vcs.add(workspace_cargo_toml) + workspace_cargo_lock = crate.workspace_root / "Cargo.lock" + if workspace_cargo_lock.exists(): + crate.vcs.add(workspace_cargo_lock) crate.vcs.commit(f"Created C bindings crate '{crate.name}'") diff --git a/src/ideas/convert_tests.py b/src/ideas/convert_tests.py index 6e2a6e8..bc03e79 100644 --- a/src/ideas/convert_tests.py +++ b/src/ideas/convert_tests.py @@ -53,6 +53,12 @@ def add_deps_for_exec(crate: Crate) -> None: crate.invalidate_metadata() +def add_deps_for_lib(crate: Crate) -> None: + # The template parses `cargo build --message-format=json` to locate the candidate library + crate.cargo_add(dep="serde_json@1", section="dev") + crate.invalidate_metadata() + + def convert_tests_for_exec(test_cases: list[Path]) -> str: test_cases = list(filter(is_bin_test, test_cases)) if len(test_cases) == 0: @@ -139,13 +145,6 @@ def is_lib_test(test_case: Path): return "lib_state_in" in test_case_json and "lib_state_out" in test_case_json -def add_deps_for_lib(crate: Crate) -> None: - # Add test dependencies - crate.cargo_add(dep="once_cell@1.21.3", section="dev") - crate.cargo_add(dep="test-cdylib@1.1.0", section="dev") - crate.invalidate_metadata() - - def convert_tests_for_lib( test_cases: list[Path], runner_manifest: Path | None, @@ -162,10 +161,13 @@ def convert_tests_for_lib( # Load template template = template_path.read_text() + # cando2 derives the candidate name, test vectors and artifact dir from the runner's parent + test_root_dir = runner_manifest.parent.parent if runner_manifest else None + # FIXME: This currently assumes that the macro generate_tests! is defined in the template # Use the generate_tests! macro to add tests lines = ["\n", "generate_tests! {"] - lines.append(f' "{runner_manifest}";') + lines.append(f' "{runner_manifest}", "{test_root_dir}";') for test_case in test_cases: # Skip tests that exercise undefined behavior test_case_json = json.loads(test_case.read_text()) diff --git a/src/ideas/hybrid.py b/src/ideas/hybrid.py new file mode 100644 index 0000000..5792f4f --- /dev/null +++ b/src/ideas/hybrid.py @@ -0,0 +1,330 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field, replace +from textwrap import dedent + +from .ast import Symbol, SymbolName +from .ast import clang_make_global_, clang_make_extern_ +from .ast import clang_function_arity, clang_function_definitions, clang_rename_ +from .ast_rust import BindgenName, CodeRust, get_root, get_referenced_type_names +from .tools import Crate, REDUCED_CONTEXT +from .wrapper import C_INTEROP_TRAIT, TYPES_PREAMBLE, wrap_values +from .wrapper import bindgen_types, bindgen_bindings, split_items +from .wrapper import generate_unimplemented_function_wrapper + + +@dataclass(frozen=True) +class HybridState: + types: Mapping[BindgenName, CodeRust] = field(default_factory=dict) + consts: Mapping[BindgenName, CodeRust] = field(default_factory=dict) + externs: tuple[CodeRust, ...] = () + wrappers: Mapping[SymbolName, CodeRust] = field(default_factory=dict) + main_wrapped: bool = False + c_stranded: bool = False + + @property + def types_code(self) -> CodeRust: + return CodeRust.join(self.types.values()) + + def type_slice( + self, bindgen_name: BindgenName | None, wrapper: CodeRust + ) -> tuple[Iterable[CodeRust], Iterable[CodeRust]]: + if not REDUCED_CONTEXT: + # Return all types because the wrapper may reference any of them + return self.types.values(), self.consts.values() + + # In reduced context mode, keep only the types reachable from this symbol, the externs + # and its own wrapper + pending = {TYPES_PREAMBLE} + if bindgen_name is not None: + pending.add(bindgen_name) + for code in (*self.externs, wrapper): + pending |= get_referenced_type_names(get_root(str(code))) + + keep: set[BindgenName] = set() + while pending: + name = pending.pop() + if name in keep or (item := self.types.get(name)) is None: + continue + keep.add(name) + pending |= get_referenced_type_names(get_root(str(item))) + return ( + [code for name, code in self.types.items() if name in keep], + [code for name, code in self.consts.items() if name in keep], + ) + + def render( + self, + wrappers: Iterable[CodeRust] | None = None, + types: Iterable[CodeRust] | None = None, + consts: Iterable[CodeRust] | None = None, + ) -> CodeRust: + if wrappers is None: + wrappers = self.wrappers.values() + if types is None: + types = self.types.values() + if consts is None: + consts = self.consts.values() + return CodeRust.join( + [ + C_INTEROP_TRAIT, + CodeRust.join(types), + wrap_values(CodeRust.join(consts), *self.externs), + *wrappers, + ] + ) + + +@dataclass(frozen=True) +class HybridSnapshot: + state: HybridState + c_src: bytes + + +class HybridWriter: + def __init__(self, sys_crate: Crate, crate: Crate, rust_lib_name: str): + if crate.lib_src_path is None and crate.main_src_path is None: + raise ValueError(f"Crate {crate.name} has neither lib.rs nor main.rs!") + assert sys_crate.lib_src_path is not None + assert sys_crate.lib_name is not None + self._crate = crate + self._c_src_path = sys_crate.lib_src_path.with_suffix(".c") + self._sys_lib_name = sys_crate.lib_name + self._rust_lib_name = rust_lib_name + self._state = HybridState() + self._bindings: dict[SymbolName, CodeRust] = {} + if crate.main_src_path is not None: + self._adopt_c_entrypoint() + self._flush() + + @property + def types(self) -> Mapping[BindgenName, CodeRust]: + return self._state.types + + @property + def consts(self) -> Mapping[BindgenName, CodeRust]: + return self._state.consts + + @property + def externs(self) -> tuple[CodeRust, ...]: + return self._state.externs + + @property + def wrappers(self) -> Mapping[SymbolName, CodeRust]: + return self._state.wrappers + + @property + def main_wrapped(self) -> bool: + return self._state.main_wrapped + + @property + def types_code(self) -> CodeRust: + return self._state.types_code + + @property + def bindings(self) -> Mapping[SymbolName, CodeRust]: + return self._bindings + + def type_slice( + self, bindgen_name: BindgenName | None, wrapper: CodeRust + ) -> tuple[Iterable[CodeRust], Iterable[CodeRust]]: + return self._state.type_slice(bindgen_name, wrapper) + + def render( + self, + wrappers: Iterable[CodeRust] | None = None, + types: Iterable[CodeRust] | None = None, + consts: Iterable[CodeRust] | None = None, + ) -> CodeRust: + return self._state.render(wrappers, types, consts) + + def set_types( + self, types: Mapping[BindgenName, CodeRust], consts: Mapping[BindgenName, CodeRust] + ): + self._state = replace(self._state, types=types, consts=consts) + self._flush() + + def add_externs(self, externs: Iterable[CodeRust]): + self._state = replace(self._state, externs=(*self._state.externs, *externs)) + self._flush() + + def write_types(self, symbols: Iterable[Symbol]): + # An anonymous record has no name to allowlist, so bindgen only reaches it through + # whatever names the field that uses it + items = bindgen_types( + self._c_src_path, [n for s in symbols if (n := s.bindgen_name) is not None] + ) + types, consts = split_items(items) + self.set_types(types, consts) + + def add_bindings(self, symbols: Iterable[Symbol]) -> list[str]: + variables = [s for s in symbols if s.is_variable] + if not variables: + return [] + + clang_make_global_(self._c_src_path, [s.spelling for s in variables]) + bindings = bindgen_bindings( + self._c_src_path, [s.spelling for s in variables], self._state.types_code + ) + for symbol in variables: + self._bindings[symbol.name] = bindings[symbol.spelling] + self.add_externs([bindings[s.spelling] for s in variables]) + + self._crate.vcs.add(self._c_src_path) + return [s.spelling for s in variables] + + def set_wrapper(self, name: SymbolName, wrapper: CodeRust): + self._state = replace(self._state, wrappers={**self._state.wrappers, name: wrapper}) + self._flush() + + def take_over_main(self): + # Declare the C entrypoint chain as extern so Rust owns the definition and we avoid + # duplicate entrypoint symbols at link time. + clang_make_extern_(self._c_src_path, "main") + clang_make_extern_(self._c_src_path, "__ideas_entry") + clang_make_extern_(self._c_src_path, "__ideas_c_main") + self._crate.vcs.add(self._c_src_path) + + self._state = replace(self._state, main_wrapped=True) + self._flush() + + def make_extern(self, spelling: str): + clang_make_extern_(self._c_src_path, spelling) + self._crate.vcs.add(self._c_src_path) + + def strand_c(self): + # Drop every C body no wrapper replaced so nothing can link the originals. Whatever the + # translation did reach survives, but it can no longer be propped up by the C it skipped. + clang_make_extern_(self._c_src_path, clang_function_definitions(self._c_src_path)) + self._crate.vcs.add(self._c_src_path) + + self._state = replace(self._state, c_stranded=True) + self._flush() + + def unimplemented_function_wrapper(self, spelling: str) -> CodeRust | None: + return generate_unimplemented_function_wrapper( + self._c_src_path, spelling, self._state.types_code + ) + + def snapshot(self) -> HybridSnapshot: + return HybridSnapshot(state=self._state, c_src=self._c_src_path.read_bytes()) + + def restore(self, snapshot: HybridSnapshot): + self._state = snapshot.state + self._flush() + self._c_src_path.write_bytes(snapshot.c_src) + self._crate.vcs.add(self._c_src_path) + + def _adopt_c_entrypoint(self): + # Free the `main` symbol for Rust to define; C's program logic lives on under the new name + clang_rename_(self._c_src_path, {"main": "__ideas_c_main"}) + + # Emitting the call in C lets the C compiler check it against the real prototype, so a + # misread `main` signature is a build error instead of silent UB + match clang_function_arity(self._c_src_path, "__ideas_c_main"): + case 0: + entry = """ + int __ideas_entry(int argc, char **argv) { + return __ideas_c_main(); + } + """ + case 2: + entry = """ + int __ideas_entry(int argc, char **argv) { + return __ideas_c_main(argc, argv); + } + """ + case 3: + entry = """ + extern char **environ; + + int __ideas_entry(int argc, char **argv) { + return __ideas_c_main(argc, argv, environ); + } + """ + case nparams: + raise NotImplementedError(f"Unhandled `main` parameter count {nparams}!") + + with self._c_src_path.open("a") as c_src: + c_src.write(dedent(entry)) + + # Keep the -sys crate's bin linkable on its own; in the hybrid the linker prefers + # Rust's strong `main` and this trampoline goes unused + c_src.write( + dedent( + """ + __attribute__((weak)) int main(int argc, char **argv) { + return __ideas_entry(argc, argv); + } + """ + ) + ) + self._crate.vcs.add(self._c_src_path) + + def _flush(self): + # Link the -sys crate to resolve the C symbols no wrapper replaced; rustc drops it + # unless something names it, and wrapping is never exhaustive + body = CodeRust(f"use {self._sys_lib_name} as _;") + body += self._state.render() + + if (lib_src_path := self._crate.lib_src_path) is not None: + lib_src_path.parent.mkdir(parents=True, exist_ok=True) + lib_src_path.write_text(str(body)) + self._crate.vcs.add(lib_src_path) + + if (main_src_path := self._crate.main_src_path) is not None: + if self._state.main_wrapped: + # C's `main` is now extern-only, so the -rs crate supplies the entrypoint + root = CodeRust(f"use {self._rust_lib_name}::main;") + elif self._state.c_stranded: + # `__ideas_entry` went with the rest of the C, so the binary has to be able to + # link without it; failing loudly beats exiting 0 without running anything + root = CodeRust( + dedent("""\ + fn main() { + unimplemented!("`main` was never translated to Rust"); + } + """) + ) + else: + # C still owns the program logic, but Rust owns the entrypoint and calls in. + root = CodeRust( + dedent("""\ + unsafe extern "C" { + fn __ideas_entry(argc: std::ffi::c_int, argv: *mut *mut std::ffi::c_char) -> std::ffi::c_int; + } + + fn main() -> std::process::ExitCode { + use std::ffi::{c_char, c_int, CString}; + + let mut args: Vec> = std::env::args_os() + .map(|arg| { + CString::new(arg.into_encoded_bytes()) + .expect("NUL in argument") + .into_bytes_with_nul() + }) + .collect(); + let argc = args.len() as c_int; + let mut argv: Vec<*mut c_char> = + args.iter_mut().map(|a| a.as_mut_ptr().cast()).collect(); + argv.push(std::ptr::null_mut()); + let code = unsafe { __ideas_entry(argc, argv.as_mut_ptr()) }; + std::process::ExitCode::from(code as u8) + } + """) + ) + + if lib_src_path is not None: + # Compile lib.rs into the bin rather than depend on it + root += CodeRust(f'#[path = "{lib_src_path.name}"]\nmod hybrid;') + else: + root += body + + main_src_path.parent.mkdir(parents=True, exist_ok=True) + main_src_path.write_text(str(root)) + self._crate.vcs.add(main_src_path) diff --git a/src/ideas/model.py b/src/ideas/model.py index 34f30d0..8728134 100644 --- a/src/ideas/model.py +++ b/src/ideas/model.py @@ -6,14 +6,20 @@ import logging from typing import Any +from contextlib import contextmanager +from collections.abc import Iterator from dataclasses import dataclass import dspy from omegaconf import MISSING from litellm import cost_per_token +from dspy.dsp.utils.settings import settings +from dspy.utils.usage_tracker import track_usage from hydra.core.config_store import ConfigStore +logger = logging.getLogger(__name__) + # Surface DSPy logging to Hydra and disable verbose to sys.stderr to avoid duplicates dspy_logger = logging.getLogger("dspy") dspy_logger.propagate = True @@ -23,6 +29,7 @@ @dataclass class ModelConfig: name: str = MISSING + reasoning_effort: str = MISSING cache: bool = False text_output: bool = True revision: str | None = None @@ -44,8 +51,37 @@ class GenerateConfig: cs.store(name="generate", node=GenerateConfig) +class CostTrackingLM(dspy.LM): + _warned_missing_cost = False + + # DSPy records only token counts in the usage tracker and discards litellm's per-call + # cost into `lm.history`. Re-injecting it lets costs be summed instead of recomputed + # from aggregated tokens, which would misapply long-context (>200k) pricing tiers. + def forward(self, prompt=None, messages=None, **kwargs): + results = super().forward(prompt=prompt, messages=messages, **kwargs) + + tracker = dspy.settings.usage_tracker + if tracker is None or getattr(results, "cache_hit", False): + return results + + cost = getattr(results, "_hidden_params", {}).get("response_cost") + if cost is not None: + # Merges into the token entry recorded by super().forward() + tracker.add_usage(self.model, {"cost": cost}) + else: + # Counted so mixed priced/unpriced totals can be flagged as underestimates + tracker.add_usage(self.model, {"cost_missing": 1}) + if not CostTrackingLM._warned_missing_cost: + CostTrackingLM._warned_missing_cost = True + logger.warning( + f"litellm reported no cost for `{self.model}`; reported spend is an underestimate" + ) + + return results + + def get_lm(model: ModelConfig, generate: GenerateConfig) -> dspy.LM: - lm = dspy.LM( + lm = CostTrackingLM( model=model.name, cache=model.cache, api_key=model.api_key, @@ -72,6 +108,9 @@ def get_lm(model: ModelConfig, generate: GenerateConfig) -> dspy.LM: if model.text_output: lm.kwargs["reasoning"] = {"exclude": True} # type: ignore[reportArgumentType] + if model.name.startswith("openai/"): + lm.model_type = "responses" + # NOTE: Covers exotic variants like gpt-5.5-pro if model.name.startswith(("openai/gpt-5.5", "openai/gpt-5.6")): lm.kwargs["temperature"] = 1.0 @@ -92,9 +131,31 @@ def configure(model: ModelConfig, generate: GenerateConfig): dspy.configure(lm=lm, track_usage=True) +@contextmanager +def track_lm_usage() -> Iterator[dict[str, dict[str, Any]]]: + # A nested tracker hides the enclosing one from dspy, so this block's usage has to be + # folded back into the parent by hand. The yielded dict is filled on exit. + parent = settings.usage_tracker + if parent is None: + yield {} + return + + total: dict[str, dict[str, Any]] = {} + with track_usage() as tracker: + try: + yield total + finally: + total.update(tracker.get_total_tokens()) + for lm_name, usage_entry in total.items(): + parent.add_usage(lm_name, usage_entry) + + def format_usage(pred: dspy.Prediction) -> str: - # get_lm_usage() returns dict[lm_name, dict[str, Any]] — aggregate across all LMs - lm_usage = pred.get_lm_usage() + return format_lm_usage(pred.get_lm_usage()) + + +def format_lm_usage(lm_usage: dict[str, dict[str, Any]] | None) -> str: + # lm_usage maps lm_name to that LM's totals — aggregate across all of them if lm_usage is None: return "unknown usage" @@ -107,9 +168,16 @@ def format_usage(pred: dspy.Prediction) -> str: prompt_tokens = usage.get("prompt_tokens") or usage.get("input_tokens") or 0 completion_tokens = usage.get("completion_tokens") or usage.get("output_tokens") or 0 total_tokens = usage.get("total_tokens") or (prompt_tokens + completion_tokens) - cost_usd = usage.get("cost") or usage.get("cost_usd") or usage.get("total_cost") - # Compute costs using litellm + # Costs summed per LM call by CostTrackingLM; a genuine $0.00 must not fall back + cost_usd = None + for key in ("cost", "cost_usd", "total_cost"): + if usage.get(key) is not None: + cost_usd = usage[key] + break + summed = cost_usd is not None + + # Fallback for models litellm cannot price per call; approximate, tiers may be wrong if cost_usd is None and lm_usage: cost_usd = 0.0 for model_name, per_lm in lm_usage.items(): @@ -121,6 +189,71 @@ def format_usage(pred: dspy.Prediction) -> str: pc, cc = 0.0, 0.0 cost_usd += pc + cc - cost = f"${cost_usd:.4f}, " if cost_usd is not None else "" + cost = f"${cost_usd:.6f}, " if cost_usd is not None else "" + + # A summed cost silently undercounts when only some calls were priced + unpriced = usage.get("cost_missing") or 0 + missing = f" ({unpriced:,} calls unpriced)" if summed and unpriced else "" + + return f"{cost}{total_tokens:,} tok ({prompt_tokens:,} in / {completion_tokens:,} out){missing}" + + +def render_prompt( + module: dspy.Module, inputs: dict[str, Any], was_generated: bool = True +) -> dict[str, str]: + # Re-rendering assumes `inputs` is verbatim what the predictor received. Other modules, + # like ReAct and ProgramOfThought, reshape them or send several prompts per forward. + if not isinstance(module, (dspy.Predict, dspy.ChainOfThought)): + logger.warning(f"Unable to record prompt for {type(module).__name__} module") + return {} + + # ChainOfThought extends the signature, so its inner Predict holds the original signature + signature = module.predictors()[0].signature + assert isinstance(signature, type) + + adapter = settings.adapter or dspy.ChatAdapter() + files = {"_system.md": adapter.format_system_message(signature)} + if not was_generated: + files["_cached.md"] = ( + "THIS PROMPT WAS NOT SENT: A CACHED OR STATIC RESULT WAS USED INSTEAD.\n" + ) + for name, input_field in signature.input_fields.items(): + # Only dspy.Code subclasses carry a `language`; anything else renders as .md + match getattr(input_field.annotation, "language", None): + case "c": + ext = ".c" + case "rust": + ext = ".rs" + case _: + ext = ".md" + files[f"{name}{ext}"] = str(inputs[name]) + + # A NUL would make git treat the file as binary and suppress the diff + return {name: text.replace("\x00", "\\0") for name, text in files.items()} + - return f"{cost}{total_tokens:,} tok ({prompt_tokens:,} in / {completion_tokens:,} out)" +def predict( + module: dspy.Module, + inputs: dict[str, Any], + output_field: str, + replay: Any | None = None, +) -> dspy.Prediction: + parent_usage_tracker = settings.usage_tracker + if replay is not None: + pred = dspy.Prediction(**{output_field: replay}) + if parent_usage_tracker is not None: + pred.set_lm_usage({}) + elif parent_usage_tracker is None: + pred = module(**inputs) + else: + # dspy installs a tracker only at the outermost module, so this call's own usage + # has to be measured locally before being folded back into the enclosing total + with track_usage() as local_usage_tracker: + pred = module(**inputs) + lm_usage = local_usage_tracker.get_total_tokens() + pred.set_lm_usage(lm_usage) + for lm_name, usage_entry in lm_usage.items(): + parent_usage_tracker.add_usage(lm_name, usage_entry) + pred.was_generated = replay is None + pred.prompt_files = render_prompt(module, inputs, pred.was_generated) + return pred diff --git a/src/ideas/oracle.py b/src/ideas/oracle.py new file mode 100644 index 0000000..8eafda4 --- /dev/null +++ b/src/ideas/oracle.py @@ -0,0 +1,404 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import json +import logging +from typing import Literal, Protocol +from dataclasses import dataclass, field + +from .ast import CodeC, Symbol, SymbolGroup +from .ast_rust import CodeRust +from .tools import Crate + +logger = logging.getLogger("ideas.oracle") + + +class UntrustedBaselineError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Candidate: + symbol: Symbol + snippet: CodeC + translation: CodeRust + wrapper: CodeRust | None = None + wrap_rejection: str = "" + wrapped: bool = True + + +@dataclass +class Verdict: + rejected: bool = False + stage: Literal["wrap", "review"] | None = None + feedback: str = "" + wrap_feedback: str = "" + evidence: str = "" + message: str = "" + blamed: set[str] = field(default_factory=set) + + +class SymbolOracle(Protocol): + def exercises(self, symbol: Symbol) -> bool: ... + + @property + def baseline_failures(self) -> frozenset[str]: ... + + @property + def expected_tests(self) -> frozenset[str]: ... + + @property + def regressions(self) -> dict[str, SymbolGroup]: ... + + def build(self) -> str: ... + + def baseline(self) -> None: ... + + def audit(self) -> str: ... + + def judge(self, candidate: Candidate) -> Verdict: ... + + def blame(self, group: SymbolGroup, signals: set[str]) -> None: ... + + +def _wrap_rejection(candidate: Candidate) -> Verdict: + return Verdict(rejected=True, stage="wrap", feedback=_unwrappable_feedback(candidate)) + + +class NullOracle: + def exercises(self, symbol: Symbol) -> bool: + return False + + @property + def baseline_failures(self) -> frozenset[str]: + return frozenset() + + @property + def expected_tests(self) -> frozenset[str]: + return frozenset() + + @property + def regressions(self) -> dict[str, SymbolGroup]: + return {} + + def build(self) -> str: + return "" + + def baseline(self) -> None: + pass + + def audit(self) -> str: + return "" + + def judge(self, candidate: Candidate) -> Verdict: + # An unwrappable translation is rejected whether or not anything can run the code + return Verdict() if candidate.wrapped else _wrap_rejection(candidate) + + def blame(self, group: SymbolGroup, signals: set[str]) -> None: + pass + + +class TestOracle: + def __init__(self, crate: Crate, tests: str, *, share_evidence: bool = False): + self._crate = crate + self._tests = tests + self._share_evidence = share_evidence + self._baseline_failures: frozenset[str] = frozenset() + self._expected_tests: frozenset[str] = frozenset() + self._regressions: dict[str, SymbolGroup] = {} + + def exercises(self, symbol: Symbol) -> bool: + return True + + @property + def baseline_failures(self) -> frozenset[str]: + return self._baseline_failures + + @property + def expected_tests(self) -> frozenset[str]: + return self._expected_tests + + @property + def regressions(self) -> dict[str, SymbolGroup]: + return dict(self._regressions) + + def build(self) -> str: + # cargo build skips test targets, which can fail to compile on their own + return self._run(self._tests, build_only=True)[0] + + def _run( + self, name: str, *, skip: list[str] | None = None, build_only: bool = False + ) -> tuple[str, dict[str, str]]: + passes, output, error, _ = self._crate.cargo_test( + name, + skip=skip, + build_only=build_only, + # nextest warns and ignores the format under --no-run, so don't ask for it + message_format=None if build_only else "libtest-json", + ) + # A --no-run invocation reports no test outcomes, so there is nothing worth recording + if not build_only: + crate_dir = self._crate.cargo_toml.parent + run_events = crate_dir / f"cargo_{name}.jsonl" + run_events.write_text(output, encoding="utf-8") + run_log = crate_dir / f"cargo_{name}.log" + run_log.write_text(error, encoding="utf-8") + # Staged, not committed: the run belongs to the caller's next commit, and forced + # because the workspace .gitignore excludes *.log + self._crate.vcs.add(run_events, run_log, force=True) + # Feedback is empty when the tests pass, so callers can treat it as the failure signal + events = _extract_test_events(output) + if passes: + return "", events + + action = f"Building test `{name}`" if build_only else f"Running `cargo test {name}`" + return f"{action} fails!\n{error}", events + + def blame(self, group: SymbolGroup, signals: set[str]): + self._regressions.update(dict.fromkeys(signals, group)) + + def baseline(self): + self._baseline_failures = frozenset() + self._expected_tests = frozenset() + self._regressions = {} + + logger.info(f"Verifying test `{self._tests}` against the untranslated crate ...") + + # A baseline nothing can build says nothing about the tests, so don't trust one + builds, output = self._crate.cargo_build() + feedback = f"Running `cargo build` fails!\n{output}" if not builds else self.build() + if feedback: + raise UntrustedBaselineError( + f"Crate `{self._crate.name}` does not build before translation!\n{feedback}" + ) + + # Then run the tests to see which pass and which fail against the untranslated C + feedback, events = self._run(self._tests) + passing = {name for name, event in events.items() if event == "ok"} + broken = {name for name, event in events.items() if event == "failed"} + # A later run missing one of these was truncated, not green + self._expected_tests = frozenset(events) + # An ignored test never ran, so it is no evidence that anything can judge a translation + if not passing: + raise UntrustedBaselineError( + f"No test in `{self._tests}` passes against the untranslated C in " + f"`{self._crate.name}`, so the tests are broken and cannot judge a " + f"translation!\n{feedback}" + ) + + if broken: + logger.warning( + "Excluding test(s) that already fail against the untranslated C: " + f"{', '.join(sorted(broken))}" + ) + logger.info(f"Verified {len(passing)}/{len(events)} of `{self._tests}` passed!") + self._baseline_failures = frozenset(broken) + + def audit(self) -> str: + error, events = self._run(self._tests, skip=sorted(self._baseline_failures)) + # A test the baseline ran but this run never mentioned did not pass, it disappeared + vanished = self._expected_tests - self._baseline_failures - set(events) + if vanished: + missing = ", ".join(sorted(vanished)) + return f"Test(s) did not run against the stranded crate: {missing}\n\n{error}" + return error + + def judge(self, candidate: Candidate) -> Verdict: + if not candidate.wrapped: + return _wrap_rejection(candidate) + + symbol = candidate.symbol + logger.info(f"Testing symbol `{symbol.name}` ...") + + # Skip only what the C already fails; an earlier regression still runs, just unblamed + error, events = self._run(self._tests, skip=sorted(self._baseline_failures)) + + # The baseline and earlier symbol groups already own their failures + prior_failures = self._baseline_failures | set(self._regressions) + # Only a test nobody else owns can say anything about this symbol + judged = set(events) - prior_failures + regressed = {n for n in judged if events[n] == "failed"} + # A test the baseline ran but this run never mentioned did not pass, it disappeared + vanished = self._expected_tests - prior_failures - set(events) + + if not events and error: # cargo test failed + regressed, vanished = set(), set() # nothing ran, so blame no test + evidence = error + message = f"Error testing symbol `{symbol.name}`" + logger.error(message) + elif regressed or vanished: + blamed = sorted(regressed) + [f"{n} (did not run)" for n in sorted(vanished)] + evidence = f"Regressed test(s): {', '.join(blamed)}\n\n{error}" + message = f"Failed to test symbol `{symbol.name}`" + logger.error(message) + elif not judged: # every test was already blamed elsewhere + evidence = "" + message = f"Skipped testing symbol `{symbol.name}`" + logger.info(message) + else: + evidence = "" + message = f"Tested symbol `{symbol.name}`" + logger.info(message) + + feedback = _diverged_feedback(symbol) if evidence else "" + wrap_feedback = _diverged_wrap_feedback(symbol) if evidence else "" + if evidence and self._share_evidence: + feedback += _evidence_feedback(evidence) + wrap_feedback += _evidence_feedback(evidence) + + return Verdict( + rejected=bool(evidence), + stage="review" if evidence else None, + feedback=feedback, + wrap_feedback=wrap_feedback, + evidence=evidence, + message=message, + blamed=regressed | vanished, + ) + + +def _unwrappable_feedback(candidate: Candidate) -> str: + symbol = candidate.symbol + name = symbol.spelling + preamble = ( + f"The attempt to give Rust `{name}` in `prior_translation` a working C-compatible FFI " + "wrapper was rejected. That alone does not prove the safe translation is wrong: the " + "attached reason may instead be a wrapper scope, syntax, build, or test-fixture defect. " + "Use its exact reason rather than guessing. If it is wrapper-only, preserve " + "`prior_translation` exactly. Only where the rejection demonstrates an incompatible safe " + "interface or representation, regenerate the complete `prior_translation` so the exact " + "Rust symbol exists once and a wrapper can bridge every valid C state without changing " + "its meaning. Do not append a renamed replacement or make the Rust behavior less faithful " + "merely to ease conversion. " + ) + coda = ( + f"The guidance above is about `{name}`: change the other items in `prior_translation` " + f"only where `{name}` requires it, and leave the rest as they are. " + "Items in `prior_translation` are the rejected group and may be revised; previously " + "accepted reference code is not. Keep the translation fully memory-safe and free of " + "`unsafe` constructs, and keep its observable behavior identical to the C `snippet`." + ) + + if symbol.is_variable: + guidance = ( + f"Keep `{name}` a `static` or `const` item, initialized exactly as the C `snippet` " + "initializes it. It must not become a function, a lazily-initialized cell, or " + "anything that hands out a fresh value per call, and a field that points at " + "another global in C must borrow that same translated global rather than a " + "private duplicate. If a field's type makes that impossible in safe Rust, change " + "that field's type instead of the item's shape. " + ) + elif symbol.is_type: + guidance = ( + f"Choose one canonical representation for `{name}` that a wrapper can convert " + "field by field while preserving live contents, null and sentinel states, aliases, " + "pointer identity, callbacks, allocation ownership, and logical length versus " + "capacity. The Rust fields need not copy the C layout, but every C state accepted by " + "the program needs a total safe representation. Keep per-instance graph ownership " + "reachable from the instance. Avoid required `'static` or exclusive borrows that a " + "call-scoped C value cannot supply, and avoid an erased or unit representation that " + "cannot be converted back. " + ) + else: + guidance = ( + f"Emit `{name}` under that exact name with explicit, concrete argument and return " + "types that a C caller can convert for the duration of one call. Preserve ownership " + "and aliases without requiring a borrow to outlive its owner, hiding required state " + "inside a closure, or returning a reference whose backing owner is unavailable at " + "the boundary. If `{name}` belongs to a mutually recursive candidate, include and " + "correct the whole required group rather than adding a differently named entry point. " + ) + + feedback = preamble + guidance + coda + # Compiler and wrapper-template output, not ported-suite evidence, so it is safe to + # hand the translator verbatim + if candidate.wrap_rejection: + feedback += ( + f"\n\nThe last attempt at a wrapper was rejected with:\n{candidate.wrap_rejection}" + ) + return feedback + + +def _diverged_feedback(symbol: Symbol) -> str: + name = symbol.spelling + return ( + f"Switching `{name}` from C to the routed Rust candidate changed the program's " + "observable behavior. This signal covers the translation together with its FFI " + "conversion, so it does not identify which layer is wrong. Re-audit `prior_translation` " + "against the C `snippet`; if any Rust step differs, it does not match the behavior of " + "the C `snippet` and must be corrected, but do not distort a correct safe operation to " + "compensate for a wrapper defect. " + f"Start with `{name}` and every translated helper first reached through it. Re-derive " + "their control flow statement by statement and make a mutation ledger for storage, " + "links, root/head/tail, size, capacity, iterator state, out-parameters, allocation " + "tokens, rollback, and destruction. Check that handles are resolved before inspecting " + "pointee values; independent conditional reads stay lazy; live length is not capacity; " + "and no idiomatic Rust layout is used as a C size. " + "Also check null and sentinel states, pointer identity and shallow aliases, callback " + "reentrancy and post-callback rereads, configured allocator call order and failure, C " + "string termination, integer conversion and wrapping, every early return, and states " + "the public C representation lets callers mutate. Do not assume inputs are normalized " + "or well formed where C has defined behavior. " + "If this audit shows the safe candidate is correct, return it unchanged and leave the " + "boundary correction to the wrapper retry. Otherwise return one complete replacement for " + "`prior_translation`, preserving exact symbol names. Do not retain a broken definition " + "and add a renamed substitute. Make minimal, targeted changes and treat C as the source " + "of truth even where it looks buggy." + ) + + +def _diverged_wrap_feedback(symbol: Symbol) -> str: + name = symbol.spelling + return ( + f"The wrapper in `prior_wrapper` passed its local build and any wrapper-specific tests, " + f"but routing `{name}` through it changed the program's observable behavior. The oracle " + "cannot distinguish a safe-operation defect from a boundary defect, so change the " + "wrapper only where a comparison with C and `wrapped_crate_code` demonstrates it is " + "wrong. Preserve exactly " + "the top-level items and signatures in `example_wrapper`; for a function wrapper, edit " + "only its existing body and keep any helper or callback context local to that body. " + f"Verify that every path on which C reaches the operation makes exactly one call to the " + f"exact safe `{name}` entry point and that the wrapper performs conversion and " + "synchronization only. Recheck " + "both directions for null and sentinel states, pointer identity and graph aliases, live " + "length versus capacity, initialized versus dead storage, integer conversions, C string " + "NUL termination, callback ABI and reentrant mutation, globals, and out-parameters. A " + "new or resized C buffer must contain all live Rust results before it is published, and " + "old storage must be released through the configured allocator only when C would do so. " + f"Do not compensate for a suspect translation by reimplementing `{name}`'s logic, " + "calling a renamed substitute, discarding a safe mutation, or stepping outside what " + "`example_wrapper` allows. If the exact safe symbol or a convertible representation is " + "missing, let wrapper generation fail so the translation can be regenerated." + ) + + +def _evidence_feedback(evidence: str) -> str: + return ( + "\n\nThe test suite that caught this reported:\n" + f"{evidence}\n\n" + "Use this only to locate the divergence in the translation. Do not edit, disable, or " + "special-case anything to satisfy a specific test: the suite is the check, not the " + "specification, and the C `snippet` remains the source of truth." + ) + + +def _extract_test_events(output: str) -> dict[str, str]: + events: dict[str, str] = {} + for line in output.splitlines(): + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if obj.get("type") != "test": + continue + event = obj.get("event") + if event not in {"ok", "failed", "ignored"}: + continue + name = str(obj.get("name", "")).rsplit("$", 1)[-1].strip() + if name: + events[name] = event + + return events diff --git a/src/ideas/refine.py b/src/ideas/refine.py new file mode 100644 index 0000000..04354fc --- /dev/null +++ b/src/ideas/refine.py @@ -0,0 +1,159 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import logging +from abc import ABC, abstractmethod +from typing import Self +from dataclasses import dataclass, field, replace +from collections.abc import Iterator, Sequence + +import dspy +from dspy.utils.exceptions import AdapterParseError + +from .model import format_usage + + +@dataclass(kw_only=True) +class Attempt: + index: int = 0 + total: int = 0 + _rejected: bool | None = field(default=None, init=False) + + def accept(self) -> None: + self._record(rejected=False) + + def reject(self) -> None: + self._record(rejected=True) + + # Subclasses record why they judged as they did; the loop only needs to know whether + def _record(self, *, rejected: bool) -> None: + if self._rejected is not None: + raise RuntimeError("Attempt was already judged!") + self._rejected = rejected + + @property + def success(self) -> bool: + if self._rejected is None: + raise RuntimeError( + f"Attempt {self.index}/{self.total} ended without accept() or reject()!" + ) + return not self._rejected + + +@dataclass(frozen=True) +class Feedback: + review: str = "" + build: str = "" + scope: str = "" + + def __bool__(self) -> bool: + return bool(self.review or self.build or self.scope) + + +@dataclass(kw_only=True) +class CodeAttempt(Attempt, ABC): + pred: dspy.Prediction + prior_feedback: Feedback + rejection: Feedback = field(default=Feedback(), init=False) + + @property + @abstractmethod + def summary(self) -> str: ... + + def reject(self, *, build: Sequence[str] = (), scope: Sequence[str] = ()) -> None: + if not build and not scope: + raise ValueError("Attempt was rejected with no reason!") + super().reject() + self.rejection = Feedback(build=_bullets(build), scope=_bullets(scope)) + + # Both channels at once, for readers that do not care which one complained + @property + def reason(self) -> str: + return "\n".join(text for text in (self.rejection.build, self.rejection.scope) if text) + + # Only the session judges build and scope, so the review carries over untouched + @property + def next_feedback(self) -> Feedback: + return replace(self.rejection, review=self.prior_feedback.review) + + @property + def headline(self) -> str: + counter = "" if self.success else f" ({self.index}/{self.total})" + return f"{self.summary}{counter}: {format_usage(self.pred)}" + + # The headline alone is what gets logged; the sections below it are for the commit body + @property + def message(self) -> str: + msg = self.headline + for heading, text in ( + ("Reasoning", self.pred.get("reasoning", "")), + ("Feedback", self.prior_feedback.review), + ("Build Feedback", self.rejection.build), + ("Scope Feedback", self.rejection.scope), + ): + if text: + msg += f"\n\n# {heading}\n{text}" + return msg + + +def _bullets(feedback: Sequence[str]) -> str: + if len(feedback) == 1: + return feedback[0] + return "\n".join(f"- {f}" for f in feedback) + + +# `A` is what each iteration produces, `R` the candidate a cache hit can replay instead +class PredictSession[A: Attempt, R](ABC): + @property + @abstractmethod + def _max_iters(self) -> int: ... + + @abstractmethod + def _prime(self) -> R | None: ... + + @abstractmethod + def _attempt(self, prior: A | None, replay: R | None) -> A: ... + + def __enter__(self) -> Self: + return self + + def __iter__(self) -> Iterator[A]: + replay: R | None = self._prime() + + # A replayed candidate spends the first iteration without generating anything, so + # grant one more and leave as many real attempts as a cache miss would have had + max_iters = self._max_iters + (1 if replay is not None else 0) + + prior: A | None = None + for i in range(max_iters): + # Replaying admits static candidates that violate safety, which the LLM then fixes + try: + attempt = self._attempt(prior, replay) + # A malformed response is a failure a fresh attempt might survive + except AdapterParseError: + # The caller's own log line names what is being generated + logging.getLogger(type(self).__module__).exception( + f"Generation failed on iteration {i + 1}/{max_iters}!" + ) + # If this is the last iteration, raise + if i == max_iters - 1: + raise + # Otherwise attempt again before any logic + continue + finally: + # Consumed once even when the attempt fails, so a retry generates instead + replay = None + + attempt.index, attempt.total = i + 1, max_iters + yield attempt + + if attempt.success: + return + + prior = attempt + + def __exit__(self, *exc_info: object) -> None: + pass diff --git a/src/ideas/tools.py b/src/ideas/tools.py index a111ef7..2a0cafd 100644 --- a/src/ideas/tools.py +++ b/src/ideas/tools.py @@ -8,10 +8,13 @@ import re import json import shutil +import signal +from textwrap import dedent import tomlkit import logging import subprocess +from contextlib import suppress from functools import cached_property from typing import Any, Literal from tempfile import TemporaryDirectory @@ -34,22 +37,24 @@ def init(self, force_init: bool = False) -> bool: if self.vcs == "none": return True - ok, out = False, "" if not force_init: - ok, out = self("rev-parse --abbrev-ref HEAD") - if not ok: - ok, out = self("init --initial-branch=main") - if not ok: - raise ValueError(f"Failed to initialize git in {self.repo_dir}!\n{out}") - return ok + ok, _ = self("rev-parse --abbrev-ref HEAD") + if ok: + return True + + for cmd in ("init --initial-branch=main", f"config core.excludesFile {os.devnull}"): + ok, out = self(cmd) + if not ok: + raise ValueError(f"Failed to initialize git in {self.repo_dir}!\n{out}") + return True - def add(self, *paths: Path) -> bool: + def add(self, *paths: Path, force: bool = False) -> bool: if self.vcs == "none": return True ok = True for path in paths: - ok, out = self(f"add {path}") + ok, out = self(f"add {'-f ' if force else ''}{path}") if not ok: raise ValueError(f"Failed to add {path}!\n{out}") return ok @@ -109,8 +114,13 @@ def __init__( vcs: Literal["none", "git"] = "none", template: Literal["bin", "lib"] | None = None, reinit: bool = False, + *, + jobs: int | None = None, + codegen_units: int | None = None, ): self.cargo_toml = cargo_toml.resolve() + self.jobs = jobs + self.codegen_units = codegen_units crate_dir = self.cargo_toml.parent self.vcs = VCS(repo_dir=crate_dir, vcs=vcs) @@ -258,6 +268,43 @@ def cargo_add( self.invalidate_metadata() return output + # Workaround for https://github.com/rust-lang/cargo/issues/9454 + def add_self_alias(self, alias: str) -> None: + lib_name = self.lib_name + if lib_name is None: + raise ValueError(f"Crate {self.name} has no library target to alias as `{alias}`!") + + shim_name = f"{self.name}-{alias.replace('_', '-')}" + shim_dir = self.cargo_toml.parent / alias + (shim_dir / "src").mkdir(parents=True, exist_ok=True) + (shim_dir / "src" / "lib.rs").write_text(f"pub use {lib_name}::*;\n") + (shim_dir / "Cargo.toml").write_text( + dedent(f"""\ + [package] + name = "{shim_name}" + version = "0.1.0" + edition = "{self.root_package["edition"]}" + + [dependencies] + {lib_name} = {{ path = "..", package = "{self.name}" }} + + [lib] + test = false + doctest = false + """) + ) + + # Add the shim crate as a fixed-name dev dependency in this crate + manifest_path = Path(self.cargo_toml) + doc = tomlkit.parse(manifest_path.read_text()) + dev_deps = doc.setdefault("dev-dependencies", tomlkit.table()) + entry = tomlkit.inline_table() + entry["path"] = alias + entry["package"] = shim_name + dev_deps[alias] = entry + manifest_path.write_text(tomlkit.dumps(doc)) + self.invalidate_metadata() + def cargo_feature(self, **features: list[str]) -> None: # Set/overwrite new features cargo_toml = tomlkit.loads(self.cargo_toml.read_text()) @@ -339,18 +386,17 @@ def add_workspace_dependencies(self, names: list[str]) -> None: self.cargo_toml.write_text(tomlkit.dumps(cargo_toml)) self.invalidate_metadata() - def cargo_clean(self) -> None: + def cargo_clean(self, workspace: bool = False) -> None: + manifest_path = self.workspace_root / "Cargo.toml" if workspace else self.cargo_toml cmd = [ "cargo", "clean", "--quiet", - f"--manifest-path={self.cargo_toml}", + f"--manifest-path={manifest_path}", ] success, output, error, _ = run_subprocess(cmd) if not success: - raise RuntimeError( - f"Failed to clean crate at {self.cargo_toml} with error:\n\n{output + error}" - ) + logger.warning(f"Failed to clean crate at {manifest_path}:\n\n{output + error}") def cargo_build(self) -> tuple[bool, str]: cmd = [ @@ -360,13 +406,47 @@ def cargo_build(self) -> tuple[bool, str]: "--color=never", f"--manifest-path={self.cargo_toml}", ] - builds, output, error, _ = run_subprocess(cmd) + if self.jobs is not None: + cmd.extend(["-j", str(self.jobs)]) + + builds, output, error, _ = run_subprocess(cmd, env=self._cargo_env()) return builds, output + error + def _cargo_env(self) -> dict[str, str]: + env = os.environ.copy() + if self.codegen_units is not None: + rustflags = env.get("RUSTFLAGS", "") + env["RUSTFLAGS"] = (rustflags + f" -C codegen-units={self.codegen_units}").strip() + return env + + def cargo_test_list(self, name: str, features: list[str] | None = None) -> list[str]: + cmd = [ + "cargo", + "nextest", + "list", + "--color=never", + "--cargo-quiet", + f"--manifest-path={self.cargo_toml}", + "--message-format=json", + ] + if name: + cmd.extend(["--test", name]) + if features: + cmd.extend(["--features", ",".join(features)]) + + ok, output, error, _ = run_subprocess(cmd) + if not ok: + raise RuntimeError( + f"Failed to list the tests of `{name}` in {self.cargo_toml.parent}!\n{output + error}" + ) + listing = json.loads(output) + return [ + test for suite in listing["rust-suites"].values() for test in suite["testcases"] + ] + def cargo_test( self, name: str, - test_harness: Literal["nextest run", "test"] = "nextest run", quiet: bool = True, fail_fast: bool = False, build_only: bool = False, @@ -376,19 +456,17 @@ def cargo_test( ) -> tuple[bool, str, str, int | Literal["timeout"]]: cmd = [ "cargo", - *test_harness.split(), + "nextest", + "run", "--color=never", f"--manifest-path={self.cargo_toml}", ] - if not fail_fast: + if self.jobs is not None: + cmd.extend(["--build-jobs", str(self.jobs)]) + if not fail_fast and not build_only: cmd.append("--no-fail-fast") if quiet: - if test_harness == "nextest run": - cmd.append("--cargo-quiet") - elif test_harness == "test": - cmd.append("--quiet") - else: - raise ValueError(f"Unsupported test harness: {test_harness}") + cmd.append("--cargo-quiet") if lib: cmd.append("--lib") if name: @@ -398,22 +476,17 @@ def cargo_test( if build_only: cmd.append("--no-run") - env = os.environ.copy() + env = self._cargo_env() if message_format is not None: cmd.extend(["--message-format", message_format]) - if message_format == "libtest-json" and test_harness == "nextest run": + if message_format == "libtest-json": # https://nexte.st/docs/machine-readable/libtest-json/ env["NEXTEST_EXPERIMENTAL_LIBTEST_JSON"] = "1" if skip: - if test_harness == "nextest run": - excluded_tests = [f"test(/^{re.escape(test_name)}$/)" for test_name in skip] - expr = " and ".join(f"not {test_expr}" for test_expr in excluded_tests) - cmd.extend(["-E", expr]) - else: - cmd.append("--") - cmd.append("--exact") - for test_name in skip: - cmd.extend(["--skip", test_name]) + excluded_tests = [f"test(/^{re.escape(test_name)}$/)" for test_name in skip] + expr = " and ".join(f"not {test_expr}" for test_expr in excluded_tests) + cmd.extend(["-E", expr]) + cmd.append("--no-tests=pass") return run_subprocess(cmd, env=env) def cargo_nextest_config( @@ -438,32 +511,52 @@ def write(self, path: Path, data, **kwargs): return path.write_text(data, **kwargs) +def _kill_group( + proc: "subprocess.Popen[str]", grace: float = 2.0, drain: float = 5.0 +) -> tuple[str, str]: + # SIGTERM first so the process group can clean up after itself, SIGKILL if it lingers + with suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGTERM) + with suppress(subprocess.TimeoutExpired): + proc.wait(timeout=grace) + # FIXME: Give grace to the process group, not just leader, to exit cleanly + os.killpg(proc.pid, signal.SIGKILL) + + # A descendant that left the process group survives the kill and holds the pipes open + try: + stdout, stderr = proc.communicate(timeout=drain) + except subprocess.TimeoutExpired as e: + stdout = e.stdout.decode(errors="replace") if e.stdout else "" + stderr = e.stderr.decode(errors="replace") if e.stderr else "" + return stdout, stderr + + def run_subprocess( cmd: list[str], input: str | None = None, timeout: float | None = None, **kwargs, ) -> tuple[bool, str, str, int | Literal["timeout"]]: - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - input=input, - timeout=timeout, - **kwargs, - ) - return True, result.stdout, result.stderr, result.returncode - except subprocess.CalledProcessError as e: - return False, e.stdout, e.stderr, e.returncode - except subprocess.TimeoutExpired as e: - return ( - False, - e.stdout.decode() if e.stdout else "", - e.stderr.decode() if e.stderr else "", - "timeout", - ) + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE if input is not None else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + **kwargs, + ) + with proc: + try: + stdout, stderr = proc.communicate(input, timeout=timeout) + except subprocess.TimeoutExpired: + stdout, stderr = _kill_group(proc) + return False, stdout, stderr, "timeout" + except BaseException: + # start_new_session detaches the child from the tty, so Ctrl-C never reaches it + _kill_group(proc) + raise + return proc.returncode == 0, stdout, stderr, proc.returncode def check_rust( @@ -472,7 +565,7 @@ def check_rust( flags: list[str] | None = None, structured_output: bool = False, ) -> tuple[bool, str]: - cmd = ["rustc"] + cmd = ["rustc", "-Awarnings"] if flags: cmd.extend(flags) diff --git a/src/ideas/translate.py b/src/ideas/translate.py index f4b3b96..8ee70bc 100644 --- a/src/ideas/translate.py +++ b/src/ideas/translate.py @@ -6,6 +6,7 @@ import sys import logging +import traceback from pathlib import Path from dataclasses import dataclass, field @@ -19,6 +20,8 @@ from ideas import SnippetTranslator, RecurrentTranslator, WrapperGenerator from ideas import create_translation_unit, extract_info_c from ideas.consolidate import get_symbols_and_dependencies +from .codex import CodexConfig, CodexSnippetTranslator, CodexWrapperGenerator +from .oracle import TestOracle, UntrustedBaselineError from .tools import Crate logger = logging.getLogger("ideas.translate") @@ -28,18 +31,20 @@ class TranslateConfig: model: ModelConfig = field(default_factory=ModelConfig) generate: GenerateConfig = field(default_factory=GenerateConfig) + codex: CodexConfig = field(default_factory=CodexConfig) cargo_toml: Path = MISSING bindings_cargo_toml: Path = MISSING - tests: str | None = MISSING + tests: Path | None = MISSING template: str = "bin" deps: list[str] = field(default_factory=list) - translator: str = "ChainOfThought" + translator: str = "codex" translator_max_iters: int = 5 - wrapper: str = "ChainOfThought" + wrapper: str = "codex" wrapper_max_iters: int = 5 max_iters: int = 3 + share_test_evidence: bool = True vcs: str = "none" @@ -48,12 +53,26 @@ class TranslateConfig: cs.store(name="translate", node=TranslateConfig) +def copy_and_port_tests(src: Path, dst: Path, sys_crate_name: str, lib_name: str) -> None: + sys_import = f"use {sys_crate_name}::*;" + replacement = f"{sys_import}\nuse ::{lib_name} as _;" + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src.read_text().replace(sys_import, replacement)) + + def _init_crates(cfg: TranslateConfig) -> tuple[Crate, Crate, Crate]: sys_crate = Crate(cfg.bindings_cargo_toml, vcs=cfg.vcs) # type: ignore[reportArgumentType] # Create fresh -rs (pure Rust translation) crate Cargo.toml so cargo init always runs and registers the crate in workspace.members rs_cargo_toml = Path(str(cfg.cargo_toml.parent) + "-rs") / "Cargo.toml" - rs_crate = Crate(rs_cargo_toml, vcs=cfg.vcs, template=cfg.template, reinit=True) # type: ignore[reportArgumentType] + rs_crate = Crate( + rs_cargo_toml, + vcs=cfg.vcs, # type: ignore[reportArgumentType] + template=cfg.template, # type: ignore[reportArgumentType] + reinit=True, + jobs=4, + codegen_units=4096, + ) # Binary -rs crates always have a lib.rs file with all the functions (including main) translated to safe Rust if cfg.template == "bin": assert rs_crate.main_src_path is not None, "Expected main.rs to exist in -rs crate!" @@ -66,16 +85,28 @@ def _init_crates(cfg: TranslateConfig) -> tuple[Crate, Crate, Crate]: workspace_cargo_toml = rs_crate.workspace_root / "Cargo.toml" if workspace_cargo_toml != rs_crate.cargo_toml and workspace_cargo_toml.exists(): rs_crate.vcs.add(workspace_cargo_toml) + workspace_cargo_lock = rs_crate.workspace_root / "Cargo.lock" + if workspace_cargo_lock.exists(): + rs_crate.vcs.add(workspace_cargo_lock) rs_crate.vcs.commit(f"Created Rust translation crate '{rs_crate.name}'") # Create fresh hybrid crate (links -rs + -sys together) Cargo.toml so cargo init always runs and registers the crate in workspace.members - crate = Crate(cfg.cargo_toml, vcs=cfg.vcs, template=cfg.template, reinit=True) # type: ignore[reportArgumentType] + crate = Crate( + cfg.cargo_toml, + vcs=cfg.vcs, # type: ignore[reportArgumentType] + template=cfg.template, # type: ignore[reportArgumentType] + reinit=True, + jobs=4, + codegen_units=4096, + ) crate.add_workspace_dependencies([rs_crate.name, sys_crate.name]) + crate.cargo_add("libc") for dep in cfg.deps: crate.cargo_add(dep) for dep in sys_crate.root_package["dependencies"]: - if dep["kind"] == "dev": + # Do not inherit path dependencies from -sys crate + if dep["kind"] == "dev" and dep.get("path") is None: name_req = dep["name"] + "@" + dep["req"] crate.cargo_add(name_req, section="dev", features=dep["features"]) if cfg.template == "bin": @@ -97,36 +128,48 @@ def _init_crates(cfg: TranslateConfig) -> tuple[Crate, Crate, Crate]: raise NotImplementedError(f"Unsupported template: {cfg.template!r}") crate.vcs.add(crate.cargo_toml.parent) - # Copy the translation test from the -sys crate; skipped entirely when tests=null + # Port translation-time tests if cfg.tests is not None: - sys_test_src = ( - cfg.bindings_cargo_toml.parent / "tests" / f"{cfg.tests}.rs" - ).read_text() - test_path = crate.cargo_toml.parent / "tests" / f"{cfg.tests}.rs" - test_path.parent.mkdir(parents=True, exist_ok=True) - # Prepend `use as _;` so the hybrid crate's `#[export_name]` functions are - # retained by the linker. The translation loop makes each C function extern-only - # (via clang_make_extern_), so if these Rust implementations are dead-code-eliminated - # the test binary will fail to link with unresolved symbol errors. - # FIXME: This could be removed if library tests were portable like binary tests - if cfg.template == "lib": - sys_test_src = f"use {crate.lib_name} as _;\n" + sys_test_src - test_path.write_text(sys_test_src) + test_path = crate.cargo_toml.parent / "tests" / f"{cfg.tests.stem}.rs" + assert sys_crate.lib_name is not None, "Expected a library target in the -sys crate!" + assert crate.lib_name is not None, "Expected a library target in the hybrid crate!" + copy_and_port_tests(cfg.tests, test_path, sys_crate.lib_name, crate.lib_name) crate.vcs.add(test_path) workspace_cargo_toml = crate.workspace_root / "Cargo.toml" if workspace_cargo_toml != crate.cargo_toml and workspace_cargo_toml.exists(): crate.vcs.add(workspace_cargo_toml) + workspace_cargo_lock = crate.workspace_root / "Cargo.lock" + if workspace_cargo_lock.exists(): + crate.vcs.add(workspace_cargo_lock) crate.vcs.commit(f"Created hybrid crate '{crate.name}'") return crate, rs_crate, sys_crate +def _log_visible(cfg: TranslateConfig) -> None: + if cfg.codex.sandbox != "danger-full-access": + return + logger.info("Visible directories:") + # cargo_toml is //Cargo.toml + root = cfg.cargo_toml.parent.parent.resolve() + for d in [root, *root.parents]: + try: + names = sorted(p.name + ("/" if p.is_dir() else "") for p in d.iterdir()) + except OSError as e: + names = [f"<{e.strerror}>"] + logger.info(f" {d}: {' '.join(names)}") + + def _main(cfg: TranslateConfig) -> None: output_dir = Path(HydraConfig.get().runtime.output_dir) logger.info(f"Saving results to {output_dir}") + _log_visible(cfg) crate, rs_crate, sys_crate = _init_crates(cfg) + if cfg.translator_max_iters == 0: + logger.warning("Init-only mode enabled, skipping translation!") + return # Get global symbol table assert sys_crate.lib_src_path is not None, "Expected lib.rs to exist in -sys crate!" @@ -140,56 +183,133 @@ def _main(cfg: TranslateConfig) -> None: # Create translation agent model.configure(cfg.model, cfg.generate) dspy.configure(adapter=adapters.ChatAdapter()) - translator = getattr(dspy, cfg.translator) - wrapper = getattr(dspy, cfg.wrapper) cache = crate.workspace_root / "cache.db" + assert rs_crate.lib_src_path is not None, "Expected lib.rs to exist in -rs crate!" + paths = (crate.workspace_root, rs_crate.lib_src_path, c_src_path) symbol_wrapper = None - tests = None if cfg.wrapper_max_iters > 0: - symbol_wrapper = WrapperGenerator(wrapper, cfg.wrapper_max_iters, cache=cache) - tests = cfg.tests + hybrid_src = crate.lib_src_path or crate.main_src_path + assert hybrid_src is not None, "Expected lib.rs or main.rs to exist in hybrid crate!" + symbol_wrapper = _make_wrapper(cfg, cache, *paths, hybrid_src=hybrid_src) + symbol_oracle = None + if cfg.tests is not None and symbol_wrapper is not None: + symbol_oracle = TestOracle( + crate, cfg.tests.stem, share_evidence=cfg.share_test_evidence + ) + if cfg.share_test_evidence: + logger.warning("Sharing test failure output with the translator!") agent = RecurrentTranslator( sys_crate=sys_crate, crate=crate, rs_crate=rs_crate, - symbol_translator=SnippetTranslator(translator, cfg.translator_max_iters, cache=cache), + symbol_translator=_make_translator(cfg, cache, *paths), symbol_wrapper=symbol_wrapper, - tests=tests, + symbol_oracle=symbol_oracle, max_iters=cfg.max_iters, ) - msg = f"Initialized translation of {cfg.template} `{crate.name}` ({len(symbols)} symbols)" - logger.info(msg) - crate.vcs.commit(msg) - # Run translation agent and write it to disk try: pred = agent(symbols, dependencies) + except UntrustedBaselineError: + # Broken input tests are not a translation failure, so don't record one + raise except Exception as e: logger.exception(e) - pred = dspy.Prediction(success=False) + crate.vcs.commit(f"{type(e).__name__}\n\n{e}\n{traceback.format_exc()}") + pred = dspy.Prediction(complete=False, regressions={}) + + # Wrapping is never exhaustive, so on its own a passing test suite only proves the hybrid + # works, not that Rust is what made it work. Dropping every C body the wrappers did not + # replace is the guarantee: whatever still links is Rust, and whatever needed C now fails. + agent.strand_c() + + # Every per-symbol verdict was reached while the surviving C could still carry the suite, + # so this is the only run that measures the Rust alone + findings = symbol_oracle.audit() if symbol_oracle is not None else "" + + if pred.regressions: + logger.warning( + "Test(s) regressed during this translation: " + + ", ".join( + f"`{t}` in `{' '.join(g)}`" for t, g in sorted(pred.regressions.items()) + ) + ) + + # Same tail a symbol group's closing log carries, since a run is just their sum + counts = ( + f" regressed={len(pred.regressions)} " + f"total={len(symbol_oracle.expected_tests)} " + f"baseline_failures={len(symbol_oracle.baseline_failures)}" + if symbol_oracle is not None + else "" + ) usage = model.format_usage(pred) - if not pred.success: - msg = f"Failed to translate `{crate.name}`: {usage}" + if pred.complete and not findings: + msg = f"Translated {cfg.template} `{crate.name}` to Rust: {usage}{counts}" + logger.info(msg) + else: + msg = f"Failed to translate {cfg.template} `{crate.name}` to Rust: {usage}{counts}" logger.error(msg) - # Force test failures by stubbing out the hybrid crate - if crate.lib_src_path is not None: - crate.lib_src_path.write_text("\n") - crate.vcs.add(crate.lib_src_path) - if crate.main_src_path is not None: - crate.main_src_path.write_text("\n") - crate.vcs.add(crate.main_src_path) - else: - msg = f"Translated {cfg.template} `{crate.name}` to Rust: {usage}" - logger.info(msg) + if findings: + msg += f"\n\n{findings}" + + if pred.regressions: + regressed = "\n".join( + f"- `{t}` in `{' '.join(g)}`" for t, g in sorted(pred.regressions.items()) + ) + msg += f"\n\n# Regressed Tests\n{regressed}" # Commit translation if (output_subdir := HydraConfig.get().output_subdir) is not None: crate.vcs.add(output_dir / output_subdir) crate.vcs.commit(msg) + crate.cargo_clean(workspace=True) + + +def _make_translator( + cfg: TranslateConfig, cache: Path, workdir: Path, rust_src: Path, c_src: Path +) -> SnippetTranslator: + if cfg.translator != "codex": + return SnippetTranslator( + getattr(dspy, cfg.translator), cfg.translator_max_iters, cache=cache + ) + return CodexSnippetTranslator( + cache=cache, + codex=cfg.codex, + model=cfg.model, + workdir=workdir, + rust_src=rust_src, + c_src=c_src, + max_iters=cfg.translator_max_iters, + ) + + +def _make_wrapper( + cfg: TranslateConfig, + cache: Path, + workdir: Path, + rust_src: Path, + c_src: Path, + *, + hybrid_src: Path, +) -> WrapperGenerator: + if cfg.wrapper != "codex": + return WrapperGenerator(getattr(dspy, cfg.wrapper), cfg.wrapper_max_iters, cache=cache) + return CodexWrapperGenerator( + cache=cache, + codex=cfg.codex, + model=cfg.model, + workdir=workdir, + rust_src=rust_src, + c_src=c_src, + hybrid_src=hybrid_src, + max_iters=cfg.wrapper_max_iters, + ) + @hydra.main(version_base=None, config_name="translate") def main(cfg: TranslateConfig) -> None: diff --git a/src/ideas/translate_context.py b/src/ideas/translate_context.py new file mode 100644 index 0000000..e1261eb --- /dev/null +++ b/src/ideas/translate_context.py @@ -0,0 +1,283 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import re +import math +import logging +from collections.abc import Mapping +from dataclasses import dataclass + +import networkx as nx + +from .ast_rust import BindgenName, CodeRust, strip_fns +from .tools import MAX_DEPENDENT_CHARS, REDUCED_CONTEXT +from .ast import CodeC, Symbol, SymbolName, SymbolGroup + +logger = logging.getLogger("ideas.translate_context") + + +@dataclass(frozen=True) +class TranslateContext: + crate_code: CodeRust + reference_code: CodeRust + dependent_code: CodeC + + +@dataclass(frozen=True) +class WrapContext: + wrapped_crate_code: CodeRust + wrappers: Mapping[BindgenName, CodeRust] + translated_code: CodeC + untranslated_code: CodeC + + def support_code(self, snippet: CodeC) -> CodeC: + return self.translated_code + snippet + self.untranslated_code + + +@dataclass(frozen=True) +class TranslationContext: + translate: TranslateContext + wrap: WrapContext + + @classmethod + def build( + cls, + G: nx.DiGraph, + group: SymbolGroup, + groups: list[SymbolGroup], + symbols: dict[SymbolName, Symbol], + translations: dict[SymbolGroup, CodeRust] | None = None, + wrappers: dict[SymbolName, CodeRust] | None = None, + ) -> "TranslationContext": + if translations is None: + translations = {} + if wrappers is None: + wrappers = {} + descendants = nx.descendants(G, group) + already_translated = [g for g in groups if g in descendants] + immediate_already_translated = set(G.successors(group)) + ancestors = nx.ancestors(G, group) + ancestor_deps = {succ for a in ancestors for succ in G.successors(a)} + to_be_translated = [ + g for g in groups if g in ancestors | (ancestor_deps - descendants - {group}) + ] + reference_groups = [g for g in groups if g in translations] + hops = nx.single_source_shortest_path_length(G, group) + dependent_hops = nx.single_source_shortest_path_length(G.reverse(copy=False), group) + crate_code = cls._build_crate_code(reference_groups, translations) + dependent_code = cls._build_dependent_code(to_be_translated, symbols, dependent_hops) + return cls( + translate=TranslateContext( + crate_code=crate_code, + reference_code=cls._build_reference_code(reference_groups, translations, hops), + dependent_code=dependent_code, + ), + wrap=WrapContext( + wrapped_crate_code=strip_fns(crate_code), + wrappers=cls._build_wrapper_context(wrappers, symbols), + translated_code=cls._build_translated_code( + already_translated, immediate_already_translated, symbols + ), + untranslated_code=dependent_code, + ), + ) + + @staticmethod + def _build_crate_code( + reference_groups: list[SymbolGroup], + translations: dict[SymbolGroup, CodeRust], + ) -> CodeRust: + # Use all unique (dict.fromkeys) translations as the crate's current contents since many symbol names can map to the same translation + return CodeRust.join(dict.fromkeys(translations[g] for g in reference_groups)) + + @staticmethod + def _build_reference_code( + reference_groups: list[SymbolGroup], + translations: dict[SymbolGroup, CodeRust], + hops: dict[SymbolGroup, int], + ) -> CodeRust: + def trim_by_distance(ref_group: SymbolGroup) -> CodeRust: + match hops.get(ref_group): + case 1: + # 1-hop successors keep full function bodies since they are likely to be directly relevant + return translations[ref_group] + case 2: + # 2-hop successors strip top-level function bodies since they are less likely to be directly relevant + return strip_fns(translations[ref_group]) + case _: + # For distant or unreachable groups delete top-level functions but keep types, + # since types may still be needed even when not reachable via static analysis + return strip_fns(translations[ref_group], delete=True) + + return CodeRust.join(dict.fromkeys(trim_by_distance(g) for g in reference_groups)) + + @staticmethod + def _build_translated_code( + already_translated: list[SymbolGroup], + immediate_already_translated: set[SymbolGroup], + symbols: dict[SymbolName, Symbol], + ) -> CodeC: + # Gather already-translated C code in topological order. + # Reduce C support code context by turning non-immediate symbols into declarations. We keep + # immediate C code in full since they are more likely to be relevant for wrappers. + return CodeC.join( + symbols[name].code + if g in immediate_already_translated + else symbols[name].forward_declaration or symbols[name].code + for g in already_translated + for name in g + ) + + @classmethod + def _build_dependent_code( + cls, + to_be_translated: list[SymbolGroup], + symbols: dict[SymbolName, Symbol], + hops: Mapping[SymbolGroup, int], + max_chars: int | None = None, + ) -> CodeC: + if max_chars is None: + max_chars = MAX_DEPENDENT_CHARS + + # Gather dependent C code in topological order. + dependent_code = CodeC.join(symbols[name].code for g in to_be_translated for name in g) + if len(str(dependent_code)) > max_chars: + logger.warning(f"Dependent code exceeds max {len(str(dependent_code))}/{max_chars}") + dependent_code = cls._select_c_code(to_be_translated, symbols, hops, max_chars) + return dependent_code + + _MEMORY_PATTERN = re.compile( + r"malloc|calloc|realloc|free|memcpy|memmove|memset|strdup|strndup|fopen|freopen|fclose" + ) + _POINTER_PATTERN = re.compile(r"->|\*|&|\[|\bNULL\b|\bsizeof\b") + + @dataclass(frozen=True) + class _DependentCandidate: + group: SymbolGroup + full: CodeC + full_chars: int + score: float + tier: int + + @classmethod + def _select_c_code( + cls, + groups: list[SymbolGroup], + symbols: dict[SymbolName, Symbol], + hops: Mapping[SymbolGroup, int], + max_chars: int, + ) -> CodeC: + candidates = cls._collect_dependent_candidates(groups, symbols, hops) + chosen: set[SymbolGroup] = set() + total_chars = 0 + exhausted_tier: int | None = None + + for candidate in sorted( + candidates, + key=lambda c: (c.tier, -c.score / math.sqrt(max(c.full_chars, 1))), + ): + if exhausted_tier is not None and candidate.tier > exhausted_tier: + break + if total_chars + candidate.full_chars > max_chars: + exhausted_tier = candidate.tier + continue + chosen.add(candidate.group) + total_chars += candidate.full_chars + + return CodeC.join( + candidate.full for candidate in candidates if candidate.group in chosen + ) + + @classmethod + def _collect_dependent_candidates( + cls, + groups: list[SymbolGroup], + symbols: dict[SymbolName, Symbol], + hops: Mapping[SymbolGroup, int], + ) -> list["TranslationContext._DependentCandidate"]: + candidates = [] + for group in groups: + full = CodeC.join(symbols[name].code for name in group) + candidates.append( + cls._DependentCandidate( + group=group, + full=full, + full_chars=len(str(full)), + score=cls._score_dependent_group(group, symbols), + tier=cls._dependent_tier(hops.get(group)), + ) + ) + return candidates + + @staticmethod + def _dependent_tier(hops: int | None) -> int: + match hops: + case 1: + return 1 + case 2: + return 2 + case None: # not reachable + return 4 + case _: + return 3 + + @classmethod + def _score_dependent_group( + cls, group: SymbolGroup, symbols: dict[SymbolName, Symbol] + ) -> float: + score = 0.0 + + # Favor groups with function definitions + if any(symbols[name].is_function and symbols[name].is_definition for name in group): + score += 3.0 + + # Favor groups with memory or pointer-related code patterns + code = "\n".join(str(symbols[name].code) for name in group) + if cls._MEMORY_PATTERN.search(code): + score += 3.0 + if cls._POINTER_PATTERN.search(code): + score += 2.0 + + # Favor smaller groups + return score + 1.0 / math.sqrt(max(len(code), 1)) + + @classmethod + def _build_wrapper_context( + cls, + wrappers: dict[SymbolName, CodeRust], + symbols: dict[SymbolName, Symbol], + ) -> dict[BindgenName, CodeRust]: + context: dict[BindgenName, CodeRust] = {} + for name, wrapper in wrappers.items(): + symbol = symbols[name] + # An anonymous record has no stable name to key its context by + if (bindgen_name := symbol.bindgen_name) is None: + continue + if symbol.is_type: + context[bindgen_name] = cls._build_type_wrapper_context(wrapper) + elif symbol.is_variable: + context[bindgen_name] = cls._build_variable_wrapper_context(wrapper) + # Drop other wrappers if requested + elif not REDUCED_CONTEXT: + context[bindgen_name] = wrapper + return context + + @classmethod + def _build_variable_wrapper_context(cls, wrapper: CodeRust) -> CodeRust: + return strip_fns(cls._strip_tests(wrapper)) + + @classmethod + def _build_type_wrapper_context(cls, wrapper: CodeRust) -> CodeRust: + # Delete functions because the trait already fixes their signatures + return strip_fns(cls._strip_tests(wrapper), delete=True) + + @staticmethod + def _strip_tests(wrapper: CodeRust) -> CodeRust: + wrapper_src = str(wrapper) + test_idx = wrapper_src.find("#[cfg(test)]") + if test_idx != -1: + wrapper_src = wrapper_src[:test_idx].strip() + return CodeRust(wrapper_src) diff --git a/src/ideas/translate_recurrent.py b/src/ideas/translate_recurrent.py index cdb715c..0fab1a0 100644 --- a/src/ideas/translate_recurrent.py +++ b/src/ideas/translate_recurrent.py @@ -4,331 +4,131 @@ # SPDX-License-Identifier: Apache-2.0 # -import re -import math -import json +import shutil import logging from pathlib import Path -from dataclasses import dataclass, field -from collections.abc import Iterable -from textwrap import indent from typing import Literal +from contextlib import contextmanager +from collections.abc import Callable, Iterable, Iterator, Mapping +from dataclasses import dataclass, field, replace import dspy import networkx as nx -from .ast_rust import CodeRust, strip_fns, mangle -from .tools import Crate, MAX_DEPENDENT_CHARS, REDUCED_CONTEXT +from . import model +from .ast_rust import BindgenName, CodeRust +from .ast_rust import mangle +from .refine import CodeAttempt, Feedback +from .hybrid import HybridSnapshot, HybridWriter +from .oracle import Candidate, NullOracle, SymbolOracle, Verdict +from .tools import Crate +from .translate_context import TranslateContext, TranslationContext, WrapContext from .ast import Symbol, SymbolName, SymbolGroup from .ast import CodeC, TreeResult, create_symbol_ordering_key_fn -from .ast import clang_make_global_, clang_make_extern_ -from .wrapper import bindgen, generate_unimplemented_function_wrapper from .wrapper import generate_unimplemented_type_wrapper +from .wrapper import generate_unimplemented_variable_wrapper +from .wrapper import WrapperGenerator, scope_errors, WrapperAttempt +from .translate_snippet import SnippetTranslator, TranslationAttempt logger = logging.getLogger("ideas.translate_recurrent") - - -WrapperName = str - - -@dataclass(frozen=True) -class TranslationContext: - crate_code: CodeRust - reference_code: CodeRust - dependent_code: CodeC - support_code: CodeC - wrappers: CodeRust - - @classmethod - def build( - cls, - G: nx.DiGraph, - group: SymbolGroup, - groups: list[SymbolGroup], - symbols: dict[SymbolName, Symbol], - translations: dict[SymbolGroup, CodeRust] | None = None, - wrappers: dict[WrapperName, CodeRust] | None = None, - ) -> "TranslationContext": - if translations is None: - translations = {} - if wrappers is None: - wrappers = {} - descendants = nx.descendants(G, group) - already_translated = [g for g in groups if g in descendants] - immediate_already_translated = set(G.successors(group)) - ancestors = nx.ancestors(G, group) - ancestor_deps = {succ for a in ancestors for succ in G.successors(a)} - to_be_translated = [ - g for g in groups if g in ancestors | (ancestor_deps - descendants - {group}) - ] - reference_groups = [g for g in groups if g in translations] - hops = nx.single_source_shortest_path_length(G, group) - return cls( - crate_code=cls._build_crate_code(reference_groups, translations), - reference_code=cls._build_reference_code(reference_groups, translations, hops), - dependent_code=cls._build_dependent_code(to_be_translated, symbols), - support_code=cls._build_support_code( - already_translated, immediate_already_translated, symbols - ), - wrappers=cls._build_wrapper_context(wrappers), - ) - - @staticmethod - def _build_crate_code( - reference_groups: list[SymbolGroup], - translations: dict[SymbolGroup, CodeRust], - ) -> CodeRust: - # Use all unique (dict.fromkeys) translations as the crate's current contents since many symbol names can map to the same translation - return CodeRust.join(dict.fromkeys(translations[g] for g in reference_groups)) - - @staticmethod - def _build_reference_code( - reference_groups: list[SymbolGroup], - translations: dict[SymbolGroup, CodeRust], - hops: dict[SymbolGroup, int], - ) -> CodeRust: - def trim_by_distance(ref_group: SymbolGroup) -> CodeRust: - match hops.get(ref_group): - case 1: - # 1-hop successors keep full function bodies since they are likely to be directly relevant - return translations[ref_group] - case 2: - # 2-hop successors strip top-level function bodies since they are less likely to be directly relevant - return strip_fns(translations[ref_group]) - case _: - # For distant or unreachable groups delete top-level functions but keep types, - # since types may still be needed even when not reachable via static analysis - return strip_fns(translations[ref_group], delete=True) - - return CodeRust.join(dict.fromkeys(trim_by_distance(g) for g in reference_groups)) - - @staticmethod - def _build_support_code( - already_translated: list[SymbolGroup], - immediate_already_translated: set[SymbolGroup], - symbols: dict[SymbolName, Symbol], - ) -> CodeC: - # Gather support code in topological order. - # Reduce C support code context by turning non-immediate symbols into declarations. We keep - # immediate C code in full since they are more likely to be relevant for wrappers. - return CodeC.join( - symbols[name].code - if g in immediate_already_translated - else CodeC(symbols[name].llm_context_declaration) - for g in already_translated - for name in g - ) - - @classmethod - def _build_dependent_code( - cls, - to_be_translated: list[SymbolGroup], - symbols: dict[SymbolName, Symbol], - max_chars: int = MAX_DEPENDENT_CHARS, - ) -> CodeC: - # Gather dependent C code in topological order. - dependent_code = CodeC.join(symbols[name].code for g in to_be_translated for name in g) - if len(str(dependent_code)) > max_chars: - logger.warning(f"Dependent code exceeds max {len(str(dependent_code))}/{max_chars}") - dependent_code = cls._select_c_code(to_be_translated, symbols, max_chars) - return dependent_code - - _MEMORY_PATTERN = re.compile( - r"\b(malloc|calloc|realloc|free|memcpy|memmove|memset|strdup|strndup|fopen|freopen|fclose)\b" - ) - _POINTER_PATTERN = re.compile(r"->|\*|&|\[|\bNULL\b|\bsizeof\b") - - @dataclass(frozen=True) - class _DependentCandidate: - group: SymbolGroup - full: CodeC - full_chars: int - score: float - - @classmethod - def _select_c_code( - cls, - groups: list[SymbolGroup], - symbols: dict[SymbolName, Symbol], - max_chars: int, - ) -> CodeC: - candidates = cls._collect_dependent_candidates(groups, symbols) - chosen: set[SymbolGroup] = set() - total_chars = 0 - - # Select the most informative dependent bodies that fit within the remaining budget. - for candidate in sorted( - candidates, - key=lambda candidate: candidate.score / math.sqrt(max(candidate.full_chars, 1)), - reverse=True, - ): - if total_chars + candidate.full_chars > max_chars: - continue - chosen.add(candidate.group) - total_chars += candidate.full_chars - - return CodeC.join( - candidate.full for candidate in candidates if candidate.group in chosen - ) - - @classmethod - def _collect_dependent_candidates( - cls, - groups: list[SymbolGroup], - symbols: dict[SymbolName, Symbol], - ) -> list: - candidates = [] - for group in groups: - full = CodeC.join(symbols[name].code for name in group) - candidates.append( - cls._DependentCandidate( - group=group, - full=full, - full_chars=len(str(full)), - score=cls._score_dependent_group(group, symbols), - ) - ) - return candidates - - @classmethod - def _score_dependent_group( - cls, group: SymbolGroup, symbols: dict[SymbolName, Symbol] - ) -> float: - score = 0.0 - - # Favor groups with function definitions - if any(symbols[name].is_function and symbols[name].is_definition for name in group): - score += 3.0 - - # Favor groups with memory or pointer-related code patterns - code = "\n".join(str(symbols[name].code) for name in group) - if cls._MEMORY_PATTERN.search(code): - score += 3.0 - if cls._POINTER_PATTERN.search(code): - score += 2.0 - - # Favor smaller groups - return score + 1.0 / math.sqrt(max(len(code), 1)) - - @classmethod - def _build_wrapper_context(cls, wrappers: dict[WrapperName, CodeRust]) -> CodeRust: - context = CodeRust("") - for name, wrapper in wrappers.items(): - if "fn c_to_r" in str(wrapper): - wrapper = cls._build_type_wrapper_context(wrapper) - elif "pub static mut" in str(wrapper): - pass - elif REDUCED_CONTEXT: - wrapper = None - - if wrapper: - context += CodeRust( - f"pub mod {name} {{\n" + indent(str(wrapper), " " * 4) + "\n}" - ) - return context - - @classmethod - def _build_type_wrapper_context(cls, wrapper: CodeRust) -> CodeRust: - # Strip tests from type wrapper - wrapper_src = str(wrapper) - test_idx = wrapper_src.find("#[cfg(test)]") - if test_idx != -1: - wrapper_src = wrapper_src[:test_idx].strip() - wrapper = CodeRust(wrapper_src) - - # Strip functions from wrapper - wrapper = strip_fns(wrapper) - - return wrapper +snippet_logger = logging.getLogger("ideas.translate_snippet") +wrapper_logger = logging.getLogger("ideas.wrapper") @dataclass class _State: - c_src: bytes rust_lib_src: bytes rust_main_src: bytes | None - hybrid_lib_src: bytes | None - hybrid_main_src: bytes | None - wrappers: dict[Path, bytes] + hybrid: HybridSnapshot + + +@dataclass(frozen=True) +class _WrapperPlan: + unimplemented_wrapper: CodeRust + extra_errors: Callable[[CodeRust], list[str]] | None = None + tests_mod: str | None = None + extern_symbol: str | None = None + + def errors(self, wrapper: CodeRust) -> list[str]: + errors = scope_errors(wrapper, self.unimplemented_wrapper) + if self.extra_errors is not None: + errors += self.extra_errors(wrapper) + return errors @dataclass class _TranslationResult: - pred: dspy.Prediction = field(repr=False, compare=False) + attempt: TranslationAttempt = field(repr=False, compare=False) @property def success(self) -> bool: - return self.pred.success + return self.attempt.success @property - def translation(self) -> CodeRust | None: - return self.pred.translation if "translation" in self.pred else None + def translation(self) -> CodeRust: + return self.attempt.translation @property - def feedback(self) -> str: - return self.pred.feedback if "feedback" in self.pred else "" - - @classmethod - def from_pred(cls, pred: dspy.Prediction) -> "_TranslationResult": - return cls(pred=pred) + def feedback(self) -> Feedback: + return self.attempt.next_feedback @dataclass -class _WrapperResult: - pred: dspy.Prediction | None = field(default=None, repr=False, compare=False) - failure: Literal["wrap", "test"] | None = None - failed_tests: set[str] = field(default_factory=set) +class _SymbolResult: + attempt: WrapperAttempt | None = field(default=None, repr=False, compare=False) + verdict: Verdict | None = None @property def success(self) -> bool: - return self.failure is None + return self.verdict is None or not self.verdict.rejected + + @property + def stage(self) -> Literal["wrap", "review"] | None: + return self.verdict.stage if self.verdict is not None else None + + @property + def wrapped(self) -> bool: + return self.attempt is None or self.attempt.success + + @property + def rejection(self) -> str: + return "" if self.attempt is None or self.attempt.success else self.attempt.reason @property def wrapper(self) -> CodeRust | None: - return self.pred.wrapper if self.pred is not None and "wrapper" in self.pred else None + return self.attempt.wrapper if self.attempt is not None else None @property - def feedback(self) -> str: - if self.failure is None: - return "" - - if self.failure == "wrap": - return ( - "It was difficult to generate a C-compatible FFI wrapper for the translation. " - "Regenerate the translation with clear, explicit, wrapper-friendly Rust function boundaries and straightforward ownership, " - "while keeping the translation fully memory-safe and free of unsafe constructs." - ) + def regressed(self) -> set[str]: + return self.verdict.blamed if self.verdict is not None else set() - return ( - "The current Rust translation in `prior_translation` does not match the behavior of the C `snippet`. " - "Carefully compare `prior_translation` against the C `snippet` and regenerate the Rust `translation` to match the C behavior exactly. " - "Do not assume inputs are well-formed: if the tests exercise malformed, invalid, partial, or adversarial input, preserve the C behavior for those cases too, including error returns, boundary handling, or other observable effects. " - "Make minimal, targeted changes to `prior_translation`, and only modify what is necessary to match the C behavior. " - "Treat the C `snippet` as the source of truth, even if it contains a bug." - ) + @property + def feedback(self) -> str: + return self.verdict.feedback if self.verdict is not None else "" - @classmethod - def from_pred(cls, pred: dspy.Prediction) -> "_WrapperResult": - failure: Literal["wrap", "test"] | None = None if pred.success else "wrap" - return cls(pred=pred, failure=failure) + @property + def wrap_feedback(self) -> Feedback: + review = self.verdict.wrap_feedback if self.verdict is not None else "" + prior = self.attempt.next_feedback if self.attempt is not None else Feedback() + return replace(prior, review=review) @dataclass class _Result: translation_result: _TranslationResult - wrapper_results: dict[WrapperName, _WrapperResult] = field(default_factory=dict) + symbol_results: dict[SymbolName, _SymbolResult] = field(default_factory=dict) @property - def translation(self) -> CodeRust | None: + def translation(self) -> CodeRust: return self.translation_result.translation @property - def failure(self) -> Literal["translate", "wrap", "test"] | None: + def failure(self) -> Literal["translate", "wrap", "review"] | None: if not self.translation_result.success: return "translate" - for r in self.wrapper_results.values(): - if r.failure is not None: - return r.failure + for r in self.symbol_results.values(): + if r.stage is not None: + return r.stage return None @property @@ -336,21 +136,26 @@ def success(self) -> bool: return self.failure is None @property - def failed_tests(self) -> set[str]: - return {t for r in self.wrapper_results.values() for t in r.failed_tests} + def regressed(self) -> set[str]: + return {t for r in self.symbol_results.values() for t in r.regressed} @property - def feedback(self) -> str: + def translation_feedback(self) -> Feedback: if not self.translation_result.success: return self.translation_result.feedback - for r in self.wrapper_results.values(): + # The translation built and stayed in scope, so only the oracle has anything to add + for r in self.symbol_results.values(): if not r.success: - return r.feedback - return "" + return Feedback(review=r.feedback) + return Feedback() @property - def wrappers(self) -> dict[WrapperName, CodeRust]: - return {n: r.wrapper for n, r in self.wrapper_results.items() if r.wrapper is not None} + def wrap_feedback(self) -> dict[SymbolName, Feedback]: + return {n: fb for n, r in self.symbol_results.items() if (fb := r.wrap_feedback)} + + @property + def wrappers(self) -> dict[SymbolName, CodeRust]: + return {n: r.wrapper for n, r in self.symbol_results.items() if r.wrapper is not None} class RecurrentTranslator(dspy.Module): @@ -359,20 +164,18 @@ def __init__( sys_crate: Crate, crate: Crate, rs_crate: Crate, - symbol_translator: dspy.Module, - symbol_wrapper: dspy.Module | None, - tests: str | None = None, + symbol_translator: SnippetTranslator, + symbol_wrapper: WrapperGenerator | None, + symbol_oracle: SymbolOracle | None = None, max_iters: int = 1, ): super().__init__() - assert sys_crate.lib_src_path is not None - self.c_src_path = sys_crate.lib_src_path.with_suffix(".c") - self.crate = crate + self.max_iters = max_iters self._translator = symbol_translator self._wrapper = symbol_wrapper - self._tests = tests - self.max_iters = max_iters - self._failed_tests: set[str] = set() + self._oracle: SymbolOracle = ( + symbol_oracle if symbol_oracle is not None else NullOracle() + ) self._init_rust_crate(rs_crate) self._init_hybrid_crate(sys_crate, crate) @@ -392,18 +195,30 @@ def _init_rust_crate(self, rs_crate: Crate): rs_crate.vcs.add(rs_crate.main_src_path) def _init_hybrid_crate(self, sys_crate: Crate, crate: Crate): - if crate.lib_src_path is None and crate.main_src_path is None: - raise ValueError(f"Crate {crate.name} has neither lib.rs nor main.rs!") + assert self.rust_crate.lib_name is not None + self.crate = crate + self._hybrid = HybridWriter(sys_crate, crate, self.rust_crate.lib_name) - assert sys_crate.lib_name is not None - use_stmt = f"use {sys_crate.lib_name} as _;\n" + def _cargo_build(self) -> str: + builds, feedback = self.crate.cargo_build() + if not builds: + return f"Running `cargo build` fails!\n{feedback}" + return self._oracle.build() - if crate.lib_src_path is not None: - crate.lib_src_path.write_text(use_stmt) - crate.vcs.add(crate.lib_src_path) - if crate.main_src_path is not None: - crate.main_src_path.write_text(f"#![no_main]\n\n{use_stmt}\n\n") - crate.vcs.add(crate.main_src_path) + def strand_c(self): + self._hybrid.strand_c() + + def _cargo_test(self, name: str, *, lib: bool = False) -> str: + passes, _, error, _ = self.crate.cargo_test(name, lib=lib) + # Feedback is empty when the tests pass, so callers can treat it as the failure signal + return "" if passes else f"Running `cargo test {name}` fails!\n{error}" + + def _test_counts(self) -> str: + return ( + f"regressed={len(self._oracle.regressions)} " + f"total={len(self._oracle.expected_tests)} " + f"baseline_failures={len(self._oracle.baseline_failures)}" + ) def forward( self, @@ -411,7 +226,35 @@ def forward( dependencies: dict[SymbolGroup, Iterable[SymbolGroup]], ast_order: dict[Path, TreeResult] | None = None, ) -> dspy.Prediction: - self._failed_tests = set() + # Tests that already fail against the untranslated C never count against a translation + self._oracle.baseline() + + # Write types to the hybrid crate so symbol wrappers can reference them + self._hybrid.write_types(symbols.values()) + + # Write variables first so any generated code sees statics it cannot shadow (E0530) + self._hybrid.add_bindings(symbols.values()) + + if feedback := self._cargo_build(): + raise RuntimeError(feedback) + + msg = f"Initialized translation of `{self.crate.name}` ({len(symbols)} symbols)" + logger.info(msg) + if self._oracle.baseline_failures: + excluded = ( + "Excluded test(s) that already fail against the untranslated C: " + f"{', '.join(sorted(self._oracle.baseline_failures))}" + ) + logger.warning(excluded) + msg += f"\n\n# Excluded Tests\n{excluded}" + if missing := _undeclared_types(self._hybrid.types, symbols.values()): + undeclared = ( + f"bindgen did not declare wrappable type(s): {', '.join(missing)}. " + "Wrapping them will fail to build." + ) + logger.warning(undeclared) + msg += f"\n\n# Undeclared Types\n{undeclared}" + self.crate.vcs.commit(msg) # Process symbols in topological order G = nx.from_dict_of_lists(dependencies, create_using=nx.DiGraph) @@ -425,9 +268,15 @@ def forward( snippets: dict[CodeC, SymbolGroup] = {} translations: dict[SymbolGroup, CodeRust] = {} - wrappers: dict[WrapperName, CodeRust] = {} + wrappers: dict[SymbolName, CodeRust] = {} count = len(groups) for i, group in enumerate(groups, start=1): + # A group that is nothing but alias typedefs has no Rust form to ask a model for + if all(symbols[name].is_alias_typedef for name in group): + logger.info(f"Skipping alias typedef `{' '.join(group)}`...") + translations[group] = CodeRust(f"// alias typedef `{' '.join(group)}`") + continue + logger.info(f"Translating symbol group `{' '.join(group)}` [{i}/{count}] ...") # Gather code for each symbol and check if we have already translated such a snippet @@ -443,24 +292,37 @@ def forward( ctx = TranslationContext.build(G, group, groups, symbols, translations, wrappers) # Translate and wrap snippet, saving it if it tests - group_result = self._translate_and_wrap_with_retries( - context=ctx, symbols=[symbols[name] for name in group] - ) + with model.track_lm_usage() as usage: + group_result = self._translate_and_wrap_with_retries( + context=ctx, symbols=[symbols[name] for name in group] + ) if group_result.failure == "translate": - # Translate failures (as opposed to wrap/test failures) are fatal + # Translate failures (as opposed to wrap/review failures) are fatal + logger.error( + f"Failed to translate symbol group `{' '.join(group)}` [{i}/{count}] " + f"to Rust: {model.format_lm_usage(usage)} {self._test_counts()}" + ) break - assert group_result.translation is not None # group_result.failure != translate translations[group] = group_result.translation wrappers.update(group_result.wrappers) - # Once a test fails, skip it for all future groups in this run - newly_failed_tests = group_result.failed_tests - self._failed_tests - if newly_failed_tests: - self._failed_tests.update(newly_failed_tests) - logger.info( - f"Disabled the following failing tests: {', '.join(sorted(newly_failed_tests))}" + # Record the culprit so no later group is blamed for the same test + if regressed := group_result.regressed: + self._oracle.blame(group, regressed) + logger.warning( + f"Test(s) regressed by symbol group `{' '.join(group)}`: " + f"{', '.join(sorted(regressed))}" ) - pred = dspy.Prediction(success=len(translations) == len(groups)) + + logger.info( + f"Translated symbol group `{' '.join(group)}` [{i}/{count}] to Rust: " + f"{model.format_lm_usage(usage)} {self._test_counts()}" + ) + + pred = dspy.Prediction( + complete=len(translations) == len(groups), + regressions=self._oracle.regressions, + ) return pred def _translate_and_wrap_with_retries( @@ -468,178 +330,211 @@ def _translate_and_wrap_with_retries( context: TranslationContext, symbols: list[Symbol], prior_translation: CodeRust | None = None, - prior_wrappers: dict[WrapperName, CodeRust] | None = None, - feedback: str = "", + prior_wrappers: dict[SymbolName, CodeRust] | None = None, + translation_feedback: Feedback = Feedback(), + wrap_feedback: dict[SymbolName, Feedback] | None = None, ) -> _Result: name = " ".join([f"`{s.name}`" for s in symbols]) num_iters = max(self.max_iters, 1) + # The last state the oracle accepted; no outcome of this call may land below it + entry = self._snapshot() + best: tuple[int, _State, _Result] | None = None + result: _Result | None = None for i in range(num_iters): - state = self._snapshot() - # Attempt translation and exit early on success result = self._translate_and_wrap( context, symbols, prior_translation=prior_translation, prior_wrappers=prior_wrappers, - feedback=feedback, + translation_feedback=translation_feedback, + wrap_feedback=wrap_feedback, ) if result.success: - break + return result + + if result.failure == "review" and ( + best is None or len(result.regressed) <= best[0] + ): + best = (len(result.regressed), self._snapshot(), result) # If neither translation nor wrappers differ from previous try, then stop retrying - if ( + looping = ( prior_translation is not None and prior_translation == result.translation and prior_wrappers is not None and prior_wrappers == result.wrappers - ): - logger.error( - f"Failed to translate symbol(s) {name} due to translation loop ({i + 1}/{num_iters})!" - ) - break - - # Translation differs so allow another retry but log an error - logger.error(f"Failed to translate symbol(s) {name} ({i + 1}/{num_iters})!") + ) + final = looping or i + 1 == num_iters # On failure, restore state based on which stage failed - if i + 1 < num_iters or result.failure == "translate": + if not final or result.failure == "translate": # Full restore for next retry or if translation failed (graceful exit) - self._restore(state) + self._restore(entry) elif result.failure == "wrap": # Wrapper restore since they failed but hopefully translation is good # FIXME: What if a wrapper is being tested? Seems fatal? - self._restore(state, wrappers_only=True) - elif result.failure == "test": - # Keep wrappers and translation even though they didn't pass tests + self._restore(entry, hybrid_only=True) + elif result.failure == "review": + # Keep wrappers and translation even though the oracle rejected them pass + if looping: + logger.error( + f"Failed to translate symbol(s) {name} due to translation loop ({i + 1}/{num_iters})!" + ) + break + + # Translation differs so allow another retry but log an error + logger.error(f"Failed to translate symbol(s) {name} ({i + 1}/{num_iters})!") + # Create feedback for next iteration prior_translation = result.translation prior_wrappers = result.wrappers - feedback = result.feedback - return result # pyright: ignore[reportPossiblyUnboundVariable] because num_iters is always >= 1 + translation_feedback = result.translation_feedback + wrap_feedback = result.wrap_feedback + + # Rewind to the best attempt that regressed the least. A later attempt that never ran + # does not invalidate an earlier one that did, so this also rescues a group whose final + # try failed to translate. + assert result is not None # num_iters is at least 1 + if best is not None and best[2] is not result: + score, state, result = best + self._restore(state) + logger.warning( + f"Restored the attempt at {name} that regressed the fewest test(s): {score}" + ) + return result def _snapshot(self) -> _State: def read(path: Path | None) -> bytes | None: return path.read_bytes() if path else None assert self.rust_crate.lib_src_path is not None - wrapper_files = self.crate.src_dir.glob("wrap_*.rs") return _State( - c_src=self.c_src_path.read_bytes(), rust_lib_src=self.rust_crate.lib_src_path.read_bytes(), rust_main_src=read(self.rust_crate.main_src_path), - hybrid_lib_src=read(self.crate.lib_src_path), - hybrid_main_src=read(self.crate.main_src_path), - wrappers={path: path.read_bytes() for path in wrapper_files if path.is_file()}, + hybrid=self._hybrid.snapshot(), ) - def _restore(self, state: _State, *, wrappers_only: bool = False): - # Wrappers are always restored - for path, src in state.wrappers.items(): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(src) - - if wrappers_only: + def _restore(self, state: _State, *, hybrid_only: bool = False): + self._hybrid.restore(state.hybrid) + if hybrid_only: return def write(path: Path | None, src: bytes | None): if path is not None and src is not None: path.write_bytes(src) + self.rust_crate.vcs.add(path) assert self.rust_crate.lib_src_path is not None - self.c_src_path.write_bytes(state.c_src) - self.rust_crate.lib_src_path.write_bytes(state.rust_lib_src) + write(self.rust_crate.lib_src_path, state.rust_lib_src) write(self.rust_crate.main_src_path, state.rust_main_src) - write(self.crate.lib_src_path, state.hybrid_lib_src) - write(self.crate.main_src_path, state.hybrid_main_src) + + @contextmanager + def _checkpoint(self, *, hybrid_only: bool = False) -> Iterator[_State]: + # Restored unconditionally, and the yielded state outlives the block so a caller can + # return to it again later + state = self._snapshot() + try: + yield state + finally: + self._restore(state, hybrid_only=hybrid_only) def _translate_and_wrap( self, context: TranslationContext, symbols: list[Symbol], prior_translation: CodeRust | None = None, - prior_wrappers: dict[WrapperName, CodeRust] | None = None, - feedback: str = "", + prior_wrappers: dict[SymbolName, CodeRust] | None = None, + translation_feedback: Feedback = Feedback(), + wrap_feedback: dict[SymbolName, Feedback] | None = None, ) -> _Result: prior_wrappers = prior_wrappers or {} + wrap_feedback = wrap_feedback or {} # Translate snippet and exit early if it fails snippet = CodeC.join(symbol.code for symbol in symbols) name = " ".join(symbol.name for symbol in symbols) translation_result = self._translate_snippet( name=name, - context=context, + context=context.translate, snippet=snippet, prior_translation=prior_translation, - feedback=feedback, + feedback=translation_feedback, ) if not translation_result.success: return _Result(translation_result=translation_result) out = _Result(translation_result=translation_result) - assert out.translation is not None # translation_result.success == True # Generate wrapper for each symbol for symbol in symbols: - wrapper_name: WrapperName = f"wrap_{mangle(symbol.spelling)}" - wrapper_result = self._wrap_symbol( - name=wrapper_name, + symbol_result = self._wrap_symbol( symbol=symbol, - context=context, + context=context.wrap, snippet=snippet, translation=out.translation, - prior_wrapper=prior_wrappers.get(wrapper_name), + prior_wrapper=prior_wrappers.get(symbol.name), + feedback=wrap_feedback.get(symbol.name, Feedback()), ) - if wrapper_result is None: + if symbol_result is None: continue - out.wrapper_results[wrapper_name] = wrapper_result - if not wrapper_result.success: + # Judged whether or not the wrapper stage succeeded: a failed wrap is a rejection + # the oracle words, and a successful one is what the tests then run against + candidate = Candidate( + symbol=symbol, + snippet=snippet, + translation=out.translation, + wrapper=symbol_result.wrapper, + wrap_rejection=symbol_result.rejection, + wrapped=symbol_result.wrapped, + ) + symbol_result = replace(symbol_result, verdict=self._judge_symbol(candidate)) + out.symbol_results[symbol.name] = symbol_result + if not symbol_result.success: break # Cache successful translation and wrappers if out.success: - self._translator.write_cache(out.translation_result.pred) - for wrapper_result in out.wrapper_results.values(): - if wrapper_result.pred is not None and self._wrapper is not None: - self._wrapper.write_cache(wrapper_result.pred) + self._translator.write_cache(out.translation_result.attempt) + for symbol_result in out.symbol_results.values(): + if symbol_result.attempt is not None and self._wrapper is not None: + self._wrapper.write_cache(symbol_result.attempt) return out def _translate_snippet( self, name: str, - context: TranslationContext, + context: TranslateContext, snippet: CodeC, prior_translation: CodeRust | None = None, - feedback: str = "", + feedback: Feedback = Feedback(), ) -> _TranslationResult: assert self.rust_crate.lib_src_path is not None lib_src_path = self.rust_crate.lib_src_path - base_rust_src = CodeRust(lib_src_path.read_text()) + main_src_path = self.rust_crate.main_src_path + base_lib_src = CodeRust(lib_src_path.read_text()) + base_main_src = main_src_path.read_text() if main_src_path is not None else None - def build(translation: CodeRust) -> str: - # Append translation to lib.rs and check if it builds - lib_src_path.write_text(str(base_rust_src + translation)) + def build(code: CodeRust) -> str: + lib_src_path.write_text(str(base_lib_src + code)) self.rust_crate.vcs.add(lib_src_path) - # Import translated `main` symbol from lib.rs for binaries to get build feedback - if self.rust_crate.main_src_path is not None and name == "c:@F@main": - self.rust_crate.main_src_path.write_text( - f"#![forbid(unsafe_code)]\n\nuse {self.rust_crate.lib_name}::*;\n" - ) - self.rust_crate.vcs.add(self.rust_crate.main_src_path) - builds, build_feedback = self.rust_crate.cargo_build() - return "Running `cargo build` fails!\n" + build_feedback if not builds else "" - - def commit(msg: str, pred: dspy.Prediction): - if "reasoning" in pred and pred.reasoning: - msg += f"\n\n# Reasoning\n{indent(pred.reasoning, ' ')}" - if "feedback" in pred and pred.feedback: - msg += f"\n\n# Feedback\n{indent(pred.feedback, ' ')}" - self.rust_crate.vcs.commit(msg) - - pred = self._translator( + builds, error = self.rust_crate.cargo_build() + return "" if builds else f"Running `cargo build` fails!\n{error}" + + # Import the translated `main` from lib.rs so a binary's build feedback covers it + switch_main = main_src_path is not None and name == "c:@F@main" + if switch_main: + assert main_src_path is not None + main_src_path.write_text( + f"#![forbid(unsafe_code)]\n\nuse {self.rust_crate.lib_name}::main;\n" + ) + self.rust_crate.vcs.add(main_src_path) + + snippet_logger.info(f"Translating snippet `{name}` ...") + with self._translator.session( name=name, crate_code=context.crate_code, reference_code=context.reference_code, @@ -647,316 +542,430 @@ def commit(msg: str, pred: dspy.Prediction): dependent_code=context.dependent_code, prior_translation=prior_translation, feedback=feedback, - feedback_fn=build, - on_attempt=commit, - ) - return _TranslationResult.from_pred(pred) + ) as session: + success = False + attempt: TranslationAttempt | None = None + for attempt in session: + # lib.rs already carries the crate-level attribute, so a second one cannot build + if CodeRust("#![forbid(unsafe_code)]") in attempt.translation: + attempt.reject( + scope=["Do not include `#![forbid(unsafe_code)]` in the translation!"] + ) + elif error := build(attempt.translation): + attempt.reject(build=[error]) + else: + attempt.accept() + success = attempt.success + _commit(self.rust_crate, attempt) + + # A rejected candidate must not be on disk when the next one is generated + if not attempt.success: + lib_src_path.write_text(str(base_lib_src)) + + # Nothing supplies the entrypoint the switch imports, so put the placeholder back + if switch_main and not success and base_main_src is not None: + assert main_src_path is not None + main_src_path.write_text(base_main_src) + assert attempt is not None # `_max_iters` is at least 1, so the loop always ran + return _TranslationResult(attempt) def _wrap_symbol( self, - name: WrapperName, symbol: Symbol, - context: TranslationContext, + context: WrapContext, snippet: CodeC, translation: CodeRust, prior_wrapper: CodeRust | None, - ) -> _WrapperResult | None: - result = None + feedback: Feedback = Feedback(), + ) -> _SymbolResult | None: + is_bin = self.crate.main_src_path is not None - # Main function must always wrapped - if self.crate.main_src_path is not None and symbol.spelling == "main": + result = None + if is_bin and symbol.spelling == "main": + # Rust has to own the entrypoint or its `main` collides with C's at link time result = self._wrap_main() + elif is_bin and symbol.is_function and not self._oracle.exercises(symbol): + # As a policy, an untested binary gets no function wrappers other than `main` + pass elif symbol.is_type and symbol.is_definition: - result = self._wrap_type(name, symbol, context, snippet, translation, prior_wrapper) + result = self._wrap_type( + symbol, context, snippet, translation, prior_wrapper, feedback + ) elif symbol.is_variable: - result = self._wrap_variable(name, symbol) + result = self._wrap_variable( + symbol, context, snippet, translation, prior_wrapper, feedback + ) elif symbol.is_function and symbol.is_definition: result = self._wrap_function( - name, symbol, context, snippet, translation, prior_wrapper + symbol, context, snippet, translation, prior_wrapper, feedback ) if result is None: + logger.debug("Skipped wrapping of symbol `%s`", symbol.name) return None - - # Don't bother testing if the wrapping failed or no tests - if not result.success or self._tests is None: - return result - - # Test the hybrid crate - passes, results = self._test_symbol(symbol, skip=sorted(self._failed_tests)) - result.failed_tests = {name for name, success in results.items() if not success} - if not passes: - result.failure = "test" return result - def _test_symbol(self, symbol: Symbol, skip: list[str]) -> tuple[bool, dict[str, bool]]: - assert self._tests is not None - logger.info(f"Testing symbol `{symbol.name}` ...") + def _wrap_main(self) -> _SymbolResult: + logger.info("Wrapping function `main` ...") - # Make sure the crate builds before testing - builds, feedback = self.crate.cargo_build() - if not builds: - raise RuntimeError(f"Crate does not build!\n{feedback}") - - # Run cargo test - passes, jsonl, feedback, _ = self.crate.cargo_test( - self._tests, skip=skip, test_harness="nextest run", message_format="libtest-json" - ) - results = _extract_test_results(jsonl) - if passes: - msg = f"Tested symbol `{symbol.name}`" - logger.info(msg) - else: - feedback = "Running `cargo test` fails!\n" + feedback - msg = f"Failed to test symbol `{symbol.name}`" - logger.error(msg) - msg += f"\n\n{feedback}" - self.crate.vcs.commit(msg) - return passes, results - - def _wrap_main(self) -> _WrapperResult: - logger.info("Generating wrapper for function `main` ...") - - # Declare C `main` as extern so Rust owns the definition and we avoid - # duplicate entrypoint symbols at link time. - clang_make_extern_(self.c_src_path, "main") - self.crate.vcs.add(self.c_src_path) - - # Import the Rust translation crate from each hybrid root so linker-visible - # symbols stay reachable from the test/build target. - for root_path in (self.crate.lib_src_path, self.crate.main_src_path): - if root_path is None: - continue - root_path.write_text(f"use {self.rust_crate.lib_name}::*;\n") - self.crate.vcs.add(root_path) + # The bin's entrypoint now comes from the -rs crate instead of the C object + self._hybrid.take_over_main() # Fail fast after entrypoint/linkage edits so linker or compile regressions # are caught before continuing with additional wrapper work. - success, output = self.crate.cargo_build() - if not success: - raise RuntimeError(f"Failed to build crate!\n{output}") + if feedback := self._cargo_build(): + raise RuntimeError(feedback) self.crate.vcs.commit("Wrapped function `main`") - return _WrapperResult() + return _SymbolResult() - def _wrap_variable(self, name: WrapperName, symbol: Symbol) -> _WrapperResult | None: - # No point in wrapping non-globals if no tests - if self._tests is None and not symbol.is_global: - return None + def _wrap_variable( + self, + symbol: Symbol, + context: WrapContext, + snippet: CodeC, + translation: CodeRust, + prior_wrapper: CodeRust | None, + feedback: Feedback = Feedback(), + ) -> _SymbolResult | None: + if self._wrapper is None: + return _SymbolResult() - # Emit a Rust module containing FFI variable bindings and persist it - # under src/ so crate-root pub mod declarations can include it. - var_wrapper = bindgen(self.c_src_path, symbol.spelling) - wrapper_path = self.crate.src_dir / f"{name}.rs" - wrapper_path.parent.mkdir(exist_ok=True, parents=True) - wrapper_path.write_text(str(var_wrapper)) - self.crate.vcs.add(wrapper_path) - - # Ensure the C variable has external linkage so the wrapper can resolve - # the symbol at link time and access the same storage across crates. - clang_make_global_(self.c_src_path, symbol.spelling) - self.crate.vcs.add(self.c_src_path) - - # Register the wrapper module in each crate root so tests and callers can - # resolve it by path and rustc includes it in the build graph. - for root_path in (self.crate.lib_src_path, self.crate.main_src_path): - if root_path is None: - continue - with root_path.open("a") as f: - f.write(f"pub mod {name};\n") - self.crate.vcs.add(root_path) + # Generate a test asserting that the translated Rust global matches what the C + # compiler initialized + unimplemented_wrapper, tests_mod, sync_fns = generate_unimplemented_variable_wrapper( + symbol.spelling, self._hybrid.types_code, self._hybrid.bindings[symbol.name] + ) - # Fail fast after wrapper/linkage edits so linker or compile regressions - # are caught before continuing with additional wrapper work. - success, output = self.crate.cargo_build() - if not success: - raise RuntimeError(f"Failed to build crate!\n{output}") + rust_lib_name = self.rust_crate.lib_name + assert rust_lib_name is not None # _init_rust_crate rejects a crate without one + c_global = f"__c_globals::{mangle(symbol.spelling)}" + + def static_errors(wrapper: CodeRust) -> list[str]: + # Report every static violation at once so one round of feedback fixes them all + src = str(wrapper) + errors: list[str] = [] + + # A renamed test module makes the filter below match nothing, which reads as a pass + if f"mod {tests_mod}" not in src: + errors.append(f"The test module must stay named `{tests_mod}`.") + + # `initial_value_matches` only means something if it reads the translated global and + # reads it unaided, so check its body for both + if "fn initial_value_matches" not in src: + errors.append("The test function `initial_value_matches` must be implemented.") + else: + body = _test_body(src, "initial_value_matches") + if rust_lib_name not in body: + errors.append( + f"`initial_value_matches` must read the translated global as " + f"`{rust_lib_name}::ITEM` and compare it against the C global " + f"`{c_global}`. Comparing the C global against " + "itself passes no matter how wrong the translation is. Spell that path " + "out in the test body rather than importing it or reading it through a " + "helper." + ) + if called := [fn for fn in sync_fns if fn in body]: + errors.append( + "`initial_value_matches` must not call " + + ", ".join(f"`{fn}`" for fn in called) + + ". That test is meant to measure whether the translated initializer " + "already agrees with C, and any synchronization function overwrites " + "one side with the other first, so the assertion would compare a value " + "against itself and pass for any translation." + ) + + # The sync functions are legal all or none, never partly gone, and the test goes + # with them + present_sync_fns = tuple(fn for fn in sync_fns if f"fn {fn}" in src) + has_round_trip = "fn round_trip_nontrivial" in src + names = ", ".join(f"`{fn}`" for fn in sync_fns) + if not sync_fns and has_round_trip: + errors.append( + "This global has no synchronization functions, so " + "`round_trip_nontrivial` has nothing to test. Remove it and implement " + "`initial_value_matches` only." + ) + elif 0 < len(present_sync_fns) < len(sync_fns): + errors.append( + f"Keep all of {names} or none of them, spelled exactly as the template " + "spells them." + ) + elif present_sync_fns and not has_round_trip: + errors.append( + f"You kept {names}, so `round_trip_nontrivial` must be implemented: it is " + "the only test that exercises them. Dropping it means dropping them with " + "it, which is allowed only when the translated global cannot be written." + ) + elif not present_sync_fns and has_round_trip: + errors.append( + f"`round_trip_nontrivial` exists to test {names}, so without them it " + "asserts nothing. Restore them and keep the test, unless the translated " + "global cannot be written, in which case remove the test too." + ) + elif present_sync_fns and has_round_trip: + body = _test_body(src, "round_trip_nontrivial") + if uncalled := [fn for fn in sync_fns if f"{fn}(" not in body]: + errors.append( + "`round_trip_nontrivial` must call " + + ", ".join(f"`{fn}`" for fn in uncalled) + + ", since it is the only test that exercises them." + ) + if c_global not in body: + errors.append( + f"`round_trip_nontrivial` must write the C global " + f"`{c_global}` and assert on it afterwards. Spell " + "that path out in the test body rather than importing it or reaching " + "it through a helper." + ) + if rust_lib_name not in body: + errors.append( + f"`round_trip_nontrivial` must read the translated global as " + f"`{rust_lib_name}::ITEM` after `{sync_fns[0]}`. Without that " + "assertion the value never leaves the C global, and two " + "empty-bodied synchronization functions pass the test. Spell that " + "path out in the test body rather than importing it or reading it " + "through a helper." + ) + + if "todo!()" in src: + errors.append( + "The `todo!()` placeholder must be replaced with a real implementation." + ) - msg = f"Wrapped variable `{symbol.name}`" - logger.info(msg) - self.crate.vcs.commit(msg) - return _WrapperResult(pred=dspy.Prediction(wrapper=var_wrapper, success=True)) + return errors + + return self._run_wrapper_session( + symbol, + context, + snippet, + translation, + prior_wrapper, + feedback, + _WrapperPlan(unimplemented_wrapper, static_errors, tests_mod=tests_mod), + ) def _wrap_type( self, - name: WrapperName, symbol: Symbol, - context: TranslationContext, + context: WrapContext, snippet: CodeC, translation: CodeRust, prior_wrapper: CodeRust | None, - ) -> _WrapperResult | None: + feedback: Feedback = Feedback(), + ) -> _SymbolResult | None: if self._wrapper is None: return None - # Only struct declarations with linkage are wrappable: other type kinds have no - # meaningful field-by-field `c_to_r`/`r_to_c` pair, and structs without linkage - # are unnameable. - if not (symbol.is_struct and symbol.is_global): + # Only externally visible structs are wrappable: other type kinds have no meaningful + # field-by-field `to_rust`/`sync_to_c` pair, and a struct the rest of the program + # cannot name has nothing to bridge. + if not (symbol.is_struct and symbol.is_externally_visible): + return None + # An anonymous record, or one nested in one, has no predictable bindgen name to implement on + if (bindgen_name := symbol.bindgen_name) is None: + logger.warning( + f"Cannot wrap type `{symbol.name}`: it is or is nested in an anonymous " + "record, which bindgen names with a run-wide counter" + ) return None - # Seed a concrete wrapper module on disk so the hybrid crate can build - # and the wrapper generator has a stable file path to iteratively replace. - unimplemented_wrapper = generate_unimplemented_type_wrapper( - self.c_src_path, symbol.spelling + # Generate an unimplemented wrapper so the hybrid crate can build and the wrapper + # generator has something to iteratively implement. + unimplemented_wrapper, tests_mod = generate_unimplemented_type_wrapper( + bindgen_name, self._hybrid.types_code ) - wrapper_path = self.crate.src_dir / f"{name}.rs" - wrapper_path.parent.mkdir(exist_ok=True, parents=True) - wrapper_path.write_text(str(unimplemented_wrapper)) - self.crate.vcs.add(wrapper_path) - - # Register the wrapper module in each crate root so tests and callers can - # resolve it by path and rustc includes it in the build graph. - for root_path in (self.crate.lib_src_path, self.crate.main_src_path): - if root_path is None: - continue - with root_path.open("a") as f: - f.write(f"pub mod {name};\n") - self.crate.vcs.add(root_path) - # Fail fast after wrapper/linkage edits so we surface compiler errors - # before iterative wrapper generation starts. - builds, build_feedback = self.crate.cargo_build() - if not builds: - raise RuntimeError(f"The crate does not build!\n\n{build_feedback}") - - def test(wrapper: CodeRust) -> str: - # Write wrapper to disk and add to VCS so commit can commit it - wrapper_path.write_text(str(wrapper)) - self.crate.vcs.add(wrapper_path) + def static_errors(wrapper: CodeRust) -> list[str]: + # Report every static violation at once so one round of feedback fixes them all + src = str(wrapper) + errors: list[str] = [] # Enforce that both required round-trip test functions are present missing = [ fn for fn in ("round_trip_zeroed", "round_trip_nontrivial") - if f"fn {fn}" not in str(wrapper) + if f"fn {fn}" not in src ] if missing: - return ( - "Required test functions are missing: " - + ", ".join(f"`{fn}`" for fn in missing) - + ". Both `round_trip_zeroed` and `round_trip_nontrivial` must be implemented." + names = ", ".join(f"`{fn}`" for fn in missing) + errors.append( + f"Required test functions are missing: {names}. Both " + "`round_trip_zeroed` and `round_trip_nontrivial` must be implemented." ) - # Run the round-trip tests: cargo test provides both compilation errors - # and test failure messages, giving richer feedback than cargo build alone. - passes, _, feedback, _ = self.crate.cargo_test(f"{name}::tests", lib=True) - return "" if passes else f"Running `cargo test` fails!\n{feedback}" + # A dropped impl header or a surviving `()` placeholder still compiles, so + # neither cargo build nor cargo test would catch them + impl_header = f"impl CInterop for {bindgen_name}" + if impl_header not in src: + errors.append(f"The wrapper must contain an `{impl_header}` block.") + if "type Rust = ()" in src: + errors.append( + "`type Rust = ()` is still the placeholder. Replace it with " + f"`type Rust = {self.rust_crate.lib_name}::;`, where " + f"`` is the actual name the C type `{symbol.spelling}` was " + "translated to in `wrapped_crate_code` (it may have been renamed)." + ) - def commit(msg: str, pred: dspy.Prediction): - if "reasoning" in pred and pred.reasoning: - msg += f"\n\n# Reasoning\n{indent(pred.reasoning, ' ')}" - if "build_feedback" in pred and pred.build_feedback: - msg += f"\n\n# Build Feedback\n{indent(pred.build_feedback, ' ')}" - self.crate.vcs.commit(msg) + # A renamed test module makes the filter below match nothing, which reads as a pass + if f"mod {tests_mod}" not in src: + errors.append(f"The test module must stay named `{tests_mod}`.") - pred = self._wrapper( - symbol=symbol, - crate_code=context.crate_code, - translation=translation, - unimplemented_wrapper=unimplemented_wrapper, - wrapper_path=wrapper_path.relative_to(self.crate.cargo_toml.parent), - wrapped_crate=self.rust_crate.lib_name, - other_wrappers=context.wrappers, - prior_wrapper=prior_wrapper, - support_code=context.support_code + snippet + context.dependent_code, - feedback_fn=test, - on_attempt=commit, + return errors + + return self._run_wrapper_session( + symbol, + context, + snippet, + translation, + prior_wrapper, + feedback, + _WrapperPlan(unimplemented_wrapper, static_errors, tests_mod=tests_mod), ) - return _WrapperResult.from_pred(pred) def _wrap_function( self, - name: WrapperName, symbol: Symbol, - context: TranslationContext, + context: WrapContext, snippet: CodeC, translation: CodeRust, prior_wrapper: CodeRust | None, - ) -> _WrapperResult | None: + feedback: Feedback = Feedback(), + ) -> _SymbolResult | None: if self._wrapper is None: return None - # No point in wrapping non-globals if no tests - if self._tests is None and not symbol.is_global: + # No point in wrapping non-globals if the oracle does not exercise it + if not symbol.is_externally_visible and not self._oracle.exercises(symbol): return None - # Seed a placeholder wrapper module so the crate can compile immediately - # and the wrapper generator has a stable file to iteratively overwrite. - unimplemented_wrapper = generate_unimplemented_function_wrapper( - self.c_src_path, symbol.spelling - ) + # Generate an unimplemented wrapper so the hybrid crate can build and the wrapper + # generator has something to iteratively implement. + unimplemented_wrapper = self._hybrid.unimplemented_function_wrapper(symbol.spelling) if unimplemented_wrapper is None: logger.warning(f"Skipping wrap of function symbol `{symbol.name}`") - return _WrapperResult() - wrapper_path = self.crate.src_dir / f"{name}.rs" - wrapper_path.parent.mkdir(exist_ok=True, parents=True) - wrapper_path.write_text(str(unimplemented_wrapper)) - self.crate.vcs.add(wrapper_path) - - # Switch the C function declaration to extern so the Rust wrapper crate - # can provide the callable definition without duplicate symbol ownership. - clang_make_extern_(self.c_src_path, symbol.spelling) - self.crate.vcs.add(self.c_src_path) - - # Register the wrapper module in each crate root so tests and callers can - # resolve it by path and rustc includes it in the build graph. - for root_path in (self.crate.lib_src_path, self.crate.main_src_path): - if root_path is None: - continue - with root_path.open("a") as f: - f.write(f"pub mod {name};\n") - self.crate.vcs.add(root_path) + return _SymbolResult() + + return self._run_wrapper_session( + symbol, + context, + snippet, + translation, + prior_wrapper, + feedback, + _WrapperPlan(unimplemented_wrapper, extern_symbol=symbol.spelling), + ) - # Fail fast after wrapper/linkage edits so linker or compile regressions - # are caught before iterative wrapper generation continues. - builds, build_feedback = self.crate.cargo_build() - if not builds: - raise RuntimeError(f"The crate does not build!\n\n{build_feedback}") + def _run_wrapper_session( + self, + symbol: Symbol, + context: WrapContext, + snippet: CodeC, + translation: CodeRust, + prior_wrapper: CodeRust | None, + feedback: Feedback, + plan: _WrapperPlan, + ) -> _SymbolResult: + assert self._wrapper is not None # every caller checks before building a plan + rust_lib_name = self.rust_crate.lib_name + assert rust_lib_name is not None # _init_rust_crate rejects a crate without one def build(wrapper: CodeRust) -> str: - wrapper_path.write_text(str(wrapper)) - self.crate.vcs.add(wrapper_path) - builds, build_feedback = self.crate.cargo_build() - return build_feedback if not builds else "" - - def commit(msg: str, pred: dspy.Prediction): - if "reasoning" in pred and pred.reasoning: - msg += f"\n\n# Reasoning\n{indent(pred.reasoning, ' ')}" - if "build_feedback" in pred and pred.build_feedback: - msg += f"\n\n# Build Feedback\n{indent(pred.build_feedback, ' ')}" - if "scope_feedback" in pred and pred.scope_feedback: - msg += f"\n\n# Scope Feedback\n{indent(pred.scope_feedback, ' ')}" - self.crate.vcs.commit(msg) - - pred = self._wrapper( + self._hybrid.set_wrapper(symbol.name, wrapper) + if plan.extern_symbol is not None: + self._hybrid.make_extern(plan.extern_symbol) + return self._cargo_build() + + # Fail fast after wrapper/linkage edits so compile or linker regressions are caught + # before iterative wrapper generation starts + with self._checkpoint(hybrid_only=True) as state: + if error := build(plan.unimplemented_wrapper): + raise RuntimeError(error) + + wrapper_logger.info(f"Generating wrapper for `{symbol.name}` ...") + with self._wrapper.session( symbol=symbol, - crate_code=context.crate_code, + wrapped_crate_code=context.wrapped_crate_code, translation=translation, - unimplemented_wrapper=unimplemented_wrapper, - wrapper_path=wrapper_path.relative_to(self.crate.cargo_toml.parent), - wrapped_crate=self.rust_crate.lib_name, - other_wrappers=context.wrappers, + unimplemented_wrapper=plan.unimplemented_wrapper, + wrapped_crate=rust_lib_name, + other_wrappers=self._hybrid.render( + context.wrappers.values(), + *self._hybrid.type_slice(symbol.bindgen_name, plan.unimplemented_wrapper), + ), prior_wrapper=prior_wrapper, - support_code=context.support_code + snippet + context.dependent_code, - feedback_fn=build, - on_attempt=commit, - ) - return _WrapperResult.from_pred(pred) + feedback=feedback, + support_code=context.support_code(snippet), + ) as session: + attempt: WrapperAttempt | None = None + for attempt in session: + if errors := plan.errors(attempt.wrapper): + attempt.reject(scope=errors) + # Build first so a compile error is not reported as a test failure + elif error := build(attempt.wrapper): + attempt.reject(build=[error]) + elif plan.tests_mod is not None and ( + error := self._cargo_test(plan.tests_mod, lib=True) + ): + attempt.reject(build=[error]) + else: + attempt.accept() + _commit(self.crate, attempt) + + # A rejected candidate must not be on disk when the next one is generated + if not attempt.success: + self._restore(state, hybrid_only=True) + return _SymbolResult(attempt=attempt) + + def _judge_symbol(self, candidate: Candidate) -> Verdict: + # Only a hybrid that builds can be tested, and only an exercised symbol will be + if candidate.wrapped and self._oracle.exercises(candidate.symbol): + if error := self._cargo_build(): + raise RuntimeError(error) + + verdict = self._oracle.judge(candidate) + # A wrap rejection leaves no message: the session already committed every attempt + if verdict.message: + self.crate.vcs.commit(f"{verdict.message}\n\n{verdict.evidence}".strip()) + return verdict + + +def _undeclared_types( + types: Mapping[BindgenName, CodeRust], symbols: Iterable[Symbol] +) -> list[BindgenName]: + # bindgen renames nested records after their whole ancestor chain, so a wrong + # `bindgen_name` silently yields a type slice that never declares the item the + # wrapper will `impl CInterop for`. + return sorted( + { + name + for s in symbols + if s.is_struct and s.is_externally_visible + if (name := s.bindgen_name) is not None and name not in types + } + ) -def _extract_test_results(output: str) -> dict[str, bool]: - test_results: dict[str, bool] = {} +def _commit(crate: Crate, attempt: CodeAttempt): + # The attempt's own module is what names the stage being committed + log = logging.getLogger(type(attempt).__module__) + (log.info if attempt.success else log.error)(attempt.headline) - for line in output.splitlines(): - try: - obj = json.loads(line) - except json.JSONDecodeError: - continue - if obj.get("type") != "test": - continue - event = obj.get("event") - if event not in {"ok", "failed", "ignored"}: - continue - name = str(obj.get("name", "")).rsplit("$", 1)[-1].strip() - if name: - # Treat ignored as non-failing for disable-list purposes - test_results[name] = event != "failed" - - return test_results + # Cleared unconditionally so the commit never carries the previous attempt's prompt + prompt_dir = crate.cargo_toml.parent / ".prompt" + crate.vcs.rm(prompt_dir, force=True) + shutil.rmtree(prompt_dir, ignore_errors=True) + if prompt_files := attempt.pred.get("prompt_files"): + prompt_dir.mkdir(parents=True, exist_ok=True) + for name, text in prompt_files.items(): + (prompt_dir / name).write_text(text, encoding="utf-8") + crate.vcs.add(prompt_dir) + + crate.vcs.commit(attempt.message) + + +def _test_body(wrapper: str, name: str) -> str: + # Crude slice of one `#[test]` function's source, used to check what a single test calls + # without paying for a full parse. + start = wrapper.find(f"fn {name}") + if start == -1: + return "" + body = wrapper[start:] + end = body.find("#[test]") + return body if end == -1 else body[:end] diff --git a/src/ideas/translate_snippet.py b/src/ideas/translate_snippet.py index 2c26a7d..c073a8d 100644 --- a/src/ideas/translate_snippet.py +++ b/src/ideas/translate_snippet.py @@ -7,23 +7,21 @@ import logging import sqlite3 from pathlib import Path -from collections.abc import Callable +from dataclasses import dataclass import dspy -from dspy.utils.exceptions import AdapterParseError -from dspy.utils.usage_tracker import track_usage -from dspy.dsp.utils.settings import settings from .ast import CodeC from .ast_rust import CodeRust -from .model import format_usage +from .refine import CodeAttempt, Feedback, PredictSession +from .model import predict logger = logging.getLogger("ideas.translate_snippet") class SnippetTranslatorSignature(dspy.Signature): - """ + r""" Generate an idiomatic, memory-safe Rust translation of a single C definition. # Inputs @@ -31,7 +29,10 @@ class SnippetTranslatorSignature(dspy.Signature): - `reference_code`: Existing Rust code the translation must build on. Use it as-is; do not refactor it. - `snippet`: The single C definition to translate. - `dependent_code`: C code that uses the snippet. Use it to determine concrete types for opaque and void pointers, ownership, lifetimes, and memory-management requirements; do not translate it. - - `prior_translation` and `feedback`: If provided, treat the feedback as a critique of the prior translation and address it in the new translation. + - `prior_translation`: a previous translation to fix, if any. + - `feedback`: a critique of `prior_translation` from running the program it was part of. Address it in the new translation. + - `build_feedback`: errors from `cargo build`. Address them. + - `scope_feedback`: violations of the hard constraints below. Address them. # Hard constraints @@ -46,7 +47,7 @@ class SnippetTranslatorSignature(dspy.Signature): # Faithfulness to C semantics - The overarching rule: reproduce the C code's runtime behavior exactly. Do not "fix", simplify, or second-guess the C code's intent. For type definitions, use idiomatic Rust types that are semantically equivalent rather than structurally identical. + The overarching rule: reproduce the C code's runtime behavior exactly. Do not "fix", simplify, or second-guess the C code's intent. For type definitions, use idiomatic Rust types that are semantically equivalent rather than structurally identical. A Rust `std` method whose name resembles a C library function is rarely a drop-in for it; derive the behavior from the C function's definition rather than from the `std` method that matches its name. ## Arithmetic and expressions @@ -61,7 +62,7 @@ class SnippetTranslatorSignature(dspy.Signature): ## Integer text parsing - Accept the full range of C-valid inputs, including negatives that wrap into unsigned types (`-1i32 as u8 == 255`). - - Always parse into a wide intermediate type that can represent the full C-valid input range before the final wrapping cast (at minimum `i64` for both narrow signed and narrow unsigned targets; `u64` is also acceptable where appropriate for unsigned-only flows), then apply a wrapping cast to the destination. Never parse digits directly as a narrow or unsigned destination type — that path rejects negatives and can overflow before the wrapping cast can run. + - Always parse into a wide intermediate type that can represent the full C-valid input range before the final wrapping cast (at minimum `i64` for both narrow signed and narrow unsigned targets; `u64` is also acceptable where appropriate for unsigned-only flows), then apply a wrapping cast to the destination. Never parse digits directly as a narrow or unsigned destination type, because that path rejects negatives and can overflow before the wrapping cast can run. - When the C code uses `scanf`/`sscanf`-style conversion, accept a leading numeric prefix and ignore trailing non-digit characters. Do not use bare `str::parse::()` on the full trimmed string. ## scanf / sscanf behavior @@ -75,47 +76,53 @@ class SnippetTranslatorSignature(dspy.Signature): - Treat any length-sensitive operation on a C string buffer (`strlen`, `%s`-style usage, comparisons, hashing, etc.) as ending at the first NUL byte. - For pointer+length inputs, classify semantics before decoding: if the C code treats the data as a string (`strcmp`, `strlen`, `%s`, token parsing, command dispatch, pattern matching), normalize at ingress by truncating at the first `\0`; if the C path is fixed-length or binary, preserve embedded `\0` bytes and honor the explicit length. - - When converting a NUL-terminated C string buffer into a Rust `String` for storage or downstream text processing, truncate at the first `\\0` *at the point of conversion*, not at the point of use. Stored string values that model C strings must never contain bytes at or after the NUL. + - When converting a NUL-terminated C string buffer into a Rust `String` for storage or downstream text processing, truncate at the first `\0` *at the point of conversion*, not at the point of use. Stored string values that model C strings must never contain bytes at or after the NUL. - Before any comparison, pattern match, token parse, or command dispatch on C-origin string data, normalize at ingress by truncating at the first NUL byte. - Apply the same normalization policy to all operands in the same logical operation. Do not compare a length-decoded value that still includes trailing `\0` bytes against a C-string-decoded value already truncated at `\0`. - When both pointer and length are present for string-style data, use length only as a safety bound for reads; derive semantic content from C string termination and stop at the first `\0`. - Truncate once at the ingestion boundary and pass only normalized string values to downstream logic. Do not defer truncation to arbitrary leaf helpers when values are stored or reused across operations. - Do not compare raw decoded Rust `String` values that may include trailing NUL bytes when those values represent C strings. - - This rule applies only when the original C code is treating the data as a NUL-terminated string (for example `strlen`, `%s`, string comparison, or string parsing). Do not truncate fixed-length, length-delimited, or binary buffers merely because they may contain `\\0`; preserve embedded NUL bytes unless the C code's semantics require string termination. + - This rule applies only when the original C code is treating the data as a NUL-terminated string (for example `strlen`, `%s`, string comparison, or string parsing). Do not truncate fixed-length, length-delimited, or binary buffers merely because they may contain `\0`; preserve embedded NUL bytes unless the C code's semantics require string termination. ## Fixed-buffer line input (`fgets`) - Do not replace `fgets(buf, N, stdin)` with `read_line` or a bulk `io::stdin().read()`. Both consume too much input. - - Replicate `fgets`: read at most N-1 bytes, stop after the first `'\\n'`, and leave all remaining input in stdin. Read byte-by-byte or use `BufRead::fill_buf` + `consume`. - - When storing or comparing `fgets` output as a Rust `String`, truncate at the first `'\\n'` or `'\\0'`, whichever comes first. `fgets` retains the newline before the NUL, and keeping it breaks C-style trimmed comparisons. + - Replicate `fgets`: read at most N-1 bytes, stop after the first `'\n'`, and leave all remaining input in stdin. Read byte-by-byte or use `BufRead::fill_buf` + `consume`. + - When storing or comparing `fgets` output as a Rust `String`, truncate at the first `'\n'` or `'\0'`, whichever comes first. `fgets` retains the newline before the NUL, and keeping it breaks C-style trimmed comparisons. ## Return values and pointer arithmetic - Return exactly what the C function returns. If C returns a success/failure code, do not substitute a byte count or length. + - C `main`'s return value becomes the process exit status, and a Rust `main` that returns `()` always exits 0. Translate a C `main` that can return non-zero as `pub fn main() -> std::process::ExitCode`, or call `std::process::exit` on those paths. Writing the error message to stderr without also setting the status is not a faithful translation. - C pointer subtraction (`end - start`) yields a count of elements, not bytes. Preserve the exact value. ## Pointer identity - When C compares pointers for identity, compare identity-equivalent Rust references. Cloning or copying changes identity and breaks the comparison. - Translate a C function that returns a pointer into a global/static container (e.g., `return &table[i]`) as a function returning `&T` into that container, not an owned clone. - - If the container is locked: acquire the lock in the caller and borrow `&T` from the held guard. If the existing accessor locks internally and returns an owned value, the caller must bypass it — lock the container directly, borrow references from the guard, and finish all identity comparisons before releasing. + - If the container is locked: acquire the lock in the caller and borrow `&T` from the held guard. If the existing accessor locks internally and returns an owned value, the caller must bypass it: lock the container directly, borrow references from the guard, and finish all identity comparisons before releasing. ## Void pointers (`void *`) - - A `void *` field or parameter is not inherently polymorphic. Inspect `dependent_code` to find every cast applied to the value. If all casts resolve to the same concrete type, translate the field using that concrete type (e.g., `Option>`). Do not use `Box`, `Box`, or any other type-erasure mechanism unless the pointer is genuinely polymorphic — i.e., cast to multiple structurally unrelated types in different code paths. + - A `void *` field or parameter is not inherently polymorphic. Inspect `dependent_code` to find every cast applied to the value. If all casts resolve to the same concrete type, translate the field using that concrete type (e.g., `Option>`). Do not use `Box`, `Box`, or any other type-erasure mechanism unless the pointer is genuinely polymorphic, i.e., cast to multiple structurally unrelated types in different code paths. - A `void *` used solely to break a forward-declaration cycle is not polymorphic. Resolve the concrete type from the casts and use it directly. - - When the `void *` is nullable in C (compared to `NULL`, initialized to `0`, or conditionally assigned), translate it as `Option>` if the containing struct owns the allocation (evidenced by `free` being called through this field), or as `Option<&T>` / `Option<&mut T>` if it is a non-owning reference. + - When the `void *` is nullable in C (compared to `NULL`, initialized to `0`, or conditionally assigned) and null is how C marks the field absent (see "What `Option` means"), translate it as `Option>` if the containing struct owns the allocation (evidenced by `free` being called through this field), or as `Option<&T>` / `Option<&mut T>` if it is a non-owning reference. ## Pointer fields in structs (`T *`, `T **`) Determine ownership from `dependent_code` before choosing a Rust type: - - If `free(field)` or equivalent is called through the struct, the struct owns the pointee. Use `Box` for a single value or `Vec` for a heap-allocated array. Wrap in `Option<>` if the pointer may be null. + - If `free(field)` or equivalent is called through the struct, the struct owns the pointee. Use `Box` for a single value or `Vec` for a heap-allocated array. Wrap in `Option<...>` only if null is how C marks this field absent (see "What `Option` means"). - If the pointer is never freed through the struct (it aliases data owned elsewhere): use `&T` or `&mut T` with an appropriate lifetime. - If the field stores a heap array whose length is tracked separately (a C dynamic array): use `Vec`, not `Box<[T]>`. - If the field is a fixed-length C array (`T arr[N]`): use `[T; N]`. - Do not use raw pointers (`*mut T`, `*const T`) as a shortcut when a safe owned or borrowed type is available. + ## What `Option` means + + - When a field becomes `Option<...>`, `None` must correspond to exactly the condition the C code tests for absence. Find that test in `dependent_code` before choosing the type. It is often a null pointer, but it may equally be a sentinel index, a zero length or count, a tag selecting a union member, or a bit in a sibling flags word. An `Option` whose `None` means "the pointer was null" when C means "the bitmap says this slot is empty" is a different type, and every consumer of it will be wrong. + - Do not encode the same fact twice. If a sibling field already carries the validity, occupancy, or length information, keep that field authoritative and leave the governed field non-optional. A struct holding two encodings of one fact that can disagree has no correct interpretation. + ## Mutable global state and locking - Never lock the same mutex/`RwLock` more than once in a single expression. @@ -125,7 +132,7 @@ class SnippetTranslatorSignature(dspy.Signature): ## Binary parsers and slice contracts - Validate every length-derived slice with an explicit bounds check before slicing or copying. - - Never reinterpret payload bytes as headers, and never re-derive a chunk's length by re-parsing its payload — use `slice.len()` on the bytes the helper was given. + - Never reinterpret payload bytes as headers, and never re-derive a chunk's length by re-parsing its payload; instead use `slice.len()` on the bytes the helper was given. - When consuming bytes from a state-machine bitreader/bytestream, read directly from the reader. Do not reconstruct a backing slice and index into it; reconstructed slices may be short. - When the C source reads sequentially from a composite buffer (e.g., a primary `&[u32]` plus a trailing partial word), iterate through every component in order. Do not read only the primary array and silently drop the tail. @@ -169,7 +176,6 @@ class SnippetTranslatorSignature(dspy.Signature): # Allowed External Crates - - `libc`: Raw FFI bindings to platform libraries like libc. - `openssl`: OpenSSL bindings - `flate2`: DEFLATE compression and decompression exposed as Read/BufRead/Write streams. Supports miniz_oxide and multiple zlib implementations. Supports zlib, gzip, and raw deflate streams. - `regex`: An implementation of regular expressions for Rust. This implementation uses finite automata and guarantees linear time matching on all inputs. @@ -182,15 +188,80 @@ class SnippetTranslatorSignature(dspy.Signature): dependent_code: CodeC = dspy.InputField() prior_translation: CodeRust = dspy.InputField() feedback: str = dspy.InputField() + build_feedback: str = dspy.InputField() + scope_feedback: str = dspy.InputField() translation: CodeRust = dspy.OutputField() -def _default_feedback_fn(translation: CodeRust) -> str: - return "" +@dataclass(kw_only=True) +class TranslationAttempt(CodeAttempt): + translation: CodeRust + name: str + snippet: CodeC + + @property + def summary(self) -> str: + verb = "Translated" if self.success else "Failed to translate" + return f"{verb} snippet `{self.name}`" -def _default_on_attempt(msg: str, pred: dspy.Prediction) -> None: - pass +@dataclass +class TranslationSession(PredictSession[TranslationAttempt, CodeRust]): + translator: "SnippetTranslator" + name: str + crate_code: CodeRust + reference_code: CodeRust + snippet: CodeC + dependent_code: CodeC + prior_translation: CodeRust | None = None + feedback: Feedback = Feedback() + + @property + def _max_iters(self) -> int: + return max(self.translator.max_iters, 1) + + def _prime(self) -> CodeRust | None: + # Use cache when no prior translation + if self.prior_translation is not None: + logger.info("Ignoring snippet cache...") + return None + # Special case for empty snippets: fall back to a static comment + if not str(self.snippet): + return CodeRust(f"// Empty snippet `{self.name}`") + translation = _read_cache(self.translator.cache, self.name, self.snippet) + return _make_public(translation) if translation is not None else None + + def _attempt( + self, prior: TranslationAttempt | None, replay: CodeRust | None + ) -> TranslationAttempt: + if prior is None: + feedback, prior_translation = self.feedback, self.prior_translation + else: + feedback, prior_translation = prior.next_feedback, prior.translation + pred = self.translator.translate( + reference_code=self.reference_code, + snippet=self.snippet, + dependent_code=self.dependent_code, + prior_translation=prior_translation, + feedback=feedback.review, + build_feedback=feedback.build, + scope_feedback=feedback.scope, + translation=replay, + ) + + translation = pred.translation + assert isinstance(translation, CodeRust) + if translation in self.crate_code: + translation = CodeRust(f"// duplicate snippet `{self.name}` detected") + if translation == prior_translation: + logger.warning("Snippet translation loop detected!") + return TranslationAttempt( + translation=translation, + pred=pred, + prior_feedback=feedback, + name=self.name, + snippet=self.snippet, + ) class SnippetTranslator(dspy.Module): @@ -206,7 +277,7 @@ def __init__( self.max_iters = max_iters self.cache = _init_cache(cache) - def forward( + def session( self, name: str, crate_code: CodeRust, @@ -214,87 +285,18 @@ def forward( snippet: CodeC, dependent_code: CodeC, prior_translation: CodeRust | None = None, - feedback: str = "", - feedback_fn: Callable[[CodeRust], str] | None = None, - on_attempt: Callable[[str, dspy.Prediction], None] | None = None, - ) -> dspy.Prediction: - if feedback_fn is None: - feedback_fn = _default_feedback_fn - if on_attempt is None: - on_attempt = _default_on_attempt - - logger.info(f"Translating snippet `{name}` ...") - - # Use cache when no prior translation - if prior_translation is None and not str(snippet): - # Special case for empty snippets: fall back to a static comment - translation = CodeRust(f"// Empty snippet `{name}`") - elif prior_translation is None: - translation = _read_cache(self.cache, name, snippet) - if translation is not None: - translation = _make_public(translation) - else: - logger.info("Ignoring snippet cache...") - translation = None - - pred = dspy.Prediction(feedback=feedback) - for i in range(max(self.max_iters, 1)): - # Use the translation from the prior iteration as feedback for the next iteration - if i > 0: - prior_translation = translation - - # Use prior translation as the translation on first iteration only. - # This allows static translations that violate safety, which will be fixed by the LLM! - try: - pred = self.translate( - reference_code=reference_code, - snippet=snippet, - dependent_code=dependent_code, - prior_translation=prior_translation, - feedback=pred.feedback, - translation=translation if i == 0 else None, - ) - except AdapterParseError: - logger.exception( - f"DSPy exception while translating snippet `{name}` on iteration {i + 1}/{self.max_iters}!" - ) - # If this is the last iteration, raise - if i == max(self.max_iters, 1) - 1: - raise - # Otherwise attempt again before any build logic - continue - - translation = pred.translation - assert isinstance(translation, CodeRust) - if translation in crate_code: - translation = CodeRust(f"// duplicate snippet `{name}` detected") - if translation == prior_translation: - logger.warning("Snippet translation loop detected!") - pred.name = name - pred.snippet = snippet - pred.crate_code = crate_code - pred.dependent_code = dependent_code - pred.prior_translation = prior_translation or CodeRust() - pred.translation = translation - - parts = [] - if CodeRust("#![forbid(unsafe_code)]") in translation: - parts.append("Do not include `#![forbid(unsafe_code)]` in the translation!") - if feedback := feedback_fn(translation): - parts.append(feedback) - pred.feedback = "\n\n".join(parts) - pred.success = not pred.feedback - - if pred.success: - msg = f"Translated snippet `{name}`: {format_usage(pred)}" - logger.info(msg) - on_attempt(msg, pred) - break - else: - msg = f"Failed to translate snippet `{name}` ({i + 1}/{self.max_iters}): {format_usage(pred)}" - logger.error(msg) - on_attempt(msg, pred) - return pred + feedback: Feedback = Feedback(), + ) -> TranslationSession: + return TranslationSession( + translator=self, + name=name, + crate_code=crate_code, + reference_code=reference_code, + snippet=snippet, + dependent_code=dependent_code, + prior_translation=prior_translation, + feedback=feedback, + ) def translate( self, @@ -303,53 +305,25 @@ def translate( dependent_code: CodeC, prior_translation: CodeRust | None, feedback: str, + build_feedback: str, + scope_feedback: str, translation: CodeRust | None, ) -> dspy.Prediction: - parent_usage_tracker = settings.usage_tracker - if translation is not None: - pred = dspy.Prediction(translation=translation) - if parent_usage_tracker is not None: - pred.set_lm_usage({}) - else: - if parent_usage_tracker is None: - pred = self._translate( - reference_code=reference_code, - snippet=snippet, - dependent_code=dependent_code, - prior_translation=prior_translation or CodeRust(), - feedback=feedback, - ) - else: - with track_usage() as local_usage_tracker: - pred = self._translate( - reference_code=reference_code, - snippet=snippet, - dependent_code=dependent_code, - prior_translation=prior_translation or CodeRust(), - feedback=feedback, - ) - lm_usage = local_usage_tracker.get_total_tokens() - pred.set_lm_usage(lm_usage) - for lm_name, usage_entry in lm_usage.items(): - parent_usage_tracker.add_usage(lm_name, usage_entry) - return pred - - def write_cache(self, pred: dspy.Prediction) -> None: - # If prediction was not generated by an LM then don't write it to cache - if not pred.get_lm_usage(): - return - - _write_cache( - self.cache, - pred.name, - pred.snippet, - pred.crate_code, - pred.dependent_code, - pred.prior_translation, - pred.feedback, - pred.translation, - pred.success, - ) + inputs = { + "reference_code": reference_code, + "snippet": snippet, + "dependent_code": dependent_code, + "prior_translation": prior_translation or CodeRust(), + "feedback": feedback, + "build_feedback": build_feedback, + "scope_feedback": scope_feedback, + } + return predict(self._translate, inputs, "translation", translation) + + def write_cache(self, attempt: TranslationAttempt) -> None: + # Re-caching a cache hit would just duplicate the entry it came from + if attempt.success and attempt.pred.get("was_generated", False): + _write_cache(self.cache, attempt.name, attempt.snippet, attempt.translation) def _init_cache(cache: Path | None) -> Path | None: @@ -371,10 +345,6 @@ def _init_cache(cache: Path | None) -> Path | None: ) """ ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_snippet_lookup" - " ON snippet_translations (name, snippet)" - ) return cache @@ -406,7 +376,7 @@ def _make_public(translation: CodeRust) -> CodeRust: source = str(translation) from typing import Any from tree_sitter import Language, Parser, Query, QueryCursor - from tree_sitter_rust import language as rust_language + from tree_sitter_rust_orchard import language as rust_language language = Language(rust_language()) parser = Parser(language) @@ -612,30 +582,15 @@ def _write_cache( cache: Path | None, name: str, snippet: CodeC, - crate_code: CodeRust, - dependent_code: CodeC, - prior_translation: CodeRust, - feedback: str, translation: CodeRust, - success: bool, ): if cache is None: return with sqlite3.connect(cache) as conn: conn.execute( - """ - INSERT INTO snippet_translations - (name, snippet, reference_code, dependent_code, prior_translation, feedback, translation, success) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - str(snippet), - str(crate_code), - str(dependent_code), - str(prior_translation), - feedback, - str(translation), - int(success), - ), + "INSERT INTO snippet_translations" + " (name, snippet, reference_code, dependent_code, prior_translation, feedback," + " translation, success)" + " VALUES (?, ?, '', '', '', '', ?, 1)", + (name, str(snippet), str(translation)), ) diff --git a/src/ideas/wrapper.py b/src/ideas/wrapper.py index 81d3a96..c0d503d 100644 --- a/src/ideas/wrapper.py +++ b/src/ideas/wrapper.py @@ -8,28 +8,39 @@ import sqlite3 import logging from pathlib import Path -from collections.abc import Callable +from textwrap import dedent, indent +from dataclasses import dataclass, field +from collections.abc import Iterable import dspy -from dspy.utils.exceptions import AdapterParseError -from dspy.utils.usage_tracker import track_usage -from dspy.dsp.utils.settings import settings from ideas.tools import check_rust, run_subprocess from ideas.ast import CodeC, Symbol, clang_make_bindable_ -from ideas.ast_rust import CodeRust, validate_changes, mangle -from ideas.model import format_usage +from tree_sitter import Node + +from ideas.ast_rust import BindgenName, CodeRust, get_node_text, get_nodes, get_root +from ideas.ast_rust import is_unimplemented, mangle, uses_catch_unwind, validate_changes +from ideas.refine import CodeAttempt, Feedback, PredictSession +from ideas.model import predict logger = logging.getLogger("ideas.wrapper") +C_INTEROP_TRAIT = CodeRust("""\ +pub trait CInterop: Sized { + type Rust; + unsafe fn to_rust(cs: *const Self) -> Self::Rust; + unsafe fn sync_to_c(rs: &Self::Rust, cs: *mut Self); +} +""") + class FunctionWrapperSignature(dspy.Signature): - """ + r""" Generate a C-compatible FFI wrapper for `{wrapped_crate}::{symbol_name}` in a hybrid C/Rust build where C globals and the Rust port must stay in sync. # Goal - Produce a `wrapper` that callers of the original C symbol can link against unchanged. The implementation of `{wrapped_crate}::{symbol_name}` lives in dependency crate `{wrapped_crate}`; the wrapper will be written to "{wrapper_path}". + Produce a `wrapper` that callers of the original C symbol can link against unchanged. The implementation of `{wrapped_crate}::{symbol_name}` lives in dependency crate `{wrapped_crate}`. # Template @@ -38,27 +49,36 @@ class FunctionWrapperSignature(dspy.Signature): # Type conversions - Types in `crate::` (bindgen-generated, C-compatible layout) are *not* layout-compatible with those in `{wrapped_crate}::` (idiomatic Rust). The wrapper must: + The bindgen-generated C-layout types in `crate` are *not* layout-compatible with the idiomatic Rust types in `{wrapped_crate}`. Each C-layout type is defined exactly once at the crate root, so the same C type is the same Rust type in every wrapper. The wrapper must: - 1. Copy field values from each `crate::` argument into a fresh `{wrapped_crate}::` value before the call. + 1. Copy field values from each C-layout parameter into a fresh value of the corresponding idiomatic Rust type before the call. 2. Call `{wrapped_crate}::{symbol_name}` with the converted values. - 3. Copy result/output values back from `{wrapped_crate}::` types into the `crate::` types the C ABI expects. + 3. Copy result/output values back from those idiomatic Rust types into the C-layout types the C ABI expects. Use `support_code` to recover the original C types behind opaque or erased Rust types (notably `void*`, `*mut c_void`, and untyped byte buffers) so each field is converted at its true C type. - When converting a struct argument, check `crate` for a `c_to_r` / `r_to_c` pair generated for that type. If one exists, call `c_to_r(ptr)` (passing a `*const` pointer) instead of converting fields inline. Likewise use `r_to_c(&rust_value, c_out_ptr)` (passing a reference and a `*mut` pointer) when writing a result or out-parameter back to a `crate::` type — `r_to_c` writes in-place and returns nothing. + Below, `CType` is a **placeholder**, not a name to emit. Every C-layout type is declared in `crate`, so read the real name out of `crate` instead of deriving it from the C spelling, because `bindgen` does not always preserve that spelling. + + When converting a struct parameter, check whether its C type implements `CInterop`. If it does you must call `CType::to_rust(ptr)` (passing a `*const` pointer) rather than converting fields inline. Likewise use `CType::sync_to_c(&rust_value, c_out_ptr)` (passing a reference and a `*mut` pointer) when writing a result or out-parameter back to a C-layout type, because `sync_to_c` writes in-place and returns nothing. + + The type and the trait are both already in scope, so prefer the bare C type name exactly as `crate` declares it: `CType::to_rust(ptr)`. The qualified forms `crate::CType::to_rust(ptr)` and `::to_rust(ptr)` resolve to the same method, but the bare form is the expected output. C globals are the exception: `crate` declares them inside the `__c_globals` module, which is not re-exported, so name them `__c_globals::NAME`. # Global synchronization - If `{wrapped_crate}::{symbol_name}` reads or writes globals, synchronize them in the wrapper: + If `{wrapped_crate}::{symbol_name}` reads or writes globals, synchronize them by calling the functions `crate` already declares for that purpose: + + - `sync__to_rust()` before the call, for every global the C code reads. + - `sync__to_c()` after the call, for every global the C code writes. - - Each C global is exposed in `crate` as a `pub static mut` bearing the variable's name. Locate its full path by inspecting `crate` directly. - - Before the call, copy each readable global from its `pub static mut` in `crate` into the corresponding Rust global in `{wrapped_crate}`. If the global's type has a `c_to_r` function in `crate`, use it for the conversion; otherwise copy field-by-field. - - After the call, copy each writable global from `{wrapped_crate}` back to its `pub static mut` in `crate`. If the global's type has an `r_to_c` function in `crate`, use it for the conversion; otherwise copy field-by-field. + Call them rather than reimplementing what they do. They are the only synchronization path the round-trip tests cover, so a wrapper that converts the global itself is untested no matter how correct it looks. + + Each one acquires and releases whatever lock its Rust global needs, so never hold a guard on that global across a call to one, or the wrapper deadlocks. + + A global with no `sync__to_rust` / `sync__to_c` pair in `crate` needs no synchronization, and you must not synchronize it by hand. # Raw pointer handling - - Null-check every raw pointer parameter before its first dereference. On null, return the same value the C function returns for null input (typically an error code, `false`, `-1`, or `null`) — never dereference and panic. Use `support_code` to determine the correct null-input sentinel. + - Null-check every raw pointer parameter before its first dereference. On null, return the same value the C function returns for null input (typically an error code, `false`, `-1`, or `null`); never dereference and panic. Use `support_code` to determine the correct null-input sentinel. - Treat every mutable raw pointer parameter as in-out unless `support_code` clearly proves it is read-only. - For every out / in-out pointer parameter, write the converted result back through the raw pointer after the Rust call. Struct and array out-parameters require full field/element write-back. - Do not route pointer-identity comparisons through detached clones/copies; identity must survive the boundary. @@ -72,12 +92,14 @@ class FunctionWrapperSignature(dspy.Signature): - Apply the same normalization policy to all operands in the same logical operation. Do not compare a length-decoded string that still contains trailing `\0` against a C-string-decoded operand that was truncated at `\0`. - When both pointer and length are present for string-style data, use length only as a safety bound for reads; derive semantic content from C string termination and stop at the first `\0`. - Preserve NUL termination wherever C expects it; never write past the C-implied capacity. - - When C truncates a buffer by writing `'\0'` at a position found by a search (e.g., `strstr`, `strchr`, or a manual scan), the wrapper must re-derive that same offset from the raw buffer and write the NUL byte explicitly — even if the Rust function has internalized the truncation and does not expose the offset. Use `support_code` to recover the exact search function, offset, and capacity assumptions the C original relied on. + - When C truncates a buffer by writing `'\0'` at a position found by a search (e.g., `strstr`, `strchr`, or a manual scan), the wrapper must re-derive that same offset from the raw buffer and write the NUL byte explicitly, even if the Rust function has internalized the truncation and does not expose the offset. Use `support_code` to recover the exact search function, offset, and capacity assumptions the C original relied on. - Treat in-place buffer mutations as **primary observable effects**. Callers (and tests) assert them directly; omitting them silently fails every assertion on the buffer regardless of the parsed return values. # Panic safety at the ABI boundary - Wrap every call into Rust code that could panic in `std::panic::catch_unwind`. On a caught panic, return the appropriate C error value for the return type (`0`, `false`, `-1`, `null`, ...). Letting a panic cross an `extern "C"` boundary is undefined behavior and aborts the process in practice. Omit `catch_unwind` only when the called Rust code provably cannot panic. + Never use `std::panic::catch_unwind`. An uncaught panic in an `extern "C"` function aborts the process after printing the panic message and source location, which is the correct outcome here: a panic means the Rust port diverges from the C original, and that divergence must reach the tests. Catching it and returning a plausible C value (`0`, `false`, `-1`, `null`, ...) turns a diagnosable abort into silently wrong behavior in the C caller. + + This is not a licence to panic. Reproduce the C behavior for every input the C function accepts, including malformed or out-of-range ones, so the panic never happens in the first place. # Calling libc / system functions @@ -91,34 +113,42 @@ class FunctionWrapperSignature(dspy.Signature): - Do not relax behavior, skip write-backs, or use placeholder/stub logic. - Do not declare libc / POSIX functions in `extern "C"` blocks; call them via the `libc` crate. + - Do not use `std::panic::catch_unwind`. # Inputs and feedback + - `crate`: the hybrid crate this wrapper is written into. Everything shown lives at the crate root, alongside your wrapper. + - `wrapped_crate_code`: the contents of the separate `{wrapped_crate}` crate. Nothing shown there is reachable through `crate::`, so always name those items as `{wrapped_crate}::Item`. - `support_code`: the original C source that was translated to Rust. - `prior_wrapper`: a previous attempt to fix, if any. + - `feedback`: a critique of `prior_wrapper` from running the program it was part of. Address it. - `build_feedback`: errors from `cargo build`. Address them. - `scope_feedback`: deviations from the `example_wrapper` template. Address them. """ # FIXME: Move crate and example_wrapper into instructions? crate: CodeRust = dspy.InputField() + wrapped_crate_code: CodeRust = dspy.InputField() support_code: CodeC = dspy.InputField() example_wrapper: CodeRust = dspy.InputField() prior_wrapper: CodeRust = dspy.InputField() + feedback: str = dspy.InputField() build_feedback: str = dspy.InputField() scope_feedback: str = dspy.InputField() wrapper: CodeRust = dspy.OutputField() -def generate_unimplemented_function_wrapper(path: Path, symbol_name: str) -> CodeRust | None: +def generate_unimplemented_function_wrapper( + path: Path, symbol_name: str, types: CodeRust +) -> CodeRust | None: # unsafe extern "C" { # #[link_name = "\u{1}match"] # pub fn match_( # threshold: f64, # ) -> ::std::os::raw::c_int; # } - bindgen_wrapper = bindgen(path, symbol_name) + binding = bindgen_binding(path, symbol_name, types) # #[unsafe(export_name="match")] # pub extern "C" fn match_( @@ -126,27 +156,28 @@ def generate_unimplemented_function_wrapper(path: Path, symbol_name: str) -> Cod # ) -> ::std::os::raw::c_int { # unimplemented!() # } - unimplemented_wrapper = re.sub( + template = re.sub( r'unsafe extern "C" {\s*.*\s+pub fn (.*);\s+}', rf'#[unsafe(export_name="{symbol_name}")]\npub extern "C" fn \1 {{\n unimplemented!()\n}}', - str(bindgen_wrapper), + str(binding), flags=re.DOTALL, ) - if unimplemented_wrapper == str(bindgen_wrapper): + if template == str(binding): raise ValueError( - f"Failed to convert bindgen output to function for `{symbol_name}`!\nWrapper:\n{unimplemented_wrapper}" + f"Failed to convert bindgen output to function for `{symbol_name}`!\nWrapper:\n{template}" ) # Format unimplemented wrapper using rustfmt - ok, unimplemented_wrapper, error, _ = run_subprocess( - ["rustfmt"], input=unimplemented_wrapper - ) + ok, template, error, _ = run_subprocess(["rustfmt"], input=template) if not ok: raise ValueError(f"rustfmt failed!\n{error}") + wrapper = CodeRust(template) - # Validate the template + # Make sure the wrapper is valid Rust before sending it to the LLM for completion + type_items, value_items = partition_values(types) success, output = check_rust( - unimplemented_wrapper, flags=["--crate-type", "lib", "--emit", "metadata"] + str(type_items + wrap_values(value_items) + wrapper), + flags=["--crate-type=lib", "--emit=metadata"], ) # Rust does not support C-compatible variadic functions on stable Rust (they require the nightly-only `c_variadic` feature): @@ -156,146 +187,426 @@ def generate_unimplemented_function_wrapper(path: Path, symbol_name: str) -> Cod if not success: raise ValueError( - f"Failed to validate wrapper template for `{symbol_name}`!\nWrapper:\n{unimplemented_wrapper}\nError:\n{output}" + f"Failed to validate function wrapper for `{symbol_name}`!\nWrapper:\n{wrapper}\nError:\n{output}" ) - return CodeRust(unimplemented_wrapper) + return wrapper class TypeWrapperSignature(dspy.Signature): """ - Generate `c_to_r` and `r_to_c` conversion functions for `{wrapped_crate}::{type_name}` in a hybrid C/Rust build. + Implement the `CInterop` trait for `{type_name}` in a hybrid C/Rust build. # Goal - Produce a `wrapper` containing two unsafe Rust synchronization functions: - - `c_to_r`: reads from a C-layout allocation (via `*const`) and produces an idiomatic Rust value. Does **not** take ownership of C memory. - - `r_to_c`: writes an idiomatic Rust value back into an **existing** C-layout allocation (via `*mut`). Does **not** allocate a new C value — it writes in-place. + Produce a `wrapper` containing one `impl CInterop for {type_name}` block with two unsafe synchronization methods: + - `to_rust`: reads from a C-layout allocation (via `*const Self`) and produces an idiomatic Rust value. Does **not** take ownership of C memory. + - `sync_to_c`: writes an idiomatic Rust value back into an **existing** C-layout allocation (via `*mut Self`). Does **not** allocate a new C value, but writes in-place. - These functions will be written to "{wrapper_path}" and used as FFI glue between the C-layout types exposed by `bindgen` and the safe Rust types in `{wrapped_crate}`. + This impl is the FFI glue between the C-layout types exposed by `bindgen` and the safe Rust types in `{wrapped_crate}`. - For pointer fields that form a linked structure (e.g. a linked list), `r_to_c` must walk the Rust chain and the existing C chain in parallel: + Below, `NodeCType` and `FieldCType` are **placeholders**, not names to emit. Every C-layout type is declared in `crate`, so read the real name out of `crate` instead of deriving it from the C spelling, because `bindgen` does not always preserve that spelling. + + For pointer fields that form a linked structure (e.g. a linked list), `sync_to_c` must walk the Rust chain and the existing C chain in parallel: - If both sides have a node: write data in-place and recurse. - - If Rust has a node but C does not (Rust function added a node): allocate a new C node via `::libc::malloc(std::mem::size_of::())` — this uses the same allocator as C's `malloc`, so C can safely `free()` it later. Do **not** use `Box::into_raw` for this; `Box` uses Rust's allocator which C cannot `free()`. + - If Rust has a node but C does not (Rust function added a node): allocate a new C node via `::libc::malloc(std::mem::size_of::())`, where `NodeCType` is the C-layout type of the pointed-to node. This uses the same allocator as C's `malloc`, so C can safely `free()` it later. Do **not** use `Box::into_raw` for this; `Box` uses Rust's allocator which C cannot `free()`. - If C has a node but Rust does not (Rust function removed a node): `::libc::free()` the C node and write null. # Template - Use `example_wrapper` as the starting template for `wrapper`. - - The `()` placeholder types in the `c_to_r` and `r_to_c` signatures **must** be replaced with the actual idiomatic Rust type from `{wrapped_crate}::{type_name}`. Keeping `()` is not a valid implementation. + - The **only** signature slot you may edit is the associated type. Replace `type Rust = ();` with the `{wrapped_crate}` type that `{type_name}` was translated to. The translation renames C types to Rust conventions (a C `foo_bar_t` typically becomes `FooBarT`), so read the real name out of `{wrapped_crate}` rather than assuming it is still `{type_name}`. It lives in a different crate, so neither a bare name nor a `crate::`-prefixed path resolves. Keeping `()` is not a valid implementation. + - The `to_rust` and `sync_to_c` signatures are fixed by the trait. Do **not** change their parameter types, return types, or `unsafe` markers: write `Self` and `Self::Rust` exactly as the template does. - Replace the `todo!()` bodies with field-by-field conversion implementations. - - Do **not** add, remove, or rename functions beyond what the template contains. You may add `use` items if needed. + - Do **not** add, remove, or rename items beyond what the template contains. In particular keep the `#[cfg(test)]` module's name exactly as the template spells it, because every wrapper shares the crate root namespace and the test filter keys on that name. You may add `use` items if needed. # Type mapping - Types in `crate::` (bindgen-generated, C-compatible layout) are structurally equivalent to their C originals but are **not** the same types as those in `{wrapped_crate}::` (idiomatic Rust). Use `support_code` (the original C source) and `crate` (the existing Rust translations) to determine the correct field-by-field mapping. + The bindgen-generated C-layout types in `crate` are structurally equivalent to their C originals but are **not** the same types as the idiomatic Rust ones in `{wrapped_crate}`. Use `support_code` (the original C source) and `wrapped_crate_code` (the existing Rust translations) to determine the correct field-by-field mapping. - - For each field in `crate::{type_name}`, locate the corresponding field in `{wrapped_crate}::{type_name}` and convert its value. + - For each field in `{type_name}`, locate the corresponding field in the `{wrapped_crate}` type and convert its value. - Primitive numeric fields (`c_int`, `c_uint`, `c_long`, etc.) map to their Rust integer equivalents (`i32`, `u32`, `i64`, etc.) with an `as` cast. - - `*const c_char` / `*mut c_char` fields that represent strings map to `String` or `Option` in `{wrapped_crate}::` — use `std::ffi::CStr` for the conversion. - - Raw pointer fields (`*mut T`, `*const T`) that are nullable map to `Option<...>` in `{wrapped_crate}::` — use `ptr::NonNull` or a null check. - - Nested struct fields: call the corresponding `c_to_r` / `r_to_c` for that field's type if one exists in `other_wrappers`; otherwise convert field-by-field inline. + - `*const c_char` / `*mut c_char` fields that represent strings usually map to a borrowed `&[u8]` in `{wrapped_crate}`, sometimes to `String`, and either may be wrapped in `Option`. Read the real type out of `{wrapped_crate}` and decode with `std::ffi::CStr` in `to_rust`. Writing one back in `sync_to_c` is only sound when the slice is still the one `to_rust` read, so compare it against the pointer already in the slot and reuse that pointer when they match. Otherwise copy the bytes into a fresh NUL-terminated `::libc::malloc` allocation. A `&[u8]` carries no terminator, so passing its `as_ptr()` to C makes C read past the end. + - Raw pointer fields (`*mut T`, `*const T`) that are nullable map to `Option<...>` in `{wrapped_crate}`, so use `ptr::NonNull` or a null check, but only once the governing field described below shows the pointer was initialized at all. + - Nested struct fields: if the field's C type also implements `CInterop`, call `FieldCType::to_rust(ptr)` / `FieldCType::sync_to_c(&rust_value, ptr)` rather than converting inline; otherwise convert field-by-field inline. Prefer the bare C type name exactly as `crate` declares it (`FieldCType::to_rust(ptr)`) over the equivalent qualified forms `crate::FieldCType::to_rust(ptr)` or `::to_rust(ptr)`. - Array fields: convert element-by-element. + # Fields that govern other fields + + A field being present does not make its contents readable. Before reading an array, buffer, or pointer field, look in `support_code` for a sibling field that decides which of its elements are initialized: an occupancy or tombstone bitmap, a count or length smaller than the allocated capacity, a tag selecting which union member is live, a sentinel index, or a flag marking the field unused. Allocation with `malloc` / `realloc` / `reallocarray` and no following `memset`, as opposed to `calloc`, is direct evidence that the remaining elements hold indeterminate values. + + Consult that governing field **before** loading the element, not after. Reading an uninitialized slot as a pointer is undefined behavior even if you never dereference it, so testing the loaded value for null has already lost. Map elements the governing field marks dead to whatever `{wrapped_crate}` uses for absent, and never pass them to `CStr::from_ptr`, `NonNull`, or a dereference. + # Round-trip correctness - The two functions must satisfy: given a valid C-layout input `cs`, after calling `r_to_c(&c_to_r(&cs), &mut cs)` the fields of `cs` must equal their original values. Pay special attention to: + The two methods must satisfy: given a valid C-layout input `cs`, after calling `{type_name}::sync_to_c(&{type_name}::to_rust(&raw const cs), &raw mut cs)` the fields of `cs` must equal their original values. Pay special attention to: - Fields that are zero-initialized in the C layout but have `Default` values in Rust. - - Pointer fields: `null` must round-trip to `null`. For non-null pointers, the **data** at the pointed address must be preserved; the pointer address itself is preserved naturally because `r_to_c` writes in-place. Do **not** use global state (provenance tables, `OnceLock`, `Mutex`, etc.) to track pointer addresses — that is a design smell indicating the wrong approach. + - Pointer fields: `null` must round-trip to `null`. For non-null pointers, the **data** at the pointed address must be preserved; the pointer address itself is preserved naturally because `sync_to_c` writes in-place. Do **not** use global state (provenance tables, `OnceLock`, `Mutex`, etc.) to track pointer addresses; that is a design smell indicating the wrong approach. - String fields where the C layout stores a pointer (document if a true round-trip is impossible without re-allocation). + Only storage the C code itself reads has to round-trip. Elements a governing field marks dead are not part of the value: do not read them in `to_rust`, and do not write them in `sync_to_c`. Leave that storage exactly as C left it, because overwriting it with null or zero is still a mutation C would never make. + # Hard constraints - - `unsafe` is permitted and expected when crossing the C/Rust boundary (e.g., dereferencing raw pointers, reading C-allocated memory). These functions ARE the FFI bridge — the no-unsafe constraint belongs to the safe Rust translation, not here. - - Do not add `extern "C"` items or FFI exports — these are conversion functions only, not ABI entry points. + - `unsafe` is permitted and expected when crossing the C/Rust boundary (e.g., dereferencing raw pointers, reading C-allocated memory). These methods ARE the FFI bridge, so do not contort the implementation to avoid it. + - Do not add `extern "C"` items or FFI exports, because these are conversion methods only, not ABI entry points. - Do not use placeholder/stub logic or `todo!()` / `unimplemented!()` in the final output. + - Do not use `std::panic::catch_unwind`, neither in the impl nor in the tests, because catching a panic only hides a broken conversion from the round-trip tests that exist to find it. # Round-trip tests - After the conversion functions, fill in the `#[cfg(test)]` skeleton from `example_wrapper` with at least two tests: + After the impl block, fill in the `#[cfg(test)]` skeleton from `example_wrapper` with at least two tests: - - `round_trip_zeroed`: construct the simplest valid C-layout input (all primitive fields 0, all pointers null if null is a valid input). Call `let rs = unsafe {{ c_to_r(&cs) }}; unsafe {{ r_to_c(&rs, &mut cs) }};` and assert each field of `cs` equals its original value with field-by-field `assert_eq!`. If a zeroed instance would cause undefined behavior inside the conversion (e.g. a null pointer would be dereferenced), construct instead the simplest valid input and document why zeroed is unsafe. - - `round_trip_nontrivial`: construct an instance with representative non-zero field values that exercise the real conversion path — non-null pointers (via `Box::leak`, a stack address cast, or a small allocation), non-zero integers, and non-trivial sizes. Call `let rs = unsafe {{ c_to_r(&cs) }}; unsafe {{ r_to_c(&rs, &mut cs) }};` and assert each field of `cs` equals its original value with field-by-field `assert_eq!`. This test is especially important for pointer-bearing types where `round_trip_zeroed` does not exercise the pointer path. - - Tests may use `unsafe`. The `todo!()` in the skeleton is not a valid test body — replace it with a real implementation. + - `round_trip_zeroed`: construct the simplest valid C-layout input (all primitive fields 0, all pointers null if null is a valid input). Call `let rs = unsafe {{ {type_name}::to_rust(&raw const cs) }}; unsafe {{ {type_name}::sync_to_c(&rs, &raw mut cs) }};` and assert each field of `cs` equals its original value with field-by-field `assert_eq!`. If a zeroed instance would cause undefined behavior inside the conversion (e.g. a null pointer would be dereferenced), construct instead the simplest valid input and document why zeroed is unsafe. + - `round_trip_nontrivial`: construct an instance with representative non-zero field values that exercise the real conversion path: non-null pointers (via `Box::leak`, a stack address cast, or a small allocation), non-zero integers, and non-trivial sizes. Convert and assert exactly as above. This test is especially important for pointer-bearing types where `round_trip_zeroed` does not exercise the pointer path. + - If a governing field decides which elements of another field are live, `round_trip_nontrivial` must set it so that at least one element is live and at least one is dead, and must fill the dead element's storage with a value that is invalid to read, such as a dangling non-null pointer or a poison byte pattern, rather than zero or null. Assert that storage is unchanged after the round trip. A fixture in which every element is live proves nothing about how the conversion treats the governing field. + - Tests may use `unsafe`. The `todo!()` in the skeleton is not a valid test body, so replace it with a real implementation. # Inputs and feedback + - `crate`: the hybrid crate this wrapper is written into. Everything shown lives at the crate root, alongside your wrapper. + - `wrapped_crate_code`: the contents of the separate `{wrapped_crate}` crate. Nothing shown there is reachable through `crate::`, so always name those items as `{wrapped_crate}::Item`. - `support_code`: the original C source that was translated to Rust. - `prior_wrapper`: a previous attempt, if any. + - `feedback`: a critique of `prior_wrapper` from running the program it was part of. Address it. - `build_feedback`: errors from `cargo build`. Address them. - `scope_feedback`: deviations from the `example_wrapper` template. Address them. """ crate: CodeRust = dspy.InputField() + wrapped_crate_code: CodeRust = dspy.InputField() support_code: CodeC = dspy.InputField() example_wrapper: CodeRust = dspy.InputField() prior_wrapper: CodeRust = dspy.InputField() + feedback: str = dspy.InputField() build_feedback: str = dspy.InputField() scope_feedback: str = dspy.InputField() wrapper: CodeRust = dspy.OutputField() -def generate_unimplemented_type_wrapper(path: Path, type_name: str) -> CodeRust: - # bindgen matches `--allowlist-item` against the mangled Rust name and emits that - # name in its output, so the template must reference the mangled name rather than - # the raw C spelling (they differ for names colliding with Rust keywords). +def generate_unimplemented_type_wrapper( + type_name: str, types: CodeRust +) -> tuple[CodeRust, str]: + # todo!() is intentional in the template (not unimplemented!()): validate_changes + # keys on unimplemented!() to mark allowed-change nodes, and the LLM must also + # replace the `type Rust` placeholder. todo!() leaves it unconstrained so + # build_feedback from cargo test enforces correctness instead. rust_name = mangle(type_name) - - # Get the C-layout struct definition from bindgen so the LLM sees the exact - # field names and types. The () placeholder marks where the LLM should fill - # in the idiomatic Rust type from the wrapped crate. - # Unlike functions/variables, struct types are already visible to bindgen - # without clang_make_bindable_, so we call bindgen directly. - ok, binding, error, _ = run_subprocess( - [ - "bindgen", - "--disable-header-comment", - "--no-doc-comments", - "--no-layout-tests", - "--sort-semantically", - str(path), - "--allowlist-item", - rust_name, - ] - ) + tests_mod = f"test_{rust_name}" + template = dedent(f"""\ + impl CInterop for {rust_name} {{ + type Rust = (); + + unsafe fn to_rust(cs: *const Self) -> Self::Rust {{ + todo!() + }} + + unsafe fn sync_to_c(rs: &Self::Rust, cs: *mut Self) {{ + todo!() + }} + }} + + #[cfg(test)] + mod {tests_mod} {{ + use super::*; + + #[test] + fn round_trip_zeroed() {{ + todo!() + }} + + #[test] + fn round_trip_nontrivial() {{ + todo!() + }} + }} + """) + ok, template, error, _ = run_subprocess(["rustfmt"], input=template) if not ok: - raise ValueError(f"Bindgen failed for `{type_name}` in '{path}'!\nError:\n{error}") - binding = binding.strip() - if not binding: - raise ValueError(f"Bindgen failed to generate a binding for `{type_name}` in '{path}'!") - - # todo!() is intentional here (not unimplemented!()): validate_changes uses the - # presence of unimplemented!() as a marker for allowed-change nodes. Since the - # LLM must replace the () placeholder *signatures* as well as the bodies, we - # use todo!() so that validate_changes returns no scope constraints, letting - # build_feedback from cargo test enforce correctness instead. - template = ( - f"{binding}\n\n" - f"pub unsafe fn c_to_r(_cs: *const {rust_name}) -> () {{\n todo!()\n}}\n\n" - f"pub unsafe fn r_to_c(_rs: &(), _cs: *mut {rust_name}) {{\n todo!()\n}}\n\n" - f"#[cfg(test)]\nmod tests {{\n use super::*;\n\n" - f" #[test]\n fn round_trip_zeroed() {{\n todo!()\n }}\n\n" - f" #[test]\n fn round_trip_nontrivial() {{\n todo!()\n }}\n}}\n" + raise ValueError(f"rustfmt failed for type wrapper template `{type_name}`!\n{error}") + wrapper = CodeRust(template) + + # Make sure the template is valid Rust before sending it to the LLM for completion + type_items, value_items = partition_values(types) + success, output = check_rust( + str(C_INTEROP_TRAIT + type_items + wrap_values(value_items) + wrapper), + flags=["--crate-type=lib", "--emit=metadata"], ) + if not success: + raise ValueError( + f"Failed to validate type wrapper for `{type_name}`!\nWrapper:\n{wrapper}\nError:\n{output}" + ) + return wrapper, tests_mod + + +class VariableWrapperSignature(dspy.Signature): + """ + Keep the C global `{variable_name}` and its `{wrapped_crate}` counterpart in agreement in a hybrid C/Rust build. + + # Goal + + The same global exists twice: as C-layout storage in `crate` and as an idiomatic Rust global in `{wrapped_crate}`. C code writes the first, translated Rust code writes the second, and they must not drift apart. Fill in every `todo!()` in `example_wrapper`. + + # Reading the two sides + + - The C side is exposed in `crate` as a `static` inside an `unsafe extern "C"` block in the `__c_globals` module, bearing the variable's name. That module is not re-exported, so name it `__c_globals::{rust_variable_name}`. Read it through `unsafe {{ &raw const __c_globals::{rust_variable_name} }}` and, where `crate` declares it `static mut`, write it through `unsafe {{ &raw mut __c_globals::{rust_variable_name} }}`. + - The Rust side lives in `{wrapped_crate}` and must be named as `{wrapped_crate}::ITEM`. The translation renames C identifiers to Rust conventions, so read the real name out of `wrapped_crate_code` rather than assuming it is still `{variable_name}`. + - The Rust global may be wrapped for interior mutability (`Mutex`, `RwLock`, `OnceLock`, `LazyLock`, `RefCell`, ...). Acquire the guard or force the initializer as that type requires, then read or write through it. Never lock the same guard twice in one expression. + # Synchronization functions + + Below, `CType` is a **placeholder**, not a name to emit. Every C-layout type is declared in `crate`, so read the real name out of `crate` instead of deriving it from the C spelling, because `bindgen` does not always preserve that spelling. + + - `sync_{rust_variable_name}_to_rust`: copy the current C value into the `{wrapped_crate}` global, so translated Rust code observes what C last wrote. If the C type implements `CInterop`, use `CType::to_rust(&raw const __c_globals::{rust_variable_name})`; otherwise convert field by field or cast the scalar. + - `sync_{rust_variable_name}_to_c`: copy the `{wrapped_crate}` value back into the C storage, so C code observes what translated Rust last wrote. If the C type implements `CInterop`, use `CType::sync_to_c(&rust_value, &raw mut __c_globals::{rust_variable_name})`, which writes in-place and returns nothing; otherwise write field by field. + + These two must be exact inverses of each other. Anything one direction drops, the other cannot restore. + + Keep them unless the `{wrapped_crate}` global cannot be written, the one case where `sync_{rust_variable_name}_to_rust` could only be a stub. Writing it needs a `static mut`, or interior mutability that accepts repeated updates: a `Mutex`, `RwLock`, `Cell`, `RefCell`, `UnsafeCell` or an `Atomic*`. `OnceLock`, `OnceCell` and `LazyLock` accept a value only once, so they count only when the value they hold supplies the mutability, as `OnceLock>` does and `OnceLock>` does not. Mutability does not spread sideways either: a `Mutex` on one field of a struct says nothing about its siblings. Follow named types into their definitions in `wrapped_crate_code` rather than judging by the spelling of the `static` alone, and never write through an immutable `static` with `mprotect` or a `*mut` cast of `&raw const`; that is undefined behavior whatever the page permissions say. + + Where nothing can write it, delete them all, and `round_trip_nontrivial` with them, leaving `initial_value_matches` as the only test to implement. Delete all of them or none, since other wrappers call them together. + + # Comparing + + Both tests compare in the Rust-to-C direction: project the `{wrapped_crate}` value into C layout and assert it equals the C global. + + - If the C type implements `CInterop`, zero a C-layout temporary, call `CType::sync_to_c(&rust_value, &raw mut tmp)`, and assert the temporary equals the C global field by field. + - Otherwise (scalars, arrays of scalars, and other types with no `CInterop` impl) compare directly, casting with `as` where the integer or float widths differ. + - For pointer-valued globals compare the **pointed-to data**, not the address. The two crates hold separate allocations, so addresses can never match and asserting on them is always wrong. For a `*const c_char` / `*mut c_char` string, decode the C side with `std::ffi::CStr` and compare the contents. + - Arrays compare element by element. + + # Tests + + Each test runs in its own process, so neither global has been touched when it starts and one test cannot disturb the other. Both tests must spell `{wrapped_crate}::ITEM` and `__c_globals::{rust_variable_name}` out in their own bodies rather than importing either global or reaching it through a helper, so that each test visibly reads both sides. + + - `initial_value_matches`: assert that the `{wrapped_crate}` global, projected into C layout, already equals the C global, **without calling any synchronization function first**. This measures whether the translation of `{variable_name}` and its initializer is faithful, so calling `sync_{rust_variable_name}_to_rust` first would copy C's value over the translated one and make the assertion compare C against itself. Such a test passes no matter how wrong the translation is and is worthless. + - `round_trip_nontrivial`: write a representative non-zero value into the C global, then assert twice: + 1. Call `sync_{rust_variable_name}_to_rust()` and assert the `{wrapped_crate}` global now holds that value, projected into C layout as the section above describes. + 2. Call `sync_{rust_variable_name}_to_c()` and assert the C global still holds it. + + Both assertions are required. Without the first, two synchronization functions with empty bodies pass this test, because the value never left the C global and nothing checks that Rust ever saw it. Choose values that exercise the real conversion path: non-zero integers, non-empty strings, populated arrays, non-null pointers. A value that would survive a conversion that drops fields is a wasted test. + + # Hard constraints + + - Do **not** compare raw bytes with `memcmp`, `slice::from_raw_parts`, or a transmute to a byte array. Padding bytes are unspecified and differ between a zeroed temporary and the C global, so a bytewise comparison reports failures that are not real. Compare C-layout struct values field by field with `assert_eq!`; `bindgen` does not derive `PartialEq` for them, so asserting on two whole struct values does not compile. + - The assertions **must** read the value from `{wrapped_crate}`. A test that only reads the C global (directly, or by copying it into a local and comparing it against itself) verifies nothing. + - Do not weaken a comparison to make it pass. If the values genuinely differ, the translation or the synchronization is wrong and the test should fail. + - Do not add or rename items beyond what the template contains, and keep the `#[cfg(test)]` module's name and the synchronization function names exactly as the template spells them, because every wrapper shares the crate root namespace, other wrappers call these functions by name, and the test filter keys on the module name. The only items you may remove are the synchronization functions together with `round_trip_nontrivial`, as described above. You may add `use` items, but not to shorten either global's path inside a test. + - Do not use `std::panic::catch_unwind`, neither in the synchronization functions nor in the tests, because catching a panic only hides broken synchronization from the tests that exist to find it. + - Tests may use `unsafe`. The `todo!()` placeholders are not valid bodies, so replace every one of them with a real implementation. + + # Inputs and feedback + + - `crate`: the hybrid crate this wrapper is written into. Everything shown lives at the crate root, alongside your wrapper. + - `wrapped_crate_code`: the contents of the separate `{wrapped_crate}` crate. Nothing shown there is reachable through `crate::`, so always name those items as `{wrapped_crate}::Item`. + - `support_code`: the original C source that was translated to Rust, including the definition and initializer of `{variable_name}`. + - `prior_wrapper`: a previous attempt, if any. + - `feedback`: a critique of `prior_wrapper` from running the program it was part of. Address it. + - `build_feedback`: errors from `cargo build` and failures from `cargo test`. Address them. + - `scope_feedback`: deviations from the `example_wrapper` template. Address them. + """ + + crate: CodeRust = dspy.InputField() + wrapped_crate_code: CodeRust = dspy.InputField() + support_code: CodeC = dspy.InputField() + example_wrapper: CodeRust = dspy.InputField() + prior_wrapper: CodeRust = dspy.InputField() + feedback: str = dspy.InputField() + build_feedback: str = dspy.InputField() + scope_feedback: str = dspy.InputField() + + wrapper: CodeRust = dspy.OutputField() + + +def generate_unimplemented_variable_wrapper( + variable_name: str, types: CodeRust, binding: CodeRust +) -> tuple[CodeRust, str, tuple[str, ...]]: + rust_name = mangle(variable_name) + tests_mod = f"test_var_{rust_name}" + + # Nothing can write a C global bindgen did not declare `mut`, so there is nothing to sync + is_mutable = f"static mut {rust_name}" in str(binding) + sync_fns = (f"sync_{rust_name}_to_rust", f"sync_{rust_name}_to_c") if is_mutable else () + + # todo!() rather than unimplemented!() because validate_changes keys on unimplemented!() + # to mark allowed-change nodes, and here every body is generated. + sync_src = round_trip_src = "" + if is_mutable: + sync_src = f""" + pub unsafe fn sync_{rust_name}_to_rust() {{ + todo!() + }} + + pub unsafe fn sync_{rust_name}_to_c() {{ + todo!() + }} + """ + round_trip_src = """ + #[test] + fn round_trip_nontrivial() { + todo!() + } + """ + + template = f""" + {sync_src} + + #[cfg(test)] + mod {tests_mod} {{ + use super::*; + + #[test] + fn initial_value_matches() {{ + todo!() + }} + + {round_trip_src} + }} + """.strip() ok, template, error, _ = run_subprocess(["rustfmt"], input=template) if not ok: - raise ValueError(f"rustfmt failed for type wrapper template `{type_name}`!\n{error}") + raise ValueError( + f"rustfmt failed for variable wrapper template `{variable_name}`!\n{error}" + ) + wrapper = CodeRust(template) - success, output = check_rust(template, flags=["--crate-type", "lib", "--emit", "metadata"]) + # Make sure the template is valid Rust before sending it to the LLM for completion + type_items, value_items = partition_values(types) + success, output = check_rust( + str(C_INTEROP_TRAIT + type_items + wrap_values(value_items) + wrapper), + flags=["--crate-type=lib", "--emit=metadata"], + ) if not success: raise ValueError( - f"Failed to validate type wrapper template for `{type_name}`!\nTemplate:\n{template}\nError:\n{output}" + f"Failed to validate variable wrapper for `{variable_name}`!\nWrapper:\n{wrapper}\nError:\n{output}" + ) + return wrapper, tests_mod, sync_fns + + +def scope_errors(wrapper: CodeRust, template: CodeRust) -> list[str]: + # Deviations from `template` that still compile, so no build and no test would catch them + errors = list(validate_changes(wrapper, template).values()) + # An untouched body is indistinguishable from the template, so `validate_changes` sees + # nothing wrong with it; this is also what a prediction with no wrapper at all falls back to + if is_unimplemented(wrapper): + errors.append( + "The wrapper still has an `unimplemented!()` body. You must respect the " + "template and instructions **exactly** and replace that body with a real " + "implementation." + ) + if uses_catch_unwind(wrapper): + errors.append( + "The wrapper must not use `std::panic::catch_unwind`. An uncaught " + 'panic in an `extern "C"` function aborts the process after printing the ' + "panic message and source location, and that is the outcome we want: a " + "panic means the Rust port diverges from the C original. Catching it and " + "returning a plausible C value hides the divergence from the tests. " + "Remove the `catch_unwind` and let the Rust call's value flow out " + "directly." + ) + return errors + + +@dataclass(kw_only=True) +class WrapperAttempt(CodeAttempt): + wrapper: CodeRust + symbol: Symbol + bindgen_template: CodeRust + + @property + def summary(self) -> str: + verb = "Wrapped" if self.success else "Failed to wrap" + return f"{verb} `{self.symbol.name}`" + + +@dataclass +class WrapperSession(PredictSession[WrapperAttempt, CodeRust]): + generator: "WrapperGenerator" + symbol: Symbol + wrapped_crate_code: CodeRust + translation: CodeRust + unimplemented_wrapper: CodeRust + wrapped_crate: str + other_wrappers: CodeRust = CodeRust() + prior_wrapper: CodeRust | None = None + # Why an earlier session for this symbol was rejected, since `prior_wrapper` carries the + # code across sessions but not the reason + feedback: Feedback = Feedback() + support_code: CodeC | None = None + _predictor: dspy.Module = field(init=False, repr=False) + + def __post_init__(self): + self._predictor = self.generator._wrapper(self._signature()) + + def _signature(self) -> type[dspy.Signature]: + # Dynamically select signature based on the symbol kind + symbol = self.symbol + if symbol.is_type: + sig_cls = TypeWrapperSignature + elif symbol.is_function: + sig_cls = FunctionWrapperSignature + elif symbol.is_variable: + sig_cls = VariableWrapperSignature + else: + raise ValueError( + f"WrapperGenerator only supports function, type and variable symbols, " + f"got `{symbol.kind}` for `{symbol.name}`" + ) + return sig_cls.with_instructions( + sig_cls.instructions.format( + symbol_name=symbol.spelling, + type_name=symbol.bindgen_name or symbol.spelling, + variable_name=symbol.spelling, + rust_variable_name=mangle(symbol.spelling), + wrapped_crate=self.wrapped_crate, + ) ) - return CodeRust(template) + @property + def _max_iters(self) -> int: + return max(self.generator.max_iters, 1) -def _default_feedback_fn(wrapper: CodeRust) -> str: - return "" + def _prime(self) -> CodeRust | None: + # Use cache when no prior wrapper + if self.prior_wrapper is not None: + logger.info("Ignoring wrapper cache...") + return None + return _read_cache( + self.generator.cache, self.symbol.spelling, self.unimplemented_wrapper + ) + def _attempt(self, prior: WrapperAttempt | None, replay: CodeRust | None) -> WrapperAttempt: + if prior is None: + feedback, prior_wrapper = self.feedback, self.prior_wrapper + else: + feedback, prior_wrapper = prior.next_feedback, prior.wrapper + pred = self.generator.generate( + self._predictor, + crate=self.other_wrappers, + wrapped_crate_code=self.wrapped_crate_code + self.translation, + support_code=self.support_code, + example_wrapper=self.unimplemented_wrapper, + prior_wrapper=prior_wrapper, + feedback=feedback.review, + build_feedback=feedback.build, + scope_feedback=feedback.scope, + wrapper=replay, + symbol=self.symbol, + wrapped_crate=self.wrapped_crate, + ) -def _default_on_attempt(msg: str, pred: dspy.Prediction) -> None: - pass + if "wrapper" not in pred or not isinstance(pred.wrapper, CodeRust): + wrapper = self.unimplemented_wrapper + else: + wrapper = pred.wrapper + + return WrapperAttempt( + wrapper=wrapper, + pred=pred, + prior_feedback=feedback, + symbol=self.symbol, + bindgen_template=self.unimplemented_wrapper, + ) class WrapperGenerator(dspy.Module): @@ -310,225 +621,315 @@ def __init__( self.max_iters = max_iters self.cache = _init_cache(cache) - def forward( + def session( self, symbol: Symbol, - crate_code: CodeRust, + wrapped_crate_code: CodeRust, translation: CodeRust, unimplemented_wrapper: CodeRust, - wrapper_path: Path, wrapped_crate: str, other_wrappers: CodeRust = CodeRust(), prior_wrapper: CodeRust | None = None, + feedback: Feedback = Feedback(), support_code: CodeC | None = None, - feedback_fn: Callable[[CodeRust], str] | None = None, - on_attempt: Callable[[str, dspy.Prediction], None] | None = None, - ) -> dspy.Prediction: - if feedback_fn is None: - feedback_fn = _default_feedback_fn - if on_attempt is None: - on_attempt = _default_on_attempt - - # Dynamically select signature based on the symbol kind - if symbol.is_type: - sig_cls = TypeWrapperSignature - elif symbol.is_function: - sig_cls = FunctionWrapperSignature - else: - raise ValueError( - f"WrapperGenerator only supports function and type symbols, " - f"got `{symbol.kind}` for `{symbol.name}`" - ) - - logger.info(f"Generating wrapper for `{symbol.name}` ...") - - # Use cache when no prior wrapper - if prior_wrapper is None: - wrapper = _read_cache(self.cache, symbol.spelling, unimplemented_wrapper) - else: - logger.info("Ignoring wrapper cache...") - wrapper = None - - # Generate dynamic signature and module for symbol - signature = sig_cls.with_instructions( - sig_cls.instructions.format( - symbol_name=symbol.spelling, - type_name=symbol.spelling, - wrapped_crate=wrapped_crate, - wrapper_path=wrapper_path, - ) + ) -> WrapperSession: + return WrapperSession( + generator=self, + symbol=symbol, + wrapped_crate_code=wrapped_crate_code, + translation=translation, + unimplemented_wrapper=unimplemented_wrapper, + wrapped_crate=wrapped_crate, + other_wrappers=other_wrappers, + prior_wrapper=prior_wrapper, + feedback=feedback, + support_code=support_code, ) - generate_wrapper = self._wrapper(signature) - - # Construct crate context for generate_wrapper - crate = crate_code + translation + other_wrappers - - pred = dspy.Prediction(build_feedback="", scope_feedback="") - for i in range(max(self.max_iters, 1)): - # Use the wrapper from the prior iteration as feedback for the next iteration - if i > 0: - prior_wrapper = wrapper - - try: - pred = self.generate( - generate_wrapper, - crate, - support_code, - unimplemented_wrapper, - prior_wrapper, - pred.build_feedback, - pred.scope_feedback, - wrapper if i == 0 else None, - ) - except AdapterParseError: - logger.exception( - f"DSPy exception while generating wrapper for `{symbol.name}` on iteration {i + 1}/{self.max_iters}!" - ) - # If this is the last iteration, raise - if i == max(self.max_iters, 1) - 1: - raise - # Otherwise attempt again before any build logic - continue - - # Scope validation - if "wrapper" not in pred or not isinstance(pred.wrapper, CodeRust): - wrapper = unimplemented_wrapper - scope_feedback = "No wrapper was generated. You must respect the template and instructions **exactly**!" - else: - wrapper = pred.wrapper - # Validate that changes are in scope - scope_feedback = "\n\n".join( - validate_changes(wrapper, unimplemented_wrapper).values() - ) - - pred.name = symbol.spelling - pred.bindgen_template = unimplemented_wrapper - pred.prior_wrapper = prior_wrapper or CodeRust() - pred.wrapper = wrapper - pred.scope_feedback = scope_feedback - pred.build_feedback = feedback_fn(wrapper) - pred.success = not pred.scope_feedback and not pred.build_feedback - - if pred.success: - msg = f"Wrapped `{symbol.name}`: {format_usage(pred)}" - logger.info(msg) - on_attempt(msg, pred) - break - else: - msg = f"Failed to wrap `{symbol.name}` ({i + 1}/{self.max_iters}): {format_usage(pred)}" - logger.error(msg) - on_attempt(msg, pred) - return pred def generate( self, generate_wrapper: dspy.Module, crate: CodeRust, + wrapped_crate_code: CodeRust, support_code: CodeC | None, example_wrapper: CodeRust, prior_wrapper: CodeRust | None, + feedback: str, build_feedback: str, scope_feedback: str, wrapper: CodeRust | None, + symbol: Symbol, + wrapped_crate: str, ) -> dspy.Prediction: - parent_usage_tracker = settings.usage_tracker - if wrapper is not None: - pred = dspy.Prediction(wrapper=wrapper) - if parent_usage_tracker is not None: - pred.set_lm_usage({}) - else: - if parent_usage_tracker is None: - pred = generate_wrapper( - crate=crate, - support_code=support_code or CodeC(), - example_wrapper=example_wrapper, - prior_wrapper=prior_wrapper or CodeRust(), - build_feedback=build_feedback, - scope_feedback=scope_feedback, - ) - else: - with track_usage() as local_usage_tracker: - pred = generate_wrapper( - crate=crate, - support_code=support_code or CodeC(), - example_wrapper=example_wrapper, - prior_wrapper=prior_wrapper or CodeRust(), - build_feedback=build_feedback, - scope_feedback=scope_feedback, - ) - lm_usage = local_usage_tracker.get_total_tokens() - pred.set_lm_usage(lm_usage) - for lm_name, usage_entry in lm_usage.items(): - parent_usage_tracker.add_usage(lm_name, usage_entry) - return pred - - def write_cache(self, pred: dspy.Prediction) -> None: - # If prediction was not generated by an LM then don't write it to cache - if not pred.get_lm_usage(): - return - - required_fields = ( - "name", - "bindgen_template", - "prior_wrapper", - "build_feedback", - "scope_feedback", - "wrapper", - "success", - ) - if not all(hasattr(pred, field) for field in required_fields): - return - - _write_cache( - self.cache, - pred.name, - pred.bindgen_template, - pred.prior_wrapper, - pred.build_feedback, - pred.scope_feedback, - pred.wrapper, - pred.success, + inputs = { + "crate": crate, + "wrapped_crate_code": wrapped_crate_code, + "support_code": support_code or CodeC(), + "example_wrapper": example_wrapper, + "prior_wrapper": prior_wrapper or CodeRust(), + "feedback": feedback, + "build_feedback": build_feedback, + "scope_feedback": scope_feedback, + } + return predict(generate_wrapper, inputs, "wrapper", wrapper) + + def write_cache(self, attempt: WrapperAttempt) -> None: + # Re-caching a cache hit would just duplicate the entry it came from + if attempt.success and attempt.pred.get("was_generated", False): + _write_cache( + self.cache, attempt.symbol.spelling, attempt.bindgen_template, attempt.wrapper + ) + + +def _run_bindgen(path: Path, what: str, *flags: str) -> CodeRust: + ok, out, error, _ = run_subprocess( + [ + "bindgen", + "--disable-header-comment", + "--no-doc-comments", + "--no-layout-tests", + "--sort-semantically", + # Make glibc internals behind `FILE`, `pthread_*_t` and `locale_t` opaque + # since they're not usable beyond the FFI boundary. + *("--opaque-type", "_IO_.*"), + *("--opaque-type", "__pthread_.*"), + *("--opaque-type", "__locale_struct"), + *flags, + str(path), + ] + ) + if not ok: + raise ValueError(f"Bindgen failed for {what} in '{path}'!\nError:\n{error}") + return CodeRust(out.strip()) + + +def bindgen_types(path: Path, symbol_names: Iterable[str]) -> CodeRust: + types = _run_bindgen( + path, + "the crate's types", + # Every symbol in one run, so bindgen assigns anonymous-type names once for the + # whole crate instead of restarting the counter per symbol + *(f for n in symbol_names for f in ("--allowlist-item", mangle(n))), + # The allowlist above reaches types through the symbols referencing them, so + # dropping every var and function leaves exactly the types behind. + *("--blocklist-var", ".*"), + *("--blocklist-function", ".*"), + ) + type_items, value_items = partition_values(types) + success, output = check_rust( + str(type_items + wrap_values(value_items)), + flags=["--crate-type=lib", "--emit=metadata"], + ) + if not success: + raise ValueError( + f"Failed to validate types in '{path}'!\nTypes:\n{types}\nError:\n{output}" ) + return types + + +VALUES_MODULE = "__c_globals" + + +def partition_values(code: CodeRust) -> tuple[CodeRust, CodeRust]: + # Separate the value namespace (bindgen's consts, the C globals' externs) from the types, + # so only the latter reach the crate root + types: list[str] = [] + values: list[str] = [] + pending: list[str] = [] + for node in get_nodes(get_root(str(code))): + # Attributes are siblings of the item they decorate, so they wait for it + if node.type == "attribute_item": + pending.append(get_node_text(node)) + continue + bucket = values if node.type in ("const_item", "static_item") else types + bucket.extend([*pending, get_node_text(node)]) + pending.clear() + types.extend(pending) + + parts = (CodeRust("\n".join(types)), CodeRust("\n".join(values))) + kept = sorted(line for part in parts for line in _significant_lines(part)) + if kept != sorted(_significant_lines(code)): + raise ValueError(f"Partitioning dropped or duplicated items!\nCode:\n{code}") + return parts + + +def wrap_values(*parts: CodeRust, module: str = VALUES_MODULE) -> CodeRust: + # A crate-root `static` or `const` is not shadowable: every same-named binding elsewhere in + # the crate becomes E0530/E0005, be it a wrapper parameter, one of bindgen's own bitfield + # accessor parameters, or a `let` the model writes. A module with no re-export keeps the + # crate-root value namespace empty, so nothing can collide. `super` rather than `crate` + # because lib.rs is also compiled as `mod hybrid` inside main.rs, where `crate` is the bin. + body = CodeRust.join([part for part in parts if str(part).strip()]) + if not str(body).strip(): + return CodeRust() + return CodeRust( + f"pub mod {module} {{\n" + " #[allow(unused_imports)]\n" + " use super::*;\n\n" + f"{indent(str(body).rstrip(), ' ')}\n}}" + ) + +# Bucket for items that belong to no single type, always kept in every slice. Not spellable +# as a Rust identifier, so no name read out of bindgen's output can collide with it. +TYPES_PREAMBLE = BindgenName("") + + +def _item_key(node: Node) -> BindgenName | None: + # A const or impl belongs to the type it names, e.g. `pub const tag_t_A: tag_t = 0;` + # joins `tag_t` and `impl __BindgenBitfieldUnit` joins `__BindgenBitfieldUnit` + if node.type not in ("const_item", "impl_item"): + name = get_node_text(node.child_by_field_name("name")) + else: + target = node.child_by_field_name("type") + while target is not None and target.type == "generic_type": + target = target.child_by_field_name("type") + name = get_node_text(target) + return BindgenName(name) if name else None + + +def split_types(types: CodeRust) -> dict[BindgenName, CodeRust]: + # Index bindgen's type output by item name so prompts can show a subset of it + entries = [] + pending: list[str] = [] + for node in get_nodes(get_root(str(types))): + # Attributes are siblings of the item they decorate, so they wait for it + if node.type == "attribute_item": + pending.append(get_node_text(node)) + continue + entries.append((node, [*pending, get_node_text(node)])) + pending.clear() + + declared = { + get_node_text(n.child_by_field_name("name")) + for n, _ in entries + if n.type in ("struct_item", "union_item", "enum_item", "type_item") + } + items: dict[BindgenName, list[str]] = {TYPES_PREAMBLE: pending} + for node, text in entries: + name = _item_key(node) + key = name if name is not None and name in declared else TYPES_PREAMBLE + items.setdefault(key, []).extend(text) + + split = {name: CodeRust("\n".join(lines)) for name, lines in items.items() if lines} + # Buckets reorder items but must never drop or duplicate one + kept = sorted(line for code in split.values() for line in _significant_lines(code)) + if kept != sorted(_significant_lines(types)): + raise ValueError(f"Splitting dropped or duplicated type items!\nTypes:\n{types}") + return split + + +def _significant_lines(code: CodeRust) -> list[str]: + # `CodeRust.join` inserts blank lines between parts, so compare only the content + return [line for line in str(code).splitlines() if line.strip()] + + +def split_items( + code: CodeRust, +) -> tuple[dict[BindgenName, CodeRust], dict[BindgenName, CodeRust]]: + # Split before partitioning so the two maps share a key: `_item_key` already files a + # const under the type it names, e.g. `pub const tag_t_A: tag_t` under `tag_t` + parts = {name: partition_values(part) for name, part in split_types(code).items()} + return ( + {n: t for n, (t, _) in parts.items() if str(t).strip()}, + {n: v for n, (_, v) in parts.items() if str(v).strip()}, + ) + + +def bindgen_binding(path: Path, symbol_name: str, types: CodeRust) -> CodeRust: + return bindgen_bindings(path, [symbol_name], types)[symbol_name] + + +def bindgen_bindings( + path: Path, symbol_names: Iterable[str], types: CodeRust +) -> dict[str, CodeRust]: + names = list(dict.fromkeys(symbol_names)) + if not names: + return {} -def bindgen(path: Path, symbol_name: str) -> CodeRust: orig_src = path.read_bytes() try: - # We want bindgen to run against an in-place extern'd declaration so it emits - # a linkable item (`pub fn` / `pub static mut`) instead of value-style - # constants for initialized globals, which we can't link against from Rust. - clang_make_bindable_(path, symbol_name) + # An initialized global otherwise comes back as `pub const foo: c_int = 7`, a value + # with no linkage. Extern'ing the declaration in place yields a linkable + # `pub static mut` instead; functions already emit `pub fn` either way. + clang_make_bindable_(path, names) # unsafe extern "C" { # pub static mut foo: ::std::os::raw::c_int; # } - ok, binding, error, _ = run_subprocess( - [ - "bindgen", - "--disable-header-comment", - "--no-doc-comments", - "--no-layout-tests", - "--sort-semantically", - str(path), - "--allowlist-item", - mangle(symbol_name), - ] + bindings = _run_bindgen( + path, + ", ".join(f"`{name}`" for name in names), + # A binding is only ever a variable or a function, never a type + *(f for name in names for f in ("--allowlist-var", mangle(name))), + *(f for name in names for f in ("--allowlist-function", mangle(name))), + # Every type the binding references is already defined in `types` + "--no-recursive-allowlist", ) finally: path.write_bytes(orig_src) - if not ok: - raise ValueError(f"Bindgen failed for `{symbol_name}` in '{path}'!\nError:\n{error}") - binding = binding.strip() - if binding == "": - raise ValueError(f"Bindgen generated an empty binding for `{symbol_name}` in '{path}'!") + split = _split_bindings(bindings, names) + if missing := [name for name in names if name not in split]: + raise ValueError( + f"Bindgen generated no binding for {', '.join(f'`{n}`' for n in missing)} " + f"in '{path}'!" + ) - success, output = check_rust(binding, flags=["--crate-type", "lib", "--emit", "metadata"]) + # Ensure the bindings compile with the given `types` + type_items, value_items = partition_values(types) + success, output = check_rust( + str(type_items + wrap_values(value_items, bindings)), + flags=["--crate-type=lib", "--emit=metadata"], + ) if not success: raise ValueError( - f"Failed to validate binding for `{symbol_name}` in '{path}'!\nWrapper:\n{binding}\nError:\n{output}" + f"Failed to validate bindings for {', '.join(f'`{n}`' for n in names)} in " + f"'{path}'!\nBindings:\n{bindings}\nError:\n{output}" ) - return CodeRust(binding) + return split + + +def _split_bindings(bindings: CodeRust, symbol_names: Iterable[str]) -> dict[str, CodeRust]: + # bindgen names items after mangling, so map back to the C spelling the caller asked for + by_rust_name = {mangle(name): name for name in symbol_names} + source = str(bindings).encode() + + items: dict[str, list[str]] = {} + pending: list[str] = [] + for node in get_nodes(get_root(source)): + # Attributes are siblings of the item they decorate, so they wait for it + if node.type == "attribute_item": + pending.append(get_node_text(node)) + continue + for rust_name, text in _binding_items(node, source): + if (name := by_rust_name.get(rust_name)) is None: + raise ValueError(f"Bindgen emitted unrequested item `{rust_name}`!\n{bindings}") + items.setdefault(name, []).extend([*pending, text]) + pending.clear() + + return {name: CodeRust("\n".join(lines)) for name, lines in items.items() if lines} + + +def _binding_items(node: Node, source: bytes) -> Iterable[tuple[str, str]]: + body = node.child_by_field_name("body") if node.type == "foreign_mod_item" else None + if body is None: + yield get_node_text(node.child_by_field_name("name")), get_node_text(node) + return + + inner = [n for n in get_nodes(body) if n.type not in ("{", "}")] + if len(inner) == 1: + # The common case: bindgen already gave this item a block of its own + yield get_node_text(inner[0].child_by_field_name("name")), get_node_text(node) + return + + # Re-wrap each item so one shared block never lands whole in two symbols' bindings + header = source[node.start_byte : body.children[0].end_byte].decode() + for item in inner: + name = get_node_text(item.child_by_field_name("name")) + # Only the item's first line lost its indentation; the rest keep the source's + pad = " " * item.start_point.column + yield name, f"{header}\n{pad}{get_node_text(item)}\n}}" def _init_cache(cache: Path | None) -> Path | None: @@ -549,10 +950,6 @@ def _init_cache(cache: Path | None) -> Path | None: ) """ ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_wrapper_lookup" - " ON wrapper_translations (name, bindgen_template)" - ) return cache @@ -584,28 +981,15 @@ def _write_cache( cache: Path | None, name: str, bindgen_template: CodeRust, - prior_wrapper: CodeRust, - build_feedback: str, - scope_feedback: str, wrapper: CodeRust, - success: bool, ) -> None: if cache is None: return with sqlite3.connect(cache) as conn: conn.execute( - """ - INSERT INTO wrapper_translations - (name, bindgen_template, prior_wrapper, build_feedback, scope_feedback, wrapper, success) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - str(bindgen_template), - str(prior_wrapper), - build_feedback, - scope_feedback, - str(wrapper), - int(success), - ), + "INSERT INTO wrapper_translations" + " (name, bindgen_template, prior_wrapper, build_feedback, scope_feedback," + " wrapper, success)" + " VALUES (?, ?, '', '', '', ?, 1)", + (name, str(bindgen_template), str(wrapper)), ) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 5fc1668..51f58ae 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -5,6 +5,7 @@ # import os +import re import subprocess from pathlib import Path @@ -20,13 +21,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2] IDEAS_MK = REPO_ROOT / "IDEAS.mk" TRANSLATION_DIR = "translation.test" +TEST_CRATES_DIR = "test_crates" CRATE = "driver" BUILD_TARGET = "bear" INIT_TARGET = "init" +TRANSLATION_TEST = "smoke" BUILD_ENV = {**os.environ, "CARGO_NET_OFFLINE": "true", "RUSTFLAGS": "-Awarnings"} -MAKE_ENV = {**os.environ, "UV_PROJECT": str(REPO_ROOT)} +MAKE_ENV = {**os.environ, "IDEAS_DOCKER_IMAGE": "", "UV_PROJECT": str(REPO_ROOT)} _MINI_H = """\ #ifndef MINI_H @@ -78,6 +81,13 @@ target_include_directories({crate} PUBLIC ${{CMAKE_CURRENT_SOURCE_DIR}}/include) """ +_SMOKE_TEST = """\ +#[test] +fn smoke() { + assert_eq!(1, 1); +} +""" + _TRANSLATION = { "add": "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", "sub": "pub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n", @@ -92,38 +102,49 @@ def get_crate_name(template: str) -> str: @pytest.fixture def instrumented_workspace(tmp_path): """ - cmake -> init -> tests/smoke.rs + test_crates/smoke.rs -> cmake -> init """ - def _factory(template: str) -> Path: + def _factory(template: str, header: str | None = None, source: str | None = None) -> Path: instrumented = tmp_path / template instrumented.mkdir() - _write_project(instrumented, template) + _write_project(instrumented, template, header, source) + test = ( + instrumented + / TEST_CRATES_DIR + / get_crate_name(template) + / "tests" + / f"{TRANSLATION_TEST}.rs" + ) + test.parent.mkdir(parents=True) + test.write_text(_SMOKE_TEST) _make(instrumented, BUILD_TARGET) _make(instrumented, INIT_TARGET) - _make( - instrumented, - f"{TRANSLATION_DIR}/{get_crate_name(template)}-sys/tests/smoke.rs", - ) return instrumented / TRANSLATION_DIR return _factory -def _write_project(instrumented: Path, template: str) -> None: - test_case = instrumented / "test_case" - (test_case / "src").mkdir(parents=True, exist_ok=True) - (test_case / "include").mkdir(parents=True, exist_ok=True) - (test_case / "include" / "mini.h").write_text(_MINI_H) +def _write_project( + instrumented: Path, + template: str, + header: str | None = None, + source: str | None = None, +) -> None: if template == "lib": - (test_case / "src" / "mini.c").write_text(_MINI_C_LIB) - (test_case / "CMakeLists.txt").write_text(_CMAKE_LIB.format(crate=CRATE)) + cmake, default_source = _CMAKE_LIB, _MINI_C_LIB elif template == "bin": - (test_case / "src" / "mini.c").write_text(_MINI_C_BIN) - (test_case / "CMakeLists.txt").write_text(_CMAKE_BIN.format(crate=CRATE)) + cmake, default_source = _CMAKE_BIN, _MINI_C_BIN else: raise ValueError(template) + test_case = instrumented / "test_case" + (test_case / "src").mkdir(parents=True, exist_ok=True) + (test_case / "include").mkdir(parents=True, exist_ok=True) + (test_case / "include" / "mini.h").write_text(header or _MINI_H) + (test_case / "src" / "mini.c").write_text(source or default_source) + (test_case / "CMakeLists.txt").write_text(cmake.format(crate=CRATE)) + def _make(instrumented: Path, goal: str) -> None: cmd = [ @@ -157,14 +178,19 @@ def build_ok(workspace: Path, crate: str, *extra: str) -> bool: return subprocess.run(cmd, env=BUILD_ENV, capture_output=True, text=True).returncode == 0 +def field_value(content: str, field: str) -> str: + """Slice one rendered `[[ ## ## ]]` block out of a prompt.""" + _, marker, rest = content.partition(f"[[ ## {field} ## ]]") + if not marker: + return "" + return rest.partition("[[ ## ")[0] + + def _snippet_field(content: str) -> str: # DummyLM keys match anywhere in the prompt, but `dependent_code` also carries the # C code of sibling symbols, so every translation prompt contains every definition. # Narrow matching to the `snippet` field so each key selects exactly one symbol. - _, marker, rest = content.partition("[[ ## snippet ## ]]") - if not marker: - return content - return rest.partition("[[ ## ")[0] + return field_value(content, "snippet") or content class _SnippetKeyedLM(DummyLM): @@ -207,12 +233,59 @@ def error_lm() -> DummyLM: ) -def make_config(workspace: Path, template: str): +_SYMBOLS = {"return a + b;": "add", "return a - b;": "sub", "int main": "main"} + +_WRAPPER = """\ +#[unsafe(export_name = "{name}")] +pub extern "C" fn {name}(a: ::std::os::raw::c_int, b: ::std::os::raw::c_int) -> ::std::os::raw::c_int {{ + {rs_crate}::{name}(a, b) +}} +""" + +_EXPORT_NAME = re.compile(r'export_name = "(?P\w+)"') + + +class _ScriptedLM(DummyLM): + # Answers both translation and wrapper prompts so a run reaches the export table + def __init__(self, template: str, fail: frozenset[str]): + super().__init__({}, adapter=adapters.ChatAdapter()) + self._rs_crate = f"{get_crate_name(template)}_rs" + self._fail = fail + + def __call__(self, prompt=None, messages=None, **kwargs): + content = messages[-1]["content"] if messages else (prompt or "") + if example := field_value(content, "example_wrapper").strip(): + match = _EXPORT_NAME.search(example) + assert match is not None, f"unrecognized wrapper template:\n{example}" + answer = { + "reasoning": "trivial wrapper", + "wrapper": f"```rust\n{_WRAPPER.format(name=match['name'], rs_crate=self._rs_crate)}```", + } + else: + snippet = _snippet_field(content) + hits = [name for key, name in _SYMBOLS.items() if key in snippet] + assert len(hits) == 1, f"expected exactly one symbol in:\n{snippet}" + answer = ( + {"unexpected_field": "not a translation"} + if hits[0] in self._fail + else {"reasoning": "trivial translation", "translation": _TRANSLATION[hits[0]]} + ) + self.answers = {"": answer} + return super().__call__(prompt=prompt, messages=messages, **kwargs) + + +def wrapping_lm(template: str, *, fail: frozenset[str] = frozenset()) -> DummyLM: + return _ScriptedLM(template, fail) + + +def make_config(workspace: Path, template: str, **overrides): crate_name = get_crate_name(template) - return TranslateConfig( + defaults = dict( cargo_toml=workspace / crate_name / "Cargo.toml", bindings_cargo_toml=workspace / f"{crate_name}-sys" / "Cargo.toml", - tests="smoke", + tests=( + workspace.parent / TEST_CRATES_DIR / crate_name / "tests" / f"{TRANSLATION_TEST}.rs" + ), template=template, translator="ChainOfThought", translator_max_iters=1, @@ -221,6 +294,7 @@ def make_config(workspace: Path, template: str): max_iters=1, vcs="none", ) + return TranslateConfig(**{**defaults, **overrides}) def install_mock(monkeypatch, workspace: Path, lm: dspy.LM, template: str) -> None: @@ -247,6 +321,8 @@ def get(): monkeypatch.setattr(translate_mod, "HydraConfig", _HydraConfig) -def run_translate(monkeypatch, workspace: Path, template: str, lm: dspy.LM) -> None: +def run_translate( + monkeypatch, workspace: Path, template: str, lm: dspy.LM, **overrides +) -> None: install_mock(monkeypatch, workspace, lm, template) - translate_mod._main(make_config(workspace, template)) + translate_mod._main(make_config(workspace, template, **overrides)) diff --git a/test/integration/test_final.py b/test/integration/test_final.py index 33c0ce3..d5cabc0 100644 --- a/test/integration/test_final.py +++ b/test/integration/test_final.py @@ -8,11 +8,38 @@ The two terminal states of a translation run: * exit after a whole translation with no irrecoverable error, and -* exit after an irrecoverable DSPy error (the hybrid crate is stubbed out). +* exit after an irrecoverable DSPy error (the C it never ported is stranded). """ +import subprocess +from pathlib import Path + import pytest -from conftest import build_ok, error_lm, get_crate_name, run_translate, success_lm +from conftest import ( + build_ok, + error_lm, + get_crate_name, + run_translate, + success_lm, + wrapping_lm, +) + + +def _dynamic_exports(workspace: Path, crate_name: str) -> set[str]: + cdylib = workspace / "target" / "debug" / f"{crate_name}.so" + proc = subprocess.run( + ["nm", "--dynamic", "--defined-only", str(cdylib)], + capture_output=True, + text=True, + check=True, + ) + return {parts[-1] for line in proc.stdout.splitlines() if len(parts := line.split()) > 1} + + +def _run_hybrid_bin(workspace: Path, crate_name: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(workspace / "target" / "debug" / crate_name)], capture_output=True, text=True + ) @pytest.mark.parametrize("template", ["lib", "bin"]) @@ -48,16 +75,56 @@ def test_crates_buildable_on_exit_after_error(instrumented_workspace, monkeypatc run_translate(monkeypatch, ws, template, error_lm()) crate_name = get_crate_name(template) + assert build_ok(ws, crate_name) + assert build_ok(ws, f"{crate_name}-rs") if template == "bin": - # Any error leaves the hybrid crate without a buildable binary - # because the app overwrites `main.rs` with an empty file - assert not build_ok(ws, crate_name) + # C no longer has an entrypoint to reach, so the binary refuses to run + assert "was never translated" in _run_hybrid_bin(ws, crate_name).stderr + # and the -sys reference binary (`#![no_main]`) can no longer link + assert not build_ok(ws, f"{crate_name}-sys") else: - assert build_ok(ws, crate_name) - assert build_ok(ws, f"{crate_name}-rs") - assert build_ok(ws, f"{crate_name}-sys") - assert build_ok(ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_ubsan") - assert build_ok(ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_asan") - assert build_ok( - ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_coverage" + assert build_ok(ws, f"{crate_name}-sys") + assert build_ok( + ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_ubsan" + ) + assert build_ok( + ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_asan" + ) + assert build_ok( + ws, f"{crate_name}-sys", "--no-default-features", "--features", "cc_coverage" + ) + + +@pytest.mark.parametrize("template", ["lib", "bin"]) +def test_no_c_functions_linkable_after_partial_translation( + instrumented_workspace, monkeypatch, template +): + ws = instrumented_workspace(template) + + # `add` translates and is wrapped, `sub` raises and ends the run + run_translate( + monkeypatch, + ws, + template, + wrapping_lm(template, fail=frozenset({"sub", "main"})), + wrapper_max_iters=1, ) + + crate_name = get_crate_name(template) + # `add` lost its body to its wrapper, `sub` to the partial-translation cleanup + c_src = (ws / f"{crate_name}-sys" / "src" / "lib.c").read_text() + assert "return a + b;" not in c_src + assert "return a - b;" not in c_src + + # The one symbol Rust did take over keeps its wrapper + assert 'export_name = "add"' in (ws / crate_name / "src" / "lib.rs").read_text() + + assert build_ok(ws, crate_name) + if template == "bin": + # C still owns the program logic but has no entrypoint left to reach it + assert "was never translated" in _run_hybrid_bin(ws, crate_name).stderr + else: + # `add` resolves to the Rust translation and `sub` resolves to nothing at all + exports = _dynamic_exports(ws, crate_name) + assert "add" in exports + assert "sub" not in exports diff --git a/test/integration/test_init.py b/test/integration/test_init.py index ce78650..43d8040 100644 --- a/test/integration/test_init.py +++ b/test/integration/test_init.py @@ -8,9 +8,10 @@ import textwrap import pytest -from conftest import get_crate_name, build_ok +from conftest import get_crate_name, build_ok, make_config from ideas.tools import Crate +from ideas.translate import _init_crates from ideas.agents.utils import write_instrumentation_script @@ -44,12 +45,100 @@ def test_bin_stripped_sys_crate_links_with_sanitizer(instrumented_workspace): ) +@pytest.mark.parametrize("template", ["lib", "bin"]) +def test_c_main_weak_only_for_bin(instrumented_workspace, template): + ws = instrumented_workspace(template) + + c_src = (ws / f"{get_crate_name(template)}-sys" / "src" / "lib.c").read_text() + + if template == "bin": + assert "__attribute__((weak)) int main(" in c_src + else: + assert "__attribute__((weak))" not in c_src + + +def test_ported_test_builds_when_binding_matches_crate_name(instrumented_workspace): + ws = instrumented_workspace( + "lib", + header="typedef struct driver { int value; } driver;\nint driver_value(driver);\n", + source='#include "mini.h"\nint driver_value(driver value) { return value.value; }\n', + ) + cfg = make_config(ws, "lib") + assert cfg.tests is not None + cfg.tests.write_text( + "use libdriver_sys::*;\n\n" + "#[test]\n" + "fn binding_can_match_crate_name() {\n" + " assert_ne!(std::mem::size_of::(), 0);\n" + "}\n" + ) + + _init_crates(cfg) + + assert build_ok(ws, "libdriver", "--test", "smoke") + + +# Referencing a C symbol makes the linker pull in the object defining `main`, which must +# not outrank the libtest harness `main` +def _add_c_referencing_test(ws, crate_name): + lib_rs = ws / f"{crate_name}-sys" / "src" / "lib.rs" + lib_rs.write_text( + lib_rs.read_text() + + textwrap.dedent( + """ + #[cfg(test)] + mod tests { + #[test] + fn references_c() { + assert!(!(super::add as *const ()).is_null()); + } + } + """ + ) + ) + + +def _cargo_test(ws, crate_name, *extra): + cmd = [ + "cargo", + "test", + "--quiet", + "--lib", + "--manifest-path", + str(ws / "Cargo.toml"), + "-p", + f"{crate_name}-sys", + *extra, + ] + return subprocess.run(cmd, capture_output=True, text=True) + + +def test_bin_sys_crate_lib_tests_link(instrumented_workspace): + ws = instrumented_workspace("bin") + crate_name = get_crate_name("bin") + _add_c_referencing_test(ws, crate_name) + + result = _cargo_test(ws, crate_name, "--no-run") + assert result.returncode == 0, result.stderr + + +def test_bin_sys_crate_lib_tests_are_discoverable(instrumented_workspace): + ws = instrumented_workspace("bin") + crate_name = get_crate_name("bin") + _add_c_referencing_test(ws, crate_name) + + # `--list` runs the harness, so it fails if the C `main` took over the entry point + result = _cargo_test(ws, crate_name, "--", "--list") + assert result.returncode == 0, result.stderr + assert "tests::references_c: test" in result.stdout, result.stdout + + @pytest.mark.parametrize("template", ["lib", "bin"]) def test_instrumentation_runs(instrumented_workspace, template): ws = instrumented_workspace(template) sys_crate = Crate(ws / f"{get_crate_name(template)}-sys" / "Cargo.toml") - test_collect = sys_crate.cargo_toml.parent / "tests" / "test_collect.rs" + test_collect = sys_crate.cargo_toml.parent / "tests" / "collect.rs" test_collect.parent.mkdir(parents=True, exist_ok=True) test_collect.write_text( textwrap.dedent( @@ -67,7 +156,7 @@ def test_instrumentation_runs(instrumented_workspace, template): ) proc = subprocess.run( - ["bash", script.name], + ["bash", script.name, "collect"], cwd=script.parent, capture_output=True, text=True, diff --git a/test/integration/test_symbol_table.py b/test/integration/test_symbol_table.py new file mode 100644 index 0000000..9ed64b7 --- /dev/null +++ b/test/integration/test_symbol_table.py @@ -0,0 +1,547 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +""" +Symbol ownership in the hybrid crate over the course of a translation. +""" + +import re +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +import pytest +from conftest import CRATE, TEST_CRATES_DIR, field_value, get_crate_name, run_translate +from dspy.utils.dummies import DummyLM + +from ideas import adapters +from ideas.oracle import TestOracle +from ideas.tools import Crate +import ideas.translate as translate_mod +from ideas.translate_recurrent import RecurrentTranslator + +TEMPLATE = "lib" +ORDER = ("add", "scale", "square", "magnitude") +C_ARCHIVE = "library" +TESTS = "smoke" + +# Refuse any linker oddity +STRICT_LINK_ARGS = ( + "-Wl,--no-undefined", + "-Wl,--no-allow-shlib-undefined", + "-Wl,--warn-common", + "-Wl,--fatal-warnings", +) + +_CHAIN_H = """\ +#ifndef MINI_H +#define MINI_H +int add(int a, int b); +int scale(int v, int k); +int square(int v); +int magnitude(int v); +#endif +""" + + +_CHAIN_C = """\ +#include +#include "mini.h" + +int add(int a, int b) { + printf("hello from C add\\n"); + fflush(stdout); + return a + b; +} + +int scale(int v, int k) { + printf("hello from C scale\\n"); + fflush(stdout); + return add(v, v) * k; +} + +int square(int v) { + printf("hello from C square\\n"); + fflush(stdout); + return scale(v, v); +} + +int magnitude(int v) { + printf("hello from C magnitude\\n"); + fflush(stdout); + return add(square(v), 1); +} +""" + +_TEST_RS_TEMPLATE = """\ +#![allow(unused_imports)] +use std::env; +use std::process::Command; + +use __SYS_CRATE__::*; + +const CAPTURE_CHILD: &str = "TRACTOR_SMOKE_CAPTURE_CHILD"; +const CAPTURE_BEGIN: &str = "--- smoke stdout begin ---"; +const CAPTURE_END: &str = "--- smoke stdout end ---"; + +fn assert_stdout(test: &str, expected: &str, call: impl FnOnce()) { + if env::var_os(CAPTURE_CHILD).is_some() { + println!("{}", CAPTURE_BEGIN); + call(); + println!("{}", CAPTURE_END); + return; + } + + let output = Command::new(env::current_exe().expect("current test executable")) + .args(["--exact", test, "--nocapture", "--test-threads=1"]) + .env(CAPTURE_CHILD, "1") + .output() + .expect("run captured smoke test"); + let stdout = String::from_utf8(output.stdout).expect("smoke stdout is UTF-8"); + let stderr = String::from_utf8(output.stderr).expect("smoke stderr is UTF-8"); + assert!( + output.status.success(), + "captured smoke test failed:\\n--- stdout ---\\n{stdout}\\n--- stderr ---\\n{stderr}" + ); + + let (_, after_begin) = stdout + .split_once(CAPTURE_BEGIN) + .unwrap_or_else(|| panic!("missing stdout start marker:\\n{stdout}")); + let (captured, _) = after_begin + .split_once(CAPTURE_END) + .unwrap_or_else(|| panic!("missing stdout end marker:\\n{stdout}")); + + assert_eq!(captured.trim(), expected); +} + +#[test] +fn smoke_1_add() { + assert_stdout( + "smoke_1_add", + r#"__ADD_STDOUT__"#, + || unsafe { + assert_eq!(add(2, 3), 5); + }, + ); +} + +#[test] +fn smoke_2_scale() { + assert_stdout( + "smoke_2_scale", + r#"__SCALE_STDOUT__"#, + || unsafe { + assert_eq!(scale(2, 3), 12); + }, + ); +} + +#[test] +fn smoke_3_square() { + assert_stdout( + "smoke_3_square", + r#"__SQUARE_STDOUT__"#, + || unsafe { + assert_eq!(square(2), 8); + }, + ); +} + +#[test] +fn smoke_4_magnitude() { + assert_stdout( + "smoke_4_magnitude", + r#"__MAGNITUDE_STDOUT__"#, + || unsafe { + assert_eq!(magnitude(2), 9); + }, + ); +} +""" + + +def _expected_stdout(wrapped: tuple[str, ...], *calls: str) -> str: + return "\n".join( + f"hello from {'Rust' if name in wrapped else 'C'} {name}" for name in calls + ) + + +def _smoke_test(wrapped: tuple[str, ...]) -> str: + return ( + _TEST_RS_TEMPLATE.replace("__ADD_STDOUT__", _expected_stdout(wrapped, "add")) + .replace("__SCALE_STDOUT__", _expected_stdout(wrapped, "scale", "add")) + .replace( + "__SQUARE_STDOUT__", + _expected_stdout(wrapped, "square", "scale", "add"), + ) + .replace( + "__MAGNITUDE_STDOUT__", + _expected_stdout(wrapped, "magnitude", "square", "scale", "add", "add"), + ) + .replace("__SYS_CRATE__", f"{get_crate_name(TEMPLATE)}_sys") + ) + + +_TRANSLATIONS = { + "return magnitude(2) - 9;": "pub fn main() { std::process::exit(magnitude(2).wrapping_sub(9)); }\n", + "return a + b;": """\ +pub fn add(a: i32, b: i32) -> i32 { + println!("hello from Rust add"); + a.wrapping_add(b) +} +""", + "return add(v, v) * k;": """\ +pub fn scale(v: i32, k: i32) -> i32 { + println!("hello from Rust scale"); + add(v, v).wrapping_mul(k) +} +""", + "return scale(v, v);": """\ +pub fn square(v: i32) -> i32 { + println!("hello from Rust square"); + scale(v, v) +} +""", + "return add(square(v), 1);": """\ +pub fn magnitude(v: i32) -> i32 { + println!("hello from Rust magnitude"); + add(square(v), 1) +} +""", +} + +_WRAPPERS = { + "add": """\ +#[unsafe(export_name = "add")] +pub extern "C" fn add(a: ::std::os::raw::c_int, b: ::std::os::raw::c_int) -> ::std::os::raw::c_int { + __RS_CRATE__::add(a, b) +} +""", + "scale": """\ +#[unsafe(export_name = "scale")] +pub extern "C" fn scale( + v: ::std::os::raw::c_int, + k: ::std::os::raw::c_int, +) -> ::std::os::raw::c_int { + __RS_CRATE__::scale(v, k) +} +""", + "square": """\ +#[unsafe(export_name = "square")] +pub extern "C" fn square(v: ::std::os::raw::c_int) -> ::std::os::raw::c_int { + __RS_CRATE__::square(v) +} +""", + "magnitude": """\ +#[unsafe(export_name = "magnitude")] +pub extern "C" fn magnitude(v: ::std::os::raw::c_int) -> ::std::os::raw::c_int { + __RS_CRATE__::magnitude(v) +} +""", +} + +_EXPORT_NAME = re.compile(r'export_name = "(?P\w+)"') + + +def _translation_for(snippet: str) -> str: + hits = [rust for c_line, rust in _TRANSLATIONS.items() if c_line in snippet] + assert len(hits) == 1, f"expected exactly one translation for:\n{snippet}" + return f"```rust\n{hits[0]}```" + + +def _wrapper_for(template: str) -> str: + match = _EXPORT_NAME.search(template) + assert match is not None, f"unrecognized wrapper template:\n{template}" + return f"```rust\n{_WRAPPERS[match['name']].replace('__RS_CRATE__', f'{get_crate_name(TEMPLATE)}_rs')}```" + + +class _ScriptedLM(DummyLM): + def __init__(self): + super().__init__({}, adapter=adapters.ChatAdapter()) + + def __call__(self, prompt=None, messages=None, **kwargs): + content = messages[-1]["content"] if messages else (prompt or "") + if template := field_value(content, "example_wrapper").strip(): + answer = {"wrapper": _wrapper_for(template)} + else: + answer = {"translation": _translation_for(field_value(content, "snippet"))} + self.answers = {"": answer} + return super().__call__(prompt=prompt, messages=messages, **kwargs) + + +@dataclass(frozen=True) +class _Step: + wrapped: tuple[str, ...] = () + c_archive: tuple[str, ...] = () + rust_wrappers: tuple[str, ...] = () + linked: tuple[str, ...] = () + undefined: tuple[str, ...] = () + test_events: tuple[tuple[str, str], ...] = () + test_feedback: str = "" + link_log: str = "" + + +def _nm(path: Path, *flags: str, names: tuple[str, ...] | None = ORDER) -> tuple[str, ...]: + """The symbols of interest an artifact carries, sorted and with any duplicate kept.""" + proc = subprocess.run(["nm", *flags, str(path)], capture_output=True, text=True, check=True) + # Archive listings interleave `member.o:` headers, which are not symbols + found = [parts[-1] for line in proc.stdout.splitlines() if len(parts := line.split()) > 1] + return tuple(sorted(name for name in found if names is None or name in names)) + + +def _c_archive(workspace: Path) -> Path: + target = workspace / "target" / "debug" + sys_crate = f"{get_crate_name(TEMPLATE)}-sys" + archives = sorted(target.glob(f"build/{sys_crate}-*/out/lib{C_ARCHIVE}.a")) + assert archives, f"no C archive under {target}" + return max(archives, key=lambda path: path.stat().st_mtime) + + +def _link_command(workspace: Path) -> list[str]: + return [ + "cargo", + "rustc", + "--manifest-path", + str(workspace / "Cargo.toml"), + "-p", + get_crate_name(TEMPLATE), + "--lib", + "--crate-type", + "cdylib", + "--", + # `+whole-archive` drags in every archive member, so a definition C and Rust both + # own is a link error instead of a member the linker never bothered to extract + "-l", + f"static:+whole-archive={C_ARCHIVE}", + *(f"-Clink-arg={arg}" for arg in STRICT_LINK_ARGS), + ] + + +def _strict_link(workspace: Path) -> subprocess.CompletedProcess: + return subprocess.run(_link_command(workspace), capture_output=True, text=True) + + +# Inspect using `nm` at every step +def _inspect( + workspace: Path, + wrapped: tuple[str, ...], + test_feedback: str, + test_events: dict[str, str], +) -> _Step: + target = workspace / "target" / "debug" + print(f"wrappers {list(wrapped)}: {shlex.join(_link_command(workspace))}") + link = _strict_link(workspace) + cdylib = target / f"lib{CRATE}.so" + return _Step( + wrapped=wrapped, + c_archive=_nm(_c_archive(workspace), "--defined-only", "--extern-only"), + rust_wrappers=_nm(target / f"lib{CRATE}.rlib", "--defined-only", "--extern-only"), + linked=_nm(cdylib, "--defined-only", "--extern-only"), + undefined=_nm(cdylib, "--undefined-only"), + test_events=tuple(sorted(test_events.items())), + test_feedback=test_feedback, + link_log=link.stdout + link.stderr if link.returncode else "", + ) + + +def _record_steps(monkeypatch, workspace: Path, source_test: Path, test: Path) -> list[_Step]: + steps: list[_Step] = [] + + class _Recorder(RecurrentTranslator): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # The oracle only ever runs the smoke test, so every call is worth recording + oracle = cast(TestOracle, self._oracle) + oracle_run = oracle._run + + def _run(name, *, skip=None, build_only=False): + wrapped = tuple(symbol.split("@")[-1] for symbol in self._hybrid.wrappers) + source = _smoke_test(wrapped) + if source_test.read_text() != source: + source_test.write_text(source) + translate_mod.copy_and_port_tests( + source_test, test, f"{get_crate_name(TEMPLATE)}_sys", "driver" + ) + result = oracle_run(name, skip=skip, build_only=build_only) + if not build_only: + steps.append(_inspect(workspace, wrapped, *result)) + return result + + oracle._run = _run # type: ignore[method-assign] + + monkeypatch.setattr(translate_mod, "RecurrentTranslator", _Recorder) + return steps + + +@pytest.fixture(params=["lib", "bin"]) +def chain_translation(instrumented_workspace, monkeypatch, request): + monkeypatch.setitem(globals(), "TEMPLATE", request.param) + source = _CHAIN_C + ( + "\nint main(void) { return magnitude(2) - 9; }\n" if TEMPLATE == "bin" else "" + ) + workspace = instrumented_workspace(TEMPLATE, header=_CHAIN_H, source=source) + source_test = ( + workspace.parent / TEST_CRATES_DIR / get_crate_name(TEMPLATE) / "tests" / f"{TESTS}.rs" + ) + test = workspace / get_crate_name(TEMPLATE) / "tests" / f"{TESTS}.rs" + source_test.write_text(_smoke_test(())) + + # Hold the pipeline's own builds to the same standard as the relink below + monkeypatch.setenv("CARGO_NET_OFFLINE", "true") + monkeypatch.setenv("RUSTFLAGS", " ".join(f"-Clink-arg={arg}" for arg in STRICT_LINK_ARGS)) + monkeypatch.setenv("CFLAGS", "-fno-common") + + # The pipeline cleans the workspace when it finishes, but these tests inspect the artifacts + # it leaves behind + monkeypatch.setattr(Crate, "cargo_clean", lambda self, workspace=False: None) + + # Snapshot at every step using a wrapped pipeline + steps = _record_steps(monkeypatch, workspace, source_test, test) + run_translate( + monkeypatch, + workspace, + TEMPLATE, + _ScriptedLM(), + tests=source_test, + translator="Predict", + wrapper="Predict", + wrapper_max_iters=1, + ) + return workspace, steps + + +def test_symbol_ownership_moves_from_c_to_rust(chain_translation): + _, steps = chain_translation + + assert steps[0].wrapped == () + assert steps[0].c_archive == ("add", "magnitude", "scale", "square") + assert steps[0].rust_wrappers == () + assert steps[0].linked == () + assert steps[0].undefined == () + # Empty link log means no errors + assert steps[0].link_log == "" + + assert steps[1].wrapped == ("add",) + assert steps[1].c_archive == ("magnitude", "scale", "square") + assert steps[1].rust_wrappers == ("add",) + assert steps[1].linked == ("add",) + assert steps[1].undefined == () + assert steps[1].link_log == "" + + assert steps[2].wrapped == ("add", "scale") + assert steps[2].c_archive == ("magnitude", "square") + assert steps[2].rust_wrappers == ("add", "scale") + assert steps[2].linked == ("add", "scale") + assert steps[2].undefined == () + assert steps[2].link_log == "" + + assert steps[3].wrapped == ("add", "scale", "square") + assert steps[3].c_archive == ("magnitude",) + assert steps[3].rust_wrappers == ("add", "scale", "square") + assert steps[3].linked == ("add", "scale", "square") + assert steps[3].undefined == () + assert steps[3].link_log == "" + + assert steps[4].wrapped == ("add", "scale", "square", "magnitude") + assert steps[4].c_archive == () + assert steps[4].rust_wrappers == ("add", "magnitude", "scale", "square") + assert steps[4].linked == ("add", "magnitude", "scale", "square") + assert steps[4].undefined == () + assert steps[4].link_log == "" + + +def test_all_smoke_tests_pass_at_every_hybrid_state(chain_translation): + _, steps = chain_translation + + assert steps[0].wrapped == () + assert steps[0].test_feedback == "" + assert steps[0].test_events == ( + ("smoke_1_add", "ok"), + ("smoke_2_scale", "ok"), + ("smoke_3_square", "ok"), + ("smoke_4_magnitude", "ok"), + ) + + assert steps[1].wrapped == ("add",) + assert steps[1].test_feedback == "" + assert steps[1].test_events == ( + ("smoke_1_add", "ok"), + ("smoke_2_scale", "ok"), + ("smoke_3_square", "ok"), + ("smoke_4_magnitude", "ok"), + ) + + assert steps[2].wrapped == ("add", "scale") + assert steps[2].test_feedback == "" + assert steps[2].test_events == ( + ("smoke_1_add", "ok"), + ("smoke_2_scale", "ok"), + ("smoke_3_square", "ok"), + ("smoke_4_magnitude", "ok"), + ) + + assert steps[3].wrapped == ("add", "scale", "square") + assert steps[3].test_feedback == "" + assert steps[3].test_events == ( + ("smoke_1_add", "ok"), + ("smoke_2_scale", "ok"), + ("smoke_3_square", "ok"), + ("smoke_4_magnitude", "ok"), + ) + + assert steps[4].wrapped == ("add", "scale", "square", "magnitude") + assert steps[4].test_feedback == "" + assert steps[4].test_events == ( + ("smoke_1_add", "ok"), + ("smoke_2_scale", "ok"), + ("smoke_3_square", "ok"), + ("smoke_4_magnitude", "ok"), + ) + + +def test_duplicate_definition_collides_and_is_fatal(chain_translation): + """The strict flags must reject a name C and Rust both own instead of picking a winner.""" + workspace, _ = chain_translation + + # Undo the extern-ification of `add` so C reclaims a name the Rust wrapper exports + c_src = workspace / f"{get_crate_name(TEMPLATE)}-sys" / "src" / "lib.c" + c_src.write_text( + c_src.read_text().replace( + "extern int add(int a, int b);", "int add(int a, int b) { return a + b; }" + ) + ) + + link = _strict_link(workspace) + + c_defines = _nm(_c_archive(workspace), "--defined-only", "--extern-only") + rust_defines = _nm( + workspace / "target" / "debug" / f"lib{CRATE}.rlib", "--defined-only", "--extern-only" + ) + assert c_defines == ("add",) + assert rust_defines == ("add", "magnitude", "scale", "square") + assert link.returncode != 0, link.stdout + link.stderr + assert "multiple definition of `add'" in link.stderr, link.stderr + + +def test_undefined_reference_is_visible_and_fatal(chain_translation): + """A C body calling a name nobody defines must break the link, not the process later.""" + workspace, _ = chain_translation + + # `retain` keeps the linker from garbage collecting the reference before it checks it + c_src = workspace / f"{get_crate_name(TEMPLATE)}-sys" / "src" / "lib.c" + c_src.write_text( + c_src.read_text() + + "\nextern int missing(int v);\n" + + "__attribute__((used, retain)) int probe(int v) { return missing(v); }\n" + ) + + link = _strict_link(workspace) + + assert _nm(_c_archive(workspace), "--undefined-only", names=None) == ("missing",) + assert link.returncode != 0, link.stdout + link.stderr + assert "undefined reference to `missing'" in link.stderr, link.stderr diff --git a/test/integration/test_verification.py b/test/integration/test_verification.py new file mode 100644 index 0000000..ebe30b8 --- /dev/null +++ b/test/integration/test_verification.py @@ -0,0 +1,467 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import os +import re +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest +from conftest import REPO_ROOT, get_crate_name + +import ideas.agents.verifiers as verifiers +from ideas.agents.utils import ( + finalize_tests, + write_instrumentation_script, + write_test_templates, +) +from ideas.agents.verifiers import ( + VERIFIER_SOCKET_ENV, + VerificationError, + get_fresh_copy, + verification_service, + verify_instrumentation, + verify_tests_are_portable, +) +from ideas.tools import Crate + +TESTS = ["collect", "io"] + +_PASSING = """\ +#[test] +fn passes() { + assert_eq!(unsafe { add(1, 1) }, 2); +} +""" + +_FAILING = """\ +#[test] +fn fails() { + assert_eq!(1 + 1, 3); +} +""" + +_NON_PORTABLE = """\ +#[test] +fn reads_agent_leftovers() { + std::fs::read_to_string("agent_scratch.txt").unwrap(); +} +""" + +_WORKSPACE_SIDE_EFFECT = """\ +#[test] +fn writes_outside_the_allowed_artifact_directories() { + std::fs::write("agent-created.txt", "unexpected").unwrap(); +} +""" + +_SANITIZER_EXIT_BIN = """\ +#include "mini.h" +#include + +int add(int a, int b) { + return a + b; +} + +int sub(int a, int b) { + return a - b; +} + +int main(void) { + fputs("simulated sanitizer diagnostic\\n", stderr); + return 86; +} +""" + +_PRINT_PROGRAM_PATH_AND_HOME_BIN = """\ +#include "mini.h" +#include +#include + +int add(int a, int b) { + return a + b; +} + +int sub(int a, int b) { + return a - b; +} + +int main(int argc, char **argv) { + if (argc > 0) { + puts(argv[0]); + } + puts(getenv("HOME")); + return 0; +} +""" + +_TEMPLATE_TESTS = { + "bin": r"""#[test] +fn scaffold_builds() { + let dir = sandbox("scaffold_builds"); + let call = run(&dir, &[], None); + assert_eq!(call.stdout, format!("\n{}\n", dir.display())); + assert!(call.changed.is_empty()); + assert!(call.removed.is_empty()); +} +""", + "lib": r"""#[test] +fn scaffold_builds() { + assert_eq!(unsafe { add(2, 3) }, 5); + let dir = sandbox("scaffold_builds"); + std::fs::write(dir.join("output.txt"), b"output").unwrap(); + assert_eq!(std::fs::read(dir.join("output.txt")).unwrap(), b"output"); + assert_eq!( + snapshot(&dir).get("output.txt"), + Some(&Some(b"output".to_vec())), + ); +} +""", +} + + +@pytest.fixture +def sys_crate(instrumented_workspace, monkeypatch): + monkeypatch.setenv("UV_PROJECT", str(REPO_ROOT)) + workspace = instrumented_workspace("lib") + + crate = Crate(workspace / f"{get_crate_name('lib')}-sys" / "Cargo.toml") + lib_name = crate.lib_name + assert lib_name is not None + test_dir = crate.cargo_toml.parent / "tests" + test_dir.mkdir(parents=True) + for test in TESTS: + (test_dir / f"{test}.rs").write_text(f"use {lib_name}::*;\n\n{_PASSING}") + return crate + + +@pytest.fixture +def fresh_crate(sys_crate, tmp_path): + return get_fresh_copy(sys_crate, tmp_path) + + +def _finalize(crate, fresh_crate): + finalize_tests( + crate, + fresh_crate, + TESTS, + ["cc_asan", "cc_ubsan", "cc_coverage"], + ) + + +def _list_tests(crate, test): + return crate.cargo_test_list(test) + + +def _tests_pass(crate, test): + return crate.cargo_test(test)[0] + + +def test_fresh_copy_has_a_random_package_and_target_name(tmp_path): + source = Crate(tmp_path / "source" / "example-sys" / "Cargo.toml", template="lib") + fresh = get_fresh_copy(source, tmp_path / "fresh") + + assert fresh.name != source.name + assert fresh.lib_name != source.lib_name + assert fresh.cargo_toml.parent.name == fresh.name + assert fresh.workspace_root == fresh.cargo_toml.parent + + +def test_reproducible_suite_verifies(sys_crate, fresh_crate): + assert sys_crate.name != fresh_crate.name + assert fresh_crate.workspace_root.name != sys_crate.workspace_root.name + verify_tests_are_portable(sys_crate, fresh_crate, TESTS) + for test in TESTS: + source = (fresh_crate.cargo_toml.parent / "tests" / f"{test}.rs").read_text() + assert f"use {fresh_crate.lib_name}::*;" in source + + +def test_tests_relying_on_agent_leftovers_do_not_verify(sys_crate, fresh_crate): + crate_dir = sys_crate.cargo_toml.parent + (crate_dir / "tests" / "io.rs").write_text(_NON_PORTABLE) + + # The pipeline never generates this, so verification must fail + (crate_dir / "agent_scratch.txt").write_text("scratch\n") + assert sys_crate.cargo_test("io")[0] + + with pytest.raises(VerificationError): + verify_tests_are_portable(sys_crate, fresh_crate, TESTS) + + +def test_edited_instrumentation_script_does_not_verify(sys_crate, fresh_crate): + script = sys_crate.cargo_toml.parent / "instrument.sh" + script.chmod(0o755) + script.write_text(script.read_text() + "# the agent was here\n") + + with pytest.raises(VerificationError, match="modified and has been reverted"): + verify_tests_are_portable(sys_crate, fresh_crate, TESTS) + assert script.read_bytes() == (fresh_crate.cargo_toml.parent / "instrument.sh").read_bytes() + assert ( + script.stat().st_mode + == (fresh_crate.cargo_toml.parent / "instrument.sh").stat().st_mode + ) + + +def test_instrumentation_invokes_portability_verification(sys_crate, fresh_crate): + crate_dir = sys_crate.cargo_toml.parent + (crate_dir / "tests" / "io.rs").write_text(_NON_PORTABLE) + (crate_dir / "agent_scratch.txt").write_text("scratch\n") + + with verification_service(sys_crate, fresh_crate): + result = subprocess.run( + ["bash", "instrument.sh", "io"], + cwd=crate_dir, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "not portable to a fresh crate copy" in result.stderr + + +def test_instrumentation_verification_compares_the_pristine_workspace(sys_crate, fresh_crate): + workspace = fresh_crate.workspace_root + manifest = fresh_crate.cargo_toml.relative_to(workspace) + verify_instrumentation(sys_crate.cargo_toml, workspace, manifest, TESTS) + + (sys_crate.cargo_toml.parent / "tests" / "io.rs").write_text(_WORKSPACE_SIDE_EFFECT) + with pytest.raises(VerificationError, match="agent-created.txt"): + verify_instrumentation(sys_crate.cargo_toml, workspace, manifest, ["io"]) + + +def test_final_verification_restores_latest_successful_pair( + sys_crate, fresh_crate, monkeypatch +): + test_dir = sys_crate.cargo_toml.parent / "tests" + + def verify(candidate_manifest, _workspace, _fresh_manifest, tests): + for test in tests: + if (candidate_manifest.parent / "tests" / f"{test}.rs").read_text() == "invalid\n": + raise VerificationError("invalid test") + + monkeypatch.setattr(verifiers, "verify_instrumentation", verify) + with verification_service(sys_crate, fresh_crate): + socket_path = os.environ[VERIFIER_SOCKET_ENV] + for test in TESTS: + assert verifiers.run_cli([socket_path, test]) == (0, None) + + latest_collect = (test_dir / "collect.rs").read_text() + "// latest collect\n" + (test_dir / "collect.rs").write_text(latest_collect) + assert verifiers.run_cli([socket_path, "collect"]) == (0, None) + verified_io = (test_dir / "io.rs").read_text() + + for test in TESTS: + (test_dir / f"{test}.rs").write_text("invalid\n") + + assert (test_dir / "collect.rs").read_text() == latest_collect + assert (test_dir / "io.rs").read_text() == verified_io + + +def test_successful_final_verification_keeps_unverified_tail( + sys_crate, fresh_crate, monkeypatch +): + monkeypatch.setattr(verifiers, "verify_instrumentation", lambda *_args: None) + io_path = sys_crate.cargo_toml.parent / "tests" / "io.rs" + + with verification_service(sys_crate, fresh_crate): + socket_path = os.environ[VERIFIER_SOCKET_ENV] + for test in TESTS: + assert verifiers.run_cli([socket_path, test]) == (0, None) + final_contents = io_path.read_text() + "// valid unverified tail\n" + io_path.write_text(final_contents) + + assert io_path.read_text() == final_contents + + +def test_failing_tests_are_dropped(sys_crate, fresh_crate): + test_path = sys_crate.cargo_toml.parent / "tests" / "io.rs" + test_path.write_text(test_path.read_text() + _FAILING) + + _finalize(sys_crate, fresh_crate) + + assert _list_tests(sys_crate, "io") == ["passes"] + assert _tests_pass(sys_crate, "io") + + +def test_suite_that_fails_falls_back_to_placeholders(sys_crate, fresh_crate): + (sys_crate.cargo_toml.parent / "tests" / "io.rs").write_text(_FAILING) + + _finalize(sys_crate, fresh_crate) + + for test in TESTS: + assert _list_tests(sys_crate, test) + assert _tests_pass(sys_crate, test) + assert "fails" not in _list_tests(sys_crate, "io") + + +def test_suite_that_does_not_compile_falls_back_to_placeholders(sys_crate, fresh_crate): + (sys_crate.cargo_toml.parent / "tests" / "io.rs").write_text("this is not Rust") + + _finalize(sys_crate, fresh_crate) + + for test in TESTS: + assert _list_tests(sys_crate, test) + assert _tests_pass(sys_crate, test) + + +def test_missing_test_file_falls_back_to_placeholders(sys_crate, fresh_crate): + test_path = sys_crate.cargo_toml.parent / "tests" / "collect.rs" + test_path.unlink() + + _finalize(sys_crate, fresh_crate) + + assert test_path.exists() + assert _tests_pass(sys_crate, "collect") + + +def test_pipeline_cannot_reproduce_falls_back_to_placeholders(sys_crate, fresh_crate): + crate_dir = sys_crate.cargo_toml.parent + (crate_dir / "tests" / "io.rs").write_text(_NON_PORTABLE) + (crate_dir / "agent_scratch.txt").write_text("scratch\n") + + _finalize(sys_crate, fresh_crate) + + assert "reads_agent_leftovers" not in _list_tests(sys_crate, "io") + for test in TESTS: + assert _tests_pass(sys_crate, test) + + +@pytest.mark.parametrize("template", ["lib", "bin"]) +def test_collect_reports_only_changed_json(instrumented_workspace, template): + ws = instrumented_workspace(template) + crate = Crate(ws / f"{get_crate_name(template)}-sys" / "Cargo.toml") + test_path = crate.cargo_toml.parent / "tests" / "collect.rs" + test_path.parent.mkdir(parents=True, exist_ok=True) + test_path.write_text( + textwrap.dedent( + """ + #[test] + fn always_pass() { + assert_eq!(1 + 1, 2); + std::fs::create_dir_all("json").unwrap(); + std::fs::write("json/stable.json", r#"{"value":1}"#).unwrap(); + std::fs::write("json/new.json", r#"{"value":2}"#).unwrap(); + } + """ + ).strip() + ) + json_dir = crate.cargo_toml.parent / "json" + json_dir.mkdir() + (json_dir / "stable.json").write_text('{"value":1}') + script = write_instrumentation_script( + crate.cargo_toml.parent / "instrument.sh", features=["cc_asan", "cc_ubsan"] + ) + + result = subprocess.run( + ["bash", script.name, "collect"], + cwd=script.parent, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Coverage summary for collect:" in result.stdout + assert "JSON values changed by this collect run:" in result.stdout + assert "--- json/new.json" in result.stdout + assert "--- json/stable.json" not in result.stdout + assert (json_dir / "stable.json").read_text() == '{"value":1}' + assert re.fullmatch( + r"Branch coverage: (?:\d+(?:\.\d+)?%|-)", result.stdout.rstrip().splitlines()[-1] + ) + + +def test_failed_collect_preserves_previous_json(instrumented_workspace): + ws = instrumented_workspace("lib") + crate = Crate(ws / f"{get_crate_name('lib')}-sys" / "Cargo.toml") + test_path = crate.cargo_toml.parent / "tests" / "collect.rs" + test_path.parent.mkdir(parents=True, exist_ok=True) + test_path.write_text("this is not Rust") + + json_dir = crate.cargo_toml.parent / "json" + json_dir.mkdir() + expected = '{"still":"valid"}' + (json_dir / "previous.json").write_text(expected) + script = write_instrumentation_script( + crate.cargo_toml.parent / "instrument.sh", features=["cc_asan", "cc_ubsan"] + ) + + result = subprocess.run( + ["bash", script.name, "collect"], + cwd=script.parent, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert (json_dir / "previous.json").read_text() == expected + + +def test_standalone_template_crate_builds(tmp_path): + source = Path(__file__).parents[2] / "src" / "ideas" / "agents" / "templates" + destination = tmp_path / "templates" + shutil.copytree(source, destination) + + result = subprocess.run( + ["cargo", "test", "--quiet", "--manifest-path", destination / "Cargo.toml"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("template", ["lib", "bin"]) +def test_generated_test_templates_build(instrumented_workspace, template): + source = _PRINT_PROGRAM_PATH_AND_HOME_BIN if template == "bin" else None + ws = instrumented_workspace(template, source=source) + crate = Crate(ws / f"{get_crate_name(template)}-sys" / "Cargo.toml") + test_dir = crate.cargo_toml.parent / "tests" + test_dir.mkdir(parents=True, exist_ok=True) + + write_test_templates(test_dir, template, crate.lib_name) + for test in TESTS: + path = test_dir / f"{test}.rs" + contents = path.read_text() + assert "__LIB_NAME__" not in contents + path.write_text(contents + "\n" + _TEMPLATE_TESTS[template]) + + for test in TESTS: + passed, output, error, _ = crate.cargo_test(test) + assert passed, output + error + + +def test_child_sanitizer_failure_is_reported(instrumented_workspace): + ws = instrumented_workspace("bin", source=_SANITIZER_EXIT_BIN) + crate = Crate(ws / f"{get_crate_name('bin')}-sys" / "Cargo.toml") + test_path = crate.cargo_toml.parent / "tests" / "io.rs" + write_test_templates(test_path.parent, template="bin", lib_name=crate.lib_name) + test_path.write_text( + test_path.read_text() + + textwrap.dedent( + """ + + #[test] + fn child_sanitizer_failure_is_reported() { + let dir = sandbox("child_sanitizer_failure_is_reported"); + let _ = run(&dir, &[], None); + } + """ + ) + ) + + result = subprocess.run( + ["bash", "instrument.sh", "io"], + cwd=crate.cargo_toml.parent, + capture_output=True, + text=True, + ) + + assert result.returncode != 0, result.stdout + result.stderr + assert "simulated sanitizer diagnostic" in result.stdout + sanitizer_log = (crate.cargo_toml.parent / "sanitizer_logs" / "cc_asan.log").read_text() + assert "simulated sanitizer diagnostic" in sanitizer_log diff --git a/test/test_ast_rust.py b/test/test_ast_rust.py new file mode 100644 index 0000000..de55c33 --- /dev/null +++ b/test/test_ast_rust.py @@ -0,0 +1,295 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +from ideas.ast_rust import CodeRust, strip_fns + + +_NESTED = CodeRust("""\ +pub trait Convert { + type Rust; + unsafe fn to_rust(cs: *const Self) -> Self::Rust; +} + +impl Convert for house_t { + type Rust = libdriver_rs::HouseT; + + unsafe fn to_rust(cs: *const Self) -> Self::Rust { + todo!() + } +} + +mod helpers { + pub fn helper(x: i32) -> i32 { + x + } + + pub struct Keep { + pub a: i32, + } +} + +pub fn top(x: i32) -> i32 { + x +} +""") + + +def test_strip_fns_nested_bodies(): + out = str(strip_fns(_NESTED)) + + # Bodies go, signatures stay, at every level + assert "todo!()" not in out + assert "unsafe fn to_rust(cs: *const Self) -> Self::Rust;" in out + assert "pub fn helper(x: i32) -> i32;" in out + assert "pub fn top(x: i32) -> i32;" in out + + # Enclosing headers survive so the signatures stay attributable + assert "impl Convert for house_t {" in out + assert "mod helpers {" in out + assert "type Rust = libdriver_rs::HouseT;" in out + + +def test_strip_fns_delete_nested(): + out = str(strip_fns(_NESTED, delete=True)) + + # Functions disappear wherever they live + assert "fn to_rust" not in out + assert "fn helper" not in out + assert "fn top" not in out + + # Non-function items are untouched + assert "impl Convert for house_t {" in out + assert "type Rust = libdriver_rs::HouseT;" in out + assert "pub struct Keep" in out + + +def test_strip_fns_empty_body(): + out = str(strip_fns(CodeRust("impl Marker for house_t {}"), delete=True)) + assert out.strip() == "impl Marker for house_t {}" + + +_CINTEROP_IMPL = CodeRust("""\ +impl CInterop for git_iterator_options { + type Rust = driver_rs::GitIteratorOptions<'static, 'static>; + + unsafe fn to_rust(cs: *const Self) -> Self::Rust { + let cs = unsafe { &*cs }; + + let start = if cs.start.is_null() { + None + } else { + Some(unsafe { std::ffi::CStr::from_ptr(cs.start) }.to_bytes()) + }; + + let end = if cs.end.is_null() { + None + } else { + Some(unsafe { std::ffi::CStr::from_ptr(cs.end) }.to_bytes()) + }; + + let strings = if cs.pathlist.count == 0 { + Vec::new() + } else { + assert!( + !cs.pathlist.strings.is_null(), + "nonempty git_strarray has a null strings pointer" + ); + + unsafe { + std::slice::from_raw_parts(cs.pathlist.strings, cs.pathlist.count) + .iter() + .map(|&string| { + assert!( + !string.is_null(), + "git_strarray contains a null string pointer" + ); + std::ffi::CStr::from_ptr(string).to_bytes().to_vec() + }) + .collect() + } + }; + + driver_rs::GitIteratorOptions { + start, + end, + pathlist: driver_rs::GitStrarray { strings }, + flags: cs.flags as u32, + oid_type: cs.oid_type as driver_rs::GitOidT, + } + } + + unsafe fn sync_to_c(rs: &Self::Rust, cs: *mut Self) { + let cs = unsafe { &mut *cs }; + + let allocate_c_string = + |bytes: &[u8]| -> *mut ::std::os::raw::c_char { + let allocation_size = bytes + .len() + .checked_add(1) + .expect("C string allocation size overflow"); + let allocation = + unsafe { ::libc::malloc(allocation_size) } + as *mut ::std::os::raw::c_char; + + assert!(!allocation.is_null(), "C string allocation failed"); + + unsafe { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + allocation.cast::(), + bytes.len(), + ); + *allocation.cast::().add(bytes.len()) = 0; + } + + allocation + }; + + let sync_borrowed_string = | + value: Option<&[u8]>, + slot: *mut *const ::std::os::raw::c_char, + | { + let current = unsafe { *slot }; + + match value { + None => unsafe { + /* + * These are borrowed `const char *` fields, so their old + * allocations must not be freed here. + */ + *slot = std::ptr::null(); + }, + Some(bytes) => { + let unchanged = !current.is_null() + && unsafe { + std::ffi::CStr::from_ptr(current).to_bytes() == bytes + }; + + if !unchanged { + /* + * A borrowed C string has no capacity metadata and may + * point to read-only storage. A changed value therefore + * requires a new C allocation. The replaced pointer is + * deliberately not freed because this structure does + * not own start/end. + */ + let replacement = allocate_c_string(bytes); + unsafe { + *slot = replacement.cast_const(); + } + } + } + } + }; + + sync_borrowed_string( + rs.start, + std::ptr::addr_of_mut!(cs.start), + ); + sync_borrowed_string( + rs.end, + std::ptr::addr_of_mut!(cs.end), + ); + + let old_strings = cs.pathlist.strings; + let old_count = cs.pathlist.count; + let new_count = rs.pathlist.strings.len(); + + if new_count == old_count { + if new_count != 0 { + assert!( + !old_strings.is_null(), + "nonempty git_strarray has a null strings pointer" + ); + + for (index, bytes) in rs.pathlist.strings.iter().enumerate() { + let slot = unsafe { old_strings.add(index) }; + let current = unsafe { *slot }; + + let unchanged = !current.is_null() + && unsafe { + std::ffi::CStr::from_ptr(current).to_bytes() + == bytes.as_slice() + }; + + if !unchanged { + let replacement = allocate_c_string(bytes); + + if !current.is_null() { + unsafe { + ::libc::free(current.cast()); + } + } + + unsafe { + *slot = replacement; + } + } + } + } + } else { + let replacement_strings = if new_count == 0 { + std::ptr::null_mut() + } else { + let allocation_size = new_count + .checked_mul(std::mem::size_of::< + *mut ::std::os::raw::c_char, + >()) + .expect("git_strarray allocation size overflow"); + + let allocation = + unsafe { ::libc::malloc(allocation_size) } + as *mut *mut ::std::os::raw::c_char; + + assert!( + !allocation.is_null(), + "git_strarray allocation failed" + ); + + for (index, bytes) in + rs.pathlist.strings.iter().enumerate() + { + unsafe { + *allocation.add(index) = allocate_c_string(bytes); + } + } + + allocation + }; + + if !old_strings.is_null() { + for index in 0..old_count { + let old_string = unsafe { *old_strings.add(index) }; + if !old_string.is_null() { + unsafe { + ::libc::free(old_string.cast()); + } + } + } + + unsafe { + ::libc::free(old_strings.cast()); + } + } + + cs.pathlist.strings = replacement_strings; + } + + cs.pathlist.count = new_count; + cs.flags = rs.flags as ::std::os::raw::c_uint; + cs.oid_type = rs.oid_type as git_oid_t; + } +} +""") + + +def test_strip_fns_cinterop_impl(): + out = str(strip_fns(_CINTEROP_IMPL)) + + assert "impl CInterop for git_iterator_options {" in out + assert "type Rust = driver_rs::GitIteratorOptions<'static, 'static>;" in out + assert "unsafe fn to_rust(cs: *const Self) -> Self::Rust;" in out + assert "unsafe fn sync_to_c(rs: &Self::Rust, cs: *mut Self);" in out diff --git a/test/test_bear.py b/test/test_bear.py index b7c8cf3..f928d14 100644 --- a/test/test_bear.py +++ b/test/test_bear.py @@ -4,9 +4,24 @@ # SPDX-License-Identifier: Apache-2.0 # +import logging from pathlib import Path -from ideas.bear import BuildDatabase, CompileCommand, LinkCommand +from ideas.bear import ( + BuildDatabase, + CompileCommand, + LinkCommand, + TargetOutputs, + is_library_file, +) + + +def _libs(outputs: TargetOutputs) -> list[str]: + return [i for i in outputs.link_inputs if isinstance(i, str)] + + +def _lib_files(outputs: TargetOutputs) -> list[str]: + return [i.name for i in outputs.link_inputs if isinstance(i, Path)] def test_resolve_targets_sphincs_build(): @@ -141,7 +156,7 @@ def test_resolve_targets_sphincs_build(): assert "-DBLAKE_TR=1" not in next( e.arguments for e in targets["libblake.so"].entries if e.source == utils_c ) - assert targets["libblake.so"].link_libs == [] + assert _libs(targets["libblake.so"]) == [] # libsphincs_core_det.so: sphincs sources, utils.c has -DBLAKE_TR=1 sphincs_sources = [e.source for e in targets["libsphincs_core_det.so"].entries] @@ -149,7 +164,7 @@ def test_resolve_targets_sphincs_build(): assert "-DBLAKE_TR=1" in next( e.arguments for e in targets["libsphincs_core_det.so"].entries if e.source == utils_c ) - assert targets["libsphincs_core_det.so"].link_libs == [] + assert _libs(targets["libsphincs_core_det.so"]) == [] # driver: transitive sources in order; utils.c appears exactly once (sphincs version) driver_sources = [e.source for e in targets["driver"].entries] @@ -158,7 +173,98 @@ def test_resolve_targets_sphincs_build(): assert "-DBLAKE_TR=1" in next( e.arguments for e in targets["driver"].entries if e.source == utils_c ) - assert targets["driver"].link_libs == ["crypto"] + assert _libs(targets["driver"]) == ["crypto"] + + +def test_resolve_targets_sqlite_tcl_absolute_library_path(): + """ + Models the sqlite build where `find_package(TCL)` makes CMake link the Tcl runtime by + absolute path (/usr/lib/x86_64-linux-gnu/libtcl8.6.so) instead of emitting -ltcl. The + library must be reported as a link lib, including transitively through libsqlite3.so. + """ + build = Path("/build/test_case") + src = Path("/src/test_case/src") + + sqlite3_c = src / "sqlite3.c" + tclsqlite_c = src / "tclsqlite.c" + shell_c = src / "shell.c" + + lib_dir = build / "CMakeFiles/sqlite3_shared.dir/src" + shell_dir = build / "CMakeFiles/sqlite3_shell.dir/src" + + libtcl = Path("/usr/lib/x86_64-linux-gnu/libtcl8.6.so") + libsqlite3 = build / "libsqlite3.so" + shell = build / "sqlite3" + + compile_cmds = [ + CompileCommand.from_arguments( + ["clang", "-c", str(sqlite3_c), "-o", str(lib_dir / "sqlite3.c.o")], + working_dir=build, + ), + CompileCommand.from_arguments( + ["clang", "-c", str(tclsqlite_c), "-o", str(lib_dir / "tclsqlite.c.o")], + working_dir=build, + ), + CompileCommand.from_arguments( + ["clang", "-c", str(shell_c), "-o", str(shell_dir / "shell.c.o")], + working_dir=build, + ), + ] + link_cmds = [ + LinkCommand.from_arguments( + [ + "clang", + "-shared", + "-o", + str(libsqlite3), + str(lib_dir / "sqlite3.c.o"), + str(lib_dir / "tclsqlite.c.o"), + str(libtcl), + "-lm", + ], + working_dir=build, + ), + LinkCommand.from_arguments( + [ + "clang", + "-o", + str(shell), + str(shell_dir / "shell.c.o"), + str(libsqlite3), + str(libtcl), + "-lm", + ], + working_dir=build, + ), + ] + db = BuildDatabase( + compile_commands=[c for c in compile_cmds if c is not None], + link_commands=[lc for lc in link_cmds if lc is not None], + ) + targets = db.resolve_targets() + + assert [e.source for e in targets["libsqlite3.so"].entries] == [sqlite3_c, tclsqlite_c] + assert _libs(targets["libsqlite3.so"]) == ["m"] + assert _lib_files(targets["libsqlite3.so"]) == ["libtcl8.6.so"] + + # The shell links libtcl8.6.so both directly and through libsqlite3.so — report it once + assert [e.source for e in targets["sqlite3"].entries] == [shell_c, sqlite3_c, tclsqlite_c] + assert _libs(targets["sqlite3"]) == ["m"] + assert _lib_files(targets["sqlite3"]) == ["libtcl8.6.so"] + + +def test_is_library_file(): + for name, expected in [ + ("libtcl8.6.so", True), + ("libz.so.1.2.13", True), + ("libcrypto.a", True), + ("libssl.so", True), + ("tcl8.6.so", True), # a plugin/MODULE library has no `lib` prefix + ("libfoo.dat", False), # not a library + ("main.c.o", False), # an object file, not a library + ("foo.sources", False), # contains `.so` but is not one + ]: + assert is_library_file(Path("/usr/lib") / name) == expected, name def test_compile_command_strips_dep_tracking_flags(): @@ -293,6 +399,75 @@ def test_link_command_versioned_linker_recognized(): assert cmd is not None, f"{linker} should be recognized as a linker" +def test_link_command_ignores_operands_of_non_input_flags(): + # shape of the internal `ld` invocation gcc execs: its flag operands look like inputs + build = Path("/build") + cmd = LinkCommand.from_arguments( + [ + "ld", + "-plugin", + "/usr/lib/gcc/x86_64-linux-gnu/11/liblto_plugin.so", + "-m", + "elf_x86_64", + "-dynamic-linker", + "/lib64/ld-linux-x86-64.so.2", + "-z", + "now", + "-soname", + "libfoo.so.1", + "-R", + "syms.o", + "-o", + str(build / "libfoo.so.1"), + str(build / "main.c.o"), + "-lm", + ], + working_dir=build, + ) + assert cmd is not None + assert cmd.target == "libfoo.so" + assert cmd.object_files == [build / "main.c.o"] + assert cmd.link_inputs == ["m"] + + +def test_resolve_targets_prefers_driver_over_internal_ld(): + # gcc execs ld, so both are captured for one link; the ld line adds CRT objects and + # toolchain libs that are not part of the build + build = Path("/build") + src = Path("/src") + main_o = build / "main.c.o" + mybin = build / "mybin" + + compile_cmd = CompileCommand( + source=src / "main.c", output=main_o, arguments=[], working_dir=build + ) + driver = LinkCommand.from_arguments( + ["gcc", "-o", str(mybin), str(main_o), "-lm"], working_dir=build + ) + internal_ld = LinkCommand.from_arguments( + [ + "ld", + "-o", + str(mybin), + "/usr/lib/x86_64-linux-gnu/Scrt1.o", + str(main_o), + "-L/usr/lib/gcc/x86_64-linux-gnu/11", + "-lm", + "-lgcc", + "-lc", + ], + working_dir=build, + ) + assert driver is not None and internal_ld is not None + + # the ld event is recorded second, so it must not win on ordering alone + db = BuildDatabase(compile_commands=[compile_cmd], link_commands=[driver, internal_ld]) + targets = db.resolve_targets() + assert list(targets) == ["mybin"] + assert targets["mybin"].link_inputs == ["m"] + assert targets["mybin"].link_search_dirs == [] + + def test_resolve_targets_static_archive_traversed(): # A .a archive in linked_binary_inputs should be transitively traversed # just like a .so, as long as it has a corresponding link command. @@ -333,9 +508,9 @@ def test_resolve_targets_static_archive_traversed(): assert [e.source for e in targets["mybin"].entries] == [src / "main.c", src / "lib.c"] -def test_resolve_targets_external_so_silently_skipped(): - # A .so passed directly to the linker but not produced by any link command - # in the database (e.g. libssl.so) should be skipped without error. +def test_resolve_targets_external_so_recorded_as_link_lib(): + # A .so passed directly to the linker but not produced by any link command in the + # database (e.g. libssl.so) contributes no sources, but must still be linked. build = Path("/build") src = Path("/src") @@ -361,6 +536,7 @@ def test_resolve_targets_external_so_silently_skipped(): targets = db.resolve_targets() assert "mybin" in targets assert [e.source for e in targets["mybin"].entries] == [src / "main.c"] + assert _lib_files(targets["mybin"]) == ["libssl.so"] def test_resolve_targets_versioned_so_traversed(): @@ -405,3 +581,140 @@ def test_resolve_targets_versioned_so_traversed(): assert "driver" in targets assert [e.source for e in targets["driver"].entries] == [src / "main.c", src / "lib.c"] + + +def test_resolve_targets_link_search_dirs(): + # `-lbar` only resolves if its directory is searched, so a library outside the default + # linker search path must contribute a search dir — whether it arrived as an absolute + # path or as `-L`. Both must also propagate from a nested project library. + build = Path("/build") + src = Path("/src") + + lib_c_o = build / "lib.c.o" + main_c_o = build / "main.c.o" + libhelper = build / "libhelper.so" + + compile_cmds = [ + CompileCommand.from_arguments( + ["clang", "-c", str(src / "lib.c"), "-o", str(lib_c_o)], + working_dir=build, + ), + CompileCommand.from_arguments( + ["clang", "-c", str(src / "main.c"), "-o", str(main_c_o)], + working_dir=build, + ), + ] + link_cmds = [ + LinkCommand.from_arguments( + [ + "clang", + "-shared", + "-o", + str(libhelper), + str(lib_c_o), + "-L/opt/acme/lib", + "-lacme", + ], + working_dir=build, + ), + LinkCommand.from_arguments( + [ + "clang", + "-o", + str(build / "mybin"), + str(main_c_o), + str(libhelper), + "/opt/zlib/lib/libz.so.1.2.13", + "-L", # separated form + "../vendor/lib", + "-lm", + ], + working_dir=build, + ), + ] + db = BuildDatabase( + compile_commands=[c for c in compile_cmds if c is not None], + link_commands=[lc for lc in link_cmds if lc is not None], + ) + targets = db.resolve_targets() + + # `-L ../vendor/lib` must not be mistaken for an input, and resolves against working_dir + assert [e.source for e in targets["mybin"].entries] == [src / "main.c", src / "lib.c"] + # link order is preserved across both kinds, and libz keeps the full path it was + # named by rather than degrading to `-lz` + assert targets["mybin"].link_inputs == [ + "acme", + Path("/opt/zlib/lib/libz.so.1.2.13"), + "m", + ] + assert targets["mybin"].link_search_dirs == [ + Path("/vendor/lib"), + Path("/opt/acme/lib"), + ] + + +def test_resolve_targets_warns_on_ambiguous_lib_file(tmp_path, caplog): + # `-l:libz.so.1.2.13` resolves against the search dirs in order, so the same filename + # in two of them may not be the file the C build pinned by absolute path. + vendor = tmp_path / "vendor" + zlib = tmp_path / "zlib" + for d in (vendor, zlib): + d.mkdir() + (d / "libz.so.1.2.13").touch() + + build = tmp_path / "build" + main_o = build / "main.c.o" + + compile_cmds = [ + CompileCommand.from_arguments( + ["clang", "-c", str(tmp_path / "main.c"), "-o", str(main_o)], + working_dir=build, + ), + ] + link_cmds = [ + LinkCommand.from_arguments( + [ + "clang", + "-o", + str(build / "mybin"), + str(main_o), + f"-L{vendor}", + str(zlib / "libz.so.1.2.13"), + ], + working_dir=build, + ), + ] + db = BuildDatabase( + compile_commands=[c for c in compile_cmds if c is not None], + link_commands=[lc for lc in link_cmds if lc is not None], + ) + with caplog.at_level(logging.WARNING, logger="ideas.bear"): + targets = db.resolve_targets() + + assert _lib_files(targets["mybin"]) == ["libz.so.1.2.13"] + assert any( + "libz.so.1.2.13" in r.message and str(vendor) in r.message and str(zlib) in r.message + for r in caplog.records + ) + + +def test_link_command_repeated_output_warns(caplog): + # No generator emits two -o, but the drivers accept it and take the last — so do we + build = Path("/build") + with caplog.at_level(logging.WARNING, logger="ideas.bear"): + cmd = LinkCommand.from_arguments( + [ + "clang", + "-o", + str(build / "first"), + str(build / "main.c.o"), + "-o", + str(build / "second"), + ], + working_dir=build, + ) + + assert cmd is not None + assert cmd.target == "second" + assert cmd.output_path == build / "second" + assert any("2 -o flags" in r.message for r in caplog.records) diff --git a/test/test_clang.py b/test/test_clang.py index a12f64e..adbc907 100644 --- a/test/test_clang.py +++ b/test/test_clang.py @@ -6,14 +6,23 @@ from textwrap import dedent + +import pytest + from ideas import ast -from clang.cindex import TranslationUnit, CursorKind +from clang.cindex import TranslationUnit, CursorKind, Diagnostic def parse_c(code: str) -> TranslationUnit: return ast.create_translation_unit(ast.CodeC(code)) +def parse_errors(code: str) -> list[str]: + # Bypasses create_translation_unit, which raises instead of reporting diagnostics + tu = TranslationUnit.from_source("file.c", unsaved_files=[("file.c", code)]) + return [d.spelling for d in tu.diagnostics if d.severity >= Diagnostic.Error] + + def test_basic_fns(): code = dedent( """ @@ -144,6 +153,60 @@ def test_forward_declaration(): assert "c:@F@return_stuff" in tr.symbols +def test_forward_declaration_of_anonymous_inline_tag_variable(): + code = dedent( + """ + struct { int x; } anon_var; + struct { int x; } anon_arr[3]; + union { int a; float b; } anon_union_var; + enum { A, B } anon_enum_var; + struct Tag { int y; } tagged_var; + static struct StaticTag { int z; } static_tagged_var[] = {{0}}; + """ + ) + tu = parse_c(code) + tr = ast.extract_info_c(tu) + + declarations = { + symbol.spelling: symbol.forward_declaration + for symbol in tr.symbols.values() + if symbol.is_variable + } + assert declarations["anon_var"] is None + assert declarations["anon_arr"] is None + assert declarations["anon_union_var"] is None + assert declarations["anon_enum_var"] is None + assert str(declarations["tagged_var"]).strip() == "extern struct Tag tagged_var;" + # `static struct StaticTag static_tagged_var[];` is a tentative definition, and the tag + # it names is only completed by the definition this would stand in for + assert declarations["static_tagged_var"] is None + + +def test_forward_declaration_of_anonymous_inline_tag_return_type(): + # Each `struct { ... }` mints a distinct type, so repeating one in a prototype conflicts + # with the definition. No valid forward declaration exists for these. + code = dedent( + """ + struct { int x; } anon_ret(void); + enum { A, B } anon_enum_ret(void); + struct Tag { int y; } tagged_ret(void); + int plain(int a); + """ + ) + tu = parse_c(code) + tr = ast.extract_info_c(tu) + + declarations = { + symbol.spelling: symbol.forward_declaration + for symbol in tr.symbols.values() + if symbol.is_function + } + assert declarations["anon_ret"] is None + assert declarations["anon_enum_ret"] is None + assert str(declarations["tagged_ret"]).strip() == "struct Tag tagged_ret(void);" + assert str(declarations["plain"]).strip() == "int plain(int a);" + + def test_fake_quotes_unicode(): # NOTE: Contains unicode character “ code = dedent( @@ -246,6 +309,50 @@ def test_nested_structs(): assert len(tr.complete_graph["c:@F@main"]) == 1 +def test_nested_structs_carry_their_bindgen_name(): + code = dedent( + """ + struct histindex { + struct record { + unsigned ptr; + } **records; + }; + + struct a { + struct b { + struct c { + int x; + } cc; + } bb; + }; + + struct outer { + struct { + struct hidden { int x; } h; + } anon; + }; + + void use(struct histindex *h, struct a *p, struct outer *o) {} + """ + ) + tu = parse_c(code) + tr = ast.extract_info_c(tu) + + # C keeps tags in a flat namespace, so the USR of a nested record is unqualified + assert tr.symbols["c:@S@histindex"].bindgen_name == "histindex" + assert tr.symbols["c:@S@record"].bindgen_name == "histindex_record" + + # The whole chain is walked, not just the outermost declaration + assert tr.symbols["c:@S@c"].bindgen_name == "a_b_c" + + # bindgen invents a counter-dependent name for an anonymous record, so it is as + # unnameable as anything nested inside it + assert tr.symbols["c:@S@hidden"].bindgen_name is None + assert any( + s.bindgen_name is None and s.kind == CursorKind.STRUCT_DECL for s in tr.symbols.values() + ) + + def test_forward_typedef_struct(): code = dedent( """ @@ -708,6 +815,72 @@ def test_clang_make_extern_multiple_declarations(tmp_path): assert "{" not in transformed +def test_clang_make_weak_definition_only(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + int f(int x); + int f(int x) { + return x + 1; + } + """ + ) + ) + + ast.clang_make_weak_(c_path, "f") + transformed = c_path.read_text() + + assert "__attribute__((weak)) int f(int x) {" in transformed + assert transformed.count("__attribute__((weak))") == 1 + + +def test_clang_make_weak_is_idempotent(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + int f(int x) { + return x + 1; + } + """ + ) + ) + + ast.clang_make_weak_(c_path, "f") + once = c_path.read_text() + ast.clang_make_weak_(c_path, "f") + + assert c_path.read_text() == once + + +def test_clang_make_weak_missing_symbol(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text("int f(int x) { return x; }\n") + + with pytest.raises(ValueError): + ast.clang_make_weak_(c_path, "main") + + +def test_clang_make_extern_strips_weak_attribute(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + __attribute__((weak)) int f(int x) { + return x + 1; + } + """ + ) + ) + + ast.clang_make_extern_(c_path, "f") + transformed = c_path.read_text() + + assert "__attribute__" not in transformed + assert transformed.strip() == "extern int f(int x);" + + def test_clang_make_global_multiple_declarations(tmp_path): c_path = tmp_path / "input.c" c_path.write_text( @@ -729,6 +902,28 @@ def test_clang_make_global_multiple_declarations(tmp_path): assert transformed.count("int v;") == 2 +def test_clang_make_global_variable_with_inline_struct_definition(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + static const struct typelen { + const char *type; + int length; + } typelen[] = {{"seconds", 1}, {"minutes", 60}}; + """ + ) + ) + + ast.clang_make_global_(c_path, "typelen") + transformed = c_path.read_text() + + assert "static" not in transformed + # The tag shares the variable's name, so a duplicated body would redefine the struct + assert transformed.count("struct typelen") == 1 + assert parse_errors(transformed) == [] + + def test_clang_make_bindable_function_multiple_declarations(tmp_path): c_path = tmp_path / "input.c" c_path.write_text( @@ -799,3 +994,172 @@ def test_clang_make_bindable_variable_array_without_initializer(tmp_path): transformed = c_path.read_text() assert transformed == "extern int array[3];\nint array[3];\n" + + +def test_clang_make_bindable_variable_with_inline_struct_definition(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + static const struct typelen { + const char *type; + int length; + } typelen[] = {{"seconds", 1}, {"minutes", 60}}; + """ + ) + ) + + ast.clang_make_bindable_(c_path, "typelen") + transformed = c_path.read_text() + + # Aggregates already bind as `pub static`, so only the linkage change is needed and a + # synthesized declaration would redefine the tag + assert parse_errors(transformed) == [] + assert "static" not in transformed + assert "extern" not in transformed + assert transformed.count("struct typelen") == 1 + + +def test_clang_make_bindable_variable_with_anonymous_struct_definition(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + static const struct { + int parent; + const char *name; + } items[] = {{1, "index"}, {2, "objects"}}; + """ + ) + ) + + ast.clang_make_bindable_(c_path, "items") + transformed = c_path.read_text() + + # An anonymous record has no spelling, so no extern declaration of `items` is expressible + assert parse_errors(transformed) == [] + assert "static" not in transformed + assert "extern" not in transformed + + +def test_clang_make_bindable_variable_with_inline_enum_values(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text("static const enum tag { A = 1, B = 2 } e = A;\n") + + ast.clang_make_bindable_(c_path, "e") + transformed = c_path.read_text() + + # The enumerator `=` must not be mistaken for the initializer + assert parse_errors(transformed) == [] + assert transformed.count("enum tag") == 1 + + +def test_clang_rename_updates_declarations_and_call_sites(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + int main(int argc, char **argv); + int main(int argc, char **argv) { + return argv ? argc : 0; + } + int again(void) { return main(0, 0); } + """ + ) + ) + + ast.clang_rename_(c_path, {"main": "ideas_c_main"}) + transformed = c_path.read_text() + + assert parse_errors(transformed) == [] + assert "main(" not in transformed.replace("ideas_c_main(", "") + assert transformed.count("ideas_c_main") == 3 + + +def test_clang_rename_typedef_leaves_same_spelled_tag_alone(tmp_path): + # Tags and ordinary identifiers are separate namespaces, so `typedef struct s s;` + # names two entities with one spelling; renaming the typedef must not touch the tag, + # whose definition may live in a file this rename never visits. + c_path = tmp_path / "input.c" + c_path.write_text( + dedent( + """ + typedef struct session session; + struct session { int x; }; + int get(session *s) { return s->x; } + """ + ) + ) + + tu = ast.create_translation_unit(c_path) + assert tu.cursor is not None + usr = next( + cursor.get_usr() + for cursor in tu.cursor.walk_preorder() + if cursor.kind == CursorKind.TYPEDEF_DECL and cursor.spelling == "session" + ) + edits = ast.clang_rename(tu, {usr: "renamed_session"})[c_path.resolve()] + source = bytearray(c_path.read_bytes()) + for (start, end), replacement in sorted(edits.items(), reverse=True): + source[start:end] = replacement + transformed = source.decode() + + assert parse_errors(transformed) == [] + assert "typedef struct session renamed_session;" in transformed + assert "struct session { int x; };" in transformed + assert "int get(renamed_session *s)" in transformed + + +def test_clang_rename_updates_type_reference_in_macro_body(tmp_path): + header_path = tmp_path / "types.h" + header_path.write_text("typedef int old_type;\n#define CAST(value) ((old_type)(value))\n") + c_path = tmp_path / "input.c" + c_path.write_text('#include "types.h"\nold_type cast(int value) { return CAST(value); }\n') + + tu = ast.create_translation_unit(c_path) + assert tu.cursor is not None + usr = next( + cursor.get_usr() + for cursor in tu.cursor.walk_preorder() + if cursor.kind == CursorKind.TYPEDEF_DECL and cursor.spelling == "old_type" + ) + for path, edits in ast.clang_rename(tu, {usr: "new_type"}).items(): + source = bytearray(path.read_bytes()) + for (start, end), replacement in sorted(edits.items(), reverse=True): + source[start:end] = replacement + path.write_bytes(source) + + assert "#define CAST(value) ((new_type)(value))" in header_path.read_text() + ast.create_translation_unit(c_path) + + +def test_clang_rename_missing_symbol(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text("int f(void) { return 0; }\n") + + with pytest.raises(ValueError): + ast.clang_rename_(c_path, {"main": "ideas_c_main"}) + + +@pytest.mark.parametrize( + "source,expected", + [ + ("int main(void) { return 0; }\n", 0), + # Pre-C23 unspecified parameters read the same as `(void)` here + ("int main() { return 0; }\n", 0), + ("int main(int argc, char **argv) { return argv ? argc : 0; }\n", 2), + ("int main(int c, char **v, char **e) { return v && e ? c : 0; }\n", 3), + ], +) +def test_clang_function_arity(tmp_path, source, expected): + c_path = tmp_path / "input.c" + c_path.write_text(source) + + assert ast.clang_function_arity(c_path, "main") == expected + + +def test_clang_function_arity_ignores_declarations(tmp_path): + c_path = tmp_path / "input.c" + c_path.write_text("int main();\nint main(int argc, char **argv) { return argc + !argv; }\n") + + assert ast.clang_function_arity(c_path, "main") == 2 diff --git a/test/test_consolidate.py b/test/test_consolidate.py index c843c55..120bff0 100644 --- a/test/test_consolidate.py +++ b/test/test_consolidate.py @@ -1212,6 +1212,106 @@ def test_renamed_symbol_shadowed_by_local_variable( ) +@pytest.mark.parametrize("order", permutations(["a.c", "b.c"]), ids="-".join) +def test_byte_identical_local_symbols_are_distinct(tmp_path: Path, order: tuple[str, ...]): + tus = _build_tus( + tmp_path, + { + "a.c": """\ + struct parser { int value; }; + typedef struct parser parser; + static int helper(parser *p) { return p->value != 0; } + int a(parser *p) { return helper(p); } + """, + "b.c": """\ + struct parser { long value; }; + typedef struct parser parser; + static int helper(parser *p) { return p->value != 0; } + int b(parser *p) { return helper(p); } + """, + }, + ) + by_name = {tu.name: tu for tu in tus} + compile_commands = _write_compile_commands(tmp_path, [by_name[name] for name in order]) + + consolidated = consolidate_init(compile_commands) + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, ( + f"Consolidated code does not compile (distinct identical symbols collided):\n" + f"{error}\n\nConsolidated output:\n{consolidated}" + ) + + +def test_nested_forward_declaration_does_not_emit_parent_definition(tmp_path: Path): + [main_c] = _build_tus( + tmp_path, + { + "main.c": """\ + typedef struct walker walker; + struct walker { + struct detail *detail; + int (*visit)(walker *); + }; + struct detail { walker *walker; int value; }; + int run(walker *w) { return w->detail->value; } + """, + }, + ) + + consolidated = consolidate_init(_write_compile_commands(tmp_path, [main_c])) + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, f"Nested declaration emitted its parent definition too early:\n{error}" + + +@pytest.mark.parametrize("order", permutations(["a.c", "b.c"]), ids="-".join) +def test_cross_tu_prototype_is_preserved(tmp_path: Path, order: tuple[str, ...]): + tus = _build_tus( + tmp_path, + { + "a.c": """\ + int callback(void); + static int (*callbacks[])(void) = {callback}; + int invoke(void) { return callbacks[0](); } + """, + "b.c": """\ + int invoke(void); + int callback(void) { return invoke(); } + """, + }, + ) + by_name = {tu.name: tu for tu in tus} + compile_commands = _write_compile_commands(tmp_path, [by_name[name] for name in order]) + + consolidated = consolidate_init( + compile_commands, source_priority=[by_name["a.c"], by_name["b.c"]] + ) + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, f"Cross-TU prototype was discarded:\n{error}" + + +def test_va_list_callback_keeps_typedef_spelling(tmp_path: Path): + [main_c] = _build_tus( + tmp_path, + { + "main.c": """\ + #include + struct api { int (*consume)(va_list); }; + static int consume(va_list args) { (void)args; return 0; } + struct api api = {consume}; + int entry(void) { return api.consume != 0; } + """, + }, + ) + + consolidated = consolidate_init(_write_compile_commands(tmp_path, [main_c])) + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, f"va_list callback types became incompatible:\n{error}" + + @pytest.fixture(scope="module") def shared_header_rename_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: return _build_tus( @@ -1417,7 +1517,9 @@ def odr_struct_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: ) -@pytest.mark.xfail(reason="UB due to conflicting struct definitions in different TUs") +@pytest.mark.xfail( + strict=True, reason="UB due to conflicting struct definitions in different TUs" +) @pytest.mark.parametrize("order", permutations(["a.c", "b.c"]), ids="-".join) def test_struct_forward_decl_completed_differently_in_two_tus( odr_struct_tus: list[Path], order: tuple[str, ...] @@ -1498,8 +1600,9 @@ def odr_typedef_struct_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Pat @pytest.mark.xfail( + strict=True, reason="Shared typedef bound to a struct with conflicting completions cannot " - "be renamed to two spellings, leaving 'token' as an incomplete type." + "be renamed to two spellings, leaving 'token' as an incomplete type.", ) @pytest.mark.parametrize("order", permutations(["a.c", "b.c"]), ids="-".join) def test_typedef_struct_forward_decl_completed_differently_in_two_tus( @@ -1594,7 +1697,7 @@ def genuine_conflict_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path] ) -@pytest.mark.xfail(reason="Known limitation") +@pytest.mark.xfail(strict=True, reason="Known limitation") @pytest.mark.parametrize("order", permutations(["a.c", "b.c", "c.c"]), ids="-".join) def test_shared_header_entity_inside_genuine_conflict_gets_single_spelling( genuine_conflict_tus: list[Path], order: tuple[str, ...] @@ -1682,6 +1785,7 @@ def cv_qualified_alias_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Pat @pytest.mark.xfail( + strict=True, reason="A cv-qualified typedef is a type of its own, so it cannot adopt the split " "tag's spelling and is rejected rather than silently losing its qualifier.", ) @@ -1775,6 +1879,7 @@ def chained_alias_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: @pytest.mark.xfail( + strict=True, reason="A typedef of a typedef must be rebuilt on the link it names rather than " "collapsed onto the tag, so it is rejected instead of flattening the chain.", ) @@ -1893,3 +1998,127 @@ def test_header_defined_gnu_source_with_default_source_flag( f"Consolidated code does not compile (header-defined _GNU_SOURCE lost):\n{error}\n\n" f"Consolidated output:\n{consolidated}" ) + + +@pytest.fixture(scope="module") +def typedef_shadowed_by_function_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + return _build_tus( + tmp_path_factory.mktemp("typedef_shadowed_by_function"), + { + "common.h": """\ + typedef int slot; + """, + # `slot_alias` is written per-TU rather than shared, so its two definitions have + # different presumed paths and it reaches the rename check + "a.c": """\ + #include "common.h" + + typedef slot slot_alias; + + int use_a(slot_alias v) { return (int)v; } + """, + "b.c": """\ + #include "common.h" + + typedef slot slot_alias; + + int use_b(slot_alias v) { return (int)v + 1; } + """, + "c.c": """\ + static int slot(void) { return 0; } + + int use_c(void) { return slot(); } + """, + }, + ) + + +@pytest.mark.parametrize("order", permutations(["a.c", "b.c", "c.c"]), ids="-".join) +def test_typedef_shadowed_by_function_does_not_split_its_aliases( + typedef_shadowed_by_function_tus: list[Path], order: tuple[str, ...] +): + # A tag spelling carries its keyword, but a typedef does not, so `typedef int slot;` and + # `static int slot(void);` land under one spelling. The function says nothing about what + # the typedef denotes: every TU that reads `slot` as a type reads the same one, so + # `slot_alias` still names a single type and must keep its spelling. + base = typedef_shadowed_by_function_tus[0].parent + by_name = {tu.name: tu for tu in typedef_shadowed_by_function_tus} + + compile_commands = _write_compile_commands( + base, [by_name[n] for n in order], extra_flags=f"-I{base}" + ) + consolidated = consolidate_init(compile_commands, source_priority=[]) + + text = str(consolidated) + assert "a_slot_alias" not in text and "b_slot_alias" not in text, consolidated + # One reading of the type means one typedef survives the dedupe + assert text.count("slot_alias;") == 1, consolidated + + # The typedef and the function genuinely collide, so that spelling is still split + assert "int use_c(void) { return slot(); }" not in text, consolidated + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, ( + f"Consolidated code does not compile:\n{error}\n\nConsolidated output:\n{consolidated}" + ) + + +@pytest.fixture(scope="module") +def rename_target_taken_by_tag_tus(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + base = tmp_path_factory.mktemp("rename_target_taken_by_tag") + return _build_tus( + base, + { + # A system header, so its cursors are absent from `local_names` and the tag is + # known only by its qualified spelling + "sysinc/fake_sys.h": """\ + struct a_S { long sys_field; }; + """, + # `struct S` is completed differently here and in b.c, so a.c's copy is renamed + # to `a_S` -- the spelling the system header already gives a different struct + "a.c": """\ + struct S { int a; }; + + int use_a(struct S *p) { return p->a; } + """, + "b.c": """\ + struct S { char *b; }; + + int use_b(struct S *p) { return (int)*p->b; } + """, + "c.c": """\ + #include + + int use_c(struct a_S *p) { return (int)p->sys_field; } + """, + }, + cflags=[f"-isystem{base / 'sysinc'}"], + ) + + +@pytest.mark.parametrize("order", permutations(["a.c", "b.c", "c.c"]), ids="-".join) +def test_rename_does_not_take_a_spelling_a_tag_already_holds( + rename_target_taken_by_tag_tus: list[Path], order: tuple[str, ...] +): + # Candidate spellings are unqualified, so the "already taken" guard has to be checked + # against unqualified names. A tag is only ever keyed as `struct a_S`, which never + # matches the candidate `a_S`, and the single rename pass gets no chance to notice that + # the rename landed on top of an existing struct. + base = rename_target_taken_by_tag_tus[0].parent + by_name = {tu.name: tu for tu in rename_target_taken_by_tag_tus} + + compile_commands = _write_compile_commands( + base, [by_name[n] for n in order], extra_flags=f"-isystem{base / 'sysinc'}" + ) + consolidated = consolidate_init(compile_commands, source_priority=[]) + + text = str(consolidated) + # The system header's struct is the only one entitled to that tag + assert "long sys_field;" in text, consolidated + assert text.count("struct a_S {") == 1, consolidated + + success, error = compile_c(consolidated, flags=["-Wall", "-Werror"]) + assert success, ( + f"Consolidated code does not compile (rename collided with an existing tag):\n" + f"{error}\n\nConsolidated output:\n{consolidated}" + ) diff --git a/test/test_oracle.py b/test/test_oracle.py new file mode 100644 index 0000000..e484d94 --- /dev/null +++ b/test/test_oracle.py @@ -0,0 +1,284 @@ +# +# Copyright (C) 2026 Intel Corporation +# +# SPDX-License-Identifier: Apache-2.0 +# + +import json +from unittest.mock import MagicMock + +import pytest + +# Module-qualified so pytest does not try to collect `TestOracle` as a test class +from ideas import oracle as oracle_mod +from ideas.ast import CodeC +from ideas.ast_rust import CodeRust +from ideas.oracle import UntrustedBaselineError + +_TESTS = "smoke" + + +def _libtest_json(**events: str) -> str: + return "\n".join( + json.dumps({"type": "test", "name": f"{_TESTS}${name}", "event": event}) + for name, event in events.items() + ) + + +# The crate comes back too so a test can stub or inspect the cargo calls +def _oracle(*runs: tuple, share: bool = False) -> tuple[oracle_mod.TestOracle, MagicMock]: + crate = MagicMock() + crate.name = "foo" + crate.cargo_build.return_value = (True, "") + crate.cargo_test.side_effect = [ + # baseline's build_only pre-pass: crate and test harness compile + (True, "", "", 0), + *runs, + ] + return oracle_mod.TestOracle(crate, _TESTS, share_evidence=share), crate + + +def _symbol(name: str = "c:@F@bar") -> MagicMock: + symbol = MagicMock() + symbol.name = name + return symbol + + +def _candidate(name: str = "c:@F@bar", *, wrapped: bool = True) -> oracle_mod.Candidate: + return oracle_mod.Candidate( + symbol=_symbol(name), + snippet=CodeC("int bar(void) { return 0; }"), + translation=CodeRust("pub fn bar() {}"), + wrapper=CodeRust("// wrapper") if wrapped else None, + wrapped=wrapped, + ) + + +def test_baseline_excludes_only_the_broken_ones() -> None: + # One test passes, one already fails against the untranslated C + oracle, _ = _oracle((False, _libtest_json(works="ok", broken="failed"), "", 101)) + + oracle.baseline() + assert oracle.baseline_failures == {"broken"} + + +def test_baseline_does_not_disable_ignored_tests() -> None: + # An ignored test never ran, so it is not a failure to exclude + oracle, _ = _oracle((True, _libtest_json(works="ok", skipped="ignored"), "", 0)) + + oracle.baseline() + assert oracle.baseline_failures == set() + + +def test_a_run_records_its_output_for_the_next_commit(tmp_path) -> None: + output = _libtest_json(works="ok") + oracle, crate = _oracle((True, output, "Summary [0.1s] 1 test run: 1 passed", 0)) + crate.cargo_toml = tmp_path / "Cargo.toml" + + oracle.baseline() + + events = tmp_path / f"cargo_{_TESTS}.jsonl" + log = tmp_path / f"cargo_{_TESTS}.log" + assert events.read_text() == output + assert log.read_text() == "Summary [0.1s] 1 test run: 1 passed" + # Staged only: the caller's commit is what puts the run in the history + crate.vcs.add.assert_called_once_with(events, log, force=True) + crate.vcs.commit.assert_not_called() + + +@pytest.mark.parametrize( + "output", + [ + _libtest_json(broken="failed"), + _libtest_json(skipped="ignored"), + _libtest_json(broken="failed", skipped="ignored"), + "", + ], + ids=["all_broken", "all_ignored", "broken_and_ignored", "never_ran"], +) +def test_baseline_aborts_when_nothing_can_judge(output: str) -> None: + oracle, _ = _oracle((False, output, "", 101)) + + with pytest.raises(UntrustedBaselineError, match="tests are broken"): + oracle.baseline() + + +def test_baseline_aborts_when_the_crate_does_not_build() -> None: + oracle, crate = _oracle() + crate.cargo_build.return_value = (False, "undefined reference") + + with pytest.raises(UntrustedBaselineError, match="does not build before translation"): + oracle.baseline() + + +def test_baseline_failure_alone_does_not_blame_the_symbol() -> None: + oracle, crate = _oracle( + (False, _libtest_json(works="ok", broken="failed"), "", 101), + # `broken` is excluded, so the run is red only for what this symbol did + (True, _libtest_json(works="ok"), "", 0), + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert not verdict.rejected + assert verdict.blamed == set() + assert crate.cargo_test.call_args.kwargs["skip"] == ["broken"] + + +def test_audit_reports_a_suite_the_stranded_crate_cannot_carry() -> None: + oracle, crate = _oracle( + (False, _libtest_json(works="ok", broken="failed"), "", 101), + (False, "", "undefined reference to `bar'", 101), + ) + oracle.baseline() + + findings = oracle.audit() + + assert "undefined reference" in findings + # The audit judges the translation, so it excludes only what the C already failed + assert crate.cargo_test.call_args.kwargs["skip"] == ["broken"] + + +def test_audit_of_a_green_suite_finds_nothing() -> None: + oracle, _ = _oracle( + (True, _libtest_json(works="ok"), "", 0), + (True, _libtest_json(works="ok"), "", 0), + ) + oracle.baseline() + + assert oracle.audit() == "" + + +def test_audit_rejects_a_zero_exit_run_that_dropped_a_test() -> None: + oracle, _ = _oracle( + (True, _libtest_json(works="ok", also="ok"), "", 0), + # The stranded crate ran only half the suite and still exited clean + (True, _libtest_json(works="ok"), "0 tests skipped", 0), + ) + oracle.baseline() + + assert "did not run against the stranded crate: also" in oracle.audit() + + +def test_new_failure_beside_a_baseline_failure_is_blamed() -> None: + oracle, _ = _oracle( + (False, _libtest_json(works="ok", fresh="ok", broken="failed"), "", 101), + (False, _libtest_json(works="ok", fresh="failed"), "1 test failed", 101), + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert verdict.rejected + assert verdict.blamed == {"fresh"} + assert "Regressed test(s): fresh" in verdict.evidence + + +def test_feedback_never_carries_ported_suite_evidence() -> None: + # The ported suite is the ground-truth signal, so a model that can read which test broke + # and how can special-case it instead of fixing the translation + oracle, _ = _oracle( + (True, _libtest_json(alpha="ok", checks_utf8_boundary="ok"), "", 0), + ( + False, + _libtest_json(alpha="ok", checks_utf8_boundary="failed"), + "assertion `left == right` failed\n left: 4\n right: 3", + 101, + ), + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert verdict.rejected + assert "checks_utf8_boundary" in verdict.evidence + assert "left: 4" in verdict.evidence + # Neither the name of the broken test nor what it asserted may reach the model + assert verdict.feedback and verdict.wrap_feedback + for text in (verdict.feedback, verdict.wrap_feedback): + assert "checks_utf8_boundary" not in text + assert "assertion" not in text + assert "left: 4" not in text + + +def test_share_evidence_opens_the_firewall() -> None: + # Opt-in experiment: measure what the model does when it can see the failure + oracle, _ = _oracle( + (True, _libtest_json(alpha="ok", checks_utf8_boundary="ok"), "", 0), + ( + False, + _libtest_json(alpha="ok", checks_utf8_boundary="failed"), + "assertion `left == right` failed\n left: 4\n right: 3", + 101, + ), + share=True, + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert verdict.rejected + for text in (verdict.feedback, verdict.wrap_feedback): + assert "checks_utf8_boundary" in text + assert "left: 4" in text + + +def test_share_evidence_stays_quiet_when_nothing_regressed() -> None: + oracle, _ = _oracle( + (True, _libtest_json(alpha="ok"), "", 0), + (True, _libtest_json(alpha="ok"), "", 0), + share=True, + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert not verdict.rejected + assert not verdict.feedback and not verdict.wrap_feedback + + +def test_truncated_test_run_is_not_a_pass() -> None: + # cargo reports a pass, but a test the baseline ran never reported at all + oracle, _ = _oracle( + (True, _libtest_json(alpha="ok", beta="ok"), "", 0), + (True, _libtest_json(alpha="ok"), "", 0), + ) + oracle.baseline() + + verdict = oracle.judge(_candidate()) + + assert verdict.rejected + assert verdict.blamed == {"beta"} + assert "beta (did not run)" in verdict.evidence + + +def test_a_regression_does_not_join_the_baseline() -> None: + oracle, _ = _oracle( + (False, _libtest_json(works="ok", fresh="ok", broken="failed"), "", 101), + (False, _libtest_json(works="ok", fresh="failed"), "1 test failed", 101), + ) + oracle.baseline() + verdict = oracle.judge(_candidate()) + oracle.blame(("c:@F@bar",), verdict.blamed) + + # The baseline is what the C fails; a regression is accounted for via regressions instead + assert oracle.baseline_failures == {"broken"} + assert oracle.regressions == {"fresh": ("c:@F@bar",)} + + +def test_a_blamed_test_does_not_judge_a_later_symbol() -> None: + oracle, _ = _oracle( + (False, _libtest_json(works="ok", broken="failed"), "", 101), + (False, _libtest_json(works="failed"), "1 test failed", 101), + (False, _libtest_json(works="failed"), "1 test failed", 101), + ) + oracle.baseline() + oracle.blame(("c:@F@bar",), oracle.judge(_candidate("c:@F@bar")).blamed) + + verdict = oracle.judge(_candidate("c:@F@baz")) + + assert not verdict.rejected + assert verdict.blamed == set() + # Every remaining test is owned elsewhere, so none of them says anything about this symbol + assert "Skipped testing symbol" in verdict.message diff --git a/test/test_translate_recurrent.py b/test/test_translate_recurrent.py index 7662432..8338e58 100644 --- a/test/test_translate_recurrent.py +++ b/test/test_translate_recurrent.py @@ -4,8 +4,11 @@ # SPDX-License-Identifier: Apache-2.0 # +import json import pytest from pathlib import Path +from textwrap import dedent +from dataclasses import fields from unittest.mock import MagicMock, patch import dspy @@ -13,12 +16,18 @@ from ideas import ast from ideas.ast import CodeC -from ideas.ast_rust import CodeRust +from ideas.ast_rust import BindgenName, CodeRust from ideas.consolidate import get_symbols_and_dependencies, create_ast_order from ideas.translate_recurrent import RecurrentTranslator -from ideas.translate_recurrent import SymbolGroup, SymbolName, TranslationContext + +# Module-qualified so pytest does not try to collect `TestOracle` as a test class +from ideas import oracle as oracle_mod +from ideas.translate_recurrent import SymbolGroup, SymbolName +from ideas.translate_context import TranslationContext from ideas.translate_snippet import SnippetTranslator -from ideas.wrapper import WrapperGenerator +from ideas.tools import check_rust +from ideas.wrapper import C_INTEROP_TRAIT, WrapperGenerator, bindgen_types +from ideas.wrapper import split_items, wrap_values def _make_graph( @@ -38,6 +47,32 @@ def _make_graph( return G, groups, symbols +# Properties live on the class; fields without defaults only show up via `fields` +_SYMBOL_PREDICATES = {n for n in dir(ast.Symbol) if n.startswith("is_")} | { + f.name for f in fields(ast.Symbol) if f.name.startswith("is_") +} + + +def _mock_function_symbol(sym: SymbolName, spelling: str) -> MagicMock: + mock_symbol = MagicMock() + # MagicMock invents truthy attributes, so a predicate added to `Symbol` would otherwise + # silently flip on every mock symbol here + for predicate in _SYMBOL_PREDICATES: + setattr(mock_symbol, predicate, False) + mock_symbol.name = sym + mock_symbol.spelling = spelling + mock_symbol.code = CodeC(f"int {spelling}(void) {{ return 0; }}") + mock_symbol.forward_declaration = CodeC(f"int {spelling}(void);") + mock_symbol.is_function = True + mock_symbol.is_definition = True + mock_symbol.difficulty = 0 + return mock_symbol + + +def _mock_bar_symbol(sym: SymbolName) -> MagicMock: + return _mock_function_symbol(sym, "bar") + + def test_context_isolated_node(): G, groups, symbols = _make_graph(ast.CodeC("int standalone(void) { return 42; }")) @@ -46,10 +81,10 @@ def test_context_isolated_node(): ctx = TranslationContext.build(G, func_group, groups, symbols) # An isolated node has no dependents, no dependencies, and nothing translated yet. - assert str(ctx.dependent_code) == "" - assert str(ctx.support_code) == "" - assert str(ctx.crate_code) == "" - assert str(ctx.reference_code) == "" + assert str(ctx.translate.dependent_code) == "" + assert str(ctx.wrap.translated_code) == "" + assert str(ctx.translate.crate_code) == "" + assert str(ctx.translate.reference_code) == "" _TYPEDEF_CHAIN = ast.CodeC(""" @@ -97,10 +132,10 @@ def test_context_typedef_indirection(): ctx = TranslationContext.build(G, struct_group, groups, symbols) # typedef is the immediate predecessor — should be in dependent_code - assert symbols["c:file.c@T@my_struct_t"].code in ctx.dependent_code + assert symbols["c:file.c@T@my_struct_t"].code in ctx.translate.dependent_code # use_my consumes the struct (via typedef); its call signature reveals whether # the type is passed by value or pointer, which informs Copy/Clone/borrowing decisions - assert symbols["c:@F@use_my"].code in ctx.dependent_code + assert symbols["c:@F@use_my"].code in ctx.translate.dependent_code def test_context_linear_chain_middle(): @@ -115,11 +150,11 @@ def test_context_linear_chain_middle(): ctx = TranslationContext.build(G, typedef_group, groups, symbols, translations=translations) # function is the immediate predecessor of typedef in the dependency graph - assert symbols["c:@F@use_my"].code in ctx.dependent_code + assert symbols["c:@F@use_my"].code in ctx.translate.dependent_code # struct is the immediate successor of typedef → full code used - assert symbols["c:@S@MyStruct"].code in ctx.support_code + assert symbols["c:@S@MyStruct"].code in ctx.wrap.translated_code # struct has been translated so its Rust code appears in crate_code - assert struct_translation in ctx.crate_code + assert struct_translation in ctx.translate.crate_code def test_context_nested_struct_fields(): @@ -130,10 +165,10 @@ def test_context_nested_struct_fields(): ctx = TranslationContext.build(G, inner_group, groups, symbols) # Outer embeds Inner — should be in dependent_code - assert symbols["c:@S@Outer"].code in ctx.dependent_code + assert symbols["c:@S@Outer"].code in ctx.translate.dependent_code # process_outer shows how Outer (and by extension Inner) is used via pointer, # informing borrowing semantics for Inner's translation - assert symbols["c:@F@process_outer"].code in ctx.dependent_code + assert symbols["c:@F@process_outer"].code in ctx.translate.dependent_code def test_context_nested_typedefs(): @@ -144,11 +179,11 @@ def test_context_nested_typedefs(): ctx = TranslationContext.build(G, struct_group, groups, symbols) # point_t is 1 hop — should be in dependent_code - assert symbols["c:file.c@T@point_t"].code in ctx.dependent_code + assert symbols["c:file.c@T@point_t"].code in ctx.translate.dependent_code # point_ptr_t aliases a pointer to point_t — still part of the usage chain - assert symbols["c:file.c@T@point_ptr_t"].code in ctx.dependent_code + assert symbols["c:file.c@T@point_ptr_t"].code in ctx.translate.dependent_code # use_point is the actual consumer — reveals how the pointer type is used - assert symbols["c:@F@use_point"].code in ctx.dependent_code + assert symbols["c:@F@use_point"].code in ctx.translate.dependent_code def test_context_enum_typedef(): @@ -159,10 +194,10 @@ def test_context_enum_typedef(): ctx = TranslationContext.build(G, enum_group, groups, symbols) # status_t is the immediate predecessor — should be in dependent_code - assert symbols["c:file.c@T@status_t"].code in ctx.dependent_code + assert symbols["c:file.c@T@status_t"].code in ctx.translate.dependent_code # check_status uses the enum; its usage informs how Status should be # represented in Rust (e.g., as a plain enum vs. integer newtype) - assert symbols["c:@F@check_status"].code in ctx.dependent_code + assert symbols["c:@F@check_status"].code in ctx.translate.dependent_code def test_context_variable_intermediate(): @@ -173,10 +208,10 @@ def test_context_variable_intermediate(): ctx = TranslationContext.build(G, struct_group, groups, symbols) # global_config is the immediate predecessor — should be in dependent_code - assert symbols["c:@global_config"].code in ctx.dependent_code + assert symbols["c:@global_config"].code in ctx.translate.dependent_code # get_timeout reveals that Config backs global state, which informs # Rust's ownership and synchronization strategy (Mutex, OnceCell, etc.) - assert symbols["c:@F@get_timeout"].code in ctx.dependent_code + assert symbols["c:@F@get_timeout"].code in ctx.translate.dependent_code def test_reference_code_none_hop(): @@ -194,16 +229,98 @@ def test_reference_code_none_hop(): # The None case falls through to strip_fns(..., delete=True), which deletes top-level # functions but keeps types as-is, since a type may still be needed even when the # dependency on it is not visible to static analysis. - assert a_translation in ctx.reference_code + assert a_translation in ctx.translate.reference_code + + +# `bucket_lookup` is the only direct user of the struct; the `tiny_*` helpers reach +# `to_be_translated` only as its dependencies, so they never reference the struct. +_BUDGETED_USERS = ast.CodeC(""" +struct Bucket { unsigned int *flags; char **keys; }; + +int tiny_01(void) { return 1; } +int tiny_02(void) { return 2; } +int tiny_03(void) { return 3; } +int tiny_04(void) { return 4; } +int tiny_05(void) { return 5; } +int tiny_06(void) { return 6; } +int tiny_07(void) { return 7; } +int tiny_08(void) { return 8; } + +int bucket_lookup(struct Bucket *b, unsigned int i) +{ + if ((b->flags[i >> 4] >> ((i & 0xfU) << 1)) & 3) + return -1; + return (b->keys[i] != 0) + tiny_01() + tiny_02() + tiny_03() + tiny_04() + + tiny_05() + tiny_06() + tiny_07() + tiny_08(); +} +""") + +_TINY_NAMES = [f"c:@F@tiny_{i:02d}" for i in range(1, 9)] + + +def test_dependent_code_prefers_direct_users(monkeypatch: pytest.MonkeyPatch): + G, groups, symbols = _make_graph(_BUDGETED_USERS) + + struct_group: SymbolGroup = ("c:@S@Bucket",) + user = symbols["c:@F@bucket_lookup"].code + # Exactly enough budget for the one direct user, so nothing else can fit + monkeypatch.setattr("ideas.translate_context.MAX_DEPENDENT_CHARS", len(str(user))) -def _make_translator(tmp_path: Path, **kwargs) -> tuple[RecurrentTranslator, Path, Path, Path]: + ctx = TranslationContext.build(G, struct_group, groups, symbols) + + assert user in ctx.translate.dependent_code + for name in _TINY_NAMES: + assert symbols[name].code not in ctx.translate.dependent_code + + +def test_dependent_code_does_not_spill_past_an_overflowing_tier( + monkeypatch: pytest.MonkeyPatch, +): + G, groups, symbols = _make_graph(_BUDGETED_USERS) + + struct_group: SymbolGroup = ("c:@S@Bucket",) + user = symbols["c:@F@bucket_lookup"].code + + # One char short of the direct user, which every `tiny_*` helper would still fit into + monkeypatch.setattr("ideas.translate_context.MAX_DEPENDENT_CHARS", len(str(user)) - 1) + + ctx = TranslationContext.build(G, struct_group, groups, symbols) + + assert str(ctx.translate.dependent_code) == "" + + +@pytest.mark.parametrize( + "code", + [ + "free(p);", + "git__free(p);", + "git__malloc(n);", + "git__reallocarray(p, n, s);", + "xmalloc(n);", + "zfree(strm, p);", + ], +) +def test_memory_pattern_matches_wrapped_allocators(code: str): + assert TranslationContext._MEMORY_PATTERN.search(code) + + +def _make_translator( + tmp_path: Path, + *, + is_bin: bool = False, + c_src: str = "", + tests: str | None = None, + broken_tests: set[str] | None = None, + **kwargs, +) -> tuple[RecurrentTranslator, Path, Path, Path]: # -sys crate sys_src_dir = tmp_path / "sys" / "src" sys_src_dir.mkdir(parents=True) sys_lib = sys_src_dir / "lib.rs" sys_lib.write_bytes(b"") - sys_lib.with_suffix(".c").write_bytes(b"") + # Must be in place before construction: a bin translator rewrites `main` there + sys_lib.with_suffix(".c").write_text(c_src) sys_crate = MagicMock() sys_crate.lib_src_path = sys_lib @@ -216,7 +333,7 @@ def _make_translator(tmp_path: Path, **kwargs) -> tuple[RecurrentTranslator, Pat rs_crate = MagicMock() rs_crate.lib_src_path = rs_src_dir / "lib.rs" - rs_crate.main_src_path = None + rs_crate.main_src_path = rs_src_dir / "main.rs" if is_bin else None rs_crate.lib_name = "foo_rs" rs_crate.cargo_build.return_value = (True, "") @@ -227,18 +344,27 @@ def _make_translator(tmp_path: Path, **kwargs) -> tuple[RecurrentTranslator, Pat crate = MagicMock() crate.lib_src_path = hybrid_src_dir / "lib.rs" - crate.main_src_path = None + crate.main_src_path = hybrid_src_dir / "main.rs" if is_bin else None crate.src_dir = hybrid_src_dir crate.cargo_toml = tmp_path / "hybrid" / "Cargo.toml" crate.cargo_build.return_value = (True, "") + crate.cargo_test.return_value = (True, "", "", 0) # RecurrentTranslator with mocked inputs + oracle = oracle_mod.TestOracle(crate, tests) if tests is not None else None translator = RecurrentTranslator( sys_crate=sys_crate, crate=crate, rs_crate=rs_crate, + symbol_oracle=oracle, **kwargs, ) + + # Verification needs a real crate to run nextest against, so inject its result instead + def _stub_baseline() -> None: + translator._oracle._baseline_failures = frozenset(broken_tests or ()) # type: ignore[attr-defined] + + translator._oracle.baseline = _stub_baseline # type: ignore[method-assign] return ( translator, hybrid_src_dir / "lib.rs", @@ -259,7 +385,7 @@ def test_wrapper_only_restore_on_final_wrapper_failure(tmp_path: Path) -> None: # Create a WrapperGenerator backed by a mock LM that returns a unique but invalid # wrapper each attempt because validate_changes produces scope_feedback cause a failure - # because we patch ideas.translate_recurrent.generate_unimplemented_function_wrapper below + # because we patch ideas.hybrid.generate_unimplemented_function_wrapper below attempt = 0 def mock_wrapper_side_effect(*args, **kwargs): @@ -286,16 +412,7 @@ def mock_wrapper_side_effect(*args, **kwargs): # Construct symbols and dependencies for a single function `bar` to be wrapped sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -303,44 +420,110 @@ def mock_wrapper_side_effect(*args, **kwargs): # Write the C definition that clang_make_extern_ will extern-ify sys_c.write_text("int bar(void) { return 0; }") - # Create wrapper file with known content - wrap_bar = hybrid_lib.parent / "wrap_bar.rs" - original_content = "// previous good wrapper" - wrap_bar.write_text(original_content) + # A previously wrapped symbol, so the rollback has something it must preserve + original_wrapper = CodeRust("// previous good wrapper") + translator._hybrid.set_wrapper("c:@F@baz", original_wrapper) # Run the translator with patch( - "ideas.translate_recurrent.generate_unimplemented_function_wrapper", + "ideas.hybrid.generate_unimplemented_function_wrapper", return_value=CodeRust("pub fn bar() { unimplemented!() }"), ): pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success + assert pred.complete # Translator should have iterated twice assert mock_translator.call_count == 2 assert mock_wrapper.call_count == 2 - # Must have rolled back wrap_bar.rs to its pre-call state - assert wrap_bar.read_text() == original_content - - # Must not have duplicated `pub mod wrap_bar;` across retries - assert hybrid_lib.read_text().count("pub mod wrap_bar;") == 1 + # Rolling back must drop the failed wrapper while keeping the earlier one + assert translator._hybrid.wrappers == {"c:@F@baz": original_wrapper} - # Must not have created spurious wrapper files across retries - assert list(hybrid_lib.parent.glob("wrap_*.rs")) == [wrap_bar] + # Must not have duplicated the surviving wrapper across retries + assert hybrid_lib.read_text().count("// previous good wrapper") == 1 # Must not have duplicated the translation in the -rs crate across retries assert rs_lib.read_text().count("pub fn bar") == 1 - # Final try does not restore the C file because translation succeeded - assert "extern int bar(void);" in sys_c.read_text() - assert "{ return 0; }" not in sys_c.read_text() + # The C definition comes back with the failed wrapper, so something still defines `bar` + assert "extern int bar(void);" not in sys_c.read_text() + assert "{ return 0; }" in sys_c.read_text() # Translation must not bleed into the hybrid crate assert "pub fn bar" not in hybrid_lib.read_text() - # Module declaration must not bleed into the -rs crate - assert "pub mod wrap_bar;" not in rs_lib.read_text() + +def test_wrapper_only_restore_keeps_wrapped_peers_defined(tmp_path: Path) -> None: + # One group, two symbols: `baz` wraps cleanly and `bar` never does. Wrapping hands a + # symbol's definition from C to Rust, so a rollback that drops a wrapper must put the C + # definition back, or the symbol ends up defined in neither language. + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub fn baz() {}\npub fn bar() {}")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + baz_stub = CodeRust("pub fn baz_wrapper() -> i32 { unimplemented!() }") + bar_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") + baz_wrapper = CodeRust("pub fn baz_wrapper() -> i32 { 0 }") + + c_src_at_bar = "" + + def mock_wrapper_side_effect(*args, **kwargs): + nonlocal c_src_at_bar + # Keeping the template's shape leaves validate_changes nothing to complain about, so + # `baz` succeeds; anything else is off-template and fails every attempt. + if kwargs["example_wrapper"] == baz_stub: + return dspy.Prediction(wrapper=baz_wrapper) + c_src_at_bar = sys_c.read_text() + return dspy.Prediction(wrapper=CodeRust("// off-template wrapper")) + + mock_wrapper = MagicMock(side_effect=mock_wrapper_side_effect) + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper), # type: ignore[arg-type] + max_iters=1, + ) + + # max_iters=1 so the wrap failure lands directly on the `hybrid_only=True` branch + translator, hybrid_lib, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + + baz: SymbolName = "c:@F@baz" + bar: SymbolName = "c:@F@bar" + # One group, wrapped in tuple order: `baz` first and then `bar` + sym_group: SymbolGroup = (baz, bar) + symbols: dict[SymbolName, MagicMock] = { + baz: _mock_function_symbol(baz, "baz"), + bar: _mock_function_symbol(bar, "bar"), + } + dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} + + sys_c.write_text("int baz(void) { return 0; }\nint bar(void) { return baz(); }\n") + + with patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + side_effect=lambda _path, spelling, _types: baz_stub if spelling == "baz" else bar_stub, + ): + translator(symbols=symbols, dependencies=dependencies) + + # Sanity: by the time `bar` was wrapped, `baz` had handed its definition over to Rust + assert "extern int baz(void);" in c_src_at_bar + + # Every symbol the C source no longer defines must still have a wrapper standing in for it + c_src = sys_c.read_text() + externed = {s for s in ("baz", "bar") if f"extern int {s}(void);" in c_src} + wrapped = {symbols[name].spelling for name in translator._hybrid.wrappers} + assert externed <= wrapped, ( + f"undefined after rollback: {sorted(externed - wrapped)} " + "(C definition removed, no wrapper left)" + ) def test_full_restore_after_two_translation_failures(tmp_path: Path) -> None: @@ -377,16 +560,7 @@ def mock_translator_side_effect(*args, **kwargs): sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -394,18 +568,16 @@ def mock_translator_side_effect(*args, **kwargs): # Write known content so the restore assertions check for something specific, # not just whatever RecurrentTranslator.__init__ happened to leave behind. initial_rs_lib = "#![forbid(unsafe_code)]\n\n// known rs baseline\n" - initial_hybrid_lib = "use libfoo_sys as _;\n// known hybrid baseline\n" initial_c_src = "int bar(void) { return 0; }" - original_wrapper_content = "// previous good wrapper" + original_wrapper = CodeRust("// previous good wrapper") rs_lib.write_text(initial_rs_lib) - hybrid_lib.write_text(initial_hybrid_lib) sys_c.write_text(initial_c_src) - wrap_bar = hybrid_lib.parent / "wrap_bar.rs" - wrap_bar.write_text(original_wrapper_content) + translator._hybrid.set_wrapper("c:@F@baz", original_wrapper) + initial_hybrid_lib = hybrid_lib.read_text() # Run the translator pred = translator(symbols=symbols, dependencies=dependencies) - assert not pred.success + assert not pred.complete # Both iterations must have attempted translation assert mock_translator.call_count == 2 @@ -413,24 +585,18 @@ def mock_translator_side_effect(*args, **kwargs): # Full restore: -rs crate must be rolled back to its pre-call state assert rs_lib.read_text() == initial_rs_lib - # Full restore: hybrid crate must be rolled back to its pre-call state + # Full restore: the hybrid crate re-renders to its pre-call state assert hybrid_lib.read_text() == initial_hybrid_lib # Full restore: C source must be rolled back (clang_make_extern_ must not have run) assert sys_c.read_text() == initial_c_src - # Full restore: the pre-existing wrapper file must be intact - assert wrap_bar.read_text() == original_wrapper_content - - # No spurious wrapper files must have been created across the two iterations - assert list(hybrid_lib.parent.glob("wrap_*.rs")) == [wrap_bar] + # Full restore: the pre-existing wrapper must be intact and nothing added + assert translator._hybrid.wrappers == {"c:@F@baz": original_wrapper} # Translation must not bleed into the hybrid crate across retries assert "pub fn bar" not in hybrid_lib.read_text() - # Module declaration must not bleed into the -rs crate across retries - assert "pub mod wrap_bar;" not in rs_lib.read_text() - def test_no_restore_on_test_failure(tmp_path: Path) -> None: # Translation and wrapping both succeed; only the test run fails. @@ -444,10 +610,11 @@ def test_no_restore_on_test_failure(tmp_path: Path) -> None: max_iters=1, ) - # Returning the same CodeRust as the unimplemented wrapper means validate_changes - # finds no diffs → scope_feedback is empty → pred.success = True → wrapping succeeds. + # A wrapper that keeps the template's shape means validate_changes finds no diffs → + # scope_feedback is empty → pred.success = True → wrapping succeeds. unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") - mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=unimplemented_stub)) + bar_wrapper = CodeRust("pub fn bar_wrapper() -> i32 { 0 }") + mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=bar_wrapper)) symbol_wrapper = WrapperGenerator( wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] max_iters=1, @@ -461,27 +628,24 @@ def test_no_restore_on_test_failure(tmp_path: Path) -> None: max_iters=1, ) - # Make cargo_test fail so result.failure == "test" + # Make the test run fail so result.failure == "test" crate_mock: MagicMock = translator.crate # type: ignore[assignment] - crate_mock.cargo_test.return_value = ( - False, - '{"type":"test","name":"test_bar","event":"failed"}', - "1 test failed", - "", - ) + crate_mock.cargo_test.side_effect = [ + # forward init gate: the crate and its test target compile + (True, "", "", 0), + # _wrap_function fail-fast gate after the unimplemented wrapper is written + (True, "", "", 0), + # wrapper build gate: the wrapped crate and its test target compile + (True, "", "", 0), + # _test_symbol build_only pre-pass: crate and test harness compile + (True, "", "", 0), + # _test_symbol run: the test itself fails + (False, '{"type":"test","name":"test_bar","event":"failed"}', "1 test failed", 101), + ] sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -490,25 +654,23 @@ def test_no_restore_on_test_failure(tmp_path: Path) -> None: # Run the translator with patch( - "ideas.translate_recurrent.generate_unimplemented_function_wrapper", + "ideas.hybrid.generate_unimplemented_function_wrapper", return_value=unimplemented_stub, ): pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success + assert pred.complete + assert pred.regressions == {"test_bar": sym_group} # No restore: translation is kept in the -rs crate assert "pub fn bar" in rs_lib.read_text() - # No restore: wrapper module declaration is kept in the hybrid crate - assert "pub mod wrap_bar;" in hybrid_lib.read_text() + # No restore: the wrapper written during wrapping is kept and rendered + assert translator._hybrid.wrappers == {sym: bar_wrapper} + assert "pub fn bar_wrapper" in hybrid_lib.read_text() # No restore: C source is kept with the extern declaration written by clang_make_extern_ assert "extern int bar(void);" in sys_c.read_text() - # No restore: wrapper file written during wrapping is kept - wrap_bar = hybrid_lib.parent / "wrap_bar.rs" - assert wrap_bar.exists() - def test_feedback_after_test_failure(tmp_path: Path) -> None: # Translation and wrapping succeed on both iterations @@ -520,9 +682,11 @@ def test_feedback_after_test_failure(tmp_path: Path) -> None: max_iters=1, ) - # Returning the same CodeRust as the unimplemented wrapper means validate_changes passes + # A wrapper that keeps the template's shape means validate_changes passes unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") - mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=unimplemented_stub)) + mock_wrapper_gen = MagicMock( + return_value=dspy.Prediction(wrapper=CodeRust("pub fn bar_wrapper() -> i32 { 0 }")) + ) symbol_wrapper = WrapperGenerator( wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] max_iters=1, @@ -536,25 +700,32 @@ def test_feedback_after_test_failure(tmp_path: Path) -> None: max_iters=2, ) - # First cargo_test call fails, second succeeds. + # First test run fails, second succeeds. crate_mock: MagicMock = translator.crate # type: ignore[assignment] crate_mock.cargo_test.side_effect = [ - (False, '{"type":"test","name":"test_bar","event":"failed"}', "1 test failed", ""), - (True, '{"type":"test","name":"test_bar","event":"ok"}', "", ""), + # forward init gate + (True, "", "", 0), + # _wrap_function fail-fast gate (iter 1) + (True, "", "", 0), + # wrapper build gate (iter 1) + (True, "", "", 0), + # _test_symbol build_only pre-pass (iter 1) + (True, "", "", 0), + # _test_symbol run (iter 1): the test fails + (False, '{"type":"test","name":"test_bar","event":"failed"}', "1 test failed", 101), + # _wrap_function fail-fast gate (iter 2) + (True, "", "", 0), + # wrapper build gate (iter 2) + (True, "", "", 0), + # _test_symbol build_only pre-pass (iter 2) + (True, "", "", 0), + # _test_symbol run (iter 2): the test passes + (True, '{"type":"test","name":"test_bar","event":"ok"}', "", 0), ] sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -563,11 +734,11 @@ def test_feedback_after_test_failure(tmp_path: Path) -> None: # Run the translator with patch( - "ideas.translate_recurrent.generate_unimplemented_function_wrapper", + "ideas.hybrid.generate_unimplemented_function_wrapper", return_value=unimplemented_stub, ): pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success + assert pred.complete # Translator must have been called twice: once per outer iteration. assert mock_translator.call_count == 2 @@ -578,6 +749,14 @@ def test_feedback_after_test_failure(tmp_path: Path) -> None: # Test failures should tell the translator its output doesn't match the C behavior. assert "does not match the behavior" in second_call_feedback + # ... and which symbol was swapped in when the divergence appeared + assert "`bar`" in second_call_feedback + + # The wrapper is a suspect too, so it hears about the divergence in its own words + second_wrap_feedback: str = mock_wrapper_gen.call_args_list[1].kwargs["feedback"] + assert "changed the program's observable behavior" in second_wrap_feedback + assert "`bar`" in second_wrap_feedback + def test_feedback_after_wrap_failure(tmp_path: Path) -> None: # Translation succeeds on both iterations @@ -589,9 +768,11 @@ def test_feedback_after_wrap_failure(tmp_path: Path) -> None: max_iters=1, ) - # Returning the same CodeRust as the unimplemented wrapper means validate_changes passes + # A wrapper that keeps the template's shape means validate_changes passes unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") - mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=unimplemented_stub)) + mock_wrapper_gen = MagicMock( + return_value=dspy.Prediction(wrapper=CodeRust("pub fn bar_wrapper() -> i32 { 0 }")) + ) symbol_wrapper = WrapperGenerator( wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] max_iters=1, @@ -608,26 +789,18 @@ def test_feedback_after_wrap_failure(tmp_path: Path) -> None: # Force failure on the first wrapper build attempt, then succeed on the second. crate_mock: MagicMock = translator.crate # type: ignore[assignment] crate_mock.cargo_build.side_effect = [ + (True, ""), # forward's pre-commit check of the types and bindings (True, ""), # _wrap_function scaffold check (iter 1) (False, "build error"), # build(wrapper) inside WrapperGenerator (iter 1) (True, ""), # _wrap_function scaffold check (iter 2) (True, ""), # build(wrapper) inside WrapperGenerator (iter 2) - (True, ""), # _test_symbol pre-test cargo_build (iter 2) + (True, ""), # _test_symbol build gate (iter 2) ] crate_mock.cargo_test.return_value = (True, "", "", "") sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -636,11 +809,11 @@ def test_feedback_after_wrap_failure(tmp_path: Path) -> None: # Run the translator with patch( - "ideas.translate_recurrent.generate_unimplemented_function_wrapper", + "ideas.hybrid.generate_unimplemented_function_wrapper", return_value=unimplemented_stub, ): pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success + assert pred.complete # Translator must have been called twice: once per outer iteration. assert mock_translator.call_count == 2 @@ -651,6 +824,45 @@ def test_feedback_after_wrap_failure(tmp_path: Path) -> None: # Wrap failures should instruct the translator to produce wrapper-friendly code. assert "C-compatible FFI wrapper" in second_call_feedback + # The retry hands the wrapper generator its own failed wrapper, so the reason goes with it + second_wrap_feedback: str = mock_wrapper_gen.call_args_list[1].kwargs["build_feedback"] + assert "build error" in second_wrap_feedback + + +@pytest.mark.parametrize( + "kind, expected", + [ + ("variable", "`static` or `const`"), + ("type", "field by field"), + ("function", "argument and return types"), + ], +) +def test_wrap_feedback_matches_the_symbol_kind(kind: str, expected: str) -> None: + symbol = MagicMock() + symbol.spelling = "add_opts" + symbol.is_variable = kind == "variable" + symbol.is_type = kind == "type" + + candidate = oracle_mod.Candidate( + symbol=symbol, + snippet=CodeC("struct add_opts { int n; };"), + translation=CodeRust("// translation"), + wrapped=False, + wrap_rejection="error[E0277]: the trait bound is not satisfied", + ) + feedback = oracle_mod.NullOracle().judge(candidate).feedback + + assert "add_opts" in feedback + assert expected in feedback + # Advising "function boundaries" is what once turned a global into a factory function + assert "function boundaries" not in feedback + # A group is an SCC and can mix kinds, so the advice must not read as group-wide + assert "The guidance above is about `add_opts`" in feedback + # ... but a mutually recursive item may still have to change for the wrapper to work + assert "only where `add_opts` requires it" in feedback + # The compiler's own words are worth more than the generic prose around them + assert "error[E0277]" in feedback + def test_feedback_after_translate_failure(tmp_path: Path) -> None: # Translation fails on the first iteration (the -rs build rejects it) and succeeds @@ -681,16 +893,7 @@ def test_feedback_after_translate_failure(tmp_path: Path) -> None: sym: SymbolName = "c:@F@bar" sym_group: SymbolGroup = (sym,) - mock_symbol = MagicMock() - mock_symbol.name = sym - mock_symbol.spelling = "bar" - mock_symbol.code = CodeC("int bar(void) { return 0; }") - mock_symbol.llm_context_declaration = "int bar(void);" - mock_symbol.is_function = True - mock_symbol.is_definition = True - mock_symbol.is_global = False - mock_symbol.is_variable = False - mock_symbol.is_type = False + mock_symbol = _mock_bar_symbol(sym) symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} @@ -699,38 +902,135 @@ def test_feedback_after_translate_failure(tmp_path: Path) -> None: pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success + assert pred.complete # Translator must have been called twice: once per outer iteration. assert mock_translator.call_count == 2 # Extract the feedback forwarded to the translator on the second (retry) call. - second_call_feedback: str = mock_translator.call_args_list[1].kwargs["feedback"] + second_call_feedback: str = mock_translator.call_args_list[1].kwargs["build_feedback"] # The build error from the first iteration must be surfaced to the retry so the # translator knows why its previous output was rejected. assert "compile error" in second_call_feedback +def test_feedback_after_scope_violation(tmp_path: Path) -> None: + # The first translation repeats the crate-level attribute, which lib.rs already carries + mock_translator = MagicMock( + side_effect=[ + dspy.Prediction(translation=CodeRust("#![forbid(unsafe_code)]\npub fn bar() {}")), + dspy.Prediction(translation=CodeRust("pub fn bar() {}")), + ] + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + # tests=None: wrapping is skipped for non-global symbols, so only translation runs. + translator, hybrid_lib, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=MagicMock(), + tests=None, + max_iters=2, + ) + + # The attribute is caught before anything is built, so every build that runs succeeds + rs_crate_mock: MagicMock = translator.rust_crate # type: ignore[assignment] + rs_crate_mock.cargo_build.return_value = (True, "") + + sym: SymbolName = "c:@F@bar" + sym_group: SymbolGroup = (sym,) + mock_symbol = _mock_bar_symbol(sym) + + symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} + dependencies: dict[SymbolGroup, list[SymbolGroup]] = {sym_group: []} + + sys_c.write_text("int bar(void) { return 0; }") + + pred = translator(symbols=symbols, dependencies=dependencies) + + assert pred.complete + assert mock_translator.call_count == 2 + + retry = mock_translator.call_args_list[1].kwargs + + # A hard-constraint violation is not a compiler error, so it must reach the retry through + # `scope_feedback` and leave `build_feedback` empty + assert "Do not include" in retry["scope_feedback"] + assert retry["build_feedback"] == "" + + _TYPEDEF_STRUCT = ast.CodeC("typedef struct MyStruct { int x; } my_struct_t;") -# An unnamed struct still gets external linkage: C11 6.7.8p3 makes the typedef name the -# tag's name for linkage purposes, so clang reports spelling `my_struct_t` for the -# STRUCT_DECL and the wrapper is named after the typedef instead of the (absent) tag. +# clang gives an unnamed tag the typedef's name, so it reports spelling `my_struct_t` for +# the STRUCT_DECL, which is externally visible and names the wrapper after the typedef +# instead of the (absent) tag. _ANONYMOUS_TYPEDEF_STRUCT = ast.CodeC("typedef struct { int x; } my_struct_t;") +# A tag may reuse the name of its typedef +_SAME_NAME_TYPEDEF_STRUCT = ast.CodeC("typedef struct my_struct_t { int x; } my_struct_t;") + +_TYPEDEF_CONSUMER = "\nint use_it(my_struct_t s) { return s.x; }\n" + + +@pytest.mark.parametrize( + "c_source", + [_TYPEDEF_STRUCT, _ANONYMOUS_TYPEDEF_STRUCT, _SAME_NAME_TYPEDEF_STRUCT], + ids=["named_tag", "anonymous_tag", "tag_named_after_typedef"], +) +def test_typedef_struct_is_the_only_struct_symbol(c_source: CodeC) -> None: + # A consumer makes the type reachable from a global function, as in a real project. + # Naming the type by its alias leaves no reference to the tag, so the STRUCT_DECL is + # dropped as unreachable and the typedef is the only symbol left to carry the wrapper. + tu = ast.create_translation_unit(CodeC(str(c_source) + _TYPEDEF_CONSUMER)) + tree = ast.extract_info_c(tu) + symbols, _ = get_symbols_and_dependencies([tree]) + + # Exactly one symbol must claim the struct, or the same type is wrapped twice + structs = [s for s in symbols.values() if s.is_struct] + assert len(structs) == 1 + (struct,) = structs + + assert struct.is_type + assert struct.is_definition + + # `_wrap_type` allowlists this in bindgen, then resolves the struct it declares + assert struct.spelling == "my_struct_t" + + # `_wrap_type` gates on this, and the tag it aliases is visible in every shape + assert struct.is_externally_visible + @pytest.mark.parametrize( - ("c_source", "wrapper_name"), + ("c_source", "consumer", "type_name"), [ - (_TYPEDEF_STRUCT, "wrap_MyStruct"), - (_ANONYMOUS_TYPEDEF_STRUCT, "wrap_my_struct_t"), + (_TYPEDEF_STRUCT, "", "MyStruct"), + (_ANONYMOUS_TYPEDEF_STRUCT, "", "my_struct_t"), + (_SAME_NAME_TYPEDEF_STRUCT, "", "my_struct_t"), + (_TYPEDEF_STRUCT, _TYPEDEF_CONSUMER, "MyStruct"), + (_ANONYMOUS_TYPEDEF_STRUCT, _TYPEDEF_CONSUMER, "my_struct_t"), + (_SAME_NAME_TYPEDEF_STRUCT, _TYPEDEF_CONSUMER, "my_struct_t"), + ], + ids=[ + "named_tag", + "anonymous_tag", + "tag_named_after_typedef", + "named_tag_alias_only", + "anonymous_tag_alias_only", + "tag_named_after_typedef_alias_only", ], - ids=["named_tag", "anonymous_tag"], ) def test_type_wrapper_generated_for_typedef_struct( - tmp_path: Path, c_source: CodeC, wrapper_name: str + tmp_path: Path, c_source: CodeC, consumer: str, type_name: str ) -> None: + # Without a consumer the tag carries the wrapper; with one that names the type only by + # its alias the tag is pruned and the typedef carries it, and a typedef of a named tag + # has no linkage of its own, so gating on linkage alone would silently skip it. + source = CodeC(str(c_source) + consumer) + mock_translator = MagicMock( return_value=dspy.Prediction(translation=CodeRust("pub struct MyStruct { pub x: i32 }")) ) @@ -740,14 +1040,24 @@ def test_type_wrapper_generated_for_typedef_struct( ) # Returning the unimplemented template unchanged means validate_changes finds no - # diffs, so wrapping succeeds. Both round-trip tests must be present because - # `_wrap_type` rejects wrappers that drop them. + # diffs, so wrapping succeeds. Both round-trip tests must be present, and the impl + # header must name the type, because `_wrap_type` rejects wrappers that drop them. type_wrapper = CodeRust( - "pub unsafe fn c_to_r(_cs: *const MyStruct) -> () { todo!() }\n" - "pub unsafe fn r_to_c(_rs: &(), _cs: *mut MyStruct) { todo!() }\n" - "#[cfg(test)]\nmod tests {\n" - " #[test]\n fn round_trip_zeroed() { todo!() }\n" - " #[test]\n fn round_trip_nontrivial() { todo!() }\n}\n" + dedent(f""" + impl CInterop for {type_name} {{ + type Rust = libfoo_rs::{type_name}; + unsafe fn to_rust(_cs: *const Self) -> Self::Rust {{ todo!() }} + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) {{ todo!() }} + }} + + #[cfg(test)] + mod test_{type_name} {{ + #[test] + fn round_trip_zeroed() {{ todo!() }} + #[test] + fn round_trip_nontrivial() {{ todo!() }} + }} + """).strip() ) mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=type_wrapper)) symbol_wrapper = WrapperGenerator( @@ -766,64 +1076,119 @@ def test_type_wrapper_generated_for_typedef_struct( crate_mock.cargo_test.return_value = (True, "", "", "") # Parse real C so the struct/typedef symbols carry genuine kind and linkage flags - tu = ast.create_translation_unit(c_source) + tu = ast.create_translation_unit(source) tree = ast.extract_info_c(tu) symbols, dependencies = get_symbols_and_dependencies([tree]) - sys_c.write_text(str(c_source)) - - with patch( - "ideas.translate_recurrent.generate_unimplemented_type_wrapper", - return_value=type_wrapper, + sys_c.write_text(str(source)) + + with ( + patch( + "ideas.translate_recurrent.generate_unimplemented_type_wrapper", + return_value=(type_wrapper, f"test_{type_name}"), + ), + # A consumer is only there to prune the tag, so keep it out of the generator + patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + return_value=None, + ), ): pred = translator(symbols=symbols, dependencies=dependencies) - assert pred.success - - # The struct definition is wrappable, so its wrapper module must be on disk - wrap_struct = hybrid_lib.parent / f"{wrapper_name}.rs" - assert wrap_struct.read_text() == str(type_wrapper) + assert pred.complete - # ... and registered in the hybrid crate root exactly once - assert hybrid_lib.read_text().count(f"pub mod {wrapper_name};") == 1 + # The struct definition is wrappable, so its wrapper must be rendered into the root + assert list(translator._hybrid.wrappers.values()) == [type_wrapper] + assert str(type_wrapper) in hybrid_lib.read_text() - # The typedef alias shares the struct's code, so it must not produce a second wrapper - assert list(hybrid_lib.parent.glob("wrap_*.rs")) == [wrap_struct] + # The tag and its alias share one snippet, so they must not produce two wrappers assert mock_wrapper_gen.call_count == 1 + # The test filter keys on the module name, so it has to reach cargo test verbatim + assert f"test_{type_name}" in [ + call.args[0] for call in crate_mock.cargo_test.call_args_list + ] + # Wrapper must not bleed into the -rs crate - assert f"pub mod {wrapper_name};" not in rs_lib.read_text() + assert f"impl CInterop for {type_name}" not in rs_lib.read_text() -# Both symbols are global definitions and reach `_wrap_type`, but neither is a -# STRUCT_DECL, so there is no field-by-field `c_to_r`/`r_to_c` pair to generate. -_UNION_TYPEDEF = ast.CodeC("typedef union { int x; } u_t;") +# A self-referential struct names its own tag, so the tag is reachable and survives +# alongside the typedef, and the two form a cycle that puts them in one group. +_SELF_REFERENTIAL_SAME_NAME = ast.CodeC(""" +typedef struct MacroParam MacroParam; +struct MacroParam { MacroParam *next; int x; }; +int use_it(MacroParam *p) { return p->x; } +""") -# The STRUCT_DECL here is only a forward declaration (no fields to convert) and the -# typedef merely aliases it, so neither symbol is wrappable. -_STRUCT_ALIAS_TYPEDEF = ast.CodeC("typedef struct Foo foo_alias_t;") +# bindgen renders this pair as `pub struct Tag` plus `pub type Alias = Tag` +_SELF_REFERENTIAL_ALIAS = ast.CodeC(""" +typedef struct Tag Alias; +struct Tag { Alias *next; int x; }; +int use_it(Alias *p) { return p->x; } +""") + +# Naming both spellings keeps both reachable without making them mutually dependent, +# so they land in separate groups and the collision spans the whole run. +_TAG_AND_ALIAS_USED_SEPARATELY = ast.CodeC(""" +typedef struct Tag Alias; +struct Tag { int x; }; +int use_alias(Alias *p) { return p->x; } +int use_tag(struct Tag *p) { return p->x; } +""") + +# The body is written inside the typedef, so both symbols own it and both are wrap +# candidates. Both render the whole typedef as their code, so the snippet dedupe in +# `forward` drops the second group before it can emit a second impl. +_INLINE_DEF_TAG_AND_ALIAS = ast.CodeC(""" +typedef struct Tag { int x; } Alias; +int use_alias(Alias *p) { return p->x; } +int use_tag(struct Tag *p) { return p->x; } +""") @pytest.mark.parametrize( - "c_source", - [_UNION_TYPEDEF, _STRUCT_ALIAS_TYPEDEF], - ids=["union_typedef", "struct_alias_typedef"], + ("c_source", "tag_name", "struct_spellings"), + [ + (_SELF_REFERENTIAL_SAME_NAME, "MacroParam", ["MacroParam"]), + (_SELF_REFERENTIAL_ALIAS, "Tag", ["Tag"]), + (_TAG_AND_ALIAS_USED_SEPARATELY, "Tag", ["Tag"]), + (_INLINE_DEF_TAG_AND_ALIAS, "Tag", ["Alias", "Tag"]), + ], + ids=[ + "tag_named_after_typedef", + "named_tag_and_alias", + "separate_groups", + "inline_def_tag_and_alias", + ], ) -def test_no_type_wrapper_for_non_struct_definition(tmp_path: Path, c_source: CodeC) -> None: +def test_tag_and_typedef_share_one_type_wrapper( + tmp_path: Path, c_source: CodeC, tag_name: str, struct_spellings: list[str] +) -> None: + # A Rust type alias is not a distinct type, so wrapping both the tag and its typedef + # emits two `impl CInterop` blocks for one type, which rustc rejects with E0119. mock_translator = MagicMock( - return_value=dspy.Prediction(translation=CodeRust("pub struct Placeholder;")) + return_value=dspy.Prediction(translation=CodeRust(f"pub struct {tag_name} {{}}")) ) symbol_translator = SnippetTranslator( translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] max_iters=1, ) - mock_wrapper_gen = MagicMock() + # `_wrap_type` rejects the `type Rust = ()` placeholder, and the rest of the template + # comes back unchanged so validate_changes has no out-of-scope diff to reject + mock_wrapper_gen = MagicMock( + side_effect=lambda **kwargs: dspy.Prediction( + wrapper=CodeRust( + str(kwargs["example_wrapper"]).replace("type Rust = ();", "type Rust = u8;") + ) + ) + ) symbol_wrapper = WrapperGenerator( wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] max_iters=1, ) - translator, hybrid_lib, rs_lib, sys_c = _make_translator( + translator, hybrid_lib, _, sys_c = _make_translator( tmp_path, symbol_translator=symbol_translator, symbol_wrapper=symbol_wrapper, @@ -833,24 +1198,1194 @@ def test_no_type_wrapper_for_non_struct_definition(tmp_path: Path, c_source: Cod crate_mock: MagicMock = translator.crate # type: ignore[assignment] crate_mock.cargo_test.return_value = (True, "", "", "") + # Compile the rendered crate for real: E0119 is exactly what a mocked build hides. + # The -sys crate it links against does not exist here, so drop the `use` that names it. + def rustc_build(*_args, **_kwargs) -> tuple[bool, str]: + body = "\n".join( + line + for line in hybrid_lib.read_text().splitlines() + if not line.startswith("use libfoo_sys") + ) + return check_rust(body, flags=["--crate-type=lib", "--emit=metadata"]) + + crate_mock.cargo_build.side_effect = rustc_build + tu = ast.create_translation_unit(c_source) tree = ast.extract_info_c(tu) symbols, dependencies = get_symbols_and_dependencies([tree]) + # Both spellings survive as symbols; only those owning the body are wrap candidates + assert len([s for s in symbols.values() if s.is_type]) == 2 + assert sorted(s.spelling for s in symbols.values() if s.is_struct) == struct_spellings + sys_c.write_text(str(c_source)) - mock_unimplemented = MagicMock() + # Function wrappers are irrelevant here, and the consumers only exist to keep the + # tag and the alias reachable with patch( - "ideas.translate_recurrent.generate_unimplemented_type_wrapper", mock_unimplemented + "ideas.hybrid.generate_unimplemented_function_wrapper", + return_value=None, ): pred = translator(symbols=symbols, dependencies=dependencies) + assert pred.complete - # Translation still succeeds; only wrapping is skipped - assert pred.success + assert hybrid_lib.read_text().count("impl CInterop for") == 1 + assert len(translator._hybrid.wrappers) == 1 + assert mock_wrapper_gen.call_count == 1 - # `_wrap_type` must bail out before seeding a template or invoking the generator - assert mock_unimplemented.call_count == 0 - assert mock_wrapper_gen.call_count == 0 - assert list(hybrid_lib.parent.glob("wrap_*.rs")) == [] - assert "pub mod wrap_" not in hybrid_lib.read_text() - assert "pub mod wrap_" not in rs_lib.read_text() + # The tag is the one name both symbols agree on: the alias resolves to it + assert f"impl CInterop for {tag_name}" in hybrid_lib.read_text() + + +# Each of these compiles and passes `cargo test`, so without an explicit check the +# wrapper would be accepted as correct. +@pytest.mark.parametrize( + ("wrapper", "expected"), + [ + ( + dedent(""" + impl CInterop for MyStruct { + type Rust = (); + unsafe fn to_rust(_cs: *const Self) -> Self::Rust { todo!() } + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) { todo!() } + } + """), + "type Rust = ()", + ), + ( + dedent(""" + pub unsafe fn to_rust(_cs: *const MyStruct) {} + pub unsafe fn sync_to_c(_cs: *mut MyStruct) {} + """), + "impl CInterop for MyStruct", + ), + ( + dedent(""" + impl CInterop for MyStruct { + type Rust = libfoo_rs::MyStruct; + unsafe fn to_rust(_cs: *const Self) -> Self::Rust { todo!() } + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) { todo!() } + } + """), + "must stay named `test_MyStruct`", + ), + ], + ids=["placeholder_rust_type", "dropped_impl_header", "renamed_test_module"], +) +def test_type_wrapper_rejected_for_silent_failures( + tmp_path: Path, wrapper: str, expected: str +) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub struct MyStruct { pub x: i32 }")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + tests_mod = "test_MyStruct" + # The test module is deliberately named `tests`, so the third case trips the + # module-name check while the first two trip theirs first + bad_wrapper = CodeRust( + wrapper + + dedent(""" + #[cfg(test)] + mod tests { + #[test] + fn round_trip_zeroed() { todo!() } + #[test] + fn round_trip_nontrivial() { todo!() } + } + """) + ) + mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=bad_wrapper)) + + # Two iterations so the rejection comes back as feedback on a second attempt + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=2, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_TYPEDEF_STRUCT) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + + sys_c.write_text(str(_TYPEDEF_STRUCT)) + + with patch( + "ideas.translate_recurrent.generate_unimplemented_type_wrapper", + return_value=(bad_wrapper, tests_mod), + ): + translator(symbols=symbols, dependencies=dependencies) + + assert mock_wrapper_gen.call_count == 2 + assert expected in mock_wrapper_gen.call_args.kwargs["scope_feedback"] + + +_SIMPLE_FUNCTION = ast.CodeC("int bar(void) { return 0; }") + + +def test_known_broken_tests_seed_the_baseline(tmp_path: Path) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub fn bar() -> i32 { 0 }")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=None, + tests="dummy_test", + max_iters=1, + broken_tests={"broken"}, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_SIMPLE_FUNCTION) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + sys_c.write_text(str(_SIMPLE_FUNCTION)) + + translator(symbols=symbols, dependencies=dependencies) + + assert translator._oracle.baseline_failures == {"broken"} + + # The exclusion lowers the bar for the whole run, so it belongs in the bisectable log + init_msg = crate_mock.vcs.commit.call_args_list[0].args[0] + assert "broken" in init_msg + + +def _libtest_json(**events: str) -> str: + return "\n".join( + json.dumps({"type": "test", "name": f"smoke${name}", "event": event}) + for name, event in events.items() + ) + + +def _delta_translator(tmp_path: Path, *, broken_tests: set[str] | None = None): + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub fn bar() {}")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + # The wrapper only has to keep the template's shape, so an implemented body passes + unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock( # type: ignore[arg-type] + return_value=MagicMock( + return_value=dspy.Prediction( + wrapper=CodeRust("pub fn bar_wrapper() -> i32 { 0 }") + ) + ) + ), + max_iters=1, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + broken_tests=broken_tests, + ) + sys_c.write_text("int bar(void) { return 0; }") + return translator, unimplemented_stub + + +def _run_one_group( + translator: RecurrentTranslator, + unimplemented_stub: CodeRust, + final_run: tuple, +) -> dspy.Prediction: + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.side_effect = [ + (True, "", "", 0), # forward init gate + (True, "", "", 0), # _wrap_function fail-fast gate + (True, "", "", 0), # wrapper build gate + (True, "", "", 0), # _test_symbol build_only pre-pass + final_run, + ] + + sym: SymbolName = "c:@F@bar" + mock_symbol = _mock_bar_symbol(sym) + + symbols: dict[SymbolName, MagicMock] = {sym: mock_symbol} + dependencies: dict[SymbolGroup, list[SymbolGroup]] = {(sym,): []} + + with patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + return_value=unimplemented_stub, + ): + return translator(symbols=symbols, dependencies=dependencies) + + +def test_new_failure_beside_a_baseline_failure_is_blamed(tmp_path: Path) -> None: + translator, stub = _delta_translator(tmp_path, broken_tests={"broken"}) + pred = _run_one_group( + translator, + stub, + (False, _libtest_json(broken="failed", fresh="failed"), "2 tests failed", 101), + ) + + assert pred.regressions == {"fresh": ("c:@F@bar",)} + + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + assert "Regressed test(s): fresh" in crate_mock.vcs.commit.call_args_list[-1].args[0] + + +def test_regression_is_reported_without_failing(tmp_path: Path) -> None: + translator, stub = _delta_translator(tmp_path) + pred = _run_one_group(translator, stub, (False, _libtest_json(fresh="failed"), "", 101)) + + # The translation is complete, so only `regressions` says it diverged + assert pred.complete + assert pred.regressions == {"fresh": ("c:@F@bar",)} + + +def test_best_review_attempt_is_restored(tmp_path: Path) -> None: + # Two attempts that both diverge, the second one regressing more than the first + mock_translator = MagicMock( + side_effect=[ + dspy.Prediction(translation=CodeRust("pub fn bar() { /* first */ }")), + dspy.Prediction(translation=CodeRust("pub fn bar() { /* second */ }")), + ] + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock( # type: ignore[arg-type] + return_value=MagicMock( + return_value=dspy.Prediction( + wrapper=CodeRust("pub fn bar_wrapper() -> i32 { 0 }") + ) + ) + ), + max_iters=1, + ) + + translator, _, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=2, + ) + sys_c.write_text("int bar(void) { return 0; }") + + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + gates = [(True, "", "", 0)] * 3 # fail-fast, wrapper build, build_only pre-pass + crate_mock.cargo_test.side_effect = [ + (True, "", "", 0), # forward init gate + *gates, + (False, _libtest_json(one="failed", two="ok"), "1 test failed", 101), + *gates, + (False, _libtest_json(one="failed", two="failed"), "2 tests failed", 101), + ] + + sym: SymbolName = "c:@F@bar" + symbols: dict[SymbolName, MagicMock] = {sym: _mock_bar_symbol(sym)} + dependencies: dict[SymbolGroup, list[SymbolGroup]] = {(sym,): []} + + with patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + return_value=unimplemented_stub, + ): + pred = translator(symbols=symbols, dependencies=dependencies) + + # Only the first attempt's regression is blamed, and its translation is what survives + assert pred.regressions == {"one": (sym,)} + assert "first" in rs_lib.read_text() + + +def test_best_review_attempt_survives_a_final_translate_failure(tmp_path: Path) -> None: + # First attempt diverges, second fails to build: the group must keep the first + mock_translator = MagicMock( + side_effect=[ + dspy.Prediction(translation=CodeRust("pub fn bar() { /* first */ }")), + dspy.Prediction(translation=CodeRust("pub fn bar() { /* second */ }")), + ] + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + unimplemented_stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock( # type: ignore[arg-type] + return_value=MagicMock( + return_value=dspy.Prediction( + wrapper=CodeRust("pub fn bar_wrapper() -> i32 { 0 }") + ) + ) + ), + max_iters=1, + ) + + translator, _, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=2, + ) + sys_c.write_text("int bar(void) { return 0; }") + + rs_crate_mock: MagicMock = translator.rust_crate # type: ignore[assignment] + rs_crate_mock.cargo_build.side_effect = [(True, ""), (False, "compilation error")] + + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.side_effect = [ + (True, "", "", 0), # forward init gate + (True, "", "", 0), # _wrap_function fail-fast gate + (True, "", "", 0), # wrapper build gate + (True, "", "", 0), # _test_symbol build_only pre-pass + (False, _libtest_json(one="failed", two="ok"), "1 test failed", 101), + ] + + sym: SymbolName = "c:@F@bar" + symbols: dict[SymbolName, MagicMock] = {sym: _mock_bar_symbol(sym)} + dependencies: dict[SymbolGroup, list[SymbolGroup]] = {(sym,): []} + + with patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + return_value=unimplemented_stub, + ): + pred = translator(symbols=symbols, dependencies=dependencies) + + # The unbuildable retry neither aborts the run nor discards the attempt that did run + assert pred.complete + assert pred.regressions == {"one": (sym,)} + assert "first" in rs_lib.read_text() + assert "second" not in rs_lib.read_text() + + +_STATIC_VARIABLE = ast.CodeC("static int counter = 0;") +_GLOBAL_VARIABLE = ast.CodeC("int counter = 0;") + + +@pytest.mark.parametrize( + ("c_source", "is_externally_visible"), + [(_STATIC_VARIABLE, False), (_GLOBAL_VARIABLE, True)], + ids=["static", "global"], +) +def test_variable_wrapper_generated_without_tests( + tmp_path: Path, c_source: CodeC, is_externally_visible: bool +) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub static mut COUNTER: i32 = 0;")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + # A variable wrapper is the only bridge between C storage and the unsafe-free -rs crate, + # so it must be emitted even with no tests and no LLM wrapper generator. + translator, hybrid_lib, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=None, + tests=None, + max_iters=1, + ) + + # Parse real C so the variable symbol carries genuine kind and linkage flags + tu = ast.create_translation_unit(c_source) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + assert [(s.is_variable, s.is_externally_visible) for s in symbols.values()] == [ + (True, is_externally_visible) + ] + + sys_c.write_text(str(c_source)) + + var_binding = CodeRust( + 'extern "C" {\n pub static mut counter: ::std::os::raw::c_int;\n}' + ) + with patch( + "ideas.hybrid.bindgen_bindings", return_value={"counter": var_binding} + ) as mock_bindgen: + pred = translator(symbols=symbols, dependencies=dependencies) + assert pred.complete + + mock_bindgen.assert_called_once_with(sys_c, ["counter"], translator._hybrid.types_code) + + # Pure bindgen output, so it is hoisted rather than given a wrapper of its own + assert translator._hybrid.wrappers == {} + assert translator._hybrid.externs == (var_binding,) + assert str(wrap_values(var_binding)) in hybrid_lib.read_text() + + # The C variable needs external linkage for the bindings to resolve at link time + assert "static" not in sys_c.read_text() + + # Bindings must not bleed into the -rs crate + assert "pub static mut counter" not in rs_lib.read_text() + + +def test_variable_bindings_precede_translation(tmp_path: Path) -> None: + # A static cannot be shadowed by a local binding of the same name (E0530), so a + # binding that lands after a wrapper is accepted breaks code that no generator can still + # edit. Every binding must be in the crate before the first symbol is translated. + hybrid_lib = tmp_path / "hybrid" / "src" / "lib.rs" + seen: list[str] = [] + + def translate(*_args, **_kwargs) -> dspy.Prediction: + seen.append(hybrid_lib.read_text()) + return dspy.Prediction(translation=CodeRust("pub static mut COUNTER: i32 = 0;")) + + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=MagicMock(side_effect=translate)), # type: ignore[arg-type] + max_iters=1, + ) + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=None, + tests=None, + max_iters=1, + ) + + tu = ast.create_translation_unit(_GLOBAL_VARIABLE) + symbols, dependencies = get_symbols_and_dependencies([ast.extract_info_c(tu)]) + sys_c.write_text(str(_GLOBAL_VARIABLE)) + + var_binding = CodeRust( + 'extern "C" {\n pub static mut counter: ::std::os::raw::c_int;\n}' + ) + with patch("ideas.hybrid.bindgen_bindings", return_value={"counter": var_binding}): + assert translator(symbols=symbols, dependencies=dependencies).complete + + assert seen + assert all(str(wrap_values(var_binding)) in crate_code for crate_code in seen) + + +_SYNC_FNS = ("sync_counter_to_rust", "sync_counter_to_c") + +# Names both globals and calls both sync functions, which is the minimum the wrapper +# validator accepts +_ROUND_TRIP = ( + "unsafe { __c_globals::counter = 7; sync_counter_to_rust(); " + "assert_eq!(foo_rs::COUNTER, 7); sync_counter_to_c(); " + "assert_eq!(__c_globals::counter, 7); }" +) + + +def _variable_wrapper( + body: str, + tests_mod: str = "test_var_counter", + round_trip: str | None = _ROUND_TRIP, + sync_fns: tuple[str, ...] = _SYNC_FNS, + sync_body: str = "", +) -> CodeRust: + syncs = "\n".join(f"pub unsafe fn {fn}() {{ {sync_body} }}" for fn in sync_fns) + round_trip_test = ( + "" if round_trip is None else f"#[test]\nfn round_trip_nontrivial() {{ {round_trip} }}" + ) + return CodeRust( + dedent(f""" + {syncs} + #[cfg(test)] + mod {tests_mod} {{ + use super::*; + + #[test] + fn initial_value_matches() {{ + {body} + }} + + {round_trip_test} + }} + """).strip() + ) + + +def test_variable_wrapper_tests_the_translated_global(tmp_path: Path) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub static COUNTER: i32 = 0;")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + tests_mod = "test_var_counter" + var_wrapper = _variable_wrapper("assert_eq!(unsafe { counter }, foo_rs::COUNTER);") + mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=var_wrapper)) + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=1, + ) + + translator, hybrid_lib, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_GLOBAL_VARIABLE) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + + sys_c.write_text(str(_GLOBAL_VARIABLE)) + + var_binding = CodeRust( + 'extern "C" {\n pub static mut counter: ::std::os::raw::c_int;\n}' + ) + with ( + patch("ideas.hybrid.bindgen_bindings", return_value={"counter": var_binding}), + patch( + "ideas.translate_recurrent.generate_unimplemented_variable_wrapper", + return_value=(var_wrapper, tests_mod, _SYNC_FNS), + ), + ): + pred = translator(symbols=symbols, dependencies=dependencies) + assert pred.complete + + # The extern is still hoisted; the wrapper adds the test on top of it + assert translator._hybrid.externs == (var_binding,) + assert translator._hybrid.wrappers == {"c:@counter": var_wrapper} + assert str(var_wrapper) in hybrid_lib.read_text() + + # The test filter keys on the module name, so it has to reach cargo test verbatim + assert tests_mod in [call.args[0] for call in crate_mock.cargo_test.call_args_list] + + # Tests must not bleed into the -rs crate + assert tests_mod not in rs_lib.read_text() + + +# Each of these compiles and passes `cargo test`, so without an explicit check the +# wrapper would be accepted as correct. +@pytest.mark.parametrize( + ("wrapper", "expected"), + [ + ( + _variable_wrapper("assert_eq!(unsafe { counter }, 0);"), + "Comparing the C global against itself", + ), + ( + # The sync functions name the translated crate, so only a body-scoped check + # catches the vacuous assertion + _variable_wrapper( + "assert_eq!(unsafe { counter }, 0);", + sync_body="unsafe { counter = foo_rs::COUNTER };", + ), + "Comparing the C global against itself", + ), + ( + _variable_wrapper( + "unsafe { sync_counter_to_rust() };" + "assert_eq!(unsafe { counter }, foo_rs::COUNTER);" + ), + "`initial_value_matches` must not call `sync_counter_to_rust`", + ), + ( + _variable_wrapper("let _ = foo_rs::COUNTER; todo!()"), + "`todo!()` placeholder", + ), + ( + _variable_wrapper( + "assert_eq!(unsafe { counter }, foo_rs::COUNTER);", tests_mod="tests" + ), + "must stay named `test_var_counter`", + ), + ( + CodeRust("#[cfg(test)]\nmod test_var_counter {\n // foo_rs\n}"), + "`initial_value_matches` must be implemented", + ), + ( + _variable_wrapper( + "assert_eq!(unsafe { counter }, foo_rs::COUNTER);", + sync_fns=("sync_counter_to_c",), + ), + "Keep all of `sync_counter_to_rust`, `sync_counter_to_c`", + ), + ( + _variable_wrapper( + "assert_eq!(unsafe { counter }, foo_rs::COUNTER);", round_trip=None + ), + "`round_trip_nontrivial` must be implemented", + ), + ( + # Dropping the pair and rewriting the test not to call it leaves a test that + # exercises nothing + _variable_wrapper( + "assert_eq!(unsafe { counter }, foo_rs::COUNTER);", + round_trip="unsafe { counter = 7 };", + sync_fns=(), + ), + "`round_trip_nontrivial` exists to test", + ), + ], + ids=[ + "vacuous", + "vacuous_with_sync_fns", + "synced_before_compare", + "unfinished", + "renamed_test_module", + "dropped_test", + "dropped_sync_fn", + "dropped_round_trip", + "dropped_sync_pair_kept_round_trip", + ], +) +def test_variable_wrapper_rejected_for_silent_failures( + tmp_path: Path, wrapper: CodeRust, expected: str +) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub static COUNTER: i32 = 0;")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=wrapper)) + + # Two iterations so the rejection comes back as feedback on a second attempt + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=2, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_GLOBAL_VARIABLE) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + + sys_c.write_text(str(_GLOBAL_VARIABLE)) + + var_binding = CodeRust( + 'extern "C" {\n pub static mut counter: ::std::os::raw::c_int;\n}' + ) + with ( + patch("ideas.hybrid.bindgen_bindings", return_value={"counter": var_binding}), + patch( + "ideas.translate_recurrent.generate_unimplemented_variable_wrapper", + return_value=(wrapper, "test_var_counter", _SYNC_FNS), + ), + ): + translator(symbols=symbols, dependencies=dependencies) + + assert mock_wrapper_gen.call_count == 2 + assert expected in mock_wrapper_gen.call_args.kwargs["scope_feedback"] + + +# Both symbols are global definitions and reach `_wrap_type`, but neither is a +# STRUCT_DECL, so there is no field-by-field `to_rust`/`sync_to_c` pair to generate. +_UNION_TYPEDEF = ast.CodeC("typedef union { int x; } u_t;") + +# The STRUCT_DECL here is only a forward declaration (no fields to convert) and the +# typedef merely aliases it, so neither symbol is wrappable. +_STRUCT_ALIAS_TYPEDEF = ast.CodeC("typedef struct Foo foo_alias_t;") + + +@pytest.mark.parametrize( + "c_source", + [_UNION_TYPEDEF, _STRUCT_ALIAS_TYPEDEF], + ids=["union_typedef", "struct_alias_typedef"], +) +def test_no_type_wrapper_for_non_struct_definition(tmp_path: Path, c_source: CodeC) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub struct Placeholder;")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + mock_wrapper_gen = MagicMock() + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=1, + ) + + translator, hybrid_lib, rs_lib, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(c_source) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + + sys_c.write_text(str(c_source)) + + mock_unimplemented = MagicMock() + with patch( + "ideas.translate_recurrent.generate_unimplemented_type_wrapper", mock_unimplemented + ): + pred = translator(symbols=symbols, dependencies=dependencies) + + # Translation still succeeds; only wrapping is skipped + assert pred.complete + + # `_wrap_type` must bail out before seeding a template or invoking the generator + assert mock_unimplemented.call_count == 0 + assert mock_wrapper_gen.call_count == 0 + assert translator._hybrid.wrappers == {} + assert "impl CInterop for" not in hybrid_lib.read_text() + assert "impl CInterop for" not in rs_lib.read_text() + + +# The typedef only restates the tag's name, so it lands in its own group with no Rust form +_REDUNDANT_TYPEDEF = ast.CodeC(""" +struct Tag { int x; }; +typedef struct Tag Tag; +int use_it(Tag *p) { return p->x; } +""") + + +def test_alias_typedef_group_is_skipped(tmp_path: Path) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub struct Placeholder;")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=None, + tests=None, + max_iters=1, + ) + + tu = ast.create_translation_unit(_REDUNDANT_TYPEDEF) + symbols, dependencies = get_symbols_and_dependencies([ast.extract_info_c(tu)]) + sys_c.write_text(str(_REDUNDANT_TYPEDEF)) + + pred = translator(symbols=symbols, dependencies=dependencies) + + # A skipped group still counts as translated, so the run stays green + assert pred.complete + + asked = [str(call.kwargs["snippet"]) for call in mock_translator.call_args_list] + assert str(symbols["c:file.c@T@Tag"].code) not in asked + assert str(symbols["c:@S@Tag"].code) in asked + + +# The struct is anonymous so the typedef carries the linkage `_wrap_type` demands +_BINARY = ast.CodeC(""" +typedef struct { int x; } my_struct_t; +int helper(my_struct_t s) { return s.x; } +int main(void) { my_struct_t s = {1}; return helper(s); } +""") + +_BINARY_TYPE_WRAPPER = CodeRust( + dedent(""" + impl CInterop for my_struct_t { + type Rust = foo_rs::MyStruct; + unsafe fn to_rust(_cs: *const Self) -> Self::Rust { todo!() } + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) { todo!() } + } + + #[cfg(test)] + mod test_my_struct_t { + #[test] + fn round_trip_zeroed() { todo!() } + #[test] + fn round_trip_nontrivial() { todo!() } + } + """).strip() +) + + +def test_untested_binary_wraps_types_but_not_functions(tmp_path: Path) -> None: + mock_translator = MagicMock( + return_value=dspy.Prediction(translation=CodeRust("pub fn helper() -> i32 { 1 }")) + ) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + mock_wrapper_gen = MagicMock(return_value=dspy.Prediction(wrapper=_BINARY_TYPE_WRAPPER)) + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=1, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + is_bin=True, + c_src=str(_BINARY), + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests=None, + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_BINARY) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies( + [tree], external_symbol_names=["c:@F@main"] + ) + assert symbols["c:@F@helper"].is_externally_visible + (struct_name,) = [name for name, symbol in symbols.items() if symbol.is_struct] + + mock_unimplemented = MagicMock() + with ( + patch( + "ideas.hybrid.generate_unimplemented_function_wrapper", + mock_unimplemented, + ), + patch( + "ideas.translate_recurrent.generate_unimplemented_type_wrapper", + return_value=(_BINARY_TYPE_WRAPPER, "test_my_struct_t"), + ), + ): + pred = translator(symbols=symbols, dependencies=dependencies) + assert pred.complete + + # `helper` keeps its C definition: no wrapper claims it and no test needs it to + assert mock_unimplemented.call_count == 0 + assert "int helper(my_struct_t s) { return s.x; }" in sys_c.read_text() + + # The struct is still wrapped: its round-trip tests validate the translated type + # regardless of whether anything calls into Rust through a function wrapper + assert translator._hybrid.wrappers == {struct_name: _BINARY_TYPE_WRAPPER} + assert mock_wrapper_gen.call_count == 1 + + # `main` is still wrapped, handing the entrypoint to the -rs crate + assert translator._hybrid.main_wrapped + transformed = sys_c.read_text() + assert "extern int main(int argc, char **argv);" in transformed + assert "extern int __ideas_entry(int argc, char **argv);" in transformed + assert "extern int __ideas_c_main(void);" in transformed + + +# Shaped like the types segment of bindgen output: attributes are siblings of the item +# they decorate, and enums arrive with companion constants that must travel with them +_BINDGEN_TYPES = CodeRust( + dedent(""" + #[repr(C)] + #[derive(Debug, Copy, Clone)] + pub struct house_t { + pub floors: ::std::os::raw::c_int, + } + pub type house_alias_t = house_t; + pub const tag_t_A: tag_t = 0; + pub type tag_t = ::std::os::raw::c_uint; + """).strip() +) + +_BINDGEN_BINDING = CodeRust( + dedent(""" + unsafe extern "C" { + pub static mut the_house: house_t; + } + """).strip() +) + + +def test_type_wrapper_context_drops_impl_method_bodies() -> None: + wrapper = CodeRust( + dedent(""" + impl CInterop for house_t { + type Rust = libdriver_rs::HouseT; + + unsafe fn to_rust(cs: *const Self) -> Self::Rust { + libdriver_rs::HouseT { floors: unsafe { (*cs).floors } as i32 } + } + + unsafe fn sync_to_c(rs: &Self::Rust, cs: *mut Self) { + unsafe { (*cs).floors = rs.floors as ::std::os::raw::c_int }; + } + } + + #[cfg(test)] + mod test_house_t { + #[test] + fn round_trip_zeroed() { todo!() } + } + """).strip() + ) + + context = str(TranslationContext._build_type_wrapper_context(wrapper)) + + # The impl header and the associated type are the only per-type information ... + assert "impl CInterop for house_t {" in context + assert "type Rust = libdriver_rs::HouseT;" in context + + # ... the method signatures are fixed by the trait, which the rendered types already show + assert "fn to_rust" not in context + assert "fn sync_to_c" not in context + assert "libdriver_rs::HouseT { floors" not in context + assert "mod test_house_t" not in context + + +def test_wrapper_context_reduces_type_impls_to_their_headers() -> None: + type_wrapper = CodeRust( + dedent(""" + impl CInterop for house_t { + type Rust = libdriver_rs::HouseT; + unsafe fn to_rust(_cs: *const Self) -> Self::Rust { todo!() } + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) { todo!() } + } + """).strip() + ) + global_wrapper = CodeRust("pub fn counter_wrapper() -> i32 { 0 }") + + # Pinned so the split under test is not masked by the reduced-context filter + with patch("ideas.translate_context.REDUCED_CONTEXT", False): + wrappers = TranslationContext._build_wrapper_context( + { + "c:@S@house_t": type_wrapper, + "c:@counter": global_wrapper, + }, + { + "c:@S@house_t": MagicMock(bindgen_name="house_t", is_type=True), + "c:@counter": MagicMock( + bindgen_name="counter", is_type=False, is_variable=False + ), + }, + ) + + context = str(CodeRust.join(wrappers.values())) + + # Nothing is reachable by module path any more, so no wrapper carries scaffolding + assert "pub mod" not in context + assert "impl CInterop for house_t {\n type Rust = libdriver_rs::HouseT;\n}" in context + assert str(global_wrapper).strip() in context + + # Every kept wrapper is keyed by its bindgen name so the type slice can allowlist it + assert tuple(wrappers) == ("house_t", "counter") + + +def test_wrapper_context_omits_spellings_of_dropped_wrappers() -> None: + global_wrapper = CodeRust("pub fn counter_wrapper() -> i32 { 0 }") + + with patch("ideas.translate_context.REDUCED_CONTEXT", True): + wrappers = TranslationContext._build_wrapper_context( + {"c:@counter": global_wrapper}, + {"c:@counter": MagicMock(bindgen_name="counter", is_type=False, is_variable=False)}, + ) + + assert wrappers == {} + + +# These tests only exercise the bindings plumbing, so no LLM modules are needed +def _make_bindings_translator(tmp_path: Path): + return _make_translator(tmp_path, symbol_translator=MagicMock(), symbol_wrapper=None) + + +def test_bindings_handle_type_free_bindings(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + + item = CodeRust('unsafe extern "C" {\n pub static mut counter: i32;\n}\n') + translator._hybrid.add_externs([item]) + + # Nothing was hoisted alongside it, so only the trait and the values module render + assert translator._hybrid.types == {} + assert str(translator._hybrid.render()) == str(C_INTEROP_TRAIT + wrap_values(item)) + + +def test_crate_context_always_exposes_the_interop_trait(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + + # Wrappers implement this trait, so it must be visible before anything is hoisted + assert "pub trait CInterop" in str(translator._hybrid.render([CodeRust("")])) + + types, consts = split_items(_BINDGEN_TYPES) + translator._hybrid.set_types(types, consts) + translator._hybrid.add_externs([_BINDGEN_BINDING]) + context = str(translator._hybrid.render([CodeRust("")])) + assert context.startswith("pub trait CInterop") + assert "pub struct house_t {" in context + + # Reduced wrappers sit alongside the types they convert, at the same level + impl = CodeRust("impl CInterop for house_t {\n type Rust = libdriver_rs::HouseT;\n}") + assert "impl CInterop for house_t {" in str(translator._hybrid.render([impl])) + + +def test_crate_context_can_narrow_the_types_it_shows(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + types, consts = split_items(_BINDGEN_TYPES) + translator._hybrid.set_types(types, consts) + translator._hybrid.add_externs([_BINDGEN_BINDING]) + + # The crate on disk takes no override, so it keeps every type + assert "pub struct house_t {" in str(translator._hybrid.render()) + + sliced = CodeRust("pub type tag_t = ::std::os::raw::c_uint;") + context = str(translator._hybrid.render([CodeRust("")], [sliced])) + + # Only the types segment narrows; the trait and the externs are still needed in full + assert "pub struct house_t {" not in context + assert str(sliced) in context + assert "pub trait CInterop" in context + assert "pub static mut the_house" in context + + +def test_type_slice_keeps_only_what_the_symbol_reaches(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + types, consts = split_items(_BINDGEN_TYPES) + translator._hybrid.set_types(types, consts) + + sliced, _ = translator._hybrid.type_slice(BindgenName("house_alias_t"), CodeRust()) + + # The alias drags in the struct it names, but nothing reaches the unrelated enum + text = str(CodeRust.join(sliced)) + assert "pub type house_alias_t" in text + assert "pub struct house_t {" in text + assert "tag_t" not in text + + +def test_type_slice_keeps_what_the_unimplemented_wrapper_names(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + types, consts = split_items(_BINDGEN_TYPES) + translator._hybrid.set_types(types, consts) + translator._hybrid.add_externs([_BINDGEN_BINDING]) + + wrapper = CodeRust("impl CInterop for tag_t {}") + sliced_types, sliced_consts = translator._hybrid.type_slice(BindgenName("paint"), wrapper) + + # The extern block and the wrapper render in full, so their types cannot be dropped + assert "pub struct house_t {" in str(CodeRust.join(sliced_types)) + # A const rides with the type it names, so the same slice carries it + assert "pub const tag_t_A" in str(CodeRust.join(sliced_consts)) + + +def test_type_slice_keeps_everything_without_reduced_context(tmp_path: Path) -> None: + translator, _, _, _ = _make_bindings_translator(tmp_path) + types, consts = split_items(_BINDGEN_TYPES) + translator._hybrid.set_types(types, consts) + + with patch("ideas.hybrid.REDUCED_CONTEXT", False): + sliced, sliced_consts = translator._hybrid.type_slice( + BindgenName("house_alias_t"), CodeRust() + ) + + assert str(CodeRust.join(sliced)) == str(CodeRust.join(translator._hybrid.types.values())) + assert str(CodeRust.join(sliced_consts)) == str( + CodeRust.join(translator._hybrid.consts.values()) + ) + + +def test_crate_types_keep_enums_as_struct_references(tmp_path: Path) -> None: + source = ( + "enum color { RED, GREEN };\n" + "struct paint { enum color hue; };\n" + "void use_paint(struct paint *p);\n" + ) + c_path = tmp_path / "input.c" + c_path.write_text(source) + _, _, symbols = _make_graph(CodeC(source)) + + # Mirrors the crate-wide run in `forward`: allowlist every symbol + types = bindgen_types(c_path, [n for s in symbols.values() if (n := s.bindgen_name)]) + + # The enum counts as a type, so it survives the blocklist and the struct field resolves + assert "pub struct paint" in str(types) + assert "pub type color" in str(types) + + +def test_wrapper_inputs_keep_the_two_crates_apart(tmp_path: Path) -> None: + translation = CodeRust("pub struct MyStructT { pub x: i32 }") + mock_translator = MagicMock(return_value=dspy.Prediction(translation=translation)) + symbol_translator = SnippetTranslator( + translator=MagicMock(return_value=mock_translator), # type: ignore[arg-type] + max_iters=1, + ) + + # The types the wrapper names come from the crate-wide bindgen run, so only the impl + # itself is stubbed here + type_wrapper = CodeRust( + dedent(""" + impl CInterop for my_struct_t { + type Rust = libfoo_rs::my_struct_t; + unsafe fn to_rust(_cs: *const Self) -> Self::Rust { todo!() } + unsafe fn sync_to_c(_rs: &Self::Rust, _cs: *mut Self) { todo!() } + } + + #[cfg(test)] + mod tests { + #[test] + fn round_trip_zeroed() { todo!() } + #[test] + fn round_trip_nontrivial() { todo!() } + } + """).strip() + ) + # The generator echoes back whatever `_wrap_type` wrote to disk so wrapping succeeds + mock_wrapper_gen = MagicMock( + side_effect=lambda **kwargs: dspy.Prediction(wrapper=kwargs["example_wrapper"]) + ) + symbol_wrapper = WrapperGenerator( + wrapper=MagicMock(return_value=mock_wrapper_gen), # type: ignore[arg-type] + max_iters=1, + ) + + translator, _, _, sys_c = _make_translator( + tmp_path, + symbol_translator=symbol_translator, + symbol_wrapper=symbol_wrapper, + tests="dummy_test", + max_iters=1, + ) + crate_mock: MagicMock = translator.crate # type: ignore[assignment] + crate_mock.cargo_test.return_value = (True, "", "", "") + + tu = ast.create_translation_unit(_ANONYMOUS_TYPEDEF_STRUCT) + tree = ast.extract_info_c(tu) + symbols, dependencies = get_symbols_and_dependencies([tree]) + + sys_c.write_text(str(_ANONYMOUS_TYPEDEF_STRUCT)) + + with patch( + "ideas.translate_recurrent.generate_unimplemented_type_wrapper", + return_value=(type_wrapper, "test_my_struct_t"), + ): + pred = translator(symbols=symbols, dependencies=dependencies) + assert pred.complete + + kwargs = mock_wrapper_gen.call_args.kwargs + crate, wrapped = str(kwargs["crate"]), str(kwargs["wrapped_crate_code"]) + + # Mixing these in one field is what made the LLM write `crate::MyStructT` + assert "pub struct my_struct_t" in crate + assert "pub struct MyStructT" not in crate + assert "pub struct MyStructT" in wrapped + assert "pub struct my_struct_t" not in wrapped diff --git a/test/test_wrapper.py b/test/test_wrapper.py index 6afe150..f80e67b 100644 --- a/test/test_wrapper.py +++ b/test/test_wrapper.py @@ -5,12 +5,19 @@ # from pathlib import Path -from textwrap import dedent +from textwrap import dedent, indent +from unittest.mock import MagicMock +import dspy import pytest -from ideas import translate_recurrent as translate_recurrent_mod +from clang.cindex import CursorKind + +from ideas import ast from ideas import wrapper as wrapper_mod +from ideas.ast_rust import BindgenName, CodeRust +from ideas.refine import Feedback +from ideas.tools import check_rust @pytest.mark.parametrize( @@ -82,6 +89,7 @@ pub x: ::std::os::raw::c_int, pub y: ::std::os::raw::c_int, } + unsafe extern "C" { pub static mut pt: Point; } @@ -99,6 +107,7 @@ pub x: ::std::os::raw::c_int, pub y: ::std::os::raw::c_int, } + unsafe extern "C" { pub static mut spt: Point; } @@ -147,9 +156,11 @@ def test_bindgen_emits_expected_text_for_global_shapes( c_path = tmp_path / "input.c" c_path.write_text(source) - binding = translate_recurrent_mod.bindgen(c_path, symbol) + types = wrapper_mod.bindgen_types(c_path, [symbol]) + binding = wrapper_mod.bindgen_binding(c_path, symbol, types) - assert str(binding).strip() == expected + # The two segments are complementary, so joined they are the full recursive binding + assert str(types + binding).strip() == expected assert c_path.read_text() == source @@ -167,7 +178,7 @@ def test_bindgen_restores_source_when_bindgen_fails( ) with pytest.raises(ValueError, match="Bindgen failed"): - wrapper_mod.bindgen(c_path, "foo") + wrapper_mod.bindgen_binding(c_path, "foo", CodeRust()) assert c_path.read_text() == original_src @@ -182,8 +193,225 @@ def test_bindgen_raises_for_empty_binding(tmp_path: Path, monkeypatch: pytest.Mo lambda *_args, **_kwargs: (True, " \n", "", 0), ) - with pytest.raises(ValueError, match="empty binding"): - wrapper_mod.bindgen(c_path, "foo") + with pytest.raises(ValueError, match="no binding"): + wrapper_mod.bindgen_binding(c_path, "foo", CodeRust()) + + +def test_bindgen_bindings_batch_matches_one_call_per_symbol(tmp_path: Path): + c_path = tmp_path / "input.c" + source = dedent( + """ + typedef struct { int x; int y; } point_t; + static int alpha = 7; + static point_t beta = {1, 2}; + static const char *gamma_s = "hi"; + static int match = 3; + int use_alpha(void) { return alpha; } + """ + ).lstrip() + c_path.write_text(source) + + symbols = ["alpha", "beta", "gamma_s", "match", "use_alpha"] + types = wrapper_mod.bindgen_types(c_path, symbols) + batched = wrapper_mod.bindgen_bindings(c_path, symbols, types) + + # Splitting one run must give each symbol exactly what its own run would have + assert batched == {s: wrapper_mod.bindgen_binding(c_path, s, types) for s in symbols} + assert c_path.read_text() == source + + +def test_bindgen_bindings_keep_per_symbol_link_name_attributes(tmp_path: Path): + c_path = tmp_path / "input.c" + # A C name that is a Rust keyword gets renamed, so bindgen emits `#[link_name]` inside + # the extern block rather than beside it + source = dedent( + """ + int match(double threshold) { return (int)threshold; } + int loop = 3; + int plain = 4; + """ + ).lstrip() + c_path.write_text(source) + + symbols = ["match", "loop", "plain"] + types = wrapper_mod.bindgen_types(c_path, symbols) + bindings = wrapper_mod.bindgen_bindings(c_path, symbols, types) + + # Each symbol keeps its own attribute, its own indentation, and none of the other's + assert ( + str(bindings["match"]).strip() + == dedent( + """ + unsafe extern "C" { + #[link_name = "\\u{1}match"] + pub fn match_(threshold: f64) -> ::std::os::raw::c_int; + } + """ + ).strip() + ) + assert ( + str(bindings["loop"]).strip() + == dedent( + """ + unsafe extern "C" { + #[link_name = "\\u{1}loop"] + pub static mut loop_: ::std::os::raw::c_int; + } + """ + ).strip() + ) + assert ( + str(bindings["plain"]).strip() + == dedent( + """ + unsafe extern "C" { + pub static mut plain: ::std::os::raw::c_int; + } + """ + ).strip() + ) + + +def _crate(types: CodeRust, *values: CodeRust) -> CodeRust: + # Mirror the hybrid crate's layout: types bare at the root, values in `__c_globals` + type_items, const_items = wrapper_mod.partition_values(types) + return type_items + wrapper_mod.wrap_values(const_items, *values) + + +def test_values_module_reaches_the_types_when_the_crate_root_is_not_its_parent(): + types = CodeRust("#[repr(C)]\npub struct house_t {\n pub floors: i32,\n}") + values = wrapper_mod.wrap_values( + CodeRust("pub static mut the_house: house_t = house_t { floors: 0 };") + ) + + # main.rs compiles lib.rs as `mod hybrid`, where `crate` is the binary root and the + # types are one level down, so the module must reach them through `super` + body = indent(str(types + values), " ") + success, output = check_rust( + f"mod hybrid {{\n{body}\n}}", flags=["--crate-type=lib", "--emit=metadata"] + ) + assert success, output + + +def test_bindgen_types_keep_a_bitfield_constructor_named_after_a_static(tmp_path: Path): + c_path = tmp_path / "input.c" + # `new_bitfield_1` takes a `pattern` parameter, which a crate-root static would make E0530 + c_path.write_text( + dedent( + """ + struct opts { + unsigned int force : 1; + unsigned int pattern : 1; + int n; + }; + static struct opts o = {0}; + static char *pattern = "x"; + """ + ).lstrip() + ) + + symbols = ["opts", "o", "pattern"] + types = wrapper_mod.bindgen_types(c_path, symbols) + assert "new_bitfield_" in str(types) + assert "pub fn set_pattern" in str(types) + + bindings = wrapper_mod.bindgen_bindings(c_path, ["o", "pattern"], types) + crate = _crate(types, *bindings.values()) + success, output = check_rust(str(crate), flags=["--crate-type=lib", "--emit=metadata"]) + assert success, output + + +def _bitfield_global_crate(c_path: Path, global_name: str) -> CodeRust: + c_path.write_text( + dedent( + f""" + struct opts {{ + unsigned int force : 1; + int n; + }}; + static struct opts o = {{0}}; + static int {global_name} = 1; + """ + ).lstrip() + ) + + types = wrapper_mod.bindgen_types(c_path, ["opts", "o", global_name]) + bindings = wrapper_mod.bindgen_bindings(c_path, ["o", global_name], types) + return _crate(types, *bindings.values()) + + +@pytest.mark.parametrize("global_name", ["val", "this", "storage", "mask"]) +def test_bindgen_types_survive_a_global_named_after_an_accessor_binding( + tmp_path: Path, global_name: str +): + crate = _bitfield_global_crate(tmp_path / "input.c", global_name) + success, output = check_rust(str(crate), flags=["--crate-type=lib", "--emit=metadata"]) + assert success, output + + +@pytest.mark.xfail( + strict=True, + reason="`clang_make_bindable_` emits `extern int index;`, which clashes with clang's " + "implicit POSIX builtin `index()`, so bindgen dies before rustc ever sees the crate.", +) +def test_bindgen_types_survive_a_global_named_after_a_clang_builtin(tmp_path: Path): + crate = _bitfield_global_crate(tmp_path / "input.c", "index") + success, output = check_rust(str(crate), flags=["--crate-type=lib", "--emit=metadata"]) + assert success, output + + +def test_function_wrapper_survives_a_parameter_named_after_an_enum_constant(tmp_path: Path): + c_path = tmp_path / "input.c" + # An anonymous enum's constants reach the crate root unprefixed, and C lets a parameter + # shadow one; Rust instead reads the parameter as a constant pattern (E0005) + c_path.write_text( + dedent( + """ + enum { flags = 1 }; + int use_flags(unsigned int flags) { return (int)flags; } + """ + ).lstrip() + ) + + types = wrapper_mod.bindgen_types(c_path, ["use_flags", "flags"]) + assert "pub const flags:" in str(types) + + wrapper = wrapper_mod.generate_unimplemented_function_wrapper(c_path, "use_flags", types) + assert wrapper is not None + success, output = check_rust( + str(_crate(types) + wrapper), + flags=["--crate-type=lib", "--emit=metadata"], + ) + assert success, output + + +def test_function_wrapper_survives_a_parameter_named_after_a_global(tmp_path: Path): + c_path = tmp_path / "input.c" + # C lets a parameter shadow a global, but the global's binding is a crate-root static + # that a same-named parameter cannot shadow (E0530) + c_path.write_text( + dedent( + """ + char *name; + int use_name(const char *name) { return name != 0; } + """ + ).lstrip() + ) + + types = wrapper_mod.bindgen_types(c_path, ["name", "use_name"]) + bindings = wrapper_mod.bindgen_bindings(c_path, ["name"], types) + wrapper = wrapper_mod.generate_unimplemented_function_wrapper(c_path, "use_name", types) + assert wrapper is not None + + # The wrapper's own validation never sees the bindings, so only the whole crate catches it + crate = _crate(types, *bindings.values()) + wrapper + success, output = check_rust(str(crate), flags=["--crate-type=lib", "--emit=metadata"]) + assert success, output + + +def _binding(c_path: Path, symbol: str) -> CodeRust: + types = wrapper_mod.bindgen_types(c_path, [symbol]) + return types + wrapper_mod.bindgen_binding(c_path, symbol, types) def test_bindgen_handles_dependent_declarations_for_target_global(tmp_path: Path): @@ -192,14 +420,14 @@ def test_bindgen_handles_dependent_declarations_for_target_global(tmp_path: Path dependent_decl = "static const int num_arr = sizeof(arr) / sizeof(arr[0]);\n" c_path.write_text(array_decl) - baseline_binding = translate_recurrent_mod.bindgen(c_path, "arr") + baseline_binding = _binding(c_path, "arr") assert c_path.read_text() == array_decl c_path.write_text(array_decl + dependent_decl) - dependent_binding = translate_recurrent_mod.bindgen(c_path, "arr") + dependent_binding = _binding(c_path, "arr") assert c_path.read_text() == array_decl + dependent_decl - assert str(dependent_binding).strip() == str(baseline_binding).strip() + assert dependent_binding == baseline_binding def test_bindgen_handles_dependent_declarations_for_target_function(tmp_path: Path): @@ -208,11 +436,531 @@ def test_bindgen_handles_dependent_declarations_for_target_function(tmp_path: Pa dependent_source = baseline_source + "int (*pf)(int) = f;\n" c_path.write_text(baseline_source) - baseline_binding = translate_recurrent_mod.bindgen(c_path, "f") + baseline_binding = _binding(c_path, "f") assert c_path.read_text() == baseline_source c_path.write_text(dependent_source) - dependent_binding = translate_recurrent_mod.bindgen(c_path, "f") + dependent_binding = _binding(c_path, "f") assert c_path.read_text() == dependent_source - assert str(dependent_binding).strip() == str(baseline_binding).strip() + assert dependent_binding == baseline_binding + + +@pytest.mark.parametrize( + "signature", + [ + wrapper_mod.FunctionWrapperSignature, + wrapper_mod.TypeWrapperSignature, + wrapper_mod.VariableWrapperSignature, + ], + ids=["function", "type", "variable"], +) +def test_wrapper_signatures_separate_the_two_crates(signature) -> None: + # Merging these into one field makes the LLM reach for `crate::` paths that + # actually live in the wrapped crate + assert "crate" in signature.input_fields + assert "wrapped_crate_code" in signature.input_fields + + +def _capture_wrapper_inputs(**session_kwargs) -> dict: + captured = {} + + def record(**kwargs): + captured.update(kwargs) + return dspy.Prediction(wrapper=CodeRust("// wrapper")) + + symbol = MagicMock() + symbol.name = "c:@F@bar" + symbol.spelling = "bar" + symbol.is_type = False + symbol.is_function = True + + generator = wrapper_mod.WrapperGenerator( + wrapper=MagicMock(return_value=MagicMock(side_effect=record)), # type: ignore[arg-type] + max_iters=1, + ) + session = generator.session( + symbol=symbol, + unimplemented_wrapper=CodeRust("// template"), + wrapped_crate="libfoo_rs", + **session_kwargs, + ) + with session: + for attempt in session: + attempt.accept() + return captured + + +def test_translations_reach_the_prompt_as_wrapped_crate_code(): + translation = CodeRust("pub fn bar() {}") + other = CodeRust("pub fn baz_wrapper() -> i32 { 0 }") + captured = _capture_wrapper_inputs( + wrapped_crate_code=CodeRust("pub struct Prior;"), + translation=translation, + other_wrappers=other, + ) + + # The -rs crate's code is not reachable via `crate::`, so it must not be labelled `crate` + assert translation in captured["wrapped_crate_code"] + assert CodeRust("pub struct Prior;") in captured["wrapped_crate_code"] + assert translation not in captured["crate"] + + # `crate` carries only what actually lives in the hybrid crate + assert other in captured["crate"] + + +def test_prior_rejection_reaches_the_first_prompt(): + # A retry hands over `prior_wrapper` but the code alone does not say what was wrong with it + captured = _capture_wrapper_inputs( + wrapped_crate_code=CodeRust(), + translation=CodeRust(), + prior_wrapper=CodeRust("// broken"), + feedback=Feedback( + review="the wrapper changed the program's behavior", + build="error[E0308]: mismatched types", + scope="do not add public items", + ), + ) + + # Each channel keeps its own field so the model knows what produced it + assert captured["feedback"] == "the wrapper changed the program's behavior" + assert captured["build_feedback"] == "error[E0308]: mismatched types" + assert captured["scope_feedback"] == "do not add public items" + + +def _generate_wrapper( + generated: CodeRust | None, + cache: Path | None = None, + attempts: list[wrapper_mod.WrapperAttempt] | None = None, + template: CodeRust = CodeRust("// template"), +) -> wrapper_mod.WrapperAttempt: + symbol = MagicMock() + symbol.name = "c:@F@bar" + symbol.spelling = "bar" + symbol.is_type = False + symbol.is_function = True + + generator = wrapper_mod.WrapperGenerator( + wrapper=MagicMock( # type: ignore[arg-type] + return_value=MagicMock( + return_value=dspy.Prediction(wrapper=generated) + if generated is not None + else dspy.Prediction() + ) + ), + max_iters=1, + cache=cache, + ) + session = generator.session( + symbol=symbol, + wrapped_crate_code=CodeRust(), + translation=CodeRust(), + unimplemented_wrapper=template, + wrapped_crate="libfoo_rs", + ) + # Nothing here builds the wrapper, so the scope check alone decides the verdict + attempt: wrapper_mod.WrapperAttempt | None = None + with session: + for attempt in session: + if errors := wrapper_mod.scope_errors(attempt.wrapper, template): + attempt.reject(scope=errors) + else: + attempt.accept() + if attempts is not None: + attempts.append(attempt) + assert attempt is not None # `max_iters` is at least 1, so the loop always ran + return attempt + + +def test_catch_unwind_in_a_generated_wrapper_is_rejected(): + attempt = _generate_wrapper( + CodeRust("fn bar() { let _ = std::panic::catch_unwind(|| ()); }") + ) + + # Swallowing the panic turns a divergence from C into a plausible return value + assert "catch_unwind" in attempt.rejection.scope + assert not attempt.success + + +@pytest.mark.parametrize( + "body", + ["unimplemented!()", 'unimplemented!("no i128 yet")', "unimplemented![]"], + ids=["bare", "with_message", "brackets"], +) +def test_an_unimplemented_body_is_rejected(body: str): + attempt = _generate_wrapper(CodeRust(f"pub fn bar_wrapper() -> i32 {{ {body} }}")) + + # A left-behind stub compiles and deviates from nothing, so only this rule catches it + assert "unimplemented!()" in attempt.rejection.scope + assert not attempt.success + + +def test_mentioning_unimplemented_in_a_comment_passes_scope_validation(): + # The instructions name the macro, so a model echoing them back must not be rejected + attempt = _generate_wrapper( + CodeRust("pub fn bar_wrapper() -> i32 { // not unimplemented!() anymore\n 0\n}") + ) + + assert attempt.rejection.scope == "" + + +def test_a_prediction_without_a_wrapper_is_rejected(): + stub = CodeRust("pub fn bar_wrapper() -> i32 { unimplemented!() }") + attempt = _generate_wrapper(None, template=stub) + + # The caller is handed the template so it still has code to write out and build + assert attempt.wrapper == stub + assert "unimplemented!()" in attempt.rejection.scope + assert not attempt.success + + +def test_a_wrapper_without_catch_unwind_passes_scope_validation(): + attempt = _generate_wrapper(CodeRust("fn bar() { libfoo_rs::bar() }")) + + assert attempt.rejection.scope == "" + + +def test_importing_catch_unwind_is_rejected(): + # The import makes every later call site a bare identifier, so it is the last place + # the panic API is still recognizable + attempt = _generate_wrapper( + CodeRust( + "use std::panic::{catch_unwind, AssertUnwindSafe};\n" + "fn bar() { let _ = catch_unwind(AssertUnwindSafe(|| ())); }" + ) + ) + + assert "catch_unwind" in attempt.rejection.scope + assert not attempt.success + + +@pytest.mark.xfail( + strict=True, + reason="`uses_catch_unwind` only accepts a matched path whose tail is literally " + "`panic`, which neither an aliased module nor a glob import leaves at the call site.", +) +@pytest.mark.parametrize( + "generated", + [ + "use std::panic as p;\nfn bar() { let _ = p::catch_unwind(|| ()); }", + "use std::panic::*;\nfn bar() { let _ = catch_unwind(|| ()); }", + ], + ids=["module_alias", "glob_import"], +) +def test_aliased_catch_unwind_is_rejected(generated: str): + attempt = _generate_wrapper(CodeRust(generated)) + + assert "catch_unwind" in attempt.rejection.scope + assert not attempt.success + + +def test_wrapping_a_c_function_named_catch_unwind_passes_scope_validation(): + attempt = _generate_wrapper( + CodeRust( + '#[unsafe(export_name = "catch_unwind")]\n' + 'pub extern "C" fn catch_unwind() { libfoo_rs::catch_unwind() }' + ) + ) + + assert attempt.rejection.scope == "" + + +def test_mentioning_catch_unwind_in_a_comment_passes_scope_validation(): + attempt = _generate_wrapper( + CodeRust("fn bar() { // no catch_unwind here\n libfoo_rs::bar()\n}") + ) + + assert attempt.rejection.scope == "" + + +def test_a_cached_wrapper_using_catch_unwind_fails_scope_validation(tmp_path: Path): + cache = wrapper_mod._init_cache(tmp_path / "cache.db") + poisoned = CodeRust("fn bar() { let _ = std::panic::catch_unwind(|| ()); }") + wrapper_mod._write_cache(cache, "bar", CodeRust("// template"), poisoned) + + attempts: list[wrapper_mod.WrapperAttempt] = [] + + # The cache key does not cover the instructions, so entries predating the rule survive + _generate_wrapper( + CodeRust("fn bar() { libfoo_rs::bar() }"), + cache=cache, + attempts=attempts, + ) + + # The rejection costs an iteration, so the clean regeneration is the session's result + replayed = attempts[0] + + # A replayed entry is scope-checked like a fresh one, so the rule still applies to it + assert not replayed.pred.was_generated + assert "catch_unwind" in replayed.rejection.scope + assert not replayed.success + + +def test_type_wrapper_template_is_an_interop_impl(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text("typedef struct { int floors; } house_t;\nhouse_t the_house;\n") + + types = wrapper_mod.bindgen_types(c_path, ["house_t"]) + wrapper, tests_mod = wrapper_mod.generate_unimplemented_type_wrapper("house_t", types) + assert tests_mod == "test_house_t" + + # The C layout is hoisted, so only the impl and its test skeleton reach the wrapper + assert "pub struct house_t" in str(types) + assert "pub struct house_t" not in str(wrapper) + + assert "impl CInterop for house_t" in str(wrapper) + # The associated type is the single slot the LLM is asked to fill + assert "type Rust = ();" in str(wrapper) + assert "fn round_trip_zeroed" in str(wrapper) + assert "fn round_trip_nontrivial" in str(wrapper) + + # todo!() keeps validate_changes from constraining the signature edits + assert "unimplemented!()" not in str(wrapper) + + +def test_nested_record_is_wrapped_under_its_bindgen_name(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "struct histindex { struct record { unsigned ptr; } **records; };\n" + "void use_histindex(struct histindex *h);\n" + ) + + # bindgen keys its allowlist by the name it generates, so the bare tag misses + assert "histindex_record" not in str(wrapper_mod.bindgen_types(c_path, ["record"])) + + types = wrapper_mod.bindgen_types(c_path, ["histindex_record"]) + assert "pub struct histindex_record" in str(types) + + # ...while the impl must name the record after its parent, or it would not compile + wrapper, tests_mod = wrapper_mod.generate_unimplemented_type_wrapper( + "histindex_record", types + ) + assert tests_mod == "test_histindex_record" + assert "impl CInterop for histindex_record" in str(wrapper) + + +@pytest.mark.parametrize( + "code", + [ + "typedef struct MyStruct { int x; } my_struct_t;", + "typedef struct { int x; } my_struct_t;", + "typedef struct my_struct_t { int x; } my_struct_t;", + ], + ids=["named_tag", "anonymous_tag", "tag_named_after_typedef"], +) +def test_struct_typedef_is_wrapped_under_the_struct_bindgen_declares(tmp_path: Path, code: str): + # bindgen renders a named tag as `pub struct MyStruct` plus `pub type my_struct_t = + # MyStruct`, and `_wrap_type` implements `CInterop` on the typedef's `bindgen_name`, so + # that name has to be the struct bindgen declares rather than the alias. + c_path = tmp_path / "input.c" + c_path.write_text(code + "\n") + + tr = ast.extract_info_c(ast.create_translation_unit(ast.CodeC(code))) + + # An unnamed tag changes the typedef's USR, so find it by kind + (typedef,) = [s for s in tr.symbols.values() if s.kind == CursorKind.TYPEDEF_DECL] + assert (bindgen_name := typedef.bindgen_name) is not None + + # The alias would compile as an `impl` target, so only the declaration catches the miss + types = wrapper_mod.bindgen_types(c_path, [bindgen_name]) + assert f"pub struct {bindgen_name}" in str(types) + + +def test_variable_wrapper_template_pairs_sync_fns_with_tests(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text("typedef struct { int floors; } house_t;\nhouse_t the_house;\n") + + types = wrapper_mod.bindgen_types(c_path, ["the_house"]) + binding = CodeRust('unsafe extern "C" { pub static mut the_house: house_t; }') + wrapper, tests_mod, sync_fns = wrapper_mod.generate_unimplemented_variable_wrapper( + "the_house", types, binding + ) + assert tests_mod == "test_var_the_house" + assert sync_fns == ("sync_the_house_to_rust", "sync_the_house_to_c") + + # The extern binding is hoisted separately, so the wrapper never redeclares it + assert "pub static mut the_house" not in str(wrapper) + assert "mod test_var_the_house {" in str(wrapper) + for fn in sync_fns: + assert f"pub unsafe fn {fn}()" in str(wrapper) + assert "fn initial_value_matches" in str(wrapper) + assert "fn round_trip_nontrivial" in str(wrapper) + + # todo!() keeps validate_changes from constraining the generated bodies + assert "unimplemented!()" not in str(wrapper) + + +def test_const_variable_wrapper_template_omits_the_sync_pair(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text("const int limit = 7;\n") + types = wrapper_mod.bindgen_types(c_path, ["limit"]) + + # bindgen drops `mut` for a const-qualified global, whose Rust counterpart is therefore + # immutable: sync_to_rust could only be a stub and the round trip could never hold + binding = CodeRust('unsafe extern "C" { pub static limit: ::std::os::raw::c_int; }') + wrapper, tests_mod, sync_fns = wrapper_mod.generate_unimplemented_variable_wrapper( + "limit", types, binding + ) + + assert tests_mod == "test_var_limit" + assert sync_fns == () + assert "sync_" not in str(wrapper) + assert "fn round_trip_nontrivial" not in str(wrapper) + assert "fn initial_value_matches" in str(wrapper) + + +def test_variable_test_module_cannot_collide_with_a_type_of_the_same_name(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text("typedef struct { int floors; } house_t;\nhouse_t house_t_value;\n") + types = wrapper_mod.bindgen_types(c_path, ["house_t"]) + + # C keeps tags and ordinary identifiers in separate namespaces, so a variable and a + # type can share a spelling while both wrappers land in the same crate root + type_wrapper, type_mod = wrapper_mod.generate_unimplemented_type_wrapper("house_t", types) + var_wrapper, var_mod, _ = wrapper_mod.generate_unimplemented_variable_wrapper( + "house_t", types, CodeRust("pub static mut house_t: house_t;") + ) + + assert type_mod != var_mod + assert f"mod {var_mod} " in str(var_wrapper) + assert f"mod {type_mod} " in str(type_wrapper) + + +def test_type_wrapper_template_names_its_test_module(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "typedef struct { int floors; } house_t;\n" + "typedef struct { int wheels; } car_t;\n" + "house_t the_house;\n" + "car_t the_car;\n" + ) + types = wrapper_mod.bindgen_types(c_path, ["house_t", "car_t"]) + + # Every wrapper shares the crate root, so a fixed `mod tests` would collide + house, _ = wrapper_mod.generate_unimplemented_type_wrapper("house_t", types) + car, _ = wrapper_mod.generate_unimplemented_type_wrapper("car_t", types) + + assert "mod test_house_t {" in str(house) + assert "mod test_car_t {" in str(car) + assert "mod tests" not in str(house) + + +def test_narrowing_the_allowlist_only_deletes_types(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "typedef struct { union { int i; float f; } payload; } house_t;\n" + "typedef struct { int wheels; } car_t;\n" + "void use_house(house_t *h);\n" + "void use_car(car_t *c);\n" + ) + + full = wrapper_mod.bindgen_types(c_path, ["house_t", "car_t", "use_house", "use_car"]) + sliced = wrapper_mod.bindgen_types(c_path, ["use_house"]) + + # Types unreachable from the allowlisted symbol drop out + assert "pub struct car_t" in str(full) + assert "car_t" not in str(sliced) + + # Anonymous types are named after their parent rather than a per-run counter, so a + # narrowed run never renames them out of sync with the crate written to disk + assert "house_t__bindgen_ty_1" in str(full) + for item in str(sliced).split("#[repr(C)]"): + assert item.strip() in str(full) + + +def test_splitting_types_keys_every_item_by_the_type_it_belongs_to(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "typedef enum { RED, GREEN } color_t;\n" + "typedef struct { union { int i; float f; } payload; color_t hue; } house_t;\n" + "typedef house_t alias_t;\n" + "void use_house(alias_t *h);\n" + ) + + types = wrapper_mod.bindgen_types(c_path, ["house_t", "alias_t", "use_house"]) + split = wrapper_mod.split_types(types) + + # Attributes stay with the item they decorate rather than starting a bucket + assert "#[repr(C)]\n#[derive" in str(split[BindgenName("house_t")]) + assert str(split[BindgenName("house_t")]).count("pub struct") == 1 + + # Enum companion constants have no declaration of their own, so they ride with the enum + assert "pub const color_t_RED: color_t = 0;" in str(split[BindgenName("color_t")]) + assert "pub type color_t" in str(split[BindgenName("color_t")]) + + # Nested anonymous records are separate items, reachable through their parent's fields + assert "house_t__bindgen_ty_1" in split + + # Nothing is dropped or duplicated on the way in + lines = sorted( + line for code in split.values() for line in str(code).splitlines() if line.strip() + ) + assert lines == sorted(line for line in str(types).splitlines() if line.strip()) + + +def test_splitting_types_attaches_bindgen_helper_impls_to_their_type(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "typedef struct { unsigned flag : 1; } flags_t;\n" + "typedef struct { int plain; } plain_t;\n" + "void use_both(flags_t *f, plain_t *p);\n" + ) + + split = wrapper_mod.split_types( + wrapper_mod.bindgen_types(c_path, ["flags_t", "plain_t", "use_both"]) + ) + + # The helper's generic impl blocks are ~130 lines that would otherwise land in the + # preamble and be charged to every slice, including ones with no bitfields at all + assert wrapper_mod.TYPES_PREAMBLE not in split + assert "impl __BindgenBitfieldUnit" in str( + split[BindgenName("__BindgenBitfieldUnit")] + ) + + +def test_libc_internals_are_collapsed_to_opaque_blobs(tmp_path: Path): + c_path = tmp_path / "input.c" + c_path.write_text( + "#include \n" + "#include \n" + "#include \n" + "struct probe { FILE *fp; pthread_mutex_t mtx; locale_t loc; };\n" + "void use_probe(struct probe *p);\n" + ) + + types = wrapper_mod.bindgen_types(c_path, ["probe", "use_probe"]) + + # A single `FILE *` otherwise drags ~30 unreadable glibc fields into every prompt + assert "_bindgen_opaque_blob" in str(types) + assert "pub struct _IO_marker" not in str(types) + assert "pub _IO_read_ptr" not in str(types) + + # The typedefs themselves must stay concrete: making them opaque drops their + # definition while references survive, which fails to compile + assert "pub struct probe" in str(types) + assert "pub union pthread_mutex_t" in str(types) + + +@pytest.mark.parametrize( + ("source", "symbol", "item"), + [ + ("struct twin { int x; };\nstruct twin twin;\n", "twin", "pub static mut twin: twin;"), + ("struct twin { int x; };\nvoid twin(void);\n", "twin", "pub fn twin();"), + ], +) +def test_tag_namespace_survives_a_same_named_variable_or_function( + tmp_path: Path, source: str, symbol: str, item: str +): + c_path = tmp_path / "input.c" + c_path.write_text(source) + + types = wrapper_mod.bindgen_types(c_path, [symbol]) + binding = wrapper_mod.bindgen_binding(c_path, symbol, types) + + # bindgen keys items by name alone, so a by-name blocklist would take the tag down + # with the identifier and leave the binding referring to a type nobody defines + assert "pub struct twin" in str(types) + + # ...and a by-name allowlist would re-emit that same tag alongside the item + assert "pub struct twin" not in str(binding) + assert item in str(binding) diff --git a/tools/cando2/src/runners/lib_runner.patch b/tools/cando2/src/runners/lib_runner.patch new file mode 100644 index 0000000..9fff231 --- /dev/null +++ b/tools/cando2/src/runners/lib_runner.patch @@ -0,0 +1,25 @@ +--- PUBLIC-Test-Corpus/tools/cando2/src/runners/lib_runner.rs 2026-09-22 13:56:16.550459880 -0700 ++++ IDEAS/tools/cando2/src/runners/lib_runner.rs 2026-09-22 13:55:25.350455837 -0700 +@@ -19,6 +19,7 @@ + serde::{de::DeserializeOwned, Serialize}, + std::{ + collections::HashMap, ++ env, + fmt::Debug, + fs::{self, File}, + io::{Read, Write}, +@@ -70,8 +71,12 @@ + lib_name: &str, + run_rust: bool, + ) -> Result { +- let build_dir = context.get_build_dir(run_rust); +- let lib = build_dir.join(get_full_lib_name(lib_name)); ++ let lib = match env::var_os("CANDO_LIBRARY_PATH") { ++ Some(path) if run_rust => PathBuf::from(path), ++ _ => context ++ .get_build_dir(run_rust) ++ .join(get_full_lib_name(lib_name)), ++ }; + if !lib.exists() { + let lib_str = lib + .to_str() diff --git a/tools/rust_tests/lib_testing.rs b/tools/rust_tests/lib_testing.rs index 6d38a7e..3c1a9c0 100644 --- a/tools/rust_tests/lib_testing.rs +++ b/tools/rust_tests/lib_testing.rs @@ -1,46 +1,84 @@ -use std::process::Command; - -use once_cell::sync::Lazy; -use std::path::PathBuf; -static ARTIFACT_DIR: Lazy = Lazy::new(|| { - test_cdylib::build_current_project() - .parent() - .expect("Failed to get parent directory of built library") - .to_path_buf() -}); - -fn parse_test_output(output: &str, vector_path: &str) -> bool { - // Check if the output contains ": true" anywhere - output.contains(&format!("{}: true", vector_path)) +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, +}; + +const DEFAULT_TOOLCHAIN: &str = "1.94.1"; + +fn candidate_lib() -> &'static Path { + static LIB: OnceLock = OnceLock::new(); + + LIB.get_or_init(|| { + const MANIFEST_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); + + // Cargo tells tests nothing about their profile, so recover it from the build layout + let exe = std::env::current_exe().expect("Couldn't get path of the test executable"); + let profile_dir = exe + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .expect("Test executable should live in `//deps`"); + // The `dev` profile builds into a directory named `debug` + let profile = if profile_dir == "debug" { "dev" } else { profile_dir }; + + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["build", "--manifest-path", MANIFEST_PATH]); + cmd.args(["--profile", profile]); + cmd.arg("--message-format=json"); + + let output = cmd.output().expect("Failed to execute `cargo build`"); + assert!( + output.status.success(), + "`cargo build` failed while locating the candidate library:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("`cargo build` emitted invalid UTF-8"); + stdout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|msg| msg["reason"] == "compiler-artifact" && msg["manifest_path"] == MANIFEST_PATH) + .flat_map(|msg| msg["filenames"].as_array().cloned().unwrap_or_default()) + .filter_map(|name| name.as_str().map(PathBuf::from)) + .find(|name| name.extension() == Some(OsStr::new(std::env::consts::DLL_EXTENSION))) + .expect("`cargo build` reported no cdylib for this crate") + }) + .as_path() } -fn run_test_vector(vector_path: &str, runner_manifest_path: &str) { +fn run_test_vector(vector_path: &str, runner_manifest_path: &str, test_root_dir: &str) { // Run the test vector let output = Command::new("cargo") + .env("RUSTUP_TOOLCHAIN", std::env::var("RUNNER_TOOLCHAIN").unwrap_or_else(|_| DEFAULT_TOOLCHAIN.to_string())) + .env("CANDO_LIBRARY_PATH", candidate_lib()) .args(&["run", "--release", "--manifest-path", runner_manifest_path]) .arg("--") - .arg("-b") - .arg(&*ARTIFACT_DIR) + .args(["--log-level", "quiet"]) + .args(["--test-root-dir", test_root_dir]) + .args(["--vectors", vector_path]) + .arg("--rust") .arg("lib") - .args(&["-c", vector_path]) - .arg("-d") .output() .expect("Failed to execute runner"); let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); // Parse and assert - let success = parse_test_output(&stdout, &vector_path); - assert!(success, "Test failed or crashed. Full output:\n{}", stdout); + let success = output.status.success(); + assert!(success, "Test failed or crashed. Full output:\n{}\n{}", stdout, stderr); } // Macro to generate tests from test vector files macro_rules! generate_tests { - ($runner_manifest:expr; $($name:ident => $path:expr),* $(,)?) => { + ($runner_manifest:expr, $test_root_dir:expr; $($name:ident => $path:expr),* $(,)?) => { $( #[test] fn $name() { - run_test_vector($path, $runner_manifest); + run_test_vector($path, $runner_manifest, $test_root_dir); } )* }; diff --git a/uv.lock b/uv.lock index 45c342b..f8564d0 100644 --- a/uv.lock +++ b/uv.lock @@ -121,14 +121,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -392,6 +392,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/18/1c93641f25f4e76772b4ffb52a4e289f1706d6bda4f3b59bb6f7c339df46/dspy-3.1.2-py3-none-any.whl", hash = "sha256:23b98bf5abeda260722c445d397d07ea27488c204b8c0ccd6d3e607c4b41bc6b", size = 312290, upload-time = "2026-01-19T14:21:45.776Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -615,16 +624,16 @@ wheels = [ [[package]] name = "hydra-core" -version = "1.3.2" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "antlr4-python3-runtime" }, { name = "omegaconf" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/8e/07e42bc434a847154083b315779b0a81d567154504624e181caf2c71cd98/hydra-core-1.3.2.tar.gz", hash = "sha256:8a878ed67216997c3e9d88a8e72e7b4767e81af37afb4ea3334b269a4390a824", size = 3263494, upload-time = "2023-02-23T18:33:43.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/dd/220f0e91743136725352497e98540772a01fc7c3ab96ff16c3c74424e984/hydra_core-1.3.4.tar.gz", hash = "sha256:ad0f7b05a0242255a8984d5a4ed2f6847f7b783ed727368a2c0155ec52d6c34c", size = 3263348, upload-time = "2026-07-04T16:25:38.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547, upload-time = "2023-02-23T18:33:40.801Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cd/a568610bafe991fdd3f628fb606316b3b2be52ded019284e895d9beb3a1e/hydra_core-1.3.4-py3-none-any.whl", hash = "sha256:e58683692904a09f1fdfffa1a9b86bfd94e215b59f1ee17e7cd7d92738090d33", size = 155478, upload-time = "2026-07-04T16:25:37.291Z" }, ] [[package]] @@ -639,7 +648,7 @@ dependencies = [ { name = "networkx" }, { name = "tomlkit" }, { name = "tree-sitter" }, - { name = "tree-sitter-rust" }, + { name = "tree-sitter-rust-orchard" }, ] [package.dev-dependencies] @@ -647,6 +656,7 @@ dev = [ { name = "basedpyright" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "vulture" }, ] @@ -655,12 +665,12 @@ dev = [ requires-dist = [ { name = "clang", specifier = "==21.1.7" }, { name = "dspy", specifier = "==3.1.2" }, - { name = "hydra-core", specifier = "==1.3.2" }, - { name = "kiss-agent-framework", extras = ["core"], git = "https://github.com/mariusarvinte/kiss_ai?rev=f4f6cc1ef1fc5e2962aec6da71677be6e057f194" }, + { name = "hydra-core", specifier = "==1.3.4" }, + { name = "kiss-agent-framework", extras = ["core"], git = "https://github.com/mariusarvinte/kiss_ai?rev=edb12535c0097bdf9d05174b3afa94a99b44bf17" }, { name = "networkx", specifier = "==3.6.1" }, { name = "tomlkit", specifier = ">=0.14.0" }, { name = "tree-sitter", specifier = "==0.25.2" }, - { name = "tree-sitter-rust", specifier = "==0.24.0" }, + { name = "tree-sitter-rust-orchard", specifier = "==0.16.8" }, ] [package.metadata.requires-dev] @@ -668,6 +678,7 @@ dev = [ { name = "basedpyright", specifier = "==1.29.4" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "ruff", specifier = "==0.13.0" }, { name = "vulture", specifier = "==2.16" }, ] @@ -788,7 +799,7 @@ wheels = [ [[package]] name = "kiss-agent-framework" version = "2026.7.23" -source = { git = "https://github.com/mariusarvinte/kiss_ai?rev=f4f6cc1ef1fc5e2962aec6da71677be6e057f194#f4f6cc1ef1fc5e2962aec6da71677be6e057f194" } +source = { git = "https://github.com/mariusarvinte/kiss_ai?rev=edb12535c0097bdf9d05174b3afa94a99b44bf17#edb12535c0097bdf9d05174b3afa94a99b44bf17" } [package.optional-dependencies] core = [ @@ -1259,6 +1270,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1563,19 +1587,10 @@ wheels = [ ] [[package]] -name = "tree-sitter-rust" -version = "0.24.0" +name = "tree-sitter-rust-orchard" +version = "0.16.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/ae/fde1ab896f3d79205add86749f6f443537f59c747616a8fc004c7a453c29/tree_sitter_rust-0.24.0.tar.gz", hash = "sha256:c7185f482717bd41f24ffcd90b5ee24e7e0d6334fecce69f1579609994cd599d", size = 335850, upload-time = "2025-04-01T21:06:03.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/29/0594a6b135d2475d1bb8478029dad127b87856eeb13b23ce55984dd22bb4/tree_sitter_rust-0.24.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ea455443f5ab245afd8c5ce63a8ae38da455ef27437b459ce3618a9d4ec4f9a", size = 131884, upload-time = "2025-04-01T21:05:56.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/00/4c400fe94eb3cb141b008b489d582dcd8b41e4168aca5dd8746c47a2b1bc/tree_sitter_rust-0.24.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:a0a1a2694117a0e86e156b28ee7def810ec94e52402069bf805be22d43e3c1a1", size = 137904, upload-time = "2025-04-01T21:05:57.743Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4d/c5eb85a68a2115d9f5c23fa5590a28873c4cf3b4e17c536ff0cb098e1a91/tree_sitter_rust-0.24.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3362992ea3150b0dd15577dd59caef4f2926b6e10806f2bb4f2533485acee2f", size = 166554, upload-time = "2025-04-01T21:05:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/ba/72/8ee8cf2bd51bc402531da7d8741838a4ea632b46a8c1e2df9968c7326cc7/tree_sitter_rust-0.24.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2c1f4b87df568352a9e523600af7cb32c5748dc75275f4794d6f811ab13dfe", size = 165457, upload-time = "2025-04-01T21:05:59.939Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/389eecb15c3f8ef4c947fcfbcc794ef4036b3b892c0f981e110860371daa/tree_sitter_rust-0.24.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:615f989241b717f14105b1bc621ff0c2200c86f1c3b36f1842d61f6605021152", size = 162857, upload-time = "2025-04-01T21:06:00.835Z" }, - { url = "https://files.pythonhosted.org/packages/b9/df/a6321043d6dee313e5fa3b6a13384119d590393368134cf12f2ee7f9e664/tree_sitter_rust-0.24.0-cp39-abi3-win_amd64.whl", hash = "sha256:2e29be0292eaf1f99389b3af4281f92187612af31ba129e90f4755f762993441", size = 130052, upload-time = "2025-04-01T21:06:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/c8/33/70b320d24cd127d6ca427d2bef1279830f0786a1f2cde160f59b4fb80728/tree_sitter_rust-0.24.0-cp39-abi3-win_arm64.whl", hash = "sha256:7a0538eaf4063b443c6cd80a47df19249f65e27dbdf129396a9193749912d0c0", size = 128583, upload-time = "2025-04-01T21:06:02.58Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/43/14/e719462c28db3113f969241684fa0f4ab5db6a2d2815a238dbc9fdbddf89/tree_sitter_rust_orchard-0.16.8.tar.gz", hash = "sha256:b53e9640c4c80797e2ba8b31851473d4cc51808f4a3b9fd4c604bf74f8615edc", size = 591136, upload-time = "2026-07-10T09:09:54.823Z" } [[package]] name = "typer-slim" From 4c21b3e0cac0654b6f35de82476e6b8b01a23edd Mon Sep 17 00:00:00 2001 From: Marius Arvinte Date: Tue, 22 Sep 2026 14:20:48 -0700 Subject: [PATCH 2/2] Update ossf scorecard action --- .github/workflows/scorecard.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 4deffac..f2a1738 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif