diff --git a/.gitattributes b/.gitattributes index 7d770bec6..89c0276ca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,7 @@ # unusually well, and shrinking those headers to win a byte count would destroy the most useful thing in # the files. test/*.sh linguist-detectable=false + +# Frozen evaluation packs are length-prefixed binary artifacts; Git must not rewrite their LF bytes on Windows. +bench/recalleval/*.mdpack -text +bench/recalleval/*.srcpack -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 960b1305f..d42e9c457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ concurrency: # superseded. A queued main run costs runner minutes; a cancelled one costs the answer. cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + # Never RIPWIRE_NATIVE in CI: -march=native would bake in whatever ISA the CI runner's host happens to # expose that week, defeating the entire point of testing the portable default. @@ -88,6 +91,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # churn/co-change gates read real git history — a shallow clone reddens them + persist-credentials: false - name: Install clang-format / clang-tidy (PINNED major — see the job comment) run: | @@ -209,6 +213,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # churn/co-change gates read real git history — a shallow clone reddens them + persist-credentials: false # clang is installed on BOTH Linux legs, not only the clang one. optremarkscheck's Clang-only # configure arms pin `clang++` when the default front end is not Clang, so the gcc leg keeps that @@ -397,6 +402,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false # RHEL 9's default gcc is 11, which does not implement C++23 — CMakeLists sets CXX_STANDARD 23 with # STANDARD_REQUIRED ON, so the configure would fail outright. gcc-toolset-N is Red Hat's own supported @@ -460,6 +466,65 @@ jobs: - name: G4 — xmllint --noout run: ./build/ripwire test/fixture --no-cache | xmllint --noout - + # ─── windows: native Windows validation with MSVC ABI + Clang ──────────────────────────────────────── + windows: + name: windows (windows-latest, clang) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Configure (portable — clang + ninja) + shell: bash + run: cmake -S . -B build -G Ninja -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl -DRIPWIRE_LTO=OFF + + - name: Build + run: cmake --build build -j + + # Build the HEAD comparison binary once, before the suite. The monotonicity gates use it in staged + # mode; building it inside a gate would multiply the Windows compile cost and can race the gate budget. + - name: Stage the HEAD comparison binary (once before the Windows suite) + shell: bash + run: | + export TMPDIR="$( cygpath -u "$RUNNER_TEMP" )" + export RIPWIRE_HEADBIN_BUILD_LOG="$TMPDIR/headbin-build.log" + . test/lib/headbinlib.sh + hb="$( ripwire_head_binary "$PWD" "$TMPDIR" )" || { echo "HEAD binary build failed; last 80 lines of its log:"; tail -n 80 "$RIPWIRE_HEADBIN_BUILD_LOG"; exit 1; } + ripwire_headbin_verify "$hb" "$( git rev-parse HEAD )" + "$hb" --version + echo "RIPWIRE_HEADBIN=$hb" >> "$GITHUB_ENV" + + - name: Doctor check + run: .\build\ripwire.exe . --doctor + + - name: Self-run on the fixture + run: .\build\ripwire.exe test/fixture --no-cache + + - name: det-gate — 2-run byte-identical diff + shell: bash + run: | + ./build/ripwire.exe test/fixture --no-cache > run_a.xml + ./build/ripwire.exe test/fixture --no-cache > run_b.xml + diff -q run_a.xml run_b.xml + + - name: Determinism gate (test/det-gate.sh) + shell: bash + run: bash test/det-gate.sh build/ripwire.exe + + - name: Full native Windows gate suite + shell: bash + run: python test/pargates.py . build/ripwire.exe -j 6 --budget-scale 4 + asan: name: asan (${{ matrix.os }}) strategy: @@ -490,6 +555,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # churn/co-change gates read real git history — a shallow clone reddens them + persist-credentials: false - name: Install tooling (Linux) if: runner.os == 'Linux' diff --git a/CMakeLists.txt b/CMakeLists.txt index 46432c70f..fb0207771 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,10 @@ option(RIPWIRE_NATIVE "build with -march=native (DEV MACHINES ONLY — bakes in include(cmake/PortableFlags.cmake) add_compile_options(${RIPWIRE_ARCH_FLAGS}) +if(WIN32) + add_compile_definitions(_CRT_SECURE_NO_WARNINGS _CRT_NONSTDC_NO_DEPRECATE) +endif() + # ---- link-time optimization: -DRIPWIRE_LTO=ON ---- # The one change the optimization-remarks pass actually justified (docs/OPTREMARKS.md, finding F1). # @@ -511,11 +515,16 @@ set(RIPWIRE_SRCS src/ingest.cpp src/pagerank.cpp src/infra/diagnostics.cpp + src/infra/platform_compat.cpp ) # The global profile uses -ffast-math, but PageRank reductions must not reassociate (the determinism # contract's no-reassociation rule — docs/ARCHITECTURE.md §3). -set_source_files_properties(src/pagerank.cpp PROPERTIES COMPILE_OPTIONS "-fno-fast-math") +if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + set_source_files_properties(src/pagerank.cpp PROPERTIES COMPILE_OPTIONS "/fp:precise") +else() + set_source_files_properties(src/pagerank.cpp PROPERTIES COMPILE_OPTIONS "-fno-fast-math") +endif() # All grammar OBJECT files, gathered once so every target links the same set. set(RIPWIRE_TS_OBJECTS @@ -560,9 +569,19 @@ add_executable(ripwire_probe ${RIPWIRE_SRCS} ${RIPWIRE_TS_OBJECTS}) target_link_libraries(ripwire_probe PRIVATE tree-sitter Threads::Threads) +if(WIN32) + target_link_libraries(ripwire_probe PRIVATE ws2_32) + target_sources(ripwire_probe PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/win32/ripwire.manifest") + if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(ripwire_probe PRIVATE /EHsc /FI "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + else() + target_compile_options(ripwire_probe PRIVATE -include "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + endif() +endif() target_include_directories(ripwire_probe PRIVATE ${tree_sitter_SOURCE_DIR}/lib/include ${_ripwire_generated_dir} + $<$:${CMAKE_CURRENT_SOURCE_DIR}/src/infra/compat> src/infra third_party src) @@ -575,9 +594,19 @@ add_executable(ripwire ${RIPWIRE_SRCS} ${RIPWIRE_TS_OBJECTS}) target_link_libraries(ripwire PRIVATE tree-sitter Threads::Threads) +if(WIN32) + target_link_libraries(ripwire PRIVATE ws2_32) + target_sources(ripwire PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/win32/ripwire.manifest") + if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(ripwire PRIVATE /EHsc /FI "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + else() + target_compile_options(ripwire PRIVATE -include "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + endif() +endif() target_include_directories(ripwire PRIVATE ${tree_sitter_SOURCE_DIR}/lib/include ${_ripwire_generated_dir} + $<$:${CMAKE_CURRENT_SOURCE_DIR}/src/infra/compat> src/infra third_party src) @@ -666,6 +695,18 @@ if(RIPWIRE_TESTS) target_compile_definitions(ripwire_test_strkern PRIVATE RIPWIRE_TEST_ROOT="${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(ripwire_test_strkern PRIVATE doctest::doctest) add_test(NAME ripwire.strkern COMMAND ripwire_test_strkern) + + if(WIN32) + foreach(_ripwire_test_target ripwire_test_csr ripwire_test_pagerank ripwire_test_radix ripwire_test_strkern) + target_sources(${_ripwire_test_target} PRIVATE src/infra/platform_compat.cpp) + target_link_libraries(${_ripwire_test_target} PRIVATE ws2_32) + if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(${_ripwire_test_target} PRIVATE /EHsc /FI "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + else() + target_compile_options(${_ripwire_test_target} PRIVATE -include "${CMAKE_CURRENT_SOURCE_DIR}/src/infra/platform_compat.h") + endif() + endforeach() + endif() endif() # ---- self-profiling build (src/infra/profileScope.h): -DRIPWIRE_PROFILE=ON ---- @@ -857,14 +898,45 @@ file(WRITE "${_ripwire_libstdcxx_ignorelist}" "src:*/include/c\\+\\+/*/print\n" "[implicit-unsigned-integer-truncation]\nsrc:*/include/c\\+\\+/*/format\n" "src:*/include/c\\+\\+/*/print\n") +if(WIN32) + # MSVC's filesystem prefix helper intentionally subtracts the lower bound from an unsigned packed value; + # Clang's integer sanitizer diagnoses that defined range test before any project code runs. Keep the + # exemption limited to the vendor header; project arithmetic remains covered by the complete G1 stack. + file(APPEND "${_ripwire_libstdcxx_ignorelist}" + "[unsigned-integer-overflow]\nsrc:*/include/filesystem\n" + "src:*filesystem\n" + "fun:*_Is_drive_prefix*\n") +endif() if(RIPWIRE_ASAN) + set(_ripwire_windows_clangcl_asan OFF) + set(_ripwire_no_omit_frame_pointer -fno-omit-frame-pointer) + # CMake's MSVC linker rule invokes CMAKE_LINKER directly. With ClangCL, passing the LLVM + # -fsanitize flags to link.exe instruments the objects but never loads the Clang sanitizer + # runtime, leaving __asan_* unresolved. Use clang-cl as the link driver for this one flavour; + # its driver preserves the MSVC ABI/import libraries and adds the matching runtime. + if(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + set(_ripwire_windows_clangcl_asan ON) + set(_ripwire_no_omit_frame_pointer /Oy-) + string(REPLACE "" "" CMAKE_CXX_LINK_EXECUTABLE + "${CMAKE_CXX_LINK_EXECUTABLE}") + # The replacement template does not carry the generator's per-target FLAGS. Keep the + # sanitizer thunk and the instrumented objects on the same dynamic CRT as the compile rule. + string(REPLACE " ${CMAKE_CL_NOLOGO} " + " ${CMAKE_CL_NOLOGO} /MD ${RIPWIRE_G1_SANITIZERS}" + CMAKE_CXX_LINK_EXECUTABLE "${CMAKE_CXX_LINK_EXECUTABLE}") + string(REPLACE " /out:" " /link /out:" + CMAKE_CXX_LINK_EXECUTABLE "${CMAKE_CXX_LINK_EXECUTABLE}") + message(STATUS "RIPWIRE_ASAN: using the ClangCL driver for Windows sanitizer links") + endif() foreach(_t IN LISTS RIPWIRE_RUNTIME_COMPILE_TARGETS) target_compile_options(${_t} PRIVATE ${RIPWIRE_G1_SANITIZERS} - -fno-sanitize-recover=all -fno-omit-frame-pointer -O2 -g) + -fno-sanitize-recover=all ${_ripwire_no_omit_frame_pointer} -O2 -g) endforeach() foreach(_t IN LISTS RIPWIRE_RUNTIME_LINK_TARGETS) - target_link_options(${_t} PRIVATE ${RIPWIRE_G1_SANITIZERS}) + if(NOT _ripwire_windows_clangcl_asan) + target_link_options(${_t} PRIVATE ${RIPWIRE_G1_SANITIZERS}) + endif() endforeach() # Every exemption below names a check inside the Clang-only `integer` group, or uses # -fsanitize-ignorelist=, which GCC does not implement. With `integer` absent there is nothing to @@ -882,15 +954,47 @@ if(RIPWIRE_ASAN) endforeach() endif() - # Xcode's arm64 Darwin runtime rejects both the standalone leak sanitizer and detect_leaks=1 at startup. - # Keep the local ASan/UBSan gate executable and explicit; LeakSanitizer remains a Linux/upstream-runtime - # CI gate using the committed tree-sitter suppressions rather than being falsely claimed on Apple Clang. - if(APPLE) + # Darwin and Windows runtimes reject detect_leaks=1 at startup. Keep the local ASan/UBSan gate + # executable and explicit; LeakSanitizer remains a Linux/upstream-runtime CI gate using the + # committed tree-sitter suppressions rather than being falsely claimed on either platform. + if(APPLE OR WIN32) set(_ripwire_asan_options "detect_leaks=0:halt_on_error=1:abort_on_error=1") else() set(_ripwire_asan_options "detect_leaks=1:halt_on_error=1:abort_on_error=1") endif() + set(_ripwire_asan_runtime_copy_command) + if(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + # clang-cl links the ASAN dynamic runtime, but Windows does not search Clang's resource + # directory for DLLs. Discover the matching directory from the active compiler so the fixture + # exercises this build without requiring a system-wide DLL installation. Copying beside the + # executable is deliberate: putting a semicolon-separated Windows PATH in a CMake command is + # parsed as a list of unrelated arguments by the Ninja generator. + execute_process( + COMMAND "${CMAKE_CXX_COMPILER}" -print-resource-dir + OUTPUT_VARIABLE _ripwire_clang_resource_dir + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(_ripwire_clang_resource_dir) + file(TO_CMAKE_PATH "${_ripwire_clang_resource_dir}/lib/windows" _ripwire_asan_runtime_dir) + list(APPEND _ripwire_asan_runtime_copy_command + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_ripwire_asan_runtime_dir}/clang_rt.asan_dynamic-x86_64.dll" + "$") + message(STATUS "RIPWIRE_ASAN: fixture copies the runtime DLL from ${_ripwire_asan_runtime_dir}") + else() + message(WARNING "RIPWIRE_ASAN: clang resource directory was not discoverable; runtime fixture may miss the ASAN DLL") + endif() + endif() + if(WIN32 AND _ripwire_asan_runtime_dir) + foreach(_t IN LISTS RIPWIRE_RUNTIME_LINK_TARGETS) + add_custom_command(TARGET ${_t} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_ripwire_asan_runtime_dir}/clang_rt.asan_dynamic-x86_64.dll" + "$") + endforeach() + endif() add_custom_target(ripwire_asan_fixture + ${_ripwire_asan_runtime_copy_command} COMMAND ${CMAKE_COMMAND} -E env "ASAN_OPTIONS=${_ripwire_asan_options}" "UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dac4a663..35b1ac1a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,6 +111,38 @@ is compiled out and freshness comes from the per-request stat sweep — that is a degradation, so it is silent and the staleness contract is unchanged. You can build and run that path on a Mac with `cmake -S . -B build-nokqueue -DCMAKE_CXX_FLAGS=-DRIPWIRE_HAS_KQUEUE=0`. +### Building on Windows + +ripwire builds natively on Windows (x64) with Clang and the MSVC ABI, with zero external runtime +dependencies (linking only system `kernel32`, `ws2_32`, `advapi32`, `shell32`). + +From an **x64 Native Tools Command Prompt for Visual Studio** (with `clang` and `ninja` on `PATH`): + +```cmd +cmake -S . -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ +cmake --build build -j +``` + +For maximum performance (Release mode with ThinLTO and host-CPU vectorization): + +```cmd +cmake -S . -B build-release -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_BUILD_TYPE=Release -DRIPWIRE_NATIVE=ON +cmake --build build-release -j +``` + +Profile-Guided Optimization (PGO) is also supported on Windows via `scripts/pgobuild.sh` (under Git Bash) or CMake (`-DRIPWIRE_PGO=generate` and `-DRIPWIRE_PGO=use`), providing an additional 2–11% speedup across hot capture, query, and AST linting paths. + +Or using the Visual Studio generator with Clang-CL: + +```cmd +cmake -S . -B build -T ClangCL +cmake --build build --config Release +``` + +The resulting binaries (`build/ripwire.exe` and `build/ripwire_probe.exe`) embed an application +manifest opting into `longPathAware` (handling arbitrary deep paths up to NTFS 32k limits) and UTF-8 +active code page. + ### Determinism gate Output is a sorted top-K. A sort has no tolerance band, so the contract is byte-identity: diff --git a/bench/agentloop/grade_answers.py b/bench/agentloop/grade_answers.py index d14e1cb61..a93fbc30c 100644 --- a/bench/agentloop/grade_answers.py +++ b/bench/agentloop/grade_answers.py @@ -156,10 +156,25 @@ def globstar_shell(): probed once and a row that NEEDS `**` is REFUSED when no capable shell exists, never guessed at.""" if not _SHELL: _SHELL.append( None ) - for candidate in ( "bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "/bin/bash" ): + if os.name == "nt": + configured = os.environ.get( "RIPWIRE_BASH", "" ) + candidates = [ configured, r"C:\\Program Files\\Git\\usr\\bin\\bash.exe", + r"C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe" ] + candidates = [ c for c in candidates if c and os.path.isfile( c ) ] + else: + candidates = ( "bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "/bin/bash" ) + for candidate in candidates: + if os.name == "nt": + normalized = os.path.normcase( os.path.abspath( candidate ) ) + if "\\windows\\system32\\" in normalized or "\\windowsapps\\" in normalized: + continue + probe_env = None + if os.name == "nt": + probe_env = os.environ.copy() + probe_env[ "PATH" ] = "/usr/bin:/bin:" + probe_env.get( "PATH", "" ) try: probe = subprocess.run( [ candidate, "-O", "globstar", "-c", "true" ], - capture_output=True, text=True, timeout=30 ) + capture_output=True, text=True, timeout=30, env=probe_env ) except ( OSError, subprocess.SubprocessError ): continue if probe.returncode == 0: @@ -170,9 +185,15 @@ def globstar_shell(): def run_gt( gt_command, pin_root, timeout_s=300 ): """Execute the derivation command at the pin, under bash (never the operator's zsh — §4).""" shell = globstar_shell() + if os.name == "nt" and shell is None: + return "", "no supported Git Bash executable found", 127 argv = ( [ shell, "-O", "globstar", "-O", "nullglob", "-c", gt_command ] if shell else [ "bash", "-c", gt_command ] ) - proc = subprocess.run( argv, capture_output=True, text=True, cwd=str( pin_root ), timeout=timeout_s ) + run_env = None + if os.name == "nt": + run_env = os.environ.copy() + run_env[ "PATH" ] = "/usr/bin:/bin:" + run_env.get( "PATH", "" ) + proc = subprocess.run( argv, capture_output=True, text=True, cwd=str( pin_root ), timeout=timeout_s, env=run_env ) return proc.stdout, proc.stderr, proc.returncode def derive_key( stdout ): diff --git a/bench/agentloop/run_agentloop.py b/bench/agentloop/run_agentloop.py index 030aac9f7..d50476c14 100644 --- a/bench/agentloop/run_agentloop.py +++ b/bench/agentloop/run_agentloop.py @@ -198,9 +198,14 @@ def sh( args, cwd=None, timeout=1800, env=None ): real cwd. A run launched from this checkout therefore had the agent's bash in the task repo and its read/glob/edit tools in THIS repository — the suite's first live run edited the committed fixture through that split. Set explicitly, once, here, for every harness (a shell started by codex or claude inherits - the same variable).""" + same variable).""" if cwd is not None: - env = dict( env if env is not None else os.environ, PWD=str( cwd ) ) + shell_pwd = str( cwd ) + if os.name == "nt": + drive, tail = os.path.splitdrive( os.path.abspath( shell_pwd ) ) + if drive: + shell_pwd = "/" + drive[ 0 ].lower() + tail.replace( "\\", "/" ) + env = dict( env if env is not None else os.environ, PWD=shell_pwd ) return subprocess.run( args, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=env ) def checkout_repo( repo, base_commit, repos_dir ): @@ -375,10 +380,18 @@ def install_ripwire_shim( run_home, ripwire_bin ): log = shim_dir / "ripwire-calls.log" shim = shim_dir / "ripwire" real = str( pathlib.Path( ripwire_bin ).resolve() ) if os.sep in str( ripwire_bin ) else str( ripwire_bin ) + if os.name == "nt": + # The wrapper is a Git-Bash script even when the harness itself is native Python. Keep paths in + # slash form inside that script: a backslash in a single-quoted Bash path is literal, not a Windows + # separator, and would otherwise turn the log/real-binary path into a different filename. + real = real.replace( "\\", "/" ) + log_for_shell = str( log ).replace( "\\", "/" ) + else: + log_for_shell = str( log ) shim.write_text( "#!/usr/bin/env bash\n" "# generated by run_agentloop.py — logs argv, then execs the real binary unchanged.\n" - f"printf '%s\\n' \"$*\" >> {shlex.quote( str( log ) )}\n" + f"printf '%s\\n' \"$*\" >> {shlex.quote( log_for_shell )}\n" f"exec {shlex.quote( real )} \"$@\"\n" ) shim.chmod( 0o755 ) return str( shim ), log diff --git a/bench/capsweep/capsweep.py b/bench/capsweep/capsweep.py index 8d9c1ba70..a7a3b8ee0 100644 --- a/bench/capsweep/capsweep.py +++ b/bench/capsweep/capsweep.py @@ -135,6 +135,26 @@ def build(root, jobs): r = subprocess.run(['cmake', '--build', str(root / 'build'), '-j', str(jobs)], capture_output=True, text=True) return r.returncode, c.stdout + c.stderr + r.stdout + r.stderr +def binary_argv(binary): + """Return an argv prefix that can execute a native binary or a POSIX shebang stub on Windows.""" + binary = str(binary) + if os.name != 'nt': + return [binary] + try: + with open(binary, 'rb') as f: + is_script = f.read(2) == b'#!' + except OSError: + is_script = False + if not is_script: + return [binary] + bash = os.environ.get('RIPWIRE_BASH') or shutil.which('bash.exe') or shutil.which('bash') + if not bash: + raise RuntimeError('Windows run-corpus needs Git Bash to execute its shebang stub') + normalized = os.path.normcase(os.path.abspath(bash)).replace('/', '\\') + if '\\windows\\system32\\' in normalized or '\\windowsapps\\' in normalized: + raise RuntimeError('Windows run-corpus refuses the WSL bash launcher; set RIPWIRE_BASH to Git Bash') + return [bash, binary] + def offenders(log, known): """Cap names the compiler rejected as non-constant — they must stay constexpr.""" bad = set() @@ -269,7 +289,7 @@ def run_corpus(binary, root, corpus, env, timeout=120): sizes[line], states[line] = None, '%s: $%s' % (kStateUnexpanded, missingVar.args[0]) continue try: - r = subprocess.run([str(binary)] + argv + ['--no-cache'], cwd=str(root), + r = subprocess.run(binary_argv(binary) + argv + ['--no-cache'], cwd=str(root), capture_output=True, stdin=subprocess.DEVNULL, env=e, timeout=timeout) except subprocess.TimeoutExpired: sizes[line], states[line] = None, kStateTimeout # recorded, never silently dropped diff --git a/cmake/PortableFlags.cmake b/cmake/PortableFlags.cmake index f419c0ff8..82d7aecfb 100644 --- a/cmake/PortableFlags.cmake +++ b/cmake/PortableFlags.cmake @@ -44,6 +44,12 @@ option(RIPWIRE_PRETEND_LINUX # clang >= 17 (AppleClang 16, the Xcode 16.2 the release pins), and on clang 16 a baseline x86-64 binary # running strkern.h's scalar twins. test/portablebuildcheck.sh #2d-#2g hold both directions. set(RIPWIRE_TARGET_ARCH "${CMAKE_SYSTEM_PROCESSOR}") +if(NOT RIPWIRE_TARGET_ARCH AND DEFINED CMAKE_CXX_COMPILER_ARCHITECTURE_ID) + set(RIPWIRE_TARGET_ARCH "${CMAKE_CXX_COMPILER_ARCHITECTURE_ID}") +endif() +if(RIPWIRE_TARGET_ARCH MATCHES "^(x64|X64|amd64|AMD64)$") + set(RIPWIRE_TARGET_ARCH "x86_64") +endif() if(APPLE AND CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES _ripwire_osx_arch_count) if(_ripwire_osx_arch_count EQUAL 1) @@ -71,7 +77,22 @@ if(RIPWIRE_TARGET_ARCH MATCHES "^(x86_64|x86_64h|amd64|AMD64)$") endif() if(RIPWIRE_NATIVE) - set(RIPWIRE_ARCH_FLAGS -O3 -march=native -ffast-math -fno-finite-math-only) + if(MSVC) + if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set(RIPWIRE_ARCH_FLAGS /O3 /clang:-march=native /fp:precise /permissive- /utf-8) + else() + set(RIPWIRE_ARCH_FLAGS /O2 /fp:precise /permissive- /utf-8) + endif() + else() + set(RIPWIRE_ARCH_FLAGS -O3 -march=native -ffast-math -fno-finite-math-only) + endif() +elseif(MSVC AND CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND RIPWIRE_IS_X86_64) + # ClangCL accepts the MSVC frontend flags but still needs the LLVM architecture level explicitly; + # keeping this branch ahead of the generic MSVC one is what enables strkern.h's AVX2 path on Windows. + set(RIPWIRE_ARCH_FLAGS /O2 /clang:-march=x86-64-v3 /fp:precise /permissive- /utf-8) +elseif(MSVC) + # MSVC compiler flags: precise math (preserves isnan/isfinite), conformant C++ mode, UTF-8 source/exec charset + set(RIPWIRE_ARCH_FLAGS /O2 /fp:precise /permissive- /utf-8) elseif(RIPWIRE_IS_APPLE_SILICON) set(RIPWIRE_ARCH_FLAGS -O2 -mcpu=apple-m1 -ffast-math -fno-finite-math-only) elseif(RIPWIRE_IS_X86_64) diff --git a/docs/TUNING.md b/docs/TUNING.md index de2807fe8..f5d705be6 100644 --- a/docs/TUNING.md +++ b/docs/TUNING.md @@ -60,7 +60,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kForLensDefaultTopN` = `40` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `320` — **12 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `320` — **12 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -79,7 +79,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kSpecificMinLen` = `8` -`src/graph.h` — discloses: `importers_capped` — probe value `64` — **12 verb(s) respond** +`src\graph.h` — discloses: `importers_capped` — probe value `64` — **12 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -98,7 +98,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kDocMentionMaxAnchors` = `8` -`src/mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `64` — **11 verb(s) respond** +`src\mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `64` — **11 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -116,7 +116,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kForFileTailShownCap` = `24` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `192` — **10 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `192` — **10 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -133,7 +133,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kForPayloadBudgetBytes` = `7500` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `60000` — **10 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `60000` — **10 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -150,7 +150,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kForCapTailSigBytes` = `96` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `768` — **8 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `768` — **8 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -165,7 +165,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kMaxExpandSibs` = `100` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `800` — **5 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `800` — **5 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -177,7 +177,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kCommonNameDefThreshold` = `5` -`src/graph.h` — discloses: `importers_capped` — probe value `40` — **4 verb(s) respond** +`src\graph.h` — discloses: `importers_capped` — probe value `40` — **4 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -188,7 +188,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kLintMaxPerRule` = `5000` -`src/lintrules.h` — discloses: **none** — probe value `40000` — **4 verb(s) respond** +`src\lintrules.h` — discloses: **none** — probe value `40000` — **4 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -199,7 +199,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kDocMentionMaxDocsPerAnchor` = `2` -`src/mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `34` — **3 verb(s) respond** +`src\mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `34` — **3 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -209,7 +209,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kExternalSurfaceRowCap` = `100` -`src/pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `800` — **2 verb(s) respond** +`src\pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `800` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -218,7 +218,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kForAutoBodyBudgetBytes` = `6000` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `48000` — **2 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `48000` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -227,7 +227,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kHandoffDocRows` = `4` -`src/handoff.h` — discloses: `syms_capped` — probe value `36` — **2 verb(s) respond** +`src\handoff.h` — discloses: `syms_capped` — probe value `36` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -236,7 +236,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kHandoffSymbolsPerCodeFile` = `50` -`src/handoff.h` — discloses: `syms_capped` — probe value `400` — **2 verb(s) respond** +`src\handoff.h` — discloses: `syms_capped` — probe value `400` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -245,7 +245,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kHandoffSymbolsPerDocFile` = `12` -`src/handoff.h` — discloses: `syms_capped` — probe value `96` — **2 verb(s) respond** +`src\handoff.h` — discloses: `syms_capped` — probe value `96` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -254,7 +254,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kOrdinalWindowCap` = `40` -`src/ensemble.h` — discloses: `files_capped`, `findings_capped`, `syms_capped` — probe value `320` — **2 verb(s) respond** +`src\ensemble.h` — discloses: `files_capped`, `findings_capped`, `syms_capped` — probe value `320` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -263,7 +263,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kPrDefaultBudgetTokens` = `8000` -`src/prcontext.h` — discloses: **none** — probe value `64000` — **2 verb(s) respond** +`src\prcontext.h` — discloses: **none** — probe value `64000` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -272,7 +272,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kUnitComplexityLowRiskMax` = `5` -`src/dmm.h` — discloses: **none** — probe value `40` — **2 verb(s) respond** +`src\dmm.h` — discloses: **none** — probe value `40` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -281,7 +281,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kUnitInterfacingLowRiskMax` = `2` -`src/dmm.h` — discloses: **none** — probe value `34` — **2 verb(s) respond** +`src\dmm.h` — discloses: **none** — probe value `34` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -290,7 +290,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kUnitSizeLowRiskMax` = `15` -`src/dmm.h` — discloses: **none** — probe value `120` — **2 verb(s) respond** +`src\dmm.h` — discloses: **none** — probe value `120` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -299,7 +299,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kZoomTopModuleCap` = `40` -`src/pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `320` — **2 verb(s) respond** +`src\pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `320` — **2 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -308,7 +308,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kBatchCap` = `16` -`src/mcpverbs.h` — discloses: `blast_radius_capped`, `coboost_commits_capped`, `forgotten_capped`, `hits_capped`, `siblings_capped`, `unindexed_candidates_capped` — probe value `128` — **1 verb(s) respond** +`src\mcpverbs.h` — discloses: `blast_radius_capped`, `coboost_commits_capped`, `forgotten_capped`, `hits_capped`, `siblings_capped`, `unindexed_candidates_capped` — probe value `128` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -316,7 +316,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kCallHierarchyRowCap` = `40` -`src/pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `320` — **1 verb(s) respond** +`src\pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `320` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -324,7 +324,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kCellsPerRowCap` = `12` -`src/nonlocalstate.h` — discloses: `cells_capped`, `decls_capped` — probe value `96` — **1 verb(s) respond** +`src\nonlocalstate.h` — discloses: `cells_capped`, `decls_capped` — probe value `96` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -332,7 +332,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kDefaultRecallMaxTokens` = `8000` -`src/recall.h` — discloses: **none** — probe value `64000` — **1 verb(s) respond** +`src\recall.h` — discloses: **none** — probe value `64000` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -340,7 +340,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kDefsPerNameCap` = `8` -`src/contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `64` — **1 verb(s) respond** +`src\contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `64` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -348,7 +348,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kEnsembleFileRowCap` = `20` -`src/ensemble.h` — discloses: `files_capped`, `findings_capped`, `syms_capped` — probe value `160` — **1 verb(s) respond** +`src\ensemble.h` — discloses: `files_capped`, `findings_capped`, `syms_capped` — probe value `160` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -356,7 +356,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kFileRowCap` = `40` -`src/contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `320` — **1 verb(s) respond** +`src\contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `320` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -364,7 +364,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kGrepMatchedLineMaxBytes` = `512` -`src/search.h` — discloses: `hits_capped` — probe value `4096` — **1 verb(s) respond** +`src\search.h` — discloses: `hits_capped` — probe value `4096` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -372,7 +372,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kMaxExpandIncludes` = `24` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `192` — **1 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `192` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -380,7 +380,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kMentionMaxSymbolsPerFile` = `3` -`src/mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `35` — **1 verb(s) respond** +`src\mention.h` — discloses: `doc_mentions_capped`, `mention_files_capped`, `mention_syms_capped`, `mention_tokens_capped` — probe value `35` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -388,7 +388,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kPanelRowCap` = `40` -`src/qualitypanel.h` — discloses: `findings_capped` — probe value `320` — **1 verb(s) respond** +`src\qualitypanel.h` — discloses: `findings_capped` — probe value `320` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -396,7 +396,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kSituBlastFilesShown` = `8` -`src/situ.h` — discloses: `tests_capped`, `untested_capped` — probe value `64` — **1 verb(s) respond** +`src\situ.h` — discloses: `tests_capped`, `untested_capped` — probe value `64` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -404,7 +404,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kSliceFlowDefaultDepth` = `8` -`src/slice.h` — discloses: **none** — probe value `64` — **1 verb(s) respond** +`src\slice.h` — discloses: **none** — probe value `64` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -412,7 +412,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kSymbolRowCap` = `40` -`src/contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `320` — **1 verb(s) respond** +`src\contextratio.h` — discloses: `defs_capped`, `files_capped`, `syms_capped` — probe value `320` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -420,7 +420,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kTreeRowCap` = `80` -`src/pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `640` — **1 verb(s) respond** +`src\pageview.h` — discloses: `count_capped`, `findings_capped`, `hits_capped`, `importers_capped`, `modules_capped` — probe value `640` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | @@ -428,7 +428,7 @@ moving. Re-run `prepare|screen|sweep` to refresh them. ### `kWithGraphNodeCap` = `8` -`src/serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `64` — **1 verb(s) respond** +`src\serialize.h` — discloses: `calls_capped`, `inc_capped`, `sibs_capped` — probe value `64` — **1 verb(s) respond** | invocation | default | at probe | delta | | --- | --- | --- | --- | diff --git a/scripts/cxxstd.sh b/scripts/cxxstd.sh index c97c91a75..bab993c7d 100644 --- a/scripts/cxxstd.sh +++ b/scripts/cxxstd.sh @@ -40,12 +40,16 @@ # ripwire_cxx_std_flag CXX → prints the C++23 flag spelling CXX accepts; rc=1 if it accepts neither. ripwire_cxx_std_flag() { - local cxx="${1:-c++}" probe flag rc=1 + local cxx="${1:-c++}" probe probe_arg flag rc=1 probe="$( mktemp -d )" printf 'int main(){ return 0; }\n' > "$probe/cxxstd_probe.cpp" + probe_arg="$probe/cxxstd_probe.cpp" + case "$( uname -s 2>/dev/null )" in + MINGW*|MSYS*) probe_arg="$( cygpath -w "$probe_arg" )" ;; + esac for flag in -std=c++23 -std=c++2b; do - if "$cxx" "$flag" -fsyntax-only "$probe/cxxstd_probe.cpp" >/dev/null 2>&1; then + if "$cxx" "$flag" -fsyntax-only "$probe_arg" >/dev/null 2>&1; then rc=0 break fi diff --git a/scripts/pgobuild.sh b/scripts/pgobuild.sh index bb6c72e3d..24de5c466 100755 --- a/scripts/pgobuild.sh +++ b/scripts/pgobuild.sh @@ -51,11 +51,11 @@ done command -v cmake >/dev/null || { echo "cmake required" >&2; exit 2; } [ -d "$CORPUS" ] || { echo "training corpus not found: $CORPUS" >&2; exit 2; } -# llvm-profdata: inside the active toolchain on macOS, on PATH (often versioned) on Linux. -PROFDATA="$( xcrun --find llvm-profdata 2>/dev/null || command -v llvm-profdata 2>/dev/null || true )" +# llvm-profdata: inside the active toolchain on macOS, on PATH (often versioned) on Linux, or with .exe on Windows. +PROFDATA="$( xcrun --find llvm-profdata 2>/dev/null || command -v llvm-profdata 2>/dev/null || command -v llvm-profdata.exe 2>/dev/null || true )" if [ -z "$PROFDATA" ]; then - for v in 21 20 19 18 17; do - cand="$( command -v "llvm-profdata-$v" 2>/dev/null || true )" + for v in 22 21 20 19 18 17; do + cand="$( command -v "llvm-profdata-$v" 2>/dev/null || command -v "llvm-profdata-$v.exe" 2>/dev/null || true )" [ -n "$cand" ] && { PROFDATA="$cand"; break; } done fi @@ -75,6 +75,7 @@ if [ "$reuse" -eq 0 ]; then # ── phase 2: train ───────────────────────────────────────────────────────────────────────────── echo "pgobuild: [3/4] training on $CORPUS" BIN="$GEN/ripwire" + [ -f "$BIN.exe" ] && BIN="$BIN.exe" TRAIN="$( mktemp -d )" run(){ "$BIN" "$@" >/dev/null 2>&1 || echo "pgobuild: training run returned non-zero (continuing): $*" >&2; } run "$CORPUS" --no-cache @@ -112,7 +113,11 @@ cmake --build "$OPT" -j "$JOBS" >"$OPT/build.log" 2>&1 || { echo "optimized build failed — see $OPT/build.log" >&2; tail -20 "$OPT/build.log" >&2; exit 1; } echo "pgobuild: done — $OPT/ripwire (profile: $PROFILE)" +OPT_BIN="$OPT/ripwire" +[ -f "$OPT_BIN.exe" ] && OPT_BIN="$OPT_BIN.exe" +BASE_BIN="$ROOT/build/ripwire" +[ -f "$BASE_BIN.exe" ] && BASE_BIN="$BASE_BIN.exe" echo "pgobuild: verify before you trust it:" -echo " $OPT/ripwire $ROOT >a; $OPT/ripwire $ROOT >b; diff -q a b # determinism is a contract" -echo " diff -q <($OPT/ripwire $ROOT) <($ROOT/build/ripwire $ROOT) # PGO must not change a byte of output" -echo " RIPWIRE_BIN=$OPT/ripwire python3 $ROOT/test/pargates.py $ROOT $OPT/ripwire -j 6" +echo " $OPT_BIN $ROOT >a; $OPT_BIN $ROOT >b; diff -q a b # determinism is a contract" +echo " diff -q <($OPT_BIN $ROOT) <($BASE_BIN $ROOT) # PGO must not change a byte of output" +echo " RIPWIRE_BIN=$OPT_BIN python3 $ROOT/test/pargates.py $ROOT $OPT_BIN -j 6" diff --git a/skills/install.sh b/skills/install.sh index 5b9ba938f..b0114271d 100755 --- a/skills/install.sh +++ b/skills/install.sh @@ -13,8 +13,29 @@ set -eu src="$( cd "$( dirname "$0" )" && pwd )" +# Git Bash's `ln -sfn` can materialize a directory-like MSYS link that native +# Windows tools cannot identify or prune. Use a real directory symlink on +# Windows, while keeping the POSIX installer path unchanged. +isWindowsShell=0 +case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) isWindowsShell=1 ;; +esac +link_skill() +{ + if [ "$isWindowsShell" -eq 1 ] && command -v cygpath >/dev/null 2>&1 && command -v cmd.exe >/dev/null 2>&1; then + targetNative="$( cygpath -w "$1" )" + destNative="$( cygpath -w "$2" )" + if [ -e "$2" ] || [ -L "$2" ]; then + [ -L "$2" ] || { echo "skills/install.sh: refusing to replace a real directory at $2" >&2; return 1; } + rm -f "$2" + fi + MSYS_NO_PATHCONV=1 cmd.exe /d /c mklink /D "$destNative" "$targetNative" >/dev/null 2>&1 + else + ln -sfn "$1" "$2" + fi +} + # ── the PreToolUse matcher, in one place. It is not cosmetic: a matcher decides which tool calls the -# hook is ever SHOWN. Read/Glob are here because the whole-file read is the largest token sink in an # agent loop and the one default a skill description cannot intercept; mcp__ripwire__.* is here for # the hook's other job, the substitution meter (docs/SUBSTITUTION_METER.md), whose numerator would # otherwise miss every agent that prefers the MCP server to the CLI; Edit/Write/MultiEdit/ @@ -350,7 +371,7 @@ for d in "$src"/ripwire-*/; do skipped=$(( skipped + 1 )) continue fi - ln -sfn "$d" "$dst/$name" + link_skill "$d" "$dst/$name" echo "installed $name -> $dst/$name" count=$(( count + 1 )) done @@ -375,7 +396,7 @@ if [ "$mode" = "hermes" ]; then skipped=$(( skipped + 1 )) continue fi - ln -sfn "$nd" "$dst/$nname" + link_skill "$nd" "$dst/$nname" echo "installed $nname -> $dst/$nname (Hermes-native skill)" count=$(( count + 1 )) done diff --git a/src/arch.h b/src/arch.h index c456f4479..11194ce32 100644 --- a/src/arch.h +++ b/src/arch.h @@ -556,26 +556,41 @@ inline std::uint64_t fnv1a64( std::string_view s ) noexcept // than an empty one. Empty root ⇒ just the leading-`./`/`/` normalization (equivalent to root "."). inline std::string_view relForHash( std::string_view path, std::string_view root ) noexcept { + const auto samePathChar = []( char a, char b ) noexcept + { + if( a == '\\' ) { a = '/'; } + if( b == '\\' ) { b = '/'; } +#if defined( _WIN32 ) + if( a >= 'A' && a <= 'Z' ) { a = char( a - 'A' + 'a' ); } + if( b >= 'A' && b <= 'Z' ) { b = char( b - 'A' + 'a' ); } +#endif + return a == b; + }; + // 1) strip the ingest-root prefix if present (allow one optional trailing '/' on the root). std::string_view rootTrim = root; - while( rootTrim.size() > 1 && rootTrim.back() == '/' ) + while( rootTrim.size() > 1 && ( rootTrim.back() == '/' || rootTrim.back() == '\\' ) ) { rootTrim.remove_suffix( 1 ); // "/abs/repo/" → "/abs/repo" } - if( !rootTrim.empty() && rootTrim != "." && path.size() >= rootTrim.size() - && path.compare( 0, rootTrim.size(), rootTrim ) == 0 ) + bool rootMatches = !rootTrim.empty() && rootTrim != "." && path.size() >= rootTrim.size(); + for( std::size_t i = 0; rootMatches && i < rootTrim.size(); ++i ) + { + rootMatches = samePathChar( path[i], rootTrim[i] ); + } + if( rootMatches ) { // matched the root; the next char (if any) must be a '/' so we strip whole path components only // ("/abs/repo" must not eat the "repo" in "/abs/repository/..."). std::string_view rest = path.substr( rootTrim.size() ); - if( rest.empty() || rest.front() == '/' ) + if( rest.empty() || rest.front() == '/' || rest.front() == '\\' ) { path = rest; } } // 2) normalize residual leading "./" then leading "/" so "." / "./x" / "/x" all collapse to "x". - while( path.size() >= 2 && path[0] == '.' && path[1] == '/' ) + while( path.size() >= 2 && path[0] == '.' && ( path[1] == '/' || path[1] == '\\' ) ) { path.remove_prefix( 2 ); } @@ -583,7 +598,7 @@ inline std::string_view relForHash( std::string_view path, std::string_view root { path.remove_prefix( 1 ); } - while( path.size() >= 2 && path[0] == '.' && path[1] == '/' ) + while( path.size() >= 2 && path[0] == '.' && ( path[1] == '/' || path[1] == '\\' ) ) { path.remove_prefix( 2 ); } diff --git a/src/ccjson.h b/src/ccjson.h index a25cadac4..2e12357a2 100644 --- a/src/ccjson.h +++ b/src/ccjson.h @@ -54,7 +54,7 @@ struct CcFileMetrics // Degrades to 0 if the file cannot be opened (deleted between crawl and export). inline std::uint32_t ccCountLoc( const std::string& path ) noexcept { - std::FILE* fp = std::fopen( path.c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( path.c_str(), "rb" ); if( !fp ) { return 0; diff --git a/src/cli.h b/src/cli.h index 6cc863543..0e5402bca 100644 --- a/src/cli.h +++ b/src/cli.h @@ -3297,6 +3297,26 @@ static_assert( std::size( kBoolFlags ) + std::size( kViewFlags ) + std::size( kI // flag matched, and did its value survive" is one question with one answer. enum class ViewFlagMatch : std::uint8_t { NoMatch, Assigned, Refused }; +inline constexpr std::string_view kPathValuePrefixes[] = +{ + "--eval-mined=", "--eval-skills=", "--arch=", "--cache=", "--index-out=", "--scip=", "--pin-census=", + "--lint-rules=", "--exercises=", "--cochange=", "--situ=", "--test-gate=", "--scan-skills=", "--dead-code=", + "--plan-lint=", "--scan-skill=", "--batch=", "--at=", "--edit-payload=", "--edit-target-file=", "--edit-plan=", + "--eval-stray=", "--from-trace=", "--with-profile=", "--brief=", "--html=", "--affected=" +}; + +inline bool isPathValuePrefix( std::string_view prefix ) noexcept +{ + for( const std::string_view pathPrefix : kPathValuePrefixes ) + { + if( prefix == pathPrefix ) + { + return true; + } + } + return false; +} + inline ViewFlagMatch applyViewFlag( std::string_view arg, Config& c ) { for( const ViewFlag& vf : kViewFlags ) @@ -3306,6 +3326,13 @@ inline ViewFlagMatch applyViewFlag( std::string_view arg, Config& c ) continue; } const std::string_view value = arg.substr( vf.prefix.size() ); +#if defined( _WIN32 ) + if( isPathValuePrefix( vf.prefix ) ) + { + // argv storage is mutable and Config deliberately borrows it as a view. + rw::compat::rw_normalize_msys_drive_paths_in_place( const_cast( value.data() ) ); + } +#endif // §B5: the EMPTY-value decision is the row's, never this loop's. Refuse prints here; Meaningful and // HandlerRefuses both fall through to the assignment — the difference between them is which code // OWNS the refusal, and the row records it (the consteval floor beside the table pins the columns). diff --git a/src/clones.h b/src/clones.h index 605146fb4..0fe599c22 100644 --- a/src/clones.h +++ b/src/clones.h @@ -350,7 +350,7 @@ inline std::vector findClones( const IngestResult& ing, int minToken { continue; } - std::FILE* fp = std::fopen( diskPath( ing, std::uint32_t( f ) ).c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( diskPath( ing, std::uint32_t( f ) ).c_str(), "rb" ); if( !fp ) { continue; @@ -647,7 +647,7 @@ inline std::vector findClonesType3( const IngestResult& ing, int min { continue; } - std::FILE* fp = std::fopen( diskPath( ing, std::uint32_t( f ) ).c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( diskPath( ing, std::uint32_t( f ) ).c_str(), "rb" ); if( !fp ) { continue; // degrade: unreadable file just contributes no candidates (never a crash) diff --git a/src/codexdoctor.h b/src/codexdoctor.h index 36b3f3bf4..ac15a4c07 100644 --- a/src/codexdoctor.h +++ b/src/codexdoctor.h @@ -5,7 +5,9 @@ // emitted: a doctor report may be pasted into an issue, so unrelated tokens and secrets stay dark. #include +#include "infra/platform_compat.h" #include +#include #include #include #include @@ -45,13 +47,70 @@ inline std::string readSmallFile( const std::filesystem::path& path, bool& ok ) return text; } +#if defined( _WIN32 ) || defined( _MSC_VER ) +inline bool windowsExecutableFile( const std::string& path ) +{ + const std::wstring widePath = rw::compat::rw_utf8_to_wide( rw::compat::rw_windows_path_from_msys( path ) ); + if( widePath.empty() ) { return false; } + const DWORD attributes = ::GetFileAttributesW( widePath.c_str() ); + if( attributes == INVALID_FILE_ATTRIBUTES || ( attributes & FILE_ATTRIBUTE_DIRECTORY ) != 0 ) { return false; } + const HANDLE handle = ::CreateFileW( widePath.c_str(), FILE_EXECUTE | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr ); + if( handle == INVALID_HANDLE_VALUE ) { return false; } + ::CloseHandle( handle ); + return true; +} + +inline std::string windowsExecutableCandidate( const std::string& path ) +{ + const std::string native = rw::compat::rw_windows_path_from_msys( path ); + if( windowsExecutableFile( native ) ) { return native; } + const std::size_t separator = native.find_last_of( "\\/" ); + const std::size_t dot = native.find( '.', separator == std::string::npos ? 0 : separator + 1 ); + if( dot == std::string::npos && windowsExecutableFile( native + ".exe" ) ) { return native + ".exe"; } + return {}; +} +#endif + inline std::string resolveExecutable( std::string_view command ) { if( command.empty() ) { return {}; } - const auto executable = []( const std::string& path ) +#if defined( _WIN32 ) || defined( _MSC_VER ) + const auto hasPath = command.find( '/' ) != std::string_view::npos || command.find( '\\' ) != std::string_view::npos + || ( command.size() >= 2 && command[ 1 ] == ':' ); + if( hasPath ) { return windowsExecutableCandidate( std::string( command ) ); } + const char* pathEnv = std::getenv( "PATH" ); + const std::string_view pathList = pathEnv ? std::string_view( pathEnv ) : std::string_view(); + const bool semicolonList = pathList.find( ';' ) != std::string_view::npos; + for( std::size_t at = 0; at <= pathList.size(); ) { - return ::access( path.c_str(), X_OK ) == 0; - }; + std::size_t split = pathList.size(); + if( semicolonList ) + { + const std::size_t found = pathList.find( ';', at ); + if( found != std::string_view::npos ) { split = found; } + } + else + { + for( std::size_t i = at; i < pathList.size(); ++i ) + { + const bool driveColon = i == at + 1 && i > 0 + && ( ( pathList[ at ] >= 'A' && pathList[ at ] <= 'Z' ) + || ( pathList[ at ] >= 'a' && pathList[ at ] <= 'z' ) ); + if( pathList[ i ] == ':' && !driveColon ) { split = i; break; } + } + } + const std::string_view dir = pathList.substr( at, split - at ); + const std::string candidate = std::string( dir.empty() ? "." : dir ) + "/" + std::string( command ); + const std::string resolved = windowsExecutableCandidate( candidate ); + if( !resolved.empty() ) { return resolved; } + if( split == pathList.size() ) { break; } + at = split + 1; + } + return {}; +#else + const auto executable = []( const std::string& path ) { return ::access( path.c_str(), X_OK ) == 0; }; if( command.find( '/' ) != std::string_view::npos ) { const std::string path( command ); @@ -69,6 +128,7 @@ inline std::string resolveExecutable( std::string_view command ) remaining.remove_prefix( split + 1 ); } return {}; +#endif } inline Check binaryCheck( const std::string& selfPath ) @@ -78,6 +138,7 @@ inline Check binaryCheck( const std::string& selfPath ) struct stat activeSt {}; const bool haveSelf = !selfPath.empty() && ::stat( selfPath.c_str(), &selfSt ) == 0; const bool haveActive = !active.empty() && ::stat( active.c_str(), &activeSt ) == 0; + const bool same = haveSelf && haveActive && selfSt.st_dev == activeSt.st_dev && selfSt.st_ino == activeSt.st_ino; const bool copied = haveSelf && haveActive && selfSt.st_mtime == activeSt.st_mtime && selfSt.st_size == activeSt.st_size; // `copied` is a HEURISTIC pass (mtime+size equality, the cp -p install shape) — it cannot prove byte @@ -310,8 +371,11 @@ inline std::vector inspect( const std::string& selfPath ) const std::string home = envOr( "HOME", "" ); const std::filesystem::path agentHome = envOr( "AGENTS_HOME", home + "/.agents" ); const std::filesystem::path codexHome = envOr( "CODEX_HOME", home + "/.codex" ); - return { binaryCheck( selfPath ), skillsCheck( agentHome / "skills" ), hooksCheck( codexHome / "hooks.json" ), - mcpCheck( codexHome / "config.toml" ) }; + const Check binary = binaryCheck( selfPath ); + const Check skills = skillsCheck( agentHome / "skills" ); + const Check hooks = hooksCheck( codexHome / "hooks.json" ); + const Check mcp = mcpCheck( codexHome / "config.toml" ); + return { binary, skills, hooks, mcp }; } } // namespace rw::codexdoctor diff --git a/src/crossref.h b/src/crossref.h index c138c26df..be0615935 100644 --- a/src/crossref.h +++ b/src/crossref.h @@ -462,6 +462,7 @@ struct StreamBlobStats } }; +/// Streams requested git objects in one framed batch while bounding buffered blob memory. template inline void streamBlobs( const std::string& root, const std::vector& shas, OnBlob onBlob, StreamBlobStats* stats = nullptr ) @@ -478,7 +479,7 @@ inline void streamBlobs( const std::string& root, const std::vector const std::string listPath = quality::cacheDirLadder() + "/ripwire-crossref-" + std::to_string( ::getpid() ) + ".shas"; { - std::FILE* lf = std::fopen( listPath.c_str(), "wb" ); + std::FILE* lf = rw::compat::rw_fopen_utf8( listPath.c_str(), "wb" ); if( !lf ) { st.startFailed = true; @@ -689,6 +690,7 @@ struct RefInfo // out.size(): a filter matching only the checked-out branch has SELECTED something (the answer is "nothing // but the ref you are on"), while a filter matching no branch name at all has selected nothing and must // refuse rather than report refs="0" — which reads as "no branch carries stray work". +/// Enumerates local branches in deterministic order and excludes the checked-out ref from stray-content results. inline std::vector enumerateRefs( const std::string& root, std::string_view filter, const std::string& headSha, std::size_t* filterNameHits = nullptr ) { @@ -814,11 +816,7 @@ inline void parallelIndexed( std::size_t count, Body body ) return; } - std::size_t hwThreadCount = std::thread::hardware_concurrency(); - if( hwThreadCount == 0 ) - { - hwThreadCount = 1; - } + const std::size_t hwThreadCount = rw::compat::rw_effective_hardware_concurrency(); const std::size_t workerCount = std::min( { hwThreadCount, count, kMaxGitWorkers } ); if( workerCount <= 1 ) { @@ -1778,7 +1776,7 @@ inline EvalReport evalStray( const std::string& root, const std::string& labelsP std::string bytes; { - std::FILE* fp = std::fopen( labelsPath.c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( labelsPath.c_str(), "rb" ); if( !fp ) { rep.ok = false; return rep; } char buf[ 65536 ]; std::size_t n = 0; diff --git a/src/darkflags.h b/src/darkflags.h index 42391b312..2880e3d3e 100644 --- a/src/darkflags.h +++ b/src/darkflags.h @@ -745,7 +745,7 @@ inline FileHarvest harvestFile( std::string_view bytes, std::string_view path, b // through keeps what was read, and an empty file is an engaged empty string. inline std::optional readWhole( const std::string& path ) { - std::FILE* fp = std::fopen( path.c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( path.c_str(), "rb" ); if( !fp ) { return std::nullopt; diff --git a/src/docdrift.h b/src/docdrift.h index ef34125b0..a13cee935 100644 --- a/src/docdrift.h +++ b/src/docdrift.h @@ -2034,7 +2034,7 @@ inline void forEachIndexParallel( std::size_t count, const char* what, Work&& wo } }; - const std::size_t hwThreadCount = std::thread::hardware_concurrency(); + const std::size_t hwThreadCount = rw::compat::rw_effective_hardware_concurrency(); const std::size_t workerCount = std::min( { hwThreadCount ? hwThreadCount : 1, count, std::size_t( 16 ) } ); if( workerCount <= 1 ) { indexWorker(); return; } @@ -2153,7 +2153,7 @@ inline std::size_t scanCorpusFacts( const IngestResult& ing, const std::string& return 0; } - const std::size_t hwThreadCount = std::thread::hardware_concurrency(); + const std::size_t hwThreadCount = rw::compat::rw_effective_hardware_concurrency(); const std::size_t blockCount = std::min( { ( hwThreadCount ? hwThreadCount : 1 ) * 4, scanCount, std::size_t( 64 ) } ); const std::size_t blockSpan = ( scanCount + blockCount - 1 ) / blockCount; diff --git a/src/docparse.h b/src/docparse.h index 22a491dad..c570ffb3b 100644 --- a/src/docparse.h +++ b/src/docparse.h @@ -1,5 +1,6 @@ #pragma once #include "infra/emit.h" // rw::emitTo / emitRaw / formatTo — THE emitter and its siblings +#include "infra/platform_compat.h" // docparse.h — P1-B document ingest. Turns non-code documents that live IN a repo @@ -181,7 +182,7 @@ namespace detail // string, not a failure — a caller for which empty and unreadable mean the same thing says so with value_or. inline std::optional readWholeFile( const std::string& path ) { - std::FILE* fp = std::fopen( path.c_str(), "rb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( path.c_str(), "rb" ); if( fp == nullptr ) { return std::nullopt; diff --git a/src/editplan.h b/src/editplan.h index 6d37cad8b..9630060ce 100644 --- a/src/editplan.h +++ b/src/editplan.h @@ -3,6 +3,7 @@ #include "mcpedit.h" #include "nextverb.h" // E4: nextFlag — the shell-safe spelling of the rollback message's one next: +#include #include namespace rw::editplan @@ -54,21 +55,71 @@ struct FileStage std::vector edits; }; +inline bool isEditPathAbsolute( std::string_view path ) noexcept +{ + if( path.empty() || path.front() == '/' ) + { + return !path.empty(); + } +#if defined( _WIN32 ) + return ( path.front() == '\\' ) || ( path.size() >= 2 && path[ 1 ] == ':' ); +#else + return false; +#endif +} + +inline std::size_t lastEditPathSeparator( std::string_view path ) noexcept +{ +#if defined( _WIN32 ) + return path.find_last_of( "/\\" ); +#else + return path.find_last_of( '/' ); +#endif +} + inline std::string siblingPath( std::string_view planPath, std::string_view payload ) { - if( payload.empty() || payload.front() == '/' ) { return std::string( payload ); } - const std::size_t slash = planPath.find_last_of( '/' ); + if( payload.empty() || isEditPathAbsolute( payload ) ) { return std::string( payload ); } + const std::size_t slash = lastEditPathSeparator( planPath ); return slash == std::string_view::npos ? std::string( payload ) : std::string( planPath.substr( 0, slash + 1 ) ) + std::string( payload ); } +inline std::string canonicalEditPlanPath( std::string_view path ) +{ +#if defined( _WIN32 ) + const std::string native = rw::compat::rw_windows_path_from_msys( path ); + std::error_code ec; + const std::filesystem::path absolute = std::filesystem::absolute( std::filesystem::path( native ), ec ); + if( ec ) + { + return {}; + } + const std::filesystem::path canonical = std::filesystem::canonical( absolute, ec ); + if( !ec ) + { + return canonical.generic_string(); + } + ec.clear(); + const std::filesystem::path weak = std::filesystem::weakly_canonical( absolute, ec ); + return ec ? std::string() : weak.generic_string(); +#else + char buf[ PATH_MAX ]; + return ::realpath( std::string( path ).c_str(), buf ) != nullptr ? std::string( buf ) : std::string(); +#endif +} + // The directory a plan's payloads must live in: the plan file's own, canonicalized. "" when it cannot be // resolved, which the confinement check below treats as "cannot prove containment" and therefore refuses. inline std::string planDirAbs( const std::string& planPath ) { - const std::size_t slash = planPath.find_last_of( '/' ); + const std::size_t slash = lastEditPathSeparator( planPath ); const std::string dir = slash == std::string::npos ? std::string( "." ) : planPath.substr( 0, slash ); +#if defined( _WIN32 ) + return canonicalEditPlanPath( dir.empty() ? std::string_view( "." ) : std::string_view( dir ) ); +#else char buf[ PATH_MAX ]; return ::realpath( dir.empty() ? "/" : dir.c_str(), buf ) != nullptr ? std::string( buf ) : std::string(); +#endif } // A5: a plan's `payload` names a file whose BYTES are spliced into a source file, so an unconfined payload @@ -95,7 +146,7 @@ inline bool payloadWithinPlanDir( const std::string& planPath, const std::string // "../" payload, which is the exact bug this function exists to catch). char cwdBuf[ PATH_MAX ]; const std::string cwd = ::getcwd( cwdBuf, sizeof( cwdBuf ) ) != nullptr ? std::string( cwdBuf ) : std::string(); - if( cwd.empty() && payloadPath.front() != '/' ) + if( cwd.empty() && !isEditPathAbsolute( payloadPath ) ) { resolved = payloadPath; return false; // cannot place a relative path in any frame ⇒ cannot prove containment ⇒ refuse @@ -103,7 +154,7 @@ inline bool payloadWithinPlanDir( const std::string& planPath, const std::string // rw::lexicalNormalize (resolve.h) is the house's segment-stack `.`/`..` folder — the SAME primitive the // include resolver keys every path index through. It returns "" for a relative `..` that escapes above // its own base, which is already the answer this check wants. - const std::string lexical = lexicalNormalize( payloadPath.front() == '/' ? payloadPath : cwd + "/" + payloadPath ); + const std::string lexical = lexicalNormalize( isEditPathAbsolute( payloadPath ) ? payloadPath : cwd + "/" + payloadPath ); if( lexical.empty() ) { resolved = payloadPath; @@ -113,12 +164,26 @@ inline bool payloadWithinPlanDir( const std::string& planPath, const std::string // realpath is the AUTHORITY when the payload exists: `dir` is canonical, so only a canonical candidate // is comparable to it (a symlinked prefix such as /tmp -> /private/tmp otherwise reads as an escape), // and it is what catches a symlink sitting INSIDE the plan directory that points out of it. +#if defined( _WIN32 ) + const std::string canonical = canonicalEditPlanPath( lexical ); + if( !canonical.empty() ) + { + resolved = canonical; + return pathIsUnder( resolved, dir ); + } + if( rw::pathguard::isSymlink( lexical ) ) + { + resolved = lexical; + return false; + } +#else char buf[ PATH_MAX ]; if( ::realpath( lexical.c_str(), buf ) != nullptr ) { resolved = std::string( buf ); return pathIsUnder( resolved, dir ); } +#endif // The payload does not exist. realpath cannot speak, so judge it lexically: "../../../../etc/nope" must // still read as an escape rather than as a merely unreadable payload. resolved = lexical; diff --git a/src/editpreview.h b/src/editpreview.h index d3cf24dfd..75903fa1b 100644 --- a/src/editpreview.h +++ b/src/editpreview.h @@ -259,7 +259,7 @@ inline IngestResult ingestOneFile( const std::string& tmpDir, const std::string& std::error_code ec; const fs::path target = fs::path( tmpDir ) / fs::path( rel ); fs::create_directories( target.parent_path(), ec ); - std::FILE* fp = std::fopen( target.string().c_str(), "wb" ); + std::FILE* fp = rw::compat::rw_fopen_utf8( target.string().c_str(), "wb" ); if( fp == nullptr ) { DEGRADED_PATH_ALERT( "edit-preview: cannot write the spliced file into the temp root" ); @@ -278,15 +278,19 @@ inline IngestResult ingestOneFile( const std::string& tmpDir, const std::string& return ingest( tmpDir.c_str(), {}, {}, maxFileBytes, captureValueUses ); } -// E3: `CDATA` — src[a,b) as on disk, budgeted by WHOLE LINES: over -// kPreviewOverwriteBudgetBytes the CDATA is the head, with shown= its size, capped="1" and elided_lines= the rest. -// The CDATA goes through appendCdataSafe like every served body (a ]]> inside the span is split, never broken). +// E3: `CDATA` — the selected src[a,b) content, with the same presentation-only +// CRLF normalization as served bodies. `bytes=` is the normalized content size carried by the CDATA; over +// kPreviewOverwriteBudgetBytes the CDATA is the head by whole lines, with shown= its size, capped="1" and elided_lines= the rest. The CDATA goes +// through appendCdataSafe like every served body (a ]]> inside the span is split, never broken). inline constexpr std::size_t kPreviewOverwriteBudgetBytes = 4096; inline std::string overwriteChildXml( const std::string& src, std::size_t a, std::size_t b ) { - const std::string_view span = std::string_view( src ).substr( a, b - a ); - const mcpedit::LineRange lines = mcpedit::lineRangeOf( src, a, b ); + const std::string_view rawSpan = std::string_view( src ).substr( a, b - a ); + const mcpedit::LineRange lines = mcpedit::lineRangeOf( src, a, b ); + std::string spanText( rawSpan ); + normalizeCrlfInPlace( spanText ); + const std::string_view span = spanText; std::size_t shown = span.size(); std::uint32_t elidedLines = 0; if( span.size() > kPreviewOverwriteBudgetBytes ) diff --git a/src/githarden.h b/src/githarden.h index d8b6a6250..a0e84047f 100644 --- a/src/githarden.h +++ b/src/githarden.h @@ -33,6 +33,7 @@ #include "gitmine.h" // rw::popenTrimmed — the one popen-and-trim shape in the tree (never a second) #include "infra/emit.h" // rw::emitTo — the house emitter; no new printf-family site #include "infra/jsonesc.h" // rw::shSingleQuote +#include "infra/platform_compat.h" #include #include @@ -209,7 +210,11 @@ inline bool appendGitConfigOverride( const char* key, const char* value ) const std::string keyN = "GIT_CONFIG_KEY_" + std::to_string( n ); const std::string valN = "GIT_CONFIG_VALUE_" + std::to_string( n ); const std::string count = std::to_string( n + 1 ); +#if defined(_WIN32) + return ::_putenv_s( keyN.c_str(), key ) == 0 && ::_putenv_s( valN.c_str(), value ) == 0 && ::_putenv_s( "GIT_CONFIG_COUNT", count.c_str() ) == 0; +#else return ::setenv( keyN.c_str(), key, 1 ) == 0 && ::setenv( valN.c_str(), value, 1 ) == 0 && ::setenv( "GIT_CONFIG_COUNT", count.c_str(), 1 ) == 0; +#endif } // ── the startup record, kept so --doctor reports the SAME probe main() acted on ───────────────────────────── @@ -247,12 +252,17 @@ inline const Report& hardenForRoots( std::span roots ) bool anyHook = false; for( std::string_view root : roots ) { + const std::string rootStr = +#if defined( _WIN32 ) + rw::compat::rw_windows_path_from_msys( root ); +#else + std::string( root ); +#endif std::error_code ec; - if( root.empty() || !std::filesystem::is_directory( std::filesystem::path( root ), ec ) || ec ) + if( rootStr.empty() || !std::filesystem::is_directory( std::filesystem::path( rootStr ), ec ) || ec ) { continue; } - const std::string rootStr = std::string( root ); const FsmonitorForm form = localConfigMayCarryFsmonitor( rootStr ) ? probeFsmonitorForm( rootStr ) : FsmonitorForm::Unset; r.forms.emplace_back( rootStr, form ); anyHook = anyHook || form == FsmonitorForm::Hook; diff --git a/src/gitmine.h b/src/gitmine.h index c7847e8f2..fb69dc045 100644 --- a/src/gitmine.h +++ b/src/gitmine.h @@ -16,6 +16,7 @@ #include "infra/stdinline.h" // readByteSafeLine — THE line reader (R4); no fixed buffer to split a long path on #include "infra/jsonesc.h" // A4-F27 residual: rw::shSingleQuote lives here (lightest shared header) — // gitmine.h no longer carries its own copy; see jsonesc.h for the dedup rationale +#include "infra/platform_compat.h" #include #include // the join's once-per-process disclosure flags @@ -26,6 +27,7 @@ #include #include #include +#include #include // gitRepoToplevel's per-directory memo — one rev-parse probe per root, not per miner #include #include @@ -513,6 +515,36 @@ inline bool isBoundarySuffix( std::string_view indexedPath, std::string_view git return indexedPath.compare( off, gitRelPath.size(), gitRelPath ) == 0 && ( off == 0 || indexedPath[ off - 1 ] == '/' ); } +inline bool gitPathPrefixMatches( std::string_view path, std::string_view prefix ) noexcept +{ + if( path.size() < prefix.size() ) + { + return false; + } + for( std::size_t i = 0; i < prefix.size(); ++i ) + { +#if defined( _WIN32 ) + const bool driveLetter = i == 0 && prefix.size() >= 2 && path.size() >= 2 && path[1] == ':' && prefix[1] == ':' + && ( ( path[0] >= 'A' && path[0] <= 'Z' ) || ( path[0] >= 'a' && path[0] <= 'z' ) ) + && ( ( prefix[0] >= 'A' && prefix[0] <= 'Z' ) || ( prefix[0] >= 'a' && prefix[0] <= 'z' ) ); + if( driveLetter ) + { + const char pathDrive = path[0] >= 'a' && path[0] <= 'z' ? char( path[0] - 'a' + 'A' ) : path[0]; + const char prefixDrive = prefix[0] >= 'a' && prefix[0] <= 'z' ? char( prefix[0] - 'a' + 'A' ) : prefix[0]; + if( pathDrive == prefixDrive ) + { + continue; + } + } +#endif + if( path[i] != prefix[i] ) + { + return false; + } + } + return true; +} + // ONE normalisation, applied to BOTH sides of the join before any byte comparison, and the only latitude the // join has. Two rewrites, in ONE pass so there is no second place to keep in step: // * every `/./` seam collapses — workspace.h spelled a merged-root file `