diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml new file mode 100644 index 0000000000..88daf25451 --- /dev/null +++ b/.github/workflows/freebsd.yml @@ -0,0 +1,65 @@ +# FreeBSD build, INFORMATIONAL ONLY. Builds the headless binary (the C core, +# no embedded UI) inside a FreeBSD VM with the canonical scripts/build.sh, +# then checks that the binary starts (--version). The UI build is not run here: +# its npm dependency tree pulls in onnxruntime-node, which publishes no FreeBSD +# build, so `npm ci` stops with EBADPLATFORM. The UI bundle itself is +# platform-independent JavaScript and is built on every other leg. +# +# NON-GATING: workflow_dispatch only. This workflow is not part of the ci-ok +# aggregate and is not called by release.yml or dry-run.yml, so a FreeBSD red +# never blocks a pull request or a release. Nothing from it is uploaded or +# published, and it runs with read-only permissions. +name: FreeBSD build (informational) + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-freebsd: + name: build (freebsd-${{ matrix.release }}, ${{ matrix.arch }}, clang) + strategy: + fail-fast: false + matrix: + release: ['15.1', '14.4'] + arch: ['x86_64'] + # arch: ['x86_64', 'aarch64'] + # aarch64 is disabled for now: it has not been proven with this + # workflow. Its earlier blocker was the frontend build (lightningcss + # publishes no freebsd-arm64 binary), which this headless job no + # longer runs. Re-enable after a green aarch64 proof run. + include: + - arch: x86_64 + sync: rsync + # - arch: aarch64 + # sync: scp + # Must be an x86_64 host (ubuntu-latest). QEMU guest emulation on the arm + # runners (ubuntu-*-arm) degrades so badly that runs hit 3+ hour timeouts. + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and run inside a FreeBSD ${{ matrix.release }} (${{ matrix.arch }}) VM + # https://github.com/vmactions/freebsd-vm + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 + with: + release: ${{ matrix.release }} + arch: ${{ matrix.arch }} + # Sync options: rsync, sshfs, nfs, scp + sync: ${{ matrix.sync }} + # Nothing from the VM is used after the run, so skip the copy back. + copyback: false + mem: 6144 + cpu: 4 + prepare: | + pkg update -f + pkg install -y gmake sqlite3 pkgconf bash + which python3 >/dev/null 2>&1 || ln -sf $(which python3.15 python3.14 python3.13 python3.12 python3.11 python3.10 2>/dev/null | head -n 1) /usr/local/bin/python3 + run: | + scripts/build.sh CC=clang CXX=clang++ + build/c/codebase-memory-mcp --version diff --git a/Makefile.cbm b/Makefile.cbm index 49f4079bf8..15011dfdea 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -889,7 +889,8 @@ TEST_PY_LSP_STRESS_SRCS = tests/test_py_lsp_stress.c TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c TEST_TS_LSP_SRCS = tests/test_ts_lsp.c -TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c +TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c \ + tests/test_java_typed_calls.c TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c diff --git a/README.md b/README.md index f42609b88f..fc063c6da1 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ To configure detected agent assets without replacing an externally managed binar codebase-memory-cli install --skip-binary ``` -Use `install --plan` to inspect asset writes and `install-hooks --plan` to inspect hook writes before applying either surface. `--clients=` is supported independently by both commands. +Use `install --plan` to inspect asset writes and `install-hooks --plan` to inspect hook writes before applying either surface. `--clients=` is supported independently by both commands. `install --no-hooks` removes CLI-owned hooks and stores the opt-out (`config get install_hooks`); later `install`, `update`, and `install-hooks` honour it until `install --hooks` clears it. ## Installation diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4e704ebe09..fdd6faa140 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -81,6 +81,7 @@ codebase-memory-cli config set watcher_enabled false codebase-memory-cli config set ignore_worktrees true codebase-memory-cli config set doc_candidates false codebase-memory-cli config reset doc_candidates +codebase-memory-cli config get install_hooks ``` `doc_candidates` is `auto` by default: unset, the index mode decides. A single @@ -103,6 +104,9 @@ Current keys: | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | | `index_max_rss_mb` | `off` | Optional maximum charged memory in MiB for the complete contained index-worker process tree (`64..1048576`). On macOS this is phys_footprint, not resident size. | | `index_max_duration_seconds` | `off` | Optional maximum wall-clock duration in seconds for the whole index request, including crash/hang recovery (`1..86400`). Like `index_max_rss_mb`, it stops a worker only before it starts to publish the new index; see [INDEX_RESOURCE_LIMITS.md](INDEX_RESOURCE_LIMITS.md). | +| `install_hooks` | `true` | Whether CLI-owned client hooks may be installed. `install --no-hooks` removes CLI-owned hooks and persists `false`; `install --hooks` restores `true`. | + +While `install_hooks` is `false`, `install`, `update`, and `install-hooks` leave CLI-owned hooks off (`install` and `update` re-remove them); user-owned hook entries are never touched. `install --hooks` only clears the opt-out; run `install-hooks` afterwards to add hooks. Skills and instructions are unaffected. > **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the > retained watcher subsystem starts at all. `auto_watch` is narrower: it controls diff --git a/docs/upstream-sync/13m1.md b/docs/upstream-sync/13m1.md new file mode 100644 index 0000000000..24c722e643 --- /dev/null +++ b/docs/upstream-sync/13m1.md @@ -0,0 +1,8 @@ +# 13M-1 integration receipt + +- Upstream checkpoint: `5689ea54ceac23d7e5bf59d54b9c718b59ae9ee0`. Merge `3a4fdb9b975106489b764556823d9528681301c2` is a true merge with parents fork `cf63fe667579482c7e3b73238b73d4ad2d01db97` and upstream checkpoint `5689ea54c`; the checkpoint contains 104 commits and 11 first-parent PR merges. Follow-up head: `c3c5271ab8f454287452f0df5eca9e38ffd250cf` on `sync/upstream-13m1-20261108`. +- Retained and adapted: upstream benchmark runtime isolation (#2198) was ported with its contract test adapted to CLI argv; #1561 added truthful index freshness to `status --verbose`; #1430 added the `ignore_worktrees` hook gate and explicit index refusal through `--index-worktree`. +- Follow-up repairs: `3cbefe210` fixed verbose status output because `cbm_git_context_resolve` returns zero on success and the pre-existing fork path never emitted the Git object. Jenkins #89 exposed an `index_policy` fixture using `th_mktempdir`'s shared static buffer for both repo and cache roots; fork commit `19f5bdfa8`'s cache-root guard correctly refused that layout. `c3c5271ab` gave the fixture distinct roots. The failure was pre-existing on main since `19f5bdfa8`; Jenkins #89 failed at `3cbefe210` with 3,044 passed and 1 failed. +- Verification: local build with `-Werror`, memory-core lint (3,211 raw sites), strict Actions policy, targeted tests 18/18, and `index_policy` 12/12 passed. Jenkins: release-tooling #90 SUCCESS on exact head `c3c5271ab`. Merged to main via PR #33 (merge commit `929adfe36`). +- Native Windows was not run. No push, PR publication, or GitHub Actions dispatch occurred for this receipt. + diff --git a/docs/upstream-sync/13m2.md b/docs/upstream-sync/13m2.md new file mode 100644 index 0000000000..4293f40e33 --- /dev/null +++ b/docs/upstream-sync/13m2.md @@ -0,0 +1,8 @@ +# 13M-2 integration receipt + +- Upstream checkpoint: `e0c7a1da6c067149716afad5a06f2465c114840d`. Merge `bff6508606c296e3d96e5a01af44a9c35ae64049` is a true merge with parents 13M-1 merge `3a4fdb9b9` and upstream checkpoint `e0c7a1da6`; the checkpoint contains 73 commits and 7 first-parent PRs. The batch integrated 13M-1 follow-ups in merges `6ec8d5873` and `1c5f82a3a`; head `1c5f82a3a340ff5fa8069e4357d3177be8c48eab` on `sync/upstream-13m2-20261108`. +- Retained #1724 resource-worker behavior through native `src/operations/index.{c,h}` and `index_supervisor`: `RESOURCE_FAILURE` disposition, request facts, resource response, and worker publish gate. Also retained #2395 Swift initialization and #2499 memory-lint changes. +- Exclusion: #1784 MCP frontend recovery was MCP-only because `src/daemon/frontend.c` is deleted in the CLI fork; no MCP frontend was restored. +- Verification: local build with `-Werror`, memory-core lint (3,201 raw sites, none grew), strict Actions policy, `index_supervisor` 21/21, daemon-application resource tests 6/6, and combined related checks 5/5 passed. Jenkins: release-tooling #91 failed on `1c5f82a3a` with 2 failures, both upstream Hermes hook cycle tests (from `e8f0f4a28`) that called the assets-only installer and named the binary `codebase-memory-mcp`. `0f1927fd3` runs them through `cbm_install_agent_hooks_for_testing` with the `codebase-memory-cli` binary name. Release-tooling #93 SUCCESS on exact head `0f1927fd3`. +- Native Windows was not run. No push, PR publication, or GitHub Actions dispatch occurred for this receipt. + diff --git a/docs/upstream-sync/13n.md b/docs/upstream-sync/13n.md new file mode 100644 index 0000000000..fce31282bb --- /dev/null +++ b/docs/upstream-sync/13n.md @@ -0,0 +1,7 @@ +# 13N integration receipt + +- Upstream checkpoint: `612eee2518ef84cc573e491fdee5fa5c90264cb5` (upstream main). Merge `2efba854891c7edbc8839c8cce0a7fb9fa7dec11` is a true merge with parents 13M-2 merge `6ec8d5873` and the exact upstream checkpoint; it contains 39 commits and 6 first-parent PRs. Follow-up `e4717235e` ports #2749 to `src/operations/source_search.c`: POSIX unscoped scans use argv `find`/`sort` stages and an in-process scan. `validate_search_args` continues to reject metacharacters in scopes and patterns, as upstream does. Merge `cf24b6a323f4944dd429820af0654bf6be29ccc0` integrated 13M-1 follow-ups. Head: `cf24b6a32` on `sync/upstream-13n-20261108`. +- Retained #1862 install hook opt-out, #2667 Java external receiver, #2840 watcher cooldown, #2903 reserved `nul` handling, and #2911 dispatch-only FreeBSD workflow. MCP-only exclusions remain: `src/mcp/*`, `tests/test_mcp.c`, `src/daemon/frontend.*`, `tests/test_daemon_frontend.c`, `.github/workflows/pr.yml`, `.github/workflows/test-impact-artifact.yml`, and MCP registry steps in `release.yml` were not restored. +- Verification: local build with `-Werror`, memory-core lint (3,201 raw sites, none grew), strict Actions policy, #1862 and #2840 tests 9/9, and source-search, integration, and index-policy tests 78/78 passed. Jenkins: release-tooling #92 on `cf24b6a32` was aborted because that head lacked the 13M-2 Hermes test fix. `98eab3ceb` merges 13M-2 into 13N; release-tooling #94 SUCCESS on exact head `98eab3ceb`. This ledger update lands as a docs-only commit on top. +- Native Windows was not run; Windows-specific `nul` handling remains unverified natively. No push, PR publication, or GitHub Actions dispatch occurred for this receipt. + diff --git a/docs/upstream-sync/CONTINUE.md b/docs/upstream-sync/CONTINUE.md index a3ddc11010..ac1c82d5cc 100644 --- a/docs/upstream-sync/CONTINUE.md +++ b/docs/upstream-sync/CONTINUE.md @@ -1,6 +1,6 @@ # Continuation prompt -> Current 2026-11-02 continuation: the pinned target `a5b248f47272413189746589b9a2c551c6b6c5ec` is integrated by true merge `8a686f225b2c64f81c0716f3129c36c50fb10ffe` on `sync/upstream-remainder-20261102`; see [13l.md](13l.md) and [ledger.json](ledger.json). The target is an ancestor with zero upstream-only remainder. Final Jenkins qualification, independent review, and host acceptance remain open. Do not chase later upstream commits, mutate stale PRs #28/#29, push main, or dispatch GitHub Actions. +> Current 2026-11-08 continuation: baseline fork `cf63fe667579482c7e3b73238b73d4ad2d01db97` was 214 commits behind upstream checkpoint `612eee2518ef84cc573e491fdee5fa5c90264cb5` (merge base `a5b248f47272413189746589b9a2c551c6b6c5ec`). Stacked checkpoints 13M-1, 13M-2, and 13N end at `c3c5271ab8f454287452f0df5eca9e38ffd250cf`, `1c5f82a3a340ff5fa8069e4357d3177be8c48eab`, and `cf24b6a323f4944dd429820af0654bf6be29ccc0`; 13N is zero upstream commits behind. See [13m1.md](13m1.md), [13m2.md](13m2.md), [13n.md](13n.md), and [ledger.json](ledger.json). Jenkins qualification is pending for all three exact heads; native Windows was not run. Independent review and host acceptance remain separate gates. No push, PR publication, or GitHub Actions dispatch occurred. Continue the 2026-11-01 CLI-first upstream campaign. Read PLAN.md, ledger.json, 13a.md and the canonical ../UPSTREAM_308_CANDIDATE_AUDIT_20260912.md first. diff --git a/docs/upstream-sync/PLAN.md b/docs/upstream-sync/PLAN.md index 15851fcd8b..8fd10579b1 100644 --- a/docs/upstream-sync/PLAN.md +++ b/docs/upstream-sync/PLAN.md @@ -2,14 +2,14 @@ ## Live continuation — 2026-11-02 -13A is on the authoritative fork main at `27b731cec45e2353303738ac6d8e77acbef42b7c`. -The pinned continuation target is `a5b248f47272413189746589b9a2c551c6b6c5ec`. -13B–13L are integrated provisionally in order. The final 13L merge is -`8a686f225b2c64f81c0716f3129c36c50fb10ffe` with receipt [13l.md](13l.md); the -pinned target is now an ancestor and no pinned upstream commits remain. Independent -review, final Jenkins qualification, and host acceptance remain open. Current state is in -[ledger.json](ledger.json); older campaign inventory and PR bases below are -historical, and do not override the continuation run contract. +13A–13L are historical checkpoints. The 2026-11-08 stacked continuation integrates +13M-1, 13M-2, and 13N, ending at `98eab3ceb0666aee5521745414f5199acacba315`; +the pinned upstream main `612eee2518ef84cc573e491fdee5fa5c90264cb5` is an ancestor +with zero upstream-only commits. See [13m1.md](13m1.md), [13m2.md](13m2.md), +[13n.md](13n.md), and [ledger.json](ledger.json). Jenkins full gate passed for exact heads +`c3c5271ab` (#90, merged as PR #33), `0f1927fd3` (#93), and `98eab3ceb` (#94); native Windows was not run. +Independent review and host acceptance remain open. Older campaign inventory and +PR bases below are historical and do not override the continuation run contract. This campaign supplements the [canonical integration ledger](../UPSTREAM_308_CANDIDATE_AUDIT_20260912.md); it does not supersede older unresolved semantic dispositions. Machine status and per-checkpoint acceptance live in [ledger.json](ledger.json). [commits.tsv](commits.tsv) enumerates all 933 reachable campaign commits once, including 578 merges and 355 non-merges. Subsystem tags derive from changed paths; they are triage, not proof of implementation. @@ -30,7 +30,7 @@ This campaign supplements the [canonical integration ledger](../UPSTREAM_308_CAN 1. Finish narrow security/dependency fixes and verify actual advisories, versions and fork applicability. Preserve installer download/extraction limits and explicit workspace authorization. 2. Harden the local/Jenkins Actions policy guard and add ancestry verification with positive/negative checks. 3. Complete true merge of upstream `5dc83ac9fa363d51706a70977840b61ea59ce9db` (13A, 79 commits); reconcile reviewed hotfix/guard commits, retaining upstream merge ancestry. Publish only sanitized draft branches. -4. Batches 13E–13L build sequentially on the latest provisional checkpoint. They remain dependent and unaccepted until independent review. Publish only sanitized stacked draft PRs with explicit dependencies; never base them on stale main. +4. Batches 13E–13N build sequentially on the latest provisional checkpoint. They remain dependent and unaccepted until independent review. Publish only sanitized stacked draft PRs with explicit dependencies; never base them on stale main. 5. Run focused checks after each completed batch, then full exact-SHA Jenkins qualification before recommending merge. Native Windows, >4 GiB SQLite offsets, graph determinism and sanitizer/recovery checks remain explicit gates when affected. Pending gates are not successful gates. Every batch must preserve CLI JSON/compact contracts, native HTTP, daemon ownership, durable storage and source provenance. No MCP server, transport/session registration or mandatory MCP dependencies. Retain useful shared changes via native CLI operations. Record every material exclusion/adaptation and schema migration in its batch receipt. diff --git a/docs/upstream-sync/ledger.json b/docs/upstream-sync/ledger.json index bc2e4dd090..56a2f82dce 100644 --- a/docs/upstream-sync/ledger.json +++ b/docs/upstream-sync/ledger.json @@ -941,6 +941,115 @@ ], "next_action": "Run the existing full release-tooling Jenkins job against the current committed candidate; then retain independent review and host acceptance as separate gates.", "publication_blocker": "GitHub push unavailable: git remote requires Username in this non-interactive executor; GitHub ref API update returned not_found. No stacked draft PRs published; stale PRs #28/#29 untouched." + }, + { + "batch": "13M-1", + "upstream_start": "a5b248f47272413189746589b9a2c551c6b6c5ec", + "upstream_checkpoint": "5689ea54ceac23d7e5bf59d54b9c718b59ae9ee0", + "upstream_commits": 104, + "ancestry_validated": true, + "fork_base": "cf63fe667579482c7e3b73238b73d4ad2d01db97", + "depends_on": [ + "13L" + ], + "state": "merged_to_main_pr33", + "branch": "sync/upstream-13m1-20261108", + "merge_commit": "3a4fdb9b975106489b764556823d9528681301c2", + "head": "c3c5271ab8f454287452f0df5eca9e38ffd250cf", + "receipt": "13m1.md", + "adaptations": [ + "Ported #2198 benchmark runtime isolation; adapted contract test to CLI argv.", + "Ported #1561 truthful index freshness to status --verbose.", + "Ported #1430 ignore_worktrees hook gate and explicit --index-worktree refusal.", + "Fixed verbose status Git object emission and gave index_policy fixture distinct repo and cache roots." + ], + "exclusions": [], + "verification": { + "local": "scripts/build-dev.sh and build/c/test-runner with -Werror passed; lint-memory-core passed at 3211 raw sites; strict Actions policy passed; targeted checks 18/18 and index_policy 12/12 passed.", + "jenkins": "#90 SUCCESS on exact head c3c5271ab", + "windows": "not_run" + }, + "upstream_prs": [ + 2198, + 1561, + 1430 + ], + "qualification": "Jenkins release-tooling #90 SUCCESS on exact head c3c5271ab (full gate); merged to main via PR #33 as merge commit 929adfe36.", + "main_merge_commit": "929adfe36c5c36563637c83ab32007289ec4c271" + }, + { + "batch": "13M-2", + "upstream_start": "5689ea54ceac23d7e5bf59d54b9c718b59ae9ee0", + "upstream_checkpoint": "e0c7a1da6c067149716afad5a06f2465c114840d", + "upstream_commits": 73, + "ancestry_validated": true, + "fork_base": "3a4fdb9b975106489b764556823d9528681301c2", + "depends_on": [ + "13M-1" + ], + "state": "jenkins_passed_draft_pr_review_pending", + "branch": "sync/upstream-13m2-20261108", + "merge_commit": "bff6508606c296e3d96e5a01af44a9c35ae64049", + "head": "0f1927fd3cf872aa3d85719339af255fb1890394", + "receipt": "13m2.md", + "adaptations": [ + "Ported #1724 resource-worker behavior to native operations/index and index_supervisor: RESOURCE_FAILURE disposition, request facts, resource response, and worker publish gate.", + "Retained #2395 Swift initialization and #2499 memory-lint changes.", + "Integrated 13M-1 follow-ups in merges 6ec8d5873 and 1c5f82a3a." + ], + "exclusions": [ + "Excluded #1784 MCP frontend recovery because src/daemon/frontend.c is deleted in this CLI fork." + ], + "verification": { + "local": "scripts/build-dev.sh and build/c/test-runner with -Werror passed; lint-memory-core passed at 3201 raw sites with none grown; strict Actions policy passed; index_supervisor 21/21, daemon_application resource tests 6/6, and combined related checks 5/5 passed.", + "jenkins": "#91 FAILURE on 1c5f82a3a (2 Hermes hook tests, fixed in 0f1927fd3); #93 SUCCESS on exact head 0f1927fd3", + "windows": "not_run" + }, + "upstream_prs": [ + 1724, + 2395, + 2499, + 1784 + ], + "qualification": "Jenkins release-tooling #91 FAILED on 1c5f82a3a (2 Hermes hook tests); fixed in 0f1927fd3; #93 SUCCESS on exact head 0f1927fd3 (full gate)." + }, + { + "batch": "13N", + "upstream_start": "e0c7a1da6c067149716afad5a06f2465c114840d", + "upstream_checkpoint": "612eee2518ef84cc573e491fdee5fa5c90264cb5", + "upstream_commits": 39, + "ancestry_validated": true, + "fork_base": "0f1927fd3cf872aa3d85719339af255fb1890394", + "depends_on": [ + "13M-2" + ], + "state": "jenkins_passed_draft_pr_review_pending", + "branch": "sync/upstream-13n-20261108", + "merge_commit": "2efba854891c7edbc8839c8cce0a7fb9fa7dec11", + "head": "98eab3ceb0666aee5521745414f5199acacba315", + "receipt": "13n.md", + "adaptations": [ + "Ported #2749 to native source_search: unscoped POSIX scan uses argv find/sort stages and in-process scanning; metacharacters remain rejected by validate_search_args.", + "Retained #1862 hook opt-out, #2667 Java external receiver, #2840 watcher cooldown, #2903 reserved nul handling, and #2911 dispatch-only FreeBSD workflow.", + "Integrated 13M-1 follow-ups in cf24b6a32." + ], + "exclusions": [ + "MCP-only files and release registry steps remain deleted; see 13n.md." + ], + "verification": { + "local": "scripts/build-dev.sh and build/c/test-runner with -Werror passed; lint-memory-core passed at 3201 raw sites with none grown; strict Actions policy passed; #1862 and #2840 tests 9/9, source_search, integration, and index_policy tests 78/78 passed.", + "jenkins": "#92 aborted (stale head cf24b6a32); #94 SUCCESS on exact head 98eab3ceb", + "windows": "not_run; reserved nul handling not natively verified" + }, + "upstream_prs": [ + 1862, + 2667, + 2749, + 2840, + 2903, + 2911 + ], + "qualification": "Jenkins release-tooling #92 on cf24b6a32 aborted (stale head); #94 SUCCESS on exact head 98eab3ceb (full gate), which merges the 13M-2 fix." } ], "reconciliation": [ @@ -1660,22 +1769,18 @@ } }, "current_continuation": { - "baseline": "27b731cec45e2353303738ac6d8e77acbef42b7c", - "pinned_target": "a5b248f47272413189746589b9a2c551c6b6c5ec", - "integrated_through": "a5b248f47272413189746589b9a2c551c6b6c5ec", - "current_merge_commit": "8a686f225b2c64f81c0716f3129c36c50fb10ffe", + "baseline": "cf63fe667579482c7e3b73238b73d4ad2d01db97", + "upstream_baseline": "612eee2518ef84cc573e491fdee5fa5c90264cb5", + "merge_base": "a5b248f47272413189746589b9a2c551c6b6c5ec", + "integrated_through": "612eee2518ef84cc573e491fdee5fa5c90264cb5", + "current_merge_commit": "2efba854891c7edbc8839c8cce0a7fb9fa7dec11", + "current_head": "98eab3ceb0666aee5521745414f5199acacba315", "upstream_commits_remaining": 0, - "status": "full_jenkins_success_independent_review_and_acceptance_pending", - "candidate_commit": "214b87b84a40e170fd8b3db2c603ab651ba5fe2c", - "full_jenkins": { - "job": "codebase-memory-cli-release-tooling", - "build": 88, - "result": "SUCCESS", - "source_commit": "214b87b84a40e170fd8b3db2c603ab651ba5fe2c", - "url": "http://192.168.88.146:8080/job/codebase-memory-cli-release-tooling/88/", - "production_binary_sha256": "c86570af70bd2dfb655a5c2a82d2ab7669e9e76b6ea95fab6af9badc9fd7877f", - "test_summary": "4141 passed, 0 failed, 2 skipped; 188 suites, 9 jobs" - } + "baseline_upstream_commits_behind": 214, + "status": "jenkins_passed_13m1_merged_13m2_13n_review_pending", + "jenkins_qualification": "PASSED: #90 c3c5271ab, #93 0f1927fd3, #94 98eab3ceb", + "native_windows": "not_run; #2903 nul handling remains unverified natively", + "publication": "no push, no PR, no Actions dispatch at time of writing" }, "upstream_commits_remaining": 0 } diff --git a/internal/cbm/lsp/java_lsp.c b/internal/cbm/lsp/java_lsp.c index cf221f06e7..437dd82852 100644 --- a/internal/cbm/lsp/java_lsp.c +++ b/internal/cbm/lsp/java_lsp.c @@ -510,7 +510,19 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 3. java.lang auto-import. Try registered first, then the conventional QN. */ + /* 3. Java package prefix. A type of the own package shadows every + * on-demand import, java.lang's included (JLS 6.4.1). */ + if (ctx->package_name && ctx->package_name[0]) { + char buf[JAVA_LSP_BUF]; + int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name); + if (n > 0 && (size_t)n < sizeof(buf)) { + if (cbm_registry_lookup_type(ctx->registry, buf)) { + return cbm_arena_strdup(ctx->arena, buf); + } + } + } + + /* 4. java.lang auto-import. Try registered first, then the conventional QN. */ for (int i = 0; JAVA_LANG_TYPES[i]; i++) { if (strcmp(JAVA_LANG_TYPES[i], name) == 0) { char buf[JAVA_LSP_BUF]; @@ -521,7 +533,7 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 4. On-demand imports. */ + /* 5. On-demand imports. */ for (int i = 0; i < ctx->import_count; i++) { if (ctx->import_kinds[i] != CBM_JAVA_IMPORT_ON_DEMAND) continue; @@ -534,17 +546,6 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 5. Java package prefix. */ - if (ctx->package_name && ctx->package_name[0]) { - char buf[JAVA_LSP_BUF]; - int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name); - if (n > 0 && (size_t)n < sizeof(buf)) { - if (cbm_registry_lookup_type(ctx->registry, buf)) { - return cbm_arena_strdup(ctx->arena, buf); - } - } - } - /* Exact already-qualified name. */ if (cbm_registry_lookup_type(ctx->registry, name)) { return cbm_arena_strdup(ctx->arena, name); @@ -969,6 +970,12 @@ const CBMRegisteredFunc *java_lookup_method(JavaLSPContext *ctx, const char *cla return fallback; } +/* A type declared in the project: the cross registrar and the own-file pass + * stamp namespace_qn on every project type; the JDK seed leaves it NULL. */ +static bool java_is_project_type(const CBMRegisteredType *rt) { + return rt && rt->namespace_qn && rt->namespace_qn[0]; +} + /* ── Method-invocation evaluation ─────────────────────────────────── */ static const CBMType *java_return_type_of(const CBMRegisteredFunc *f) { @@ -1797,11 +1804,135 @@ void java_lsp_process_file(JavaLSPContext *ctx, TSNode root) { /* ── Call-edge resolution ─────────────────────────────────────────── */ +/* True when `callee_qn` names a member (or a constructor) of a registered type + * the project does not declare — a JDK seed type. Such a target has no graph + * node, and the pipeline must not hand it to a same-named project method. */ +static bool java_callee_is_external(const JavaLSPContext *ctx, const char *callee_qn) { + if (!ctx->registry || !callee_qn) + return false; + const CBMRegisteredType *self = cbm_registry_lookup_type(ctx->registry, callee_qn); + if (self) + return !java_is_project_type(self); + const char *dot = strrchr(callee_qn, '.'); + if (!dot || dot == callee_qn) + return false; + char owner[JAVA_LSP_BUF]; + size_t n = (size_t)(dot - callee_qn); + if (n >= sizeof(owner)) + return false; + memcpy(owner, callee_qn, n); + owner[n] = '\0'; + const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, owner); + return rt && !java_is_project_type(rt); +} + +/* `T`, `ID`, `SELF`: a type-variable spelling — one or two characters, or + * capitals, digits and '_' only — that the file imports neither by name nor + * through its package (`import org.jooq.impl.*;` makes `DSL` a type). A type + * variable the resolver failed to bind can reach a receiver qualified by some + * package; it is unknown, not a library type. */ +static bool java_is_type_var_spelling(const JavaLSPContext *ctx, const char *pkg, size_t pkg_len, + const char *simple) { + size_t n = strlen(simple); + bool caps = n > 0; + for (size_t i = 0; i < n && caps; i++) { + caps = isupper((unsigned char)simple[i]) || isdigit((unsigned char)simple[i]) || + simple[i] == '_'; + } + if (!caps && n > 2) { + return false; + } + for (int i = 0; i < ctx->import_count; i++) { + const char *target = ctx->import_target_qns[i]; + if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_TYPE && ctx->import_local_names[i] && + strcmp(ctx->import_local_names[i], simple) == 0) { + return false; + } + if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_ON_DEMAND && target && + strlen(target) == pkg_len && strncmp(target, pkg, pkg_len) == 0) { + return false; + } + } + return true; +} + +/* Whether the shared registry holds no project type under `prefix` + * ("org.jooq."), memoised per package. */ +static bool java_package_is_external(JavaLSPContext *ctx, const CBMTypeRegistry *base, + const char *prefix) { + for (int i = 0; i < JAVA_EXT_MEMO; i++) { + if (ctx->ext_memo_pkg[i] && strcmp(ctx->ext_memo_pkg[i], prefix) == 0) { + return ctx->ext_memo_val[i]; + } + } + CBMArena scratch; + cbm_arena_init(&scratch); + CBMQNPrefixIter it; + bool project = false; + /* The prefix search reads one layer, and the layer above the shared + * registry is a per-file overlay that is often empty: walk the chain. */ + for (const CBMTypeRegistry *reg = base; reg && !project; reg = reg->fallback) { + if (!cbm_registry_types_with_qn_prefix(reg, prefix, true, &scratch, &it)) + continue; + for (int ti = cbm_qn_prefix_iter_next(&it); ti >= 0 && !project; + ti = cbm_qn_prefix_iter_next(&it)) { + project = java_is_project_type(®->types[ti]); + } + } + cbm_arena_destroy(&scratch); + int slot = ctx->ext_memo_next++ % JAVA_EXT_MEMO; + ctx->ext_memo_pkg[slot] = prefix; + ctx->ext_memo_val[slot] = !project; + return !project; +} + +/* A receiver type the registry does not know, spelled with a package the + * project declares no type in (`org.jooq.DSLContext` from an import): a + * library type. The package is the segments before the first capitalised + * one, so a nested type (`com.acme.Order.OrderBuilder`, registered flattened + * as `com.acme.OrderBuilder`) is judged by `com.acme`. Cross-file runs only — + * the shared registry is the one that knows every project package, and its + * type order makes the probe a binary search. A path-derived QN + * (project-prefixed) is an unresolved project spelling, never a library. */ +static bool java_receiver_is_external(JavaLSPContext *ctx, const char *recv_qn) { + const CBMTypeRegistry *base = ctx->registry ? ctx->registry->fallback : NULL; + if (!base || !recv_qn || !islower((unsigned char)recv_qn[0])) + return false; + const char *type = NULL; + for (const char *p = recv_qn; (p = strchr(p, '.')) != NULL; p++) { + if (isupper((unsigned char)p[1])) { + type = p + 1; + break; + } + } + if (!type) + return false; + size_t pkg_len = (size_t)(type - 1 - recv_qn); + const char *last = strrchr(recv_qn, '.'); + if (java_is_type_var_spelling(ctx, recv_qn, pkg_len, last + 1)) + return false; + /* The project segment; a file at the repository root has a module QN + * that is the project name alone. */ + const char *mod_dot = ctx->module_qn ? strchr(ctx->module_qn, '.') : NULL; + size_t mod_len = mod_dot ? (size_t)(mod_dot - ctx->module_qn) + : (ctx->module_qn ? strlen(ctx->module_qn) : 0); + if (mod_len && strncmp(recv_qn, ctx->module_qn, mod_len) == 0 && recv_qn[mod_len] == '.') + return false; + if (ctx->package_name && strlen(ctx->package_name) == pkg_len && + strncmp(ctx->package_name, recv_qn, pkg_len) == 0) + return false; + /* "org.jooq." */ + return java_package_is_external(ctx, base, cbm_arena_strndup(ctx->arena, recv_qn, pkg_len + 1)); +} + static void java_emit_resolved_orig_kind(JavaLSPContext *ctx, const char *callee_qn, const char *orig, const char *strategy, float confidence, CBMResolvedKind kind) { if (!ctx->resolved_calls || !ctx->enclosing_method_qn || !callee_qn) return; + if (kind == CBM_RESOLVED_INVOCATION && java_callee_is_external(ctx, callee_qn)) { + strategy = "lsp_external"; + } CBMResolvedCall rc = {0}; rc.caller_qn = ctx->enclosing_method_qn; rc.callee_qn = callee_qn; @@ -2145,6 +2276,15 @@ static void resolve_method_call(JavaLSPContext *ctx, TSNode call) { "lsp_interface_dispatch", 0.80f); return; } + /* A library type the registry does not hold: no method of it can be + * looked up, but no project method is its target either. A seeded + * JDK type with no such method stays a diagnostic below, as Rust's + * gate (#2053) needs a positively resolved std method. */ + if (!rt && java_receiver_is_external(ctx, recv_qn)) { + java_emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname), + "lsp_external", 0.90f); + return; + } java_emit_unresolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname), "no_method_match"); return; @@ -3214,6 +3354,9 @@ static void register_local_func_or_type_from_file(JavaLSPContext *ctx, CBMTypeRe stub.qualified_name = d->qualified_name; stub.short_name = d->name; stub.is_interface = (strcmp(d->label, "Interface") == 0); + stub.namespace_qn = (ctx->package_name && ctx->package_name[0]) ? ctx->package_name + : (module_qn && module_qn[0]) ? module_qn + : d->qualified_name; cbm_registry_add_type(reg, stub); } @@ -3740,6 +3883,11 @@ void cbm_java_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, const CBM rt.qualified_name = d->qualified_name; rt.short_name = d->short_name; rt.is_interface = (strcmp(d->label, "Interface") == 0) || d->is_interface; + /* Project marker (java_is_project_type) and the package that + * qualifies the type's own member signatures. */ + rt.namespace_qn = (d->namespace_name && d->namespace_name[0]) ? d->namespace_name + : (d->def_module_qn && d->def_module_qn[0]) ? d->def_module_qn + : d->qualified_name; /* Embedded types from "|"-separated text. */ if (d->embedded_types && d->embedded_types[0]) { int n = 1; @@ -3883,6 +4031,9 @@ CBMTypeRegistry *cbm_java_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, cbm_java_register_lsp_defs(arena, reg, jvm + type_count, total - type_count); } cbm_registry_finalize(reg); + /* java_receiver_is_external asks "does the project declare a type in + * this package?" per unresolved receiver: a binary search on this order. */ + cbm_registry_build_type_qn_order(reg, arena); reg->read_only = true; /* seal: shared Tier-2 registry is read-only during resolve */ return reg; } @@ -3958,6 +4109,9 @@ void cbm_run_java_lsp_cross_with_registry(CBMArena *arena, CBMFileResult *result } } +/* The per-file fallback when no shared cross registry was built. Its + * registry has no fallback layer, so java_receiver_is_external never fires + * here: library calls keep the textual registry fallback, as before. */ void cbm_run_java_lsp_cross(CBMArena *arena, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **import_names, const char **import_qns, int import_count, diff --git a/internal/cbm/lsp/java_lsp.h b/internal/cbm/lsp/java_lsp.h index ea304c64ba..18d0642358 100644 --- a/internal/cbm/lsp/java_lsp.h +++ b/internal/cbm/lsp/java_lsp.h @@ -35,6 +35,8 @@ #include "../cbm.h" #include "go_lsp.h" /* CBMLSPDef, CBMResolvedCallArray reused across languages */ +#define JAVA_EXT_MEMO 8 + /* Java `use`-style import kinds. Mirrors PHP's enum with Java semantics. */ enum { CBM_JAVA_IMPORT_TYPE = 0, /* import com.foo.Bar; — type import */ @@ -90,6 +92,12 @@ typedef struct { /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; + + /* java_receiver_is_external's answers for the packages probed so far + * (a few per file), so each costs one registry walk. */ + const char *ext_memo_pkg[JAVA_EXT_MEMO]; + bool ext_memo_val[JAVA_EXT_MEMO]; + int ext_memo_next; } JavaLSPContext; /* ── Initialization / configuration ───────────────────────────────── */ diff --git a/scripts/build.sh b/scripts/build.sh index 56e9ba818e..bf79e56fa7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -193,17 +193,18 @@ else cbm_remove_build_dir "$ROOT" "$BUILD_DIR" fi -# Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS) +# Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS; +# $MAKE comes from env.sh: gmake on FreeBSD, make elsewhere) if [[ "$PHASE" == vendored ]]; then - make -j"$NPROC" -f Makefile.cbm cbm-third-party \ + "$MAKE" -j"$NPROC" -f Makefile.cbm cbm-third-party \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" echo "=== Third-party objects built in ${BUILD_DIR} (no binary) ===" exit 0 elif $WITH_UI; then - make -j"$NPROC" -f Makefile.cbm cbm-with-ui \ + "$MAKE" -j"$NPROC" -f Makefile.cbm cbm-with-ui \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" else - make -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \ + "$MAKE" -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" fi diff --git a/scripts/env.sh b/scripts/env.sh index e23affe4ca..13ece00b52 100755 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -6,8 +6,9 @@ # Exports: # ARCH — target architecture (arm64 / x86_64) # ARCHFLAGS — "-arch " on macOS (target slice for clang/ld), empty elsewhere +# MAKE — make program for Makefile.cbm (gmake on FreeBSD, make elsewhere) # NPROC — number of CPU cores -# OS — darwin / linux / windows +# OS — darwin / linux / windows / freebsd set -euo pipefail @@ -16,10 +17,22 @@ OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in darwin) OS="darwin" ;; linux) OS="linux" ;; + freebsd*) OS="freebsd" ;; mingw*|msys*|cygwin*) OS="windows" ;; *) OS="unknown" ;; esac +# ── Select the make program ──────────────────────────────────── +# Makefile.cbm is written in GNU make syntax. FreeBSD ships BSD make as its +# default make, so builds there run gmake (pkg install gmake); every other +# platform runs make. Not exported: build.sh sources this file and runs +# $MAKE in the same shell. +if [[ "$OS" == "freebsd" ]]; then + MAKE="gmake" +else + MAKE="make" +fi + # ── Detect / override architecture ───────────────────────────── # Default: native HARDWARE architecture (not Rosetta-translated). # On macOS under Rosetta, uname -m returns x86_64 even on Apple Silicon. @@ -128,5 +141,5 @@ fi # ── Print environment summary ────────────────────────────────── print_env() { local context="$1" - echo "=== $context: os=$OS arch=$ARCH cores=$NPROC cc=${CC:-default} cxx=${CXX:-default} ===" + echo "=== $context: os=$OS arch=$ARCH cores=$NPROC cc=${CC:-default} cxx=${CXX:-default} make=$MAKE ===" } diff --git a/src/cli/cli.c b/src/cli/cli.c index 5172beff40..34f2f1ee26 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7508,6 +7508,7 @@ static const config_key_def_t CONFIG_KEYS[] = { "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_WATCH_NON_GIT, "false", "Also poll non-git project roots for changes (file-tree scan); off by default"}, + {CBM_CONFIG_INSTALL_HOOKS, "true", "Install managed client hooks"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, {CBM_CONFIG_UI_ENABLED, "false", "Serve the graph UI on a loopback HTTP port"}, {CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"}, @@ -8259,6 +8260,11 @@ static bool cbm_installing_hooks(void) { } static int g_agent_install_errors = 0; static int g_agent_uninstall_errors = 0; +static bool g_install_hooks = true; + +static void print_hooks_skipped(void) { + printf(" hooks: skipped (install --hooks, then install-hooks, to enable)\n"); +} /* Every agent configuration uninstall could not clean, kept for the closing * summary. A cleanup failure no longer stops executable and index removal @@ -8348,6 +8354,9 @@ static void plan_record(const char *agent, const char *kind, const char *path) { if (!g_install_plan || !path || !path[0]) { return; } + if (!g_install_hooks && strcmp(kind, "hook") == 0) { + return; + } cbm_install_plan_t *pl = g_install_plan; if (pl->count >= pl->cap) { int ncap = pl->cap ? pl->cap * 2 : CLI_BUF_16; @@ -8471,6 +8480,94 @@ static void uninstall_tiered_profile_prompts(const char *label, const char *veri cbm_graph_profile_dialect_t dialect, const char *legacy_verify_content, bool dry_run); +static int remove_claude_owned_hook_scripts(const char *config_dir, const char *binary_path, + bool uninstall) { + static const struct { + const char *name; + const char *legacy_name; + const char *prefix; + } hook_types[] = { + {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix}, + {CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY, + cmm_session_script_prefix}, + {CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, + cmm_subagent_script_prefix}, + }; +#ifdef _WIN32 + /* Released shapes only identify the extensionless Windows twin. */ + char released_gate[CMM_RELEASED_GATE_VARIANT_COUNT][CLI_BUF_8K]; + const char *gate_legacy[CMM_RELEASED_GATE_VARIANT_COUNT] = {0}; + size_t gate_legacy_count = + cbm_build_released_gate_candidates(binary_path, released_gate, gate_legacy); + const char *const session_legacy[] = {cmm_legacy_mcp_session_script}; + const char *const subagent_legacy[] = {cmm_legacy_mcp_subagent_script}; +#endif + char hooks_dir[CLI_BUF_1K]; + snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); + const char *op = uninstall ? "hook_script_uninstall" : "hook_script_disable"; + int rc = CLI_OK; + for (size_t i = 0; i < sizeof(hook_types) / sizeof(hook_types[0]); i++) { + char expected[CLI_BUF_8K]; + char script_path[CLI_BUF_1K]; + int written = + snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, hook_types[i].name); + if (written <= 0 || (size_t)written >= sizeof(script_path) || + cbm_build_current_hook_script(hook_types[i].prefix, binary_path, expected, + sizeof(expected)) != CLI_OK || + cbm_remove_owned_hook_script(script_path, expected, NULL, 0U) < CLI_OK) { + record_agent_config_error(uninstall, "Claude Code", op, hook_types[i].name); + rc = CLI_ERR; + continue; + } +#ifdef _WIN32 + /* Keyed by name so reordering hook_types cannot mismatch lists. */ + bool is_gate = strcmp(hook_types[i].legacy_name, CMM_HOOK_GATE_SCRIPT_LEGACY) == 0; + bool is_session = + strcmp(hook_types[i].legacy_name, CMM_SESSION_REMINDER_SCRIPT_LEGACY) == 0; + const char *const *legacy = is_gate ? gate_legacy + : is_session ? session_legacy + : subagent_legacy; + size_t legacy_count = is_gate ? gate_legacy_count : 1U; + if (cbm_remove_owned_legacy_hook_script(hooks_dir, hook_types[i].legacy_name, expected, + legacy, legacy_count) != CLI_OK) { + record_agent_config_error(uninstall, "Claude Code", "legacy_hook_script_uninstall", + hook_types[i].legacy_name); + rc = CLI_ERR; + } +#endif + } + return rc; +} + +/* Augment's SessionStart and PostToolUse view scripts are exact-owned: each is + * rebuilt from the binary path the install embedded and deleted only on a + * byte-for-byte match, so a script the user edited stays. Uninstall and the + * install-hook opt-out share this rule. */ +static void remove_augment_owned_hook_scripts(const char *binary_path, const char *session_path, + const char *coverage_path, bool uninstall) { + const struct { + const char *path; + int (*build)(const char *, char *, size_t); + const char *build_op; + const char *remove_op; + } scripts[] = { + {session_path, cbm_build_augment_session_script, "session_script_build", + uninstall ? "session_script_uninstall" : "session_script_disable"}, + {coverage_path, cbm_build_augment_coverage_script, "coverage_script_build", + uninstall ? "coverage_script_uninstall" : "coverage_script_disable"}, + }; + for (size_t i = 0; i < sizeof(scripts) / sizeof(scripts[0]); i++) { + char script[CLI_BUF_8K]; + if (scripts[i].build(binary_path, script, sizeof(script)) != CLI_OK) { + record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].build_op, + scripts[i].path); + } else if (cbm_text_remove_owned_document(scripts[i].path, script) < 0) { + record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].remove_op, + scripts[i].path); + } + } +} + static void install_claude_code_config(const char *home, const char *binary_path, bool force, bool dry_run) { char config_dir[CLI_BUF_1K]; @@ -8510,6 +8607,23 @@ static void install_claude_code_config(const char *home, const char *binary_path printf(" integration: explicit CLI hooks\n"); char settings_path[CLI_BUF_1K]; snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); + if (!g_install_hooks) { + if (!dry_run) { + if (cbm_remove_claude_hooks_with_binary(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_session_hooks(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_claude_subagent_hooks_with_binary(settings_path, binary_path) != + CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + remove_claude_owned_hook_scripts(config_dir, binary_path, false); + } + print_hooks_skipped(); + return; + } bool gate_ok; bool session_ok; bool subagent_ok; @@ -9119,6 +9233,14 @@ static void install_copilot_durable_context(const char *home, const char *binary plan_record("Copilot", "hook", hook_path); return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hook_path) && + cbm_remove_copilot_hooks(hook_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Copilot", "hook_disable", hook_path); + } + print_hooks_skipped(); + return; + } bool hook_ok = true; if (!dry_run && (!cbm_mkdir_p_ex(hooks_dir, CLI_OCTAL_PERM, CBM_MKDIR_FOLLOW_OWNED) || cbm_upsert_copilot_hooks(binary_path, hook_path) != CLI_OK)) { @@ -9292,6 +9414,14 @@ static void install_qoder_durable_context(const char *home, const char *binary_p } return; } + if (!g_install_hooks) { + if (!dry_run && config_resolved && cbm_file_exists(settings_path) && + cbm_remove_qoder_context_hook(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Qoder CLI", "hook_disable", settings_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hooks: withheld because no documented executor is available\n"); return; @@ -9354,6 +9484,14 @@ static void install_gitlab_durable_context(const cbm_agent_registry_context_t *r } return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hooks_path) && + cbm_remove_gitlab_session_hook(hooks_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "GitLab Duo CLI", "hook_disable", hooks_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hook: withheld on Windows (vendor hook shell is undocumented)\n"); return; @@ -9394,6 +9532,14 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re } return; } + if (!g_install_hooks) { + if (!dry_run && config_resolved && cbm_file_exists(config_path) && + cbm_remove_devin_context_hooks(config_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Devin CLI / Local", "hook_disable", config_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hooks: withheld on Windows (vendor hook shell is undocumented)\n"); return; @@ -9458,6 +9604,14 @@ static void install_kimi_durable_context(const cbm_agent_registry_context_t *reg plan_record("Kimi Code CLI", "hook", config_path); return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(config_path) && + cbm_remove_kimi_context_hook(config_path) != CLI_OK) { + record_agent_config_error(false, "Kimi Code CLI", "hook_disable", config_path); + } + print_hooks_skipped(); + return; + } bool installed = true; if (!dry_run && (!prepare_config_parent(config_path) || cbm_upsert_kimi_context_hook(config_path, binary_path) != CLI_OK)) { @@ -9611,6 +9765,22 @@ static void install_gemini_config(const char *home, const char *binary_path, boo plan_record("Gemini CLI", "hook", cp); /* BeforeTool + SessionStart in settings.json */ return; } + if (!g_install_hooks) { + if (!dry_run) { + int tool_result = cbm_remove_gemini_hooks(cp); + int coverage_result = CLI_OK; +#ifndef _WIN32 + coverage_result = cbm_remove_gemini_coverage_hook(cp, binary_path); +#endif + int session_result = cbm_remove_gemini_session_hooks(cp); + if (tool_result != CLI_OK || coverage_result != CLI_OK || session_result != CLI_OK) { + record_agent_config_error(false, "Gemini CLI", "hook_disable", cp); + } + } + print_hooks_skipped(); + printf(" subagents: use the CLI-first skill\n"); + return; + } if (!dry_run) { if (cbm_upsert_gemini_hooks(cp) != CLI_OK) { record_agent_config_error(false, "Gemini CLI", "before_tool_hook_install", cp); @@ -9669,8 +9839,9 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const cbm_build_augment_command(binary_path, command, sizeof(command)) == CLI_OK && cbm_build_augment_command_windows(binary_path, command_windows, sizeof(command_windows)) == CLI_OK; - cbm_toml_codex_hook_action_t preflight_action = - use_hooks_json ? CBM_TOML_CODEX_HOOK_REMOVE : CBM_TOML_CODEX_HOOK_UPSERT; + cbm_toml_codex_hook_action_t preflight_action = !g_install_hooks || use_hooks_json + ? CBM_TOML_CODEX_HOOK_REMOVE + : CBM_TOML_CODEX_HOOK_UPSERT; cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE; int preflight_result = commands_ok ? cbm_reconcile_codex_hooks_command_detailed( cp, command, command_windows, preflight_action, @@ -9695,7 +9866,22 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const } else { bool hook_ok = true; int pretool_written = 1; - if (!dry_run && use_hooks_json) { + if (!g_install_hooks && !dry_run) { + int toml_result = cbm_reconcile_codex_hooks_command( + cp, command, command_windows, CBM_TOML_CODEX_HOOK_REMOVE, false); + int json_result = + !cbm_file_exists(hooks_json) + ? CLI_OK + : cbm_remove_paired_lifecycle_hooks_json(hooks_json, command); + /* The PreToolUse Bash hook shares hooks.json with the lifecycle + * pair, so the opt-out removes it the way uninstall does. */ + int pretool_result = + !cbm_file_exists(hooks_json) + ? CLI_OK + : cbm_remove_codex_pretool_hooks_json(hooks_json, command); + hook_ok = + toml_result == CLI_OK && json_result == CLI_OK && pretool_result == CLI_OK; + } else if (!dry_run && use_hooks_json) { hook_ok = cbm_upsert_paired_lifecycle_hooks_json(hooks_json, command, command_windows, NULL, @@ -9711,7 +9897,11 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const NULL, &pretool_written) == CLI_OK; } if (!hook_ok) { - record_agent_config_error(false, "Codex CLI", "hook_install", hook_target); + record_agent_config_error(false, "Codex CLI", + g_install_hooks ? "hook_install" : "hook_disable", + hook_target); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(pretool_written ? " hooks: SessionStart + SubagentStart + PreToolUse Bash (dynamic " @@ -9897,11 +10087,19 @@ static void install_editor_agent_configs(const cbm_detected_agents_t *agents, co plan_record("OpenClaw", "hook", cp); } else { bool compaction_installed = true; - if (!dry_run && cbm_upsert_openclaw_compaction(cp) != CLI_OK) { + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(cp) && + cbm_remove_openclaw_compaction(cp) != CLI_OK) { + record_agent_config_error(false, "OpenClaw", "hook_disable", cp); + } + print_hooks_skipped(); + } else if (!dry_run && cbm_upsert_openclaw_compaction(cp) != CLI_OK) { compaction_installed = false; record_agent_config_error(false, "OpenClaw", "compaction_install", cp); } - if (compaction_installed) { + if (!g_install_hooks) { + /* Opt-out reported above. */ + } else if (compaction_installed) { printf(" compaction: CLI context reinjection enabled\n"); } else { printf(" compaction: could not update CLI-owned augmentation\n"); @@ -9965,14 +10163,19 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents } else { int hook_result = CBM_YAML_IDENTITY_EDIT_OK; if (!dry_run) { - hook_result = prepare_config_parent(cp) - ? cbm_upsert_hermes_context_hook(cp, binary_path) - : CBM_YAML_IDENTITY_EDIT_ERROR; + hook_result = g_install_hooks + ? (prepare_config_parent(cp) + ? cbm_upsert_hermes_context_hook(cp, binary_path) + : CBM_YAML_IDENTITY_EDIT_ERROR) + : cbm_remove_hermes_context_hook(cp, binary_path); } if (hook_result == CBM_YAML_IDENTITY_EDIT_FOREIGN) { record_agent_config_error(false, "Hermes", "pre_llm_hook_foreign", cp); } else if (hook_result != CBM_YAML_IDENTITY_EDIT_OK) { - record_agent_config_error(false, "Hermes", "pre_llm_hook_install", cp); + record_agent_config_error( + false, "Hermes", g_install_hooks ? "pre_llm_hook_install" : "hook_disable", cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(" hook: %s (pre_llm_call)\n", cp); } @@ -10011,7 +10214,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents } else { bool hook_ok = true; if (!dry_run) { - if (!cbm_install_augment_session_script(binary_path, session_hp)) { + if (!g_install_hooks) { + int session_result = cbm_remove_augment_session_hook(cp, session_hp); + int coverage_result = cbm_remove_augment_coverage_hook(cp, coverage_hp); + hook_ok = session_result == CLI_OK && coverage_result == CLI_OK; + remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, false); + } else if (!cbm_install_augment_session_script(binary_path, session_hp)) { hook_ok = false; record_agent_config_error(false, "Augment/Auggie", "session_script_install", session_hp); @@ -10019,16 +10227,23 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents hook_ok = false; record_agent_config_error(false, "Augment/Auggie", "session_hook_install", cp); } - if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) { - hook_ok = false; - record_agent_config_error(false, "Augment/Auggie", "coverage_script_install", - coverage_hp); - } else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { - hook_ok = false; - record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install", cp); + if (g_install_hooks) { + if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) { + hook_ok = false; + record_agent_config_error(false, "Augment/Auggie", + "coverage_script_install", coverage_hp); + } else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { + hook_ok = false; + record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install", + cp); + } } } - if (hook_ok) { + if (!hook_ok && !g_install_hooks) { + record_agent_config_error(false, "Augment/Auggie", "hook_disable", cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); + } else if (hook_ok) { printf(" hooks: SessionStart + PostToolUse view coverage\n"); } } @@ -10081,11 +10296,20 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents #else bool windows = false; #endif - if (cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows) != CLI_OK) { - record_agent_config_error(false, "Qwen Code", "lifecycle_hook_install", cp); + int hook_result = g_install_hooks + ? cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows) + : cbm_remove_qwen_lifecycle_hooks(cp, binary_path, windows); + if (hook_result != CLI_OK) { + record_agent_config_error( + false, "Qwen Code", g_install_hooks ? "lifecycle_hook_install" : "hook_disable", + cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(" hooks: SessionStart + SubagentStart + PostToolUse ReadFile\n"); } + } else if (!g_install_hooks) { + print_hooks_skipped(); } qwen_done:; } @@ -10123,6 +10347,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents if (hook_supported) { plan_record("Factory Droid", "hook", hp); } + } else if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hp) && + cbm_remove_factory_hooks(hp, binary_path) != CLI_OK) { + record_agent_config_error(false, "Factory Droid", "hook_disable", hp); + } + print_hooks_skipped(); } else if (!hook_supported) { printf(" hooks: withheld on Windows (vendor documents Bash only)\n"); } else { @@ -10249,6 +10479,21 @@ static int cbm_install_agent_configs_in_scope(const char *home, const char *bina bool inherit_claude_session = agents.claude_code && !dry_run && cbm_has_complete_claude_session_hooks(home, binary_path); install_agent_client_registry(home, binary_path, inherit_claude_session, force, dry_run); + if (cbm_installing_assets() && !g_install_hooks && !g_install_plan) { + /* Persisted hook opt-out: asset installs and updates also retire the + * CLI-owned hooks, through the same ownership-checked removal paths the + * explicit hook surface uses. User-owned hook entries stay. */ + printf("\nHooks disabled by install_hooks=false; removing CLI-owned hooks:\n"); + g_agent_install_surface = CBM_AGENT_INSTALL_HOOKS; + if (agents.claude_code) { + install_claude_code_config(home, binary_path, force, dry_run); + } + install_cli_agent_configs(&agents, home, binary_path, force, dry_run); + install_editor_agent_configs(&agents, home, binary_path, force, dry_run); + install_additional_agent_configs(&agents, home, binary_path, force, dry_run); + install_agent_client_registry(home, binary_path, inherit_claude_session, force, dry_run); + g_agent_install_surface = CBM_AGENT_INSTALL_ASSETS; + } return g_agent_install_errors == 0 ? CLI_OK : CLI_ERR; } @@ -10671,6 +10916,44 @@ static bool cli_binary_is_externally_managed(const char *self_path, bool self_pa return cli_external_manager_name(self_path) != NULL; } +static int cli_resolve_install_hooks(bool override_set, bool override_value) { + bool enabled = true; + if (!override_set) { + const char *cache_dir = cbm_resolve_cache_dir(); + char db_path[CLI_BUF_1K]; + int written = + cache_dir ? snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir) : CLI_ERR; + if (written <= 0 || (size_t)written >= sizeof(db_path)) { + (void)fprintf(stderr, "error: config database path is unavailable\n"); + return CLI_ERR; + } + if (cbm_file_exists(db_path)) { + cbm_config_t *cfg = cbm_config_open(cache_dir); + if (!cfg) { + (void)fprintf(stderr, "error: cannot read install_hooks preference\n"); + return CLI_ERR; + } + enabled = cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + } + } else { + enabled = override_value; + } + g_install_hooks = enabled; + return CLI_OK; +} + +static int cli_persist_install_hooks(void) { + const char *cache_dir = cbm_resolve_cache_dir(); + cbm_config_t *cfg = cache_dir ? cbm_config_open(cache_dir) : NULL; + if (!cfg) { + return CLI_ERR; + } + int result = cbm_config_set(cfg, CBM_CONFIG_INSTALL_HOOKS, g_install_hooks ? "true" : "false"); + cbm_config_close(cfg); + return result; +} + /* Build the agent.install.plan.v1 receipt (#388): a machine-readable list of * the config / instruction / skill / agent / hook files `install` WOULD write, produced by * running the real install dispatch in record-only mode (no mutation, no @@ -10838,6 +11121,7 @@ typedef struct { bool skip_binary; bool delete_indexes; bool skip_config; + bool persist_install_hooks; bool force; bool dry_run; } cli_install_activation_t; @@ -10893,6 +11177,13 @@ static int cli_install_activate(void *opaque) { } printf("Installed binary -> %s\n\n", activation->bin_target); } + if (!activation->dry_run && activation->persist_install_hooks && + cli_persist_install_hooks() != CLI_OK) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, "install_transaction_config_failure_finalize"); + (void)fprintf(stderr, "error: cannot persist install_hooks preference\n"); + return CLI_ACTIVATION_PARTIAL; + } /* Config and PATH refreshes are install mutations too. Keep them in this * callback so the startup lock covers the complete filesystem window, * including same-binary and non-force installs. */ @@ -10969,6 +11260,7 @@ int cbm_cmd_install(int argc, char **argv) { g_agent_install_surface = CBM_AGENT_INSTALL_ASSETS; g_client_selection = NULL; parse_auto_answer(argc, argv); + g_client_selection = NULL; bool dry_run = false; bool force = false; bool plan = false; @@ -10976,6 +11268,8 @@ int cbm_cmd_install(int argc, char **argv) { bool skip_config = false; bool skip_binary = false; bool force_binary = false; + bool hooks_flag = false; + bool no_hooks_flag = false; const char *requested_clients = NULL; const char *requested_bin_dir = NULL; for (int i = 0; i < argc; i++) { @@ -10983,9 +11277,12 @@ int cbm_cmd_install(int argc, char **argv) { printf( "Usage: codebase-memory-cli install [-y|-n] [--force] [--dry-run] [--plan]\n" " [--dir PATH] [--skip-config] [--skip-binary]\n" - " [--clients[=LIST]] [--reset-indexes]\n\n" + " [--clients[=LIST]] [--reset-indexes]\n" + " [--hooks|--no-hooks]\n\n" "Installs the codebase-memory-cli executable plus CLI-first skills and durable\n" - "instructions. Hooks are never installed by this command; use install-hooks.\n"); + "instructions. Hooks are never installed by this command; use install-hooks.\n" + "--no-hooks removes CLI-owned hooks and stores install_hooks=false so later\n" + "installs, updates, and install-hooks leave hooks off; --hooks clears it.\n"); return CLI_OK; } else if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; @@ -10997,6 +11294,10 @@ int cbm_cmd_install(int argc, char **argv) { reset_indexes = true; } else if (strcmp(argv[i], "--skip-config") == 0) { skip_config = true; + } else if (strcmp(argv[i], "--hooks") == 0) { + hooks_flag = true; + } else if (strcmp(argv[i], "--no-hooks") == 0) { + no_hooks_flag = true; } else if (strncmp(argv[i], "--clients=", SLEN("--clients=")) == 0) { requested_clients = argv[i] + SLEN("--clients="); if (!requested_clients[0]) { @@ -11034,11 +11335,19 @@ int cbm_cmd_install(int argc, char **argv) { } } + if (hooks_flag && no_hooks_flag) { + (void)fprintf(stderr, "error: --hooks and --no-hooks cannot be used together\n"); + return CLI_TRUE; + } + const char *home = cbm_get_home_dir(); if (!home) { (void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n"); return CLI_TRUE; } + if (cli_resolve_install_hooks(hooks_flag || no_hooks_flag, hooks_flag) != CLI_OK) { + return CLI_TRUE; + } char bin_dir[CLI_BUF_1K]; int bin_dir_length = requested_bin_dir @@ -11306,6 +11615,7 @@ int cbm_cmd_install(int argc, char **argv) { .copy_binary = do_copy, .delete_indexes = delete_indexes, .skip_config = skip_config, + .persist_install_hooks = hooks_flag || no_hooks_flag, .skip_binary = skip_binary, .force = force, .dry_run = dry_run, @@ -11400,6 +11710,17 @@ int cbm_cmd_install_hooks(int argc, char **argv) { return CLI_TRUE; } + /* A stored `install --no-hooks` opt-out wins over a later bare install-hooks: + * nothing is written until `install --hooks` clears it. */ + if (cli_resolve_install_hooks(false, true) != CLI_OK) { + return CLI_TRUE; + } + if (!g_install_hooks && !plan) { + printf("Hooks are disabled by install_hooks=false; nothing to install.\n" + "Run `codebase-memory-cli install --hooks` to clear the opt-out first.\n"); + return CLI_OK; + } + if (requested_clients) { cbm_detected_agents_t probe = cbm_detect_agents(home); if (!cli_clients_apply_selection(requested_clients, &probe)) { @@ -11465,58 +11786,7 @@ static void uninstall_claude_code(const char *home, const char *installed_binary record_agent_config_error(true, "Claude Code", "subagent_hook_uninstall", settings_path); } - - static const struct { - const char *name; - const char *legacy_name; - const char *prefix; - } hook_types[] = { - {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix}, - {CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY, - cmm_session_script_prefix}, - {CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, - cmm_subagent_script_prefix}, - }; -#ifdef _WIN32 - /* Released shapes only identify the extensionless Windows twin. */ - char released_gate[CMM_RELEASED_GATE_VARIANT_COUNT][CLI_BUF_8K]; - const char *gate_legacy[CMM_RELEASED_GATE_VARIANT_COUNT] = {0}; - size_t gate_legacy_count = - cbm_build_released_gate_candidates(installed_binary, released_gate, gate_legacy); - const char *const session_legacy[] = {cmm_legacy_mcp_session_script}; - const char *const subagent_legacy[] = {cmm_legacy_mcp_subagent_script}; -#endif - char hooks_dir[CLI_BUF_1K]; - snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); - for (size_t i = 0; i < sizeof(hook_types) / sizeof(hook_types[0]); i++) { - char expected[CLI_BUF_8K]; - char script_path[CLI_BUF_1K]; - int written = - snprintf(script_path, sizeof(script_path), "%s/%s", hooks_dir, hook_types[i].name); - if (written <= 0 || (size_t)written >= sizeof(script_path) || - cbm_build_current_hook_script(hook_types[i].prefix, installed_binary, expected, - sizeof(expected)) != CLI_OK || - cbm_remove_owned_hook_script(script_path, expected, NULL, 0U) < CLI_OK) { - record_agent_config_error(true, "Claude Code", "hook_script_uninstall", - hook_types[i].name); - continue; - } -#ifdef _WIN32 - /* Keyed by name so reordering hook_types cannot mismatch lists. */ - bool is_gate = strcmp(hook_types[i].legacy_name, CMM_HOOK_GATE_SCRIPT_LEGACY) == 0; - bool is_session = - strcmp(hook_types[i].legacy_name, CMM_SESSION_REMINDER_SCRIPT_LEGACY) == 0; - const char *const *legacy = is_gate ? gate_legacy - : is_session ? session_legacy - : subagent_legacy; - size_t legacy_count = is_gate ? gate_legacy_count : 1U; - if (cbm_remove_owned_legacy_hook_script(hooks_dir, hook_types[i].legacy_name, expected, - legacy, legacy_count) != CLI_OK) { - record_agent_config_error(true, "Claude Code", "legacy_hook_script_uninstall", - hook_types[i].legacy_name); - } -#endif - } + remove_claude_owned_hook_scripts(config_dir, installed_binary, true); } printf(" removed CLI-owned PreToolUse + SessionStart + SubagentStart hooks\n"); } @@ -12336,30 +12606,7 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con if (cbm_remove_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { record_agent_config_error(true, "Augment/Auggie", "coverage_hook_uninstall", cp); } - char session_script[CLI_BUF_8K]; - if (cbm_build_augment_session_script(binary_path, session_script, - sizeof(session_script)) != CLI_OK) { - record_agent_config_error(true, "Augment/Auggie", "session_script_build", - session_hp); - } else { - int script_rc = cbm_text_remove_owned_document(session_hp, session_script); - if (script_rc < 0) { - record_agent_config_error(true, "Augment/Auggie", "session_script_uninstall", - session_hp); - } - } - char coverage_script[CLI_BUF_8K]; - if (cbm_build_augment_coverage_script(binary_path, coverage_script, - sizeof(coverage_script)) != CLI_OK) { - record_agent_config_error(true, "Augment/Auggie", "coverage_script_build", - coverage_hp); - } else { - int script_rc = cbm_text_remove_owned_document(coverage_hp, coverage_script); - if (script_rc < 0) { - record_agent_config_error(true, "Augment/Auggie", "coverage_script_uninstall", - coverage_hp); - } - } + remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, true); } printf(" removed SessionStart + PostToolUse hooks + dedicated subagent\n"); } @@ -13433,6 +13680,9 @@ int cbm_cmd_update(int argc, char **argv) { "v0.10.0 there is one build per platform and it always includes the " "graph UI.\n"); } + if (cli_resolve_install_hooks(false, true) != CLI_OK) { + return CLI_TRUE; + } /* #1566: we cannot update a binary a package manager owns, and pretending * otherwise is the dishonest-success pattern this project keeps fixing: diff --git a/src/cli/cli.h b/src/cli/cli.h index 25ad69e15b..cef47c01e9 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -493,6 +493,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_WATCH "auto_watch" #define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_DOC_CANDIDATES "doc_candidates" +#define CBM_CONFIG_INSTALL_HOOKS "install_hooks" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" #define CBM_CONFIG_WATCH_NON_GIT "watch_non_git" diff --git a/src/main.c b/src/main.c index 858822b788..cd97dccfb4 100644 --- a/src/main.c +++ b/src/main.c @@ -1247,7 +1247,7 @@ static void print_help(void) { printf("\nAdministration:\n"); printf(" codebase-memory-cli allow-root [--approve-sensitive | --approve-broad] \n"); printf(" codebase-memory-cli install [-y|-n] [--force] [--dry-run] [--dir=] " - "[--skip-config]\n"); + "[--skip-config] [--hooks|--no-hooks]\n"); printf(" [--clients=] Run " "'install --clients' to list tokens\n"); printf(" codebase-memory-cli install-hooks [--dry-run] [--plan] [--clients=]\n"); diff --git a/src/operations/command_runner.c b/src/operations/command_runner.c index df305bf400..55314f1aef 100644 --- a/src/operations/command_runner.c +++ b/src/operations/command_runner.c @@ -241,6 +241,100 @@ cbm_operation_command_cause_t cbm_operation_run_shell_command_bounded( : CBM_OPERATION_COMMAND_CONTAINED_FAILURE; } +#ifndef _WIN32 +static void operation_argv_stage_poll_latches(const cbm_operation_runtime_t *runtime, + const char *measured_path, uint64_t output_base, + size_t output_limit, uint64_t deadline_ms, + bool deadline_enabled, bool *cancellation_latched, + bool *deadline_latched, bool *output_limit_latched) { + *cancellation_latched = *cancellation_latched || cbm_operation_runtime_cancelled(runtime); + *deadline_latched = *deadline_latched || (deadline_enabled && cbm_now_ms() >= deadline_ms); + if (!*output_limit_latched && output_limit > 0 && measured_path && measured_path[0]) { + int64_t measured_size = cbm_file_size(measured_path); + *output_limit_latched = + measured_size > 0 && output_base + (uint64_t)measured_size > output_limit; + } +} + +cbm_operation_command_cause_t cbm_operation_run_argv_stage_bounded( + const cbm_operation_runtime_t *runtime, const char *bin, const char *const *argv, + const char *const *env_overrides, const char *stdout_capture, uint64_t output_base, + size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one, + bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched, + cbm_proc_result_t *result_out) { + if (!bin || !argv || !result_out || !cancellation_latched || !deadline_latched || + !output_limit_latched) { + return CBM_OPERATION_COMMAND_FAILURE; + } + memset(result_out, 0, sizeof(*result_out)); + operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit, + deadline_ms, deadline_enabled, cancellation_latched, + deadline_latched, output_limit_latched); + if (*cancellation_latched || *deadline_latched || *output_limit_latched) { + result_out->tree_quiesced = true; /* no child was spawned */ + if (*cancellation_latched) { + return CBM_OPERATION_COMMAND_CANCELLED; + } + return *deadline_latched ? CBM_OPERATION_COMMAND_DEADLINE + : CBM_OPERATION_COMMAND_OUTPUT_LIMIT; + } + cbm_proc_opts_t options = { + .bin = bin, + .argv = argv, + .env_overrides = env_overrides, + .log_file = NULL, + .quiet_timeout_ms = 0, + .cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS, + .delete_log_on_exit = false, + .strip_git_repo_env = false, + .stdout_file = stdout_capture, + }; + cbm_subprocess_t *process = NULL; + if (cbm_subprocess_spawn(&options, &process) != 0) { + result_out->tree_quiesced = true; + return CBM_OPERATION_COMMAND_FAILURE; + } + cbm_proc_poll_t state; + for (;;) { + operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit, + deadline_ms, deadline_enabled, cancellation_latched, + deadline_latched, output_limit_latched); + if (*cancellation_latched || *deadline_latched || *output_limit_latched) { + (void)cbm_subprocess_request_cancel(process); + } + state = cbm_subprocess_poll(process, result_out); + if (state != CBM_PROC_POLL_RUNNING) { + break; + } + cbm_usleep(OPERATION_COMMAND_POLL_US); + } + bool contained = state == CBM_PROC_POLL_TERMINAL && result_out->tree_quiesced && + !result_out->supervision_failed; + cbm_subprocess_destroy(process); + operation_argv_stage_poll_latches(runtime, stdout_capture, output_base, output_limit, + deadline_ms, deadline_enabled, cancellation_latched, + deadline_latched, output_limit_latched); + if (!contained) { + return CBM_OPERATION_COMMAND_SUPERVISION_FAILURE; + } + if (*cancellation_latched) { + return CBM_OPERATION_COMMAND_CANCELLED; + } + if (*deadline_latched) { + return CBM_OPERATION_COMMAND_DEADLINE; + } + if (*output_limit_latched) { + return CBM_OPERATION_COMMAND_OUTPUT_LIMIT; + } + if (tolerate_exit_one && result_out->outcome == CBM_PROC_EXIT_NONZERO && + result_out->exit_code == 1) { + return CBM_OPERATION_COMMAND_SUCCESS; /* ordinary grep-style no-match */ + } + return result_out->outcome == CBM_PROC_CLEAN ? CBM_OPERATION_COMMAND_SUCCESS + : CBM_OPERATION_COMMAND_CONTAINED_FAILURE; +} +#endif + int cbm_operation_run_shell_command(const cbm_operation_runtime_t *runtime, const char *command, char output_path[CBM_SZ_2K], cbm_proc_result_t *result_out) { cbm_operation_command_cause_t cause = cbm_operation_run_shell_command_bounded( diff --git a/src/operations/command_runner.h b/src/operations/command_runner.h index a92229afac..2519afa393 100644 --- a/src/operations/command_runner.h +++ b/src/operations/command_runner.h @@ -26,6 +26,19 @@ cbm_operation_command_cause_t cbm_operation_run_shell_command_bounded( size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool deadline_latched, bool exit_one_is_no_match, cbm_proc_result_t *result_out); +#ifndef _WIN32 +/* POSIX-only: supervised run of one argv stage (no shell). Same latch and + * containment contract as the shell runner. The measured stdout_capture file's + * size plus output_base counts toward output_limit so chained stages share one + * budget. tolerate_exit_one maps a plain exit-1 (grep's no-match) to SUCCESS. */ +cbm_operation_command_cause_t cbm_operation_run_argv_stage_bounded( + const cbm_operation_runtime_t *runtime, const char *bin, const char *const *argv, + const char *const *env_overrides, const char *stdout_capture, uint64_t output_base, + size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one, + bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched, + cbm_proc_result_t *result_out); +#endif + /* Compatibility shape used by detect-changes style callers: any contained * terminal process tree is considered wrapper success and the caller inspects * result_out for exit/cancellation classification. */ diff --git a/src/operations/source_search.c b/src/operations/source_search.c index 416c3730a9..e573b645a8 100644 --- a/src/operations/source_search.c +++ b/src/operations/source_search.c @@ -2294,6 +2294,51 @@ int cbm_source_search_scan_for_test(const cbm_operation_runtime_t *runtime, } #endif +#ifndef _WIN32 +/* The unscoped scan with no shell anywhere: find writes the NUL-delimited + * candidate list and LC_ALL=C sort orders it in place, both as supervised argv + * stages, then the list feeds the same in-process scanner scoped projects use. + * The search root and file pattern reach find as plain arguments, never as + * shell text. find/sort failures fail closed; an empty list is a successful + * empty scan. */ +static cbm_operation_command_cause_t search_run_unscoped_argv( + const cbm_operation_runtime_t *runtime, const char *root_path, const char *file_pattern, + const char *pattern, bool use_regex, const char *filelist, FILE *out, size_t output_limit, + uint64_t deadline_ms, bool deadline_latched, cbm_proc_result_t *result_out) { + bool cancellation_latched = false; + bool output_limit_latched = false; + enum { FIND_ARGV_MAX = 9 }; + const char *find_argv[FIND_ARGV_MAX]; + int fa = 0; + find_argv[fa++] = "find"; + find_argv[fa++] = root_path; + find_argv[fa++] = "-type"; + find_argv[fa++] = "f"; + if (file_pattern) { + find_argv[fa++] = "-name"; + find_argv[fa++] = file_pattern; + } + find_argv[fa++] = "-print0"; + find_argv[fa] = NULL; + cbm_operation_command_cause_t cause = cbm_operation_run_argv_stage_bounded( + runtime, "find", find_argv, NULL, filelist, 0, 0, deadline_ms, true, false, + &cancellation_latched, &deadline_latched, &output_limit_latched, result_out); + if (cause != CBM_OPERATION_COMMAND_SUCCESS) { + return cause; + } + const char *sort_argv[] = {"sort", "-z", "-o", filelist, filelist, NULL}; + const char *sort_env[] = {"LC_ALL=C", NULL}; + cause = cbm_operation_run_argv_stage_bounded( + runtime, "sort", sort_argv, sort_env, NULL, 0, 0, deadline_ms, true, false, + &cancellation_latched, &deadline_latched, &output_limit_latched, result_out); + if (cause != CBM_OPERATION_COMMAND_SUCCESS) { + return cause; + } + return search_scan_in_process(runtime, filelist, pattern, use_regex, out, output_limit, + deadline_ms); +} +#endif + /* Open the scratch directory, write `pattern` to the grep -f file, and leave the * file list open for write_scoped_filelist. Returns true on success; on failure * everything already created is removed before returning. */ @@ -2791,13 +2836,20 @@ cbm_operation_result_t cbm_source_search_operation_execute(const char *args, * command overrides, and Windows patterns that need PowerShell's * post-filter semantics. */ bool scan_in_process = scoped && !(runtime && runtime->command_override); + bool scan_argv = false; +#ifndef _WIN32 + /* POSIX unscoped scans run as find/sort argv stages feeding the + * in-process scanner: no shell parses the root or file pattern. The + * command override keeps the shell runner for tests. */ + scan_argv = !scoped && !(runtime && runtime->command_override); +#endif #ifdef _WIN32 scan_in_process = scan_in_process && (!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern)); #endif cbm_operation_command_cause_t scan_cause; - if (scan_in_process) { + if (scan_in_process || scan_argv) { FILE *scan_out = NULL; if (scan_cancellation_latched || cbm_operation_runtime_cancelled(runtime)) { scan_cause = CBM_OPERATION_COMMAND_CANCELLED; @@ -2806,6 +2858,12 @@ cbm_operation_result_t cbm_source_search_operation_execute(const char *args, } else if (!(scan_out = search_scratch_file(scratch.dir, "scan", output_path, sizeof(output_path)))) { scan_cause = CBM_OPERATION_COMMAND_FAILURE; + } else if (scan_argv) { +#ifndef _WIN32 + scan_cause = search_run_unscoped_argv( + runtime, root_path, file_pattern, pattern, use_regex, filelist, scan_out, + scan_output_limit, scan_deadline_ms, scan_deadline_latched, &scan_result); +#endif } else { scan_cause = search_scan_in_process(runtime, filelist, pattern, use_regex, scan_out, scan_output_limit, scan_deadline_ms); diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 7fd3905d33..7498b97e21 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -636,6 +636,18 @@ static inline bool cbm_pipeline_kotlin_external_target(CBMLanguage lang, const c strncmp(callee_qn, "javax.", strlen("javax.")) == 0; } +/* Java: the LSP typed the receiver as a type OUTSIDE the project (a JDK or + * library class, `lsp_external`), so the callee is `java.util.Optional.map` + * and no project method. Without this gate the tail/unique-leaf node policy + * and then the textual registry bound such a call to whatever project method + * shares the leaf (`Optional.map` -> `Filters.map`). Such a call gets no + * CALLS edge, as #2053 does for Rust. Pure; unit-tested. */ +static inline bool cbm_pipeline_java_external_target(CBMLanguage lang, const CBMResolvedCall *lsp) { + return lang == CBM_LANG_JAVA && lsp && lsp->strategy && + strcmp(lsp->strategy, "lsp_external") == 0 && lsp->callee_qn && + strchr(lsp->callee_qn, '.') != NULL; +} + /* Rust (#2053): true when the Rust LSP positively resolved a call to a * registered symbol that lives OUTSIDE the project — a std/core/alloc seed * method (`root.join()` on `root: &Path` -> std.path.Path.join) or a seeded diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index c60f92555f..3d74ace17d 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -996,21 +996,25 @@ static const cbm_gbuf_node_t *calls_field_target(cbm_pipeline_ctx_t *ctx, const } static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, - const CBMResolvedCall *lsp, bool allow_tail, CBMLanguage lang, - const CBMImportArray *imports, const cbm_gbuf_node_t *source_node, - const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count, const char *route_mount, - CBMCallEvidence *evidence) { + const CBMResolvedCall *lsp, bool allow_tail, bool java_external, + CBMLanguage lang, const CBMImportArray *imports, + const cbm_gbuf_node_t *source_node, const char *module_qn, + const char **imp_keys, const char **imp_vals, int imp_count, + const char *route_mount, CBMCallEvidence *evidence) { if (!lsp) { return false; } bool exact_external_target = call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); + /* A Java call on a JDK/library type binds its exact QN only: no tail + * match onto a same-named project method. MUST match pass_parallel.c. */ const cbm_gbuf_node_t *target_node = - exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name, - lsp->callee_qn, allow_tail) - : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, - lsp->callee_qn, allow_tail); + java_external ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name, + lsp->callee_qn, false) + : exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name, + lsp->callee_qn, allow_tail) + : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, + lsp->callee_qn, allow_tail); if (!target_node || source_node->id == target_node->id) { return false; } @@ -1038,15 +1042,16 @@ static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, return true; } -/* Vendor receivers and cross-language name guesses cannot bind project calls. - * Keep the registry policy shared with the parallel resolver. */ +/* Vendor receivers, Java library receivers and cross-language name guesses + * cannot bind project calls. Keep the registry policy shared with the + * parallel resolver. */ static cbm_resolution_t calls_registry_resolution(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, CBMLanguage lang, bool rust_external, - bool php_vendor_call) { + bool php_vendor_call, bool java_external) { cbm_resolution_t res = {0}; - if (!rust_external && !php_vendor_call) { + if (!rust_external && !php_vendor_call && !java_external) { /* Python LEGB fallback first (MUST match pass_parallel.c). */ res = cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys, @@ -1114,8 +1119,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * Service/route classification below still runs. Mirrors pass_parallel.c. */ bool php_vendor_call = cbm_pipeline_php_vendor_call(php_vendor, result, call) || cbm_pipeline_lsp_external_receiver(lsp); - if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, lang, imports, source_node, module_qn, - imp_keys, imp_vals, imp_count, route_mount, evidence)) { + /* A Java call on a JDK/library type is never bound to a project method + * found by name (no tail match, no registry guess). MUST match + * pass_parallel.c. */ + bool java_external = cbm_pipeline_java_external_target(lang, lsp); + if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, java_external, lang, imports, source_node, + module_qn, imp_keys, imp_vals, imp_count, route_mount, evidence)) { return SKIP_ONE; } @@ -1177,8 +1186,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * classify the call. MUST match pass_parallel.c. */ bool rust_external = lsp && cbm_pipeline_rust_external_target( lang, lsp->strategy, lsp->callee_qn, ctx->project_name); - cbm_resolution_t res = calls_registry_resolution( - ctx, call, module_qn, imp_keys, imp_vals, imp_count, lang, rust_external, php_vendor_call); + cbm_resolution_t res = + calls_registry_resolution(ctx, call, module_qn, imp_keys, imp_vals, imp_count, lang, + rust_external, php_vendor_call, java_external); /* `p->open(fd)`: the registry does not split a callee on the arrow, and a * member is not a name it could resolve anyway. The object's type decides, * exactly as for the dot call handled further down. MUST match diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index a0a769fd12..a5b47fdcb9 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2597,6 +2597,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * cross_registries.c = cbm_c_build_cross_registry(xa, all_defs, def_count); cross_registries.cs = cbm_cs_build_cross_registry(xa, all_defs, def_count); cross_registries.ts = cbm_ts_build_cross_registry(xa, all_defs, def_count); + cross_registries.java = cbm_java_build_cross_registry(xa, all_defs, def_count); } int processed = 0; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 72e2a2e237..7ca5db056c 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3032,13 +3032,18 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB memory_order_relaxed); _rc_t0 = extract_now_ns(); const cbm_gbuf_node_t *lsp_target = NULL; + /* Mirrors pass_calls.c: a Java call on a JDK/library type is never + * bound to a project method found by name. */ + bool java_external = cbm_pipeline_java_external_target(lang, lsp); if (lsp) { /* Canonicalise to the gbuf node's QN so res.qualified_name matches * the gbuf even when the cross-file fallback had to prefix the * project name. */ bool exact_external_target = call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); - lsp_target = exact_external_target + lsp_target = java_external ? cbm_pipeline_lsp_target_node_strict( + rc->main_gbuf, rc->project_name, lsp->callee_qn, false) + : exact_external_target ? cbm_pipeline_lsp_target_node_strict(rc->main_gbuf, rc->project_name, lsp->callee_qn, allow_tail) : cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name, @@ -3075,7 +3080,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool rust_external = lsp && cbm_pipeline_rust_external_target( lang, lsp->strategy, lsp->callee_qn, rc->project_name); if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution && - !rust_external && !php_vendor_call) { + !rust_external && !php_vendor_call && !java_external) { /* Python LEGB fallback first (MUST match pass_calls.c). */ res = cbm_registry_resolve_call_scoped(rc->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count, lang, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 4e96e732af..3390ecde2e 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -379,10 +379,37 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, const char *rel_dir, int depth, char **excluded_dirs, int excluded_count); +/* Control files are the only regular files the walk records. */ +static bool semantic_manifest_control_name(const char *rel_dir, const char *name) { + bool root_control = (!rel_dir || !rel_dir[0]) && (strcmp(name, ".cbmignore") == 0 || + strcmp(name, ".codebase-memory.json") == 0); + /* A build context's ignore file (or BuildKit's .dockerignore) + * shapes which files an image holds (pass_docker.c image_ignore), and + * discovery indexes neither. */ + static const char DOCKERIGNORE[] = ".dockerignore"; + size_t nl = strlen(name); + bool dockerignore = nl >= sizeof(DOCKERIGNORE) - SKIP_ONE && + strcmp(name + nl - (sizeof(DOCKERIGNORE) - SKIP_ONE), DOCKERIGNORE) == 0; + return strcmp(name, ".gitignore") == 0 || root_control || + semantic_manifest_package_control(name) || dockerignore; +} + +/* True when the directory listing alone shows that the entry is not a + * directory. A Windows listing always carries the attributes; on POSIX a + * d_type of DT_UNKNOWN (0), which readdir could not resolve, proves nothing. */ +static bool semantic_manifest_listed_non_directory(const cbm_dirent_t *entry) { +#ifdef _WIN32 + return !entry->is_dir; +#else + return entry->d_type != 0 && !entry->is_dir; +#endif +} + static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder, const char *project, const char *abs_dir, const char *rel_dir, int depth, char **excluded_dirs, - int excluded_count, const char *name) { + int excluded_count, const cbm_dirent_t *entry) { + const char *name = entry->name; if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) return 0; char abs_path[CBM_SZ_4K], rel_path[CBM_SZ_4K]; @@ -392,6 +419,15 @@ static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder, if (abs_n < 0 || (size_t)abs_n >= sizeof(abs_path) || rel_n < 0 || (size_t)rel_n >= sizeof(rel_path)) return CBM_NOT_FOUND; + /* Only directories and control files contribute, so an entry the listing + * already shows to be neither is skipped without reading its metadata. + * That read can fail on such an entry: on Windows the plain path of a file + * named nul names the NUL device (#2646), and the plain path of a name + * ending in a dot or a space does not reach the file. */ + bool control = semantic_manifest_control_name(rel_dir, name); + if (!control && semantic_manifest_listed_non_directory(entry)) { + return 0; + } cbm_path_info_t info; if (cbm_path_info_utf8(abs_path, &info) != 0) return CBM_NOT_FOUND; @@ -404,17 +440,6 @@ static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder, return semantic_manifest_walk_controls(builder, project, abs_path, rel_path, depth + 1, excluded_dirs, excluded_count); } - bool root_control = (!rel_dir || !rel_dir[0]) && (strcmp(name, ".cbmignore") == 0 || - strcmp(name, ".codebase-memory.json") == 0); - /* A build context's ignore file (or BuildKit's .dockerignore) - * shapes which files an image holds (pass_docker.c image_ignore), and - * discovery indexes neither. */ - static const char DOCKERIGNORE[] = ".dockerignore"; - size_t nl = strlen(name); - bool dockerignore = nl >= sizeof(DOCKERIGNORE) - SKIP_ONE && - strcmp(name + nl - (sizeof(DOCKERIGNORE) - SKIP_ONE), DOCKERIGNORE) == 0; - bool control = strcmp(name, ".gitignore") == 0 || root_control || - semantic_manifest_package_control(name) || dockerignore; return info.is_regular && control ? semantic_manifest_add(builder, project, rel_path, abs_path) : 0; } @@ -439,7 +464,7 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, rc = cbm_pipeline_frozen_checkpoint(builder->frozen); if (rc == 0) rc = semantic_manifest_control_entry(builder, project, abs_dir, rel_dir, depth, - excluded_dirs, excluded_count, entry->name); + excluded_dirs, excluded_count, entry); } cbm_closedir(dir); return rc; diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 73bdf2d5e5..5970613808 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -154,6 +154,15 @@ struct cbm_watcher { * daemon log names the stuck project instead of only repeating the warning. */ #define INDEX_FAIL_SUSTAINED 10 +/* Success cooldown (#2835): after a successful re-index the next poll waits + * INDEX_COOLDOWN_FACTOR x the index's own duration, counted from when the + * index finished, so short rebuilds spend at most ~1/(K+1) of wall time + * indexing one project. Sub-second indexes stay at the plain cadence. The + * cooldown is capped at INDEX_FAIL_CEILING_MS (5 min) so that, under + * continuous writes, a project never falls more than one rebuild plus five + * minutes behind. */ +#define INDEX_COOLDOWN_FACTOR 3 + /* Stale-root pruning (#286): a watched project whose root directory stays * missing is pruned — its cached DB is deleted and the watch entry removed. * Deletion is destructive (the DB can hold user-authored data such as the @@ -175,7 +184,17 @@ struct cbm_watcher { /* ── Time helper ────────────────────────────────────────────────── */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +/* Test seam: nonzero freezes the watcher's scheduling clock at this value. */ +static int64_t test_clock_ns = 0; +#endif + static int64_t now_ns(void) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (test_clock_ns != 0) { + return test_clock_ns; + } +#endif struct timespec ts; cbm_clock_gettime(CLOCK_MONOTONIC, &ts); return ((int64_t)ts.tv_sec * NS_PER_SEC) + ts.tv_nsec; @@ -204,6 +223,20 @@ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures) { return (int)(delay < interval_ms ? interval_ms : delay); } +int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms) { + if (interval_ms < 0) { + interval_ms = 0; + } + if (index_elapsed_ms <= 0) { + return interval_ms; + } + /* Divide instead of multiply so a huge elapsed cannot overflow. */ + int64_t cooldown = index_elapsed_ms > INDEX_FAIL_CEILING_MS / INDEX_COOLDOWN_FACTOR + ? INDEX_FAIL_CEILING_MS + : index_elapsed_ms * INDEX_COOLDOWN_FACTOR; + return (int)(cooldown > interval_ms ? cooldown : interval_ms); +} + int cbm_watcher_poll_interval_ms(int file_count) { int ms = POLL_BASE_MS + ((file_count / POLL_FILE_STEP) * CBM_MSEC_PER_SEC); if (ms > POLL_MAX_MS) { @@ -1693,12 +1726,22 @@ static void poll_project(const char *key, void *val, void *ud) { } cbm_log_info("watcher.changed", "project", s->project_name, "strategy", s->is_git ? "git" : "tree"); + int delay_ms = -1; /* < 0: use the failure-backoff cadence below */ + int64_t anchor_ns = ctx->now; /* success re-anchors to when the index finished */ if (ctx->w->index_fn) { + int64_t index_start = now_ns(); int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data); if (rc == 0) { ctx->reindexed++; s->index_failures = 0; commit_baselines(ctx->w, s); + anchor_ns = now_ns(); + delay_ms = + cbm_watcher_success_delay_ms(s->interval_ms, (anchor_ns - index_start) / US_PER_MS); + if (delay_ms > s->interval_ms) { + cbm_log_info("watcher.cooldown", "project", s->project_name, "delay_ms", + itoa_buf(delay_ms)); + } } else if (rc > 0) { /* Busy-skip keeps the uncommitted baseline and any prior failure streak. */ cbm_log_info("watcher.index.retry", "project", s->project_name); @@ -1721,11 +1764,15 @@ static void poll_project(const char *key, void *val, void *ud) { } } - /* Failures back off; success and busy-skip keep the adaptive cadence. */ - int64_t after = now_ns(); - int64_t delay_ns = - (int64_t)cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures) * US_PER_MS; - s->next_poll_ns = after > INT64_MAX - delay_ns ? INT64_MAX : after + delay_ns; + /* Success pays back the index's cost (upstream #2840) anchored at index + * completion; failures back off and busy-skip keeps the adaptive cadence, + * both anchored after the index attempt returns. */ + if (delay_ms < 0) { + delay_ms = cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures); + anchor_ns = now_ns(); + } + int64_t delay_ns = (int64_t)delay_ms * US_PER_MS; + s->next_poll_ns = anchor_ns > INT64_MAX - delay_ns ? INT64_MAX : anchor_ns + delay_ns; } /* Callback to snapshot project state pointers into an array. */ @@ -1888,4 +1935,8 @@ int cbm_watcher_test_pending_free_count(cbm_watcher_t *w) { cbm_mutex_unlock(&w->projects_lock); return count; } + +void cbm_watcher_test_set_clock_ns(int64_t ns) { + test_clock_ns = ns; +} #endif diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index dbf7fb182f..c3dc65dad2 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -145,7 +145,13 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name); * otherwise unobservable — cbm_watcher_free frees the same list, so a poll * that stopped draining would neither crash nor leak. Test builds only. */ int cbm_watcher_test_pending_free_count(cbm_watcher_t *w); -/* Single-threaded test inspection of touch/backoff scheduling. */ +/* Test seam: freeze the watcher's monotonic scheduling clock at `ns` + * (nonzero), or restore the real clock (0). Process-global; single-threaded + * tests only. Lets a test advance time inside index_fn without sleeping. */ +void cbm_watcher_test_set_clock_ns(int64_t ns); + +/* Test seam: a project's scheduled next poll (monotonic ns), read under + * projects_lock; -1 when it is not watched. */ int64_t cbm_watcher_test_next_poll_ns(cbm_watcher_t *w, const char *project_name); #endif @@ -197,6 +203,15 @@ int cbm_watcher_poll_interval_ms(int file_count); * Negative inputs are treated as zero. */ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures); +/* Return the delay (ms) before the next poll after a SUCCESSFUL re-index that + * took `index_elapsed_ms`: max(interval_ms, INDEX_COOLDOWN_FACTOR (3) x + * index_elapsed_ms), with the cooldown term capped at INDEX_FAIL_CEILING_MS + * (5 min). The caller counts it from when the index finished, so short + * rebuilds spend at most ~1/4 of wall time indexing a project; sub-second + * indexes keep `interval_ms`. + * Never below `interval_ms`; overflow-safe; negative inputs act as zero. */ +int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms); + /* Classify a stat() errno observed on a watched project root: returns true * only for values that mean the root itself is gone (ENOENT, ENOTDIR) and * may count toward stale-root pruning (#286). Any other failure (EACCES, diff --git a/tests/test_cli.c b/tests/test_cli.c index 7065ce0496..c8f5d0e383 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2527,6 +2527,538 @@ TEST(cli_activation_commands_reject_malformed_and_unknown_flags) { PASS(); } +TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-install-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CLAUDE_CONFIG_DIR", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char claude_dir[640]; + char settings_path[768]; + char db_path[768]; + char gate_path[768]; + char session_path[768]; + char subagent_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", claude_dir); + snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir); +#ifdef _WIN32 + snprintf(gate_path, sizeof(gate_path), "%s/hooks/codebase-memory-cli-discovery-gate.cmd", + claude_dir); + snprintf(session_path, sizeof(session_path), + "%s/hooks/codebase-memory-cli-session-reminder.cmd", claude_dir); + snprintf(subagent_path, sizeof(subagent_path), + "%s/hooks/codebase-memory-cli-subagent-reminder.cmd", claude_dir); +#else + snprintf(gate_path, sizeof(gate_path), "%s/hooks/codebase-memory-cli-discovery-gate", + claude_dir); + snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-cli-session-reminder", + claude_dir); + snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/codebase-memory-cli-subagent-reminder", + claude_dir); +#endif + test_mkdirp(claude_dir); + write_test_file(settings_path, + "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":" + "\"command\",\"command\":\"/usr/bin/foreign-hook\"}]}]}}\n"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + cli_activation_fake_t fake = {.mutation_reserve_result = 1}; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *conflict_a[] = {"--hooks", "--no-hooks"}; + char *conflict_b[] = {"--no-hooks", "--hooks"}; + int conflict_a_rc = cli_test_cmd_install(2, conflict_a); + int conflict_b_rc = cli_test_cmd_install(2, conflict_b); + + /* Hooks are installed by install-hooks only; install never adds them. */ + char *hook_surface_args[] = {"--clients=claude"}; + int default_rc = cbm_cmd_install_hooks(1, hook_surface_args); + char *default_state = read_test_file_alloc(settings_path); + bool default_hook = default_state && strstr(default_state, "hook-augment") && + strstr(default_state, "/usr/bin/foreign-hook"); + bool default_scripts = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + free(default_state); + + char *bare_args[] = {"--skip-binary", "--clients=claude", "--yes"}; + int asset_rc = cli_test_cmd_install(3, bare_args); + bool asset_keeps_hooks = asset_rc == 0 && cbm_file_exists(gate_path) && + cbm_file_exists(session_path) && cbm_file_exists(subagent_path); + + char *no_hooks_plan_args[] = {"--plan", "--no-hooks"}; + int no_hooks_plan_rc = cli_test_cmd_install(2, no_hooks_plan_args); + char *disabled_plan = cbm_build_hook_install_plan_json(tmpdir, "/opt/codebase-memory-cli"); + yyjson_doc *disabled_doc = + disabled_plan ? yyjson_read(disabled_plan, strlen(disabled_plan), 0) : NULL; + yyjson_val *disabled_root = disabled_doc ? yyjson_doc_get_root(disabled_doc) : NULL; + yyjson_val *disabled_hooks = + disabled_root ? yyjson_obj_get(disabled_root, "hooks_planned") : NULL; + bool disabled_plan_ok = + disabled_hooks && yyjson_is_arr(disabled_hooks) && yyjson_arr_size(disabled_hooks) == 0U; + yyjson_doc_free(disabled_doc); + free(disabled_plan); + + char *no_hooks_dry_args[] = {"--dry-run", "--no-hooks", "--skip-binary", "--clients=claude", + "--yes"}; + int no_hooks_dry_rc = cli_test_cmd_install(5, no_hooks_dry_args); + struct stat db_status; + bool previews_read_only = stat(db_path, &db_status) != 0 && cbm_file_exists(gate_path) && + cbm_file_exists(session_path) && cbm_file_exists(subagent_path); + + char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=claude", "--yes"}; + int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + cbm_config_t *cfg = cbm_config_open(cache_dir); + bool stored_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + char *disabled_state = read_test_file_alloc(settings_path); + bool disabled_ok = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && + !strstr(disabled_state, "hook-augment"); + bool no_hooks_scripts_gone = !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) && + !cbm_file_exists(subagent_path); + free(disabled_state); + + int bare_disabled_rc = cli_test_cmd_install(3, bare_args); + disabled_state = read_test_file_alloc(settings_path); + bool bare_disabled = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && + !strstr(disabled_state, "hook-augment"); + bool bare_scripts_gone = !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) && + !cbm_file_exists(subagent_path); + free(disabled_state); + + /* The stored opt-out also wins over an explicit install-hooks. */ + int blocked_rc = cbm_cmd_install_hooks(1, hook_surface_args); + disabled_state = read_test_file_alloc(settings_path); + bool blocked_hooks = blocked_rc == 0 && disabled_state && + !strstr(disabled_state, "hook-augment") && !cbm_file_exists(gate_path) && + !cbm_file_exists(session_path) && !cbm_file_exists(subagent_path); + free(disabled_state); + + char *hooks_plan_args[] = {"--plan", "--hooks"}; + char *hooks_dry_args[] = {"--dry-run", "--hooks", "--skip-binary", "--clients=claude", "--yes"}; + int hooks_plan_rc = cli_test_cmd_install(2, hooks_plan_args); + int hooks_dry_rc = cli_test_cmd_install(5, hooks_dry_args); + cfg = cbm_config_open(cache_dir); + bool previews_retained_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + + char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=claude", "--yes"}; + int hooks_rc = cli_test_cmd_install(4, hooks_args); + cfg = cbm_config_open(cache_dir); + bool stored_true = cfg && cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, false); + cbm_config_close(cfg); + bool hooks_flag_adds_nothing = !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) && + !cbm_file_exists(subagent_path); + int restore_rc = cbm_cmd_install_hooks(1, hook_surface_args); + char *enabled_state = read_test_file_alloc(settings_path); + bool enabled_ok = restore_rc == 0 && enabled_state && strstr(enabled_state, "hook-augment") && + strstr(enabled_state, "/usr/bin/foreign-hook"); + bool hooks_scripts_back = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + free(enabled_state); + int bare_enabled_rc = cli_test_cmd_install(3, bare_args); + bool bare_scripts_enabled = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + + const char *foreign_session_script = +#ifdef _WIN32 + "@echo off\r\nC:\\tmp\\user-owned-session-hook.cmd\r\n"; +#else + "#!/bin/sh\n/usr/bin/user-owned-session-hook\n"; +#endif + write_test_file(session_path, foreign_session_script); + int foreign_no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + char *preserved_session = read_test_file_alloc(session_path); + bool foreign_session_preserved = foreign_no_hooks_rc == 0 && preserved_session && + strcmp(preserved_session, foreign_session_script) == 0 && + !cbm_file_exists(gate_path) && !cbm_file_exists(subagent_path); + free(preserved_session); + /* Leave the process-wide preference enabled for later tests. */ + int final_rc = cli_test_cmd_install(4, hooks_args); + + bool results_ok = + conflict_a_rc == 1 && conflict_b_rc == 1 && default_rc == 0 && default_hook && + default_scripts && asset_keeps_hooks && no_hooks_plan_rc == 0 && disabled_plan_ok && + no_hooks_dry_rc == 0 && previews_read_only && no_hooks_rc == 0 && stored_false && + disabled_ok && no_hooks_scripts_gone && bare_disabled_rc == 0 && bare_disabled && + bare_scripts_gone && blocked_hooks && hooks_plan_rc == 0 && hooks_dry_rc == 0 && + previews_retained_false && hooks_rc == 0 && stored_true && hooks_flag_adds_nothing && + enabled_ok && hooks_scripts_back && bare_enabled_rc == 0 && bare_scripts_enabled && + foreign_session_preserved && final_rc == 0 && fake.mutation_reserve_count > 0; + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(results_ok); + PASS(); +} + +TEST(cli_no_hooks_plan_covers_every_hook_installer_family) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-hook-families-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH", "XDG_CONFIG_HOME"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + const char *const dirs[] = {".claude", ".codex", ".gemini", + ".config/opencode", ".augment", ".openclaw/workspace", + ".hermes", ".qwen", ".copilot", + ".factory", ".qoder", ".kimi-code", + ".config/devin", ".gitlab/duo", "bin"}; + char path[768]; + for (size_t i = 0U; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]); + test_mkdirp(path); + } + snprintf(path, sizeof(path), "%s/.openclaw/openclaw.json", tmpdir); + write_test_file(path, "{}\n"); + snprintf(path, sizeof(path), "%s/.copilot/mcp-config.json", tmpdir); + write_test_file(path, "{}\n"); + /* #1180: a bare ~/.claude is not a Claude Code install; its settings.json is. */ + snprintf(path, sizeof(path), "%s/.claude/settings.json", tmpdir); + write_test_file(path, "{}\n"); + + const char *const commands[] = {"qodercli", "kimi", "duo", "devin", "gemini", + "copilot", "auggie", "hermes", "droid"}; + char bin_dir[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/bin", tmpdir); + for (size_t i = 0U; i < sizeof(commands) / sizeof(commands[0]); i++) { +#ifdef _WIN32 + snprintf(path, sizeof(path), "%s/%s.exe", bin_dir, commands[i]); +#else + snprintf(path, sizeof(path), "%s/%s", bin_dir, commands[i]); +#endif + write_test_file(path, "exit 0\n"); + chmod(path, 0700); + } + + char cache_dir[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", bin_dir, 1); + char *no_hooks_args[] = {"--plan", "--no-hooks"}; + int no_hooks_rc = cli_test_cmd_install(2, no_hooks_args); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + char *hook_plan = cbm_build_hook_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL; + yyjson_doc *hook_doc = hook_plan ? yyjson_read(hook_plan, strlen(hook_plan), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *hook_root = hook_doc ? yyjson_doc_get_root(hook_doc) : NULL; + yyjson_val *hooks = hook_root ? yyjson_obj_get(hook_root, "hooks_planned") : NULL; + yyjson_val *configs = hook_root ? yyjson_obj_get(hook_root, "config_files_planned") : NULL; + yyjson_val *instructions = root ? yyjson_obj_get(root, "instruction_files_planned") : NULL; + const char *const detected[] = {"claude-code", "codex", "gemini", "opencode", + "augment-auggie", "openclaw", "hermes", "qwen", + "copilot-cli", "factory-droid", "qoder", "kimi", + "gitlab-duo", "devin"}; + bool families = no_hooks_rc == 0 && hooks && yyjson_is_arr(hooks) && + yyjson_arr_size(hooks) == 0U && configs && yyjson_is_arr(configs) && + yyjson_arr_size(configs) == 0U && instructions && yyjson_is_arr(instructions) && + yyjson_arr_size(instructions) > 0U && plan && !strstr(plan, "cbm-augment.ts"); + for (size_t i = 0U; families && i < sizeof(detected) / sizeof(detected[0]); i++) { + families = test_json_string_array_contains(root, "agents_detected", detected[i]); + } + yyjson_doc_free(doc); + yyjson_doc_free(hook_doc); + free(plan); + free(hook_plan); + + char *hooks_args[] = {"--plan", "--hooks"}; + int hooks_rc = cli_test_cmd_install(2, hooks_args); + char *enabled_plan = cbm_build_hook_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *enabled_doc = + enabled_plan ? yyjson_read(enabled_plan, strlen(enabled_plan), 0) : NULL; + yyjson_val *enabled_root = enabled_doc ? yyjson_doc_get_root(enabled_doc) : NULL; + yyjson_val *enabled_hooks = enabled_root ? yyjson_obj_get(enabled_root, "hooks_planned") : NULL; + bool enabled_plan_ok = + enabled_hooks && yyjson_is_arr(enabled_hooks) && yyjson_arr_size(enabled_hooks) > 0U; + yyjson_doc_free(enabled_doc); + free(enabled_plan); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(families); + ASSERT_EQ(hooks_rc, 0); + ASSERT_TRUE(enabled_plan_ok); + PASS(); +} + +/* Counts the PreToolUse hooks in a Codex hooks.json: ours run hook-augment, the + * foreign one is matched by its exact command. */ +static void test_codex_pretool_hook_counts(const char *path, const char *foreign_command, + size_t *owned, size_t *foreign) { + *owned = 0U; + *foreign = 0U; + char *text = read_test_file_alloc(path); + yyjson_doc *doc = text ? yyjson_read(text, strlen(text), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *hooks = root ? yyjson_obj_get(root, "hooks") : NULL; + yyjson_val *entries = hooks ? yyjson_obj_get(hooks, "PreToolUse") : NULL; + size_t entry_index; + size_t entry_count; + yyjson_val *entry; + if (entries && yyjson_is_arr(entries)) { + yyjson_arr_foreach(entries, entry_index, entry_count, entry) { + yyjson_val *entry_hooks = yyjson_obj_get(entry, "hooks"); + size_t hook_index; + size_t hook_count; + yyjson_val *hook; + if (!entry_hooks || !yyjson_is_arr(entry_hooks)) { + continue; + } + yyjson_arr_foreach(entry_hooks, hook_index, hook_count, hook) { + const char *command = yyjson_get_str(yyjson_obj_get(hook, "command")); + if (command && strstr(command, "hook-augment")) { + (*owned)++; + } else if (command && strcmp(command, foreign_command) == 0) { + (*foreign)++; + } + } + } + } + yyjson_doc_free(doc); + free(text); +} + +/* The Codex PreToolUse Bash hook (#2719) follows the opt-out in both hook + * representations: with hooks disabled the hook surface removes ours from + * hooks.json and from the config.toml managed block, a repeat keeps it off, + * the user's own PreToolUse hook stays, and re-enabling restores ours. The + * opt-out is switched through `install --plan` (process state) because the + * Codex hook command needs a binary named like the product, which the test + * runner is not. */ +TEST(cli_no_hooks_removes_codex_pretool_hook) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-codex-no-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CODEX_HOME", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char codex_dir[640]; + char hooks_path[768]; + char config_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir); + snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); + test_mkdirp(codex_dir); + const char *binary_path = "/usr/local/bin/codebase-memory-cli"; + const char *foreign_command = "/usr/bin/foreign-shell-audit"; + write_test_file(hooks_path, + "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":" + "\"command\",\"command\":\"/usr/bin/foreign-shell-audit\"}]}]}}\n"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + char *enable_args[] = {"--plan", "--hooks", "--clients=codex"}; + char *disable_args[] = {"--plan", "--no-hooks", "--clients=codex"}; + + size_t owned = 0U; + size_t foreign = 0U; + int enable_rc = cli_test_cmd_install(3, enable_args); + int default_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + bool default_json = enable_rc == 0 && default_rc == 0 && owned == 1U && foreign == 1U; + + int disable_rc = cli_test_cmd_install(3, disable_args); + int no_hooks_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + char *json_state = read_test_file_alloc(hooks_path); + char *toml_state = read_test_file_alloc(config_path); + bool json_disabled = disable_rc == 0 && no_hooks_rc == 0 && owned == 0U && foreign == 1U && + json_state && !strstr(json_state, "hook-augment"); + bool json_toml_clean = !toml_state || !strstr(toml_state, "hook-augment"); + free(json_state); + free(toml_state); + + int bare_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + bool bare_disabled = bare_rc == 0 && owned == 0U && foreign == 1U; + + /* Without hooks.json the config.toml managed block carries the hooks. */ + (void)remove(hooks_path); + int toml_on_rc = cli_test_cmd_install(3, enable_args); + int toml_enable_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + toml_state = read_test_file_alloc(config_path); + bool toml_enabled = toml_on_rc == 0 && toml_enable_rc == 0 && toml_state && + test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U; + free(toml_state); + int toml_off_rc = cli_test_cmd_install(3, disable_args); + int toml_disable_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + toml_state = read_test_file_alloc(config_path); + bool toml_disabled = toml_off_rc == 0 && toml_disable_rc == 0 && toml_state && + !strstr(toml_state, "hooks.PreToolUse") && + !strstr(toml_state, "hook-augment"); + free(toml_state); + int restore_on_rc = cli_test_cmd_install(3, enable_args); + int restore_rc = cbm_install_agent_hooks_for_testing(tmpdir, binary_path, false, false); + toml_state = read_test_file_alloc(config_path); + bool restored = restore_on_rc == 0 && restore_rc == 0 && toml_state && + test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U; + free(toml_state); + + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(default_json); + ASSERT_TRUE(json_toml_clean); + ASSERT_TRUE(toml_enabled); + ASSERT_TRUE(toml_disabled); + ASSERT_TRUE(restored); + ASSERT_TRUE(json_disabled); + ASSERT_TRUE(bare_disabled); + PASS(); +} + +/* The Augment SessionStart and PostToolUse view scripts follow the persisted + * opt-out the way uninstall treats them: --no-hooks deletes both exact-owned + * scripts, a bare install keeps them off, --hooks followed by install-hooks + * restores them, and a script the user edited is kept. */ +TEST(cli_no_hooks_removes_augment_owned_scripts) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-augment-no-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char augment_dir[640]; + char settings_path[768]; + char session_path[768]; + char coverage_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(augment_dir, sizeof(augment_dir), "%s/.augment", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", augment_dir); +#ifdef _WIN32 + snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-cli-session.ps1", + augment_dir); + snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-cli-coverage.ps1", + augment_dir); +#else + snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-cli-session.sh", + augment_dir); + snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-cli-coverage.sh", + augment_dir); +#endif + test_mkdirp(augment_dir); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + cli_activation_fake_t fake = {.mutation_reserve_result = 1}; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *default_args[] = {"--skip-binary", "--clients=augment", "--yes"}; + char *hook_surface_args[] = {"--clients=augment"}; + int default_rc = cbm_cmd_install_hooks(1, hook_surface_args); + bool default_scripts = + default_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path); + + char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=augment", "--yes"}; + int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + bool scripts_removed = + no_hooks_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path); + char *settings = read_test_file_alloc(settings_path); + bool settings_clean = !settings || (!strstr(settings, "codebase-memory-cli-session") && + !strstr(settings, "codebase-memory-cli-coverage")); + free(settings); + + int bare_rc = cli_test_cmd_install(3, default_args); + bool bare_disabled = + bare_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path); + + char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=augment", "--yes"}; + int hooks_clear_rc = cli_test_cmd_install(4, hooks_args); + int hooks_rc = hooks_clear_rc == 0 ? cbm_cmd_install_hooks(1, hook_surface_args) : -1; + settings = read_test_file_alloc(settings_path); + bool restored = hooks_rc == 0 && cbm_file_exists(session_path) && + cbm_file_exists(coverage_path) && settings && + strstr(settings, "codebase-memory-cli-session") && + strstr(settings, "codebase-memory-cli-coverage"); + free(settings); + + /* A script the user edited is no longer the owned document: the opt-out + * keeps it, as uninstall does, and still deletes the untouched one. */ + char *owned_session = read_test_file_alloc(session_path); + char user_session[4096]; + snprintf(user_session, sizeof(user_session), "%s# local edit\n", + owned_session ? owned_session : ""); + free(owned_session); + write_test_file(session_path, user_session); + int user_rc = cli_test_cmd_install(4, no_hooks_args); + char *kept_session = read_test_file_alloc(session_path); + bool user_script_kept = user_rc == 0 && kept_session && + strcmp(kept_session, user_session) == 0 && + !cbm_file_exists(coverage_path); + free(kept_session); + + /* End on --hooks, as the Codex case does: the opt-out is process state, + * and later tests install without the command line. */ + (void)remove(session_path); + int final_clear_rc = cli_test_cmd_install(4, hooks_args); + int final_rc = final_clear_rc == 0 ? cbm_cmd_install_hooks(1, hook_surface_args) : -1; + bool final_restored = + final_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path); + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(default_scripts); + ASSERT_TRUE(settings_clean); + ASSERT_TRUE(restored); + ASSERT_TRUE(scripts_removed); + ASSERT_TRUE(bare_disabled); + ASSERT_TRUE(user_script_kept); + ASSERT_TRUE(final_restored); + PASS(); +} + TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX"); @@ -15535,18 +16067,32 @@ TEST(cli_config_cmd_get_prints_defaults_and_rejects_unknown_keys) { char *get_limit[] = {"get", "auto_index_limit"}; ASSERT_EQ(cli_config_cmd_capture(2, get_limit, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "50000\n"); + char *get_install_hooks[] = {"get", "install_hooks"}; + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "true\n"); + char *list[] = {"list"}; + ASSERT_EQ(cli_config_cmd_capture(1, list, out, sizeof(out)), 0); + ASSERT_NOT_NULL(strstr(out, "install_hooks")); /* Stored value round-trips. */ char *set_watch[] = {"set", "auto_watch", "false"}; ASSERT_EQ(cli_config_cmd_capture(3, set_watch, out, sizeof(out)), 0); ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "false\n"); + char *set_install_hooks[] = {"set", "install_hooks", "false"}; + ASSERT_EQ(cli_config_cmd_capture(3, set_install_hooks, out, sizeof(out)), 0); + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "false\n"); /* Reset returns the key to its default. */ char *reset_watch[] = {"reset", "auto_watch"}; ASSERT_EQ(cli_config_cmd_capture(2, reset_watch, out, sizeof(out)), 0); ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "true\n"); + char *reset_install_hooks[] = {"reset", "install_hooks"}; + ASSERT_EQ(cli_config_cmd_capture(2, reset_install_hooks, out, sizeof(out)), 0); + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "true\n"); /* Unknown keys are errors on every subcommand, with clean stdout. */ char *get_bogus[] = {"get", "totally_bogus_key"}; @@ -17607,6 +18153,10 @@ SUITE(cli) { RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary); RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely); RUN_TEST(cli_activation_commands_reject_malformed_and_unknown_flags); + RUN_TEST(cli_install_hooks_preference_persists_and_migrates_owned_state); + RUN_TEST(cli_no_hooks_plan_covers_every_hook_installer_family); + RUN_TEST(cli_no_hooks_removes_codex_pretool_hook); + RUN_TEST(cli_no_hooks_removes_augment_owned_scripts); RUN_TEST(cli_install_reset_deletion_waits_for_final_activation_guard); RUN_TEST(cli_remove_indexes_deletes_orphan_sqlite_sidecars_issue2054); RUN_TEST(cli_index_enumeration_preserves_internal_stores); diff --git a/tests/test_integration.c b/tests/test_integration.c index df4a2a337c..8dcc8184cc 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -2429,6 +2429,158 @@ TEST(port_source_search_drops_walker_hits_that_resolve_outside_root) { ASSERT_TRUE(total_counts_inside_only); PASS(); } + +/* #2634: the POSIX unscoped scan runs find/sort as argv stages with no shell, + * feeding the in-process scanner. Run one query against a project whose root + * has no indexed files, so the scan walks the tree. */ +typedef struct { + port_cache_fixture_t cache; + char root[CBM_SZ_512]; +} port_argv_fixture_t; + +static bool port_argv_fixture_begin(port_argv_fixture_t *fx, const char *project) { + memset(fx, 0, sizeof(*fx)); + if (!port_cache_begin(&fx->cache)) { + return false; + } + snprintf(fx->root, sizeof(fx->root), "%s/cbm-search-argv-XXXXXX", cbm_tmpdir()); + return cbm_mkdtemp(fx->root) != NULL && port_create_project(fx->cache.cache, project, fx->root); +} + +static void port_argv_fixture_end(port_argv_fixture_t *fx) { + if (fx->root[0]) { + th_rmtree(fx->root); + } + port_cache_end(&fx->cache); +} + +static bool port_argv_write(const port_argv_fixture_t *fx, const char *name, const char *content) { + char path[CBM_SZ_1K]; + int written = snprintf(path, sizeof(path), "%s/%s", fx->root, name); + return written > 0 && (size_t)written < sizeof(path) && th_write_file(path, content) == 0; +} + +TEST(port_source_search_unscoped_argv_scans_metachar_filenames_issue2634) { + port_argv_fixture_t fx; + ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-meta")); + ASSERT_TRUE(port_argv_write(&fx, "we'ird ;na`me$.go", "func HandleHostile2634() {}\n")); + ASSERT_TRUE(port_argv_write(&fx, "$(touch pwned).c", "int HandleHostile2634;\n")); + ASSERT_TRUE(port_argv_write(&fx, "-lead.c", "int HandleHostile2634;\n")); + ASSERT_TRUE(port_argv_write(&fx, "a|b&c.c", "int HandleHostile2634;\n")); + + char *resp = port_operation_execute( + "search_code", + "{\"pattern\":\"HandleHostile2634\",\"project\":\"argv-meta\",\"format\":\"json\"}", NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "we'ird ;na`me$.go")); + ASSERT_NOT_NULL(strstr(resp, "$(touch pwned).c")); + ASSERT_NOT_NULL(strstr(resp, "-lead.c")); + ASSERT_NOT_NULL(strstr(resp, "a|b&c.c")); + ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":4")); + free(resp); + + char pwned[CBM_SZ_1K]; + snprintf(pwned, sizeof(pwned), "%s/pwned", fx.root); + ASSERT_FALSE(access(pwned, F_OK) == 0); + ASSERT_FALSE(access("pwned", F_OK) == 0); + port_argv_fixture_end(&fx); + PASS(); +} + +TEST(port_source_search_unscoped_argv_no_match_is_success_issue2634) { + port_argv_fixture_t fx; + ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-nomatch")); + ASSERT_TRUE(port_argv_write(&fx, "handler.go", "func HandlePresent2634() {}\n")); + + char *resp = port_operation_execute( + "search_code", + "{\"pattern\":\"NoSuchSymbol2634\",\"project\":\"argv-nomatch\",\"format\":\"json\"}", + NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":0")); + ASSERT_NOT_NULL(strstr(resp, "\"total_relation\":\"eq\"")); + free(resp); + port_argv_fixture_end(&fx); + PASS(); +} + +TEST(port_source_search_unscoped_argv_honours_file_pattern_issue2634) { + port_argv_fixture_t fx; + ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-glob")); + ASSERT_TRUE(port_argv_write(&fx, "handler.py", "def handle_python2634():\n return 1\n")); + + char *resp = + port_operation_execute("search_code", + "{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\"," + "\"file_pattern\":\"*.go\",\"format\":\"json\"}", + NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "handler.py")); + ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":0")); + free(resp); + + resp = port_operation_execute( + "search_code", + "{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\",\"format\":\"json\"}", NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "handler.py")); + ASSERT_NOT_NULL(strstr(resp, "\"total_grep_matches\":1")); + free(resp); + + /* A file_pattern carrying shell syntax is rejected before any process runs. */ + resp = port_operation_execute("search_code", + "{\"pattern\":\"handle_python2634\",\"project\":\"argv-glob\"," + "\"file_pattern\":\"*.py;touch pwned\"}", + NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "invalid characters")); + free(resp); + port_argv_fixture_end(&fx); + PASS(); +} + +static bool port_argv_always_cancelled(void *context) { + (void)context; + return true; +} + +TEST(port_source_search_unscoped_argv_cancel_deadline_and_output_limit) { + port_argv_fixture_t fx; + ASSERT_TRUE(port_argv_fixture_begin(&fx, "argv-bounds")); + ASSERT_TRUE(port_argv_write(&fx, "a.go", "func Bound2634() {}\nfunc Bound2634b() {}\n")); + ASSERT_TRUE(port_argv_write(&fx, "b.go", "func Bound2634c() {}\n")); + static const char args[] = "{\"pattern\":\"Bound2634\",\"project\":\"argv-bounds\"}"; + + cbm_operation_runtime_t cancel = {0}; + cancel.cancelled = port_argv_always_cancelled; + char *resp = port_operation_execute("search_code", args, &cancel); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "cancelled")); + free(resp); + + cbm_operation_runtime_t deadline = {0}; + deadline.command_timeout_override_set = true; + deadline.command_timeout_override_ms = 0; + resp = port_operation_execute("search_code", args, &deadline); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "request_timeout")); + free(resp); + + cbm_operation_runtime_t limit = {0}; + limit.command_output_limit_override = 16; + resp = port_operation_execute("search_code", args, &limit); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "safety limit")); + free(resp); + + /* Same query without bounds still succeeds and the scratch was cleaned. */ + resp = port_operation_execute("search_code", args, NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Bound2634")); + free(resp); + port_argv_fixture_end(&fx); + PASS(); +} #endif /* !_WIN32 */ /* #2346 (cross-repo-status): every index_status answer carries a read-only @@ -3248,6 +3400,10 @@ SUITE(integration) { #if !defined(_WIN32) RUN_TEST(port_source_search_matches_stay_inside_project_root); RUN_TEST(port_source_search_drops_walker_hits_that_resolve_outside_root); + RUN_TEST(port_source_search_unscoped_argv_scans_metachar_filenames_issue2634); + RUN_TEST(port_source_search_unscoped_argv_no_match_is_success_issue2634); + RUN_TEST(port_source_search_unscoped_argv_honours_file_pattern_issue2634); + RUN_TEST(port_source_search_unscoped_argv_cancel_deadline_and_output_limit); #endif RUN_TEST(port_status_reports_cross_repo_never_run); RUN_TEST(port_search_graph_infra_filters_pin_the_bm25_mirror); diff --git a/tests/test_java_typed_calls.c b/tests/test_java_typed_calls.c new file mode 100644 index 0000000000..93ad9fbd41 --- /dev/null +++ b/tests/test_java_typed_calls.c @@ -0,0 +1,447 @@ +/* + * test_java_typed_calls.c — Java CALLS edges follow the receiver's type + * through the whole pipeline (definitions → cross-file LSP → calls pass). + * + * One small project is indexed with cbm_pipeline_run and the CALLS edges of + * InvoiceService are read back from the store. Each project method carries a + * same-named decoy in an unrelated class (Filters), so a name-only guess is + * visible as an edge to Filters. Covered: + * - JDK and library receivers (Stream, List, org.jooq.DSLContext), whose + * calls get no edge instead of a project method of the same name; + * - a project receiver, whose call keeps its edge, also when its type is + * spelled with its package; + * - a receiver whose type is missing from the graph but whose package is + * the project's (a generated class that was not indexed, spelled with + * its package): unknown, not external, so its call keeps the name + * fallback; + * - a project type in the caller's own package that shares its simple + * name with a java.lang type (Process), or such a type declared in the + * caller's own file (Record): the own package wins; + * - the same rule on a project class named Enum (com.acme.protogen): + * `new Enum(..)` there binds that class node, not java.lang.Enum. + * The same fixture runs on the sequential path and, padded past + * MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree on every edge + * found by type. A name guess is left out of that comparison: on Windows the + * parallel path can pick another same-named method for it. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "sqlite3.h" + +#include +#include +#include + +/* ── Fixture ─────────────────────────────────────────────────────── */ + +static const char *const JT_CUSTOMER = "package com.acme.model;\n\n" + "public class Customer {\n" + " public String getName() { return \"\"; }\n" + "}\n"; + +/* Every method here shares a name with a real target elsewhere. */ +static const char *const JT_FILTERS = "package com.acme.other;\n\n" + "public class Filters {\n" + " public Filters filter(Object p) { return this; }\n" + " public Filters map(Object f) { return this; }\n" + " public String getName() { return \"\"; }\n" + " public Object select(Object a) { return a; }\n" + " public Object build() { return null; }\n" + " public String getCity() { return \"\"; }\n" + " public int size() { return 0; }\n" + " public int start() { return 0; }\n" + " public Filters number(String n) { return this; }\n" + " public void setPaid(boolean p) {}\n" + " public Object getCustomer() { return null; }\n" + " public String label() { return \"\"; }\n" + "}\n"; + +/* Process and Record share their simple names with java.lang types: the own + * package and the file's own types win (JLS 6.4.1), so these are the types + * InvoiceService and Process.start mean. */ +static const char *const JT_PROCESS = "package com.acme.svc;\n\n" + "public class Process {\n" + " public Process(String name) {}\n" + " public int start() { return new Record().id(); }\n" + "}\n\n" + "class Record {\n" + " int id() { return 0; }\n" + "}\n"; + +static const char *const JT_SERVICE = + "package com.acme.svc;\n\n" + "import com.acme.model.Customer;\n" + "import java.util.List;\n" + "import java.util.stream.Collectors;\n" + "import org.jooq.DSLContext;\n\n" + "public class InvoiceService {\n" + " private final DSLContext dsl;\n\n" + " public InvoiceService(DSLContext dsl) {\n" + " this.dsl = dsl;\n" + " }\n\n" + " public List numbers(List names) {\n" + " return names.stream().filter(n -> n.isEmpty()).map(n -> n.trim())\n" + " .collect(Collectors.toList());\n" + " }\n\n" + " public int touch(List names) {\n" + " dsl.select(\"a\");\n" + " return names.size();\n" + " }\n\n" + " public String customerName(Customer c) {\n" + " return c.getName();\n" + " }\n\n" + " public int run() {\n" + " Process p = new Process(\"job\");\n" + " return p.start();\n" + " }\n\n" + " public String qualifiedName(com.acme.other.Filters f) {\n" + " return f.label();\n" + " }\n\n" + " public String label() {\n" + " return \"\";\n" + " }\n\n" + " public String branchCity(com.acme.model.Branch b) {\n" + " return b.getCity();\n" + " }\n" + "}\n"; + +/* JLS 6.4.1: java.lang is an on-demand import, and an on-demand import never + * shadows a type of the compilation unit's own package. So `Enum` in + * Message means com.acme.protogen.Enum (the shape of Netflix conductor's + * protogen.Enum). Read as java.lang.Enum, the call counts as external and + * loses its edge. */ +static const char *const JT_PROTO_ENUM = "package com.acme.protogen;\n\n" + "public class Enum {\n" + " public Enum(Class cls, String parent) {}\n" + "}\n"; + +static const char *const JT_PROTO_MESSAGE = "package com.acme.protogen;\n\n" + "public class Message {\n" + " public void addNestedEnum(Class cls) {\n" + " Enum e = new Enum(cls, \"parent\");\n" + " }\n" + "}\n"; + +static void jt_write_fixture(const char *tmp, int filler) { + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/model/Customer.java"), JT_CUSTOMER); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/other/Filters.java"), JT_FILTERS); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/InvoiceService.java"), JT_SERVICE); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/Process.java"), JT_PROCESS); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Enum.java"), JT_PROTO_ENUM); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Message.java"), JT_PROTO_MESSAGE); + for (int i = 0; i < filler; i++) { + char rel[128]; + char body[256]; + snprintf(rel, sizeof(rel), "src/main/java/com/acme/fill/Fill%d.java", i); + snprintf(body, sizeof(body), + "package com.acme.fill;\n\npublic class Fill%d {\n" + " public int value%d() { return %d; }\n}\n", + i, i, i); + th_write_file(TH_PATH(tmp, rel), body); + } +} + +/* ── Store reads ─────────────────────────────────────────────────── */ + +typedef struct { + char tmp[256]; + char db_path[512]; + char project[512]; + sqlite3 *db; +} jt_index_t; + +static int jt_index(jt_index_t *ix, int filler) { + memset(ix, 0, sizeof(*ix)); + snprintf(ix->tmp, sizeof(ix->tmp), "/tmp/cbm_jtyped_XXXXXX"); + if (!cbm_mkdtemp(ix->tmp)) { + return -1; + } + jt_write_fixture(ix->tmp, filler); + snprintf(ix->db_path, sizeof(ix->db_path), "%s/jt.db", ix->tmp); + cbm_pipeline_t *p = cbm_pipeline_new(ix->tmp, ix->db_path, CBM_MODE_FULL); + if (!p) { + return -1; + } + int rc = cbm_pipeline_run(p); + snprintf(ix->project, sizeof(ix->project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + if (rc != 0 || sqlite3_open_v2(ix->db_path, &ix->db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + return -1; + } + return 0; +} + +static void jt_close(jt_index_t *ix) { + if (ix->db) { + sqlite3_close(ix->db); + } + th_cleanup(ix->tmp); +} + +/* Targets of the CALLS edges from method `caller` whose textual callee ends + * in `.leaf` or is `leaf`, as "|" lines. */ +static int jt_targets(jt_index_t *ix, const char *caller, const char *leaf, char *out, + size_t out_len) { + out[0] = '\0'; + const char *sql = "SELECT t.qualified_name, json_extract(e.properties,'$.strategy'), " + "json_extract(e.properties,'$.callee') FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.name = ?1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC); + int n = 0; + size_t plen = strlen(ix->project); + size_t llen = strlen(leaf); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *qn = (const char *)sqlite3_column_text(st, 0); + const char *strategy = (const char *)sqlite3_column_text(st, 1); + const char *callee = (const char *)sqlite3_column_text(st, 2); + if (!qn || !callee) { + continue; + } + size_t clen = strlen(callee); + bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) || + (clen > llen && callee[clen - llen - 1] == '.' && + strcmp(callee + clen - llen, leaf) == 0); + if (!leaf_match) { + continue; + } + if (strncmp(qn, ix->project, plen) == 0 && qn[plen] == '.') { + qn += plen + 1; + } + size_t used = strlen(out); + snprintf(out + used, out_len - used, "%s|%s\n", qn, strategy ? strategy : ""); + n++; + } + sqlite3_finalize(st); + return n; +} + +/* Exactly one CALLS edge for (caller, leaf), and its target QN ends in + * `want_suffix`. Prints what the graph has on failure. */ +static int jt_expect(jt_index_t *ix, const char *caller, const char *leaf, + const char *want_suffix) { + char got[4096]; + int n = jt_targets(ix, caller, leaf, got, sizeof(got)); + if (n == 1) { + const char *bar = strchr(got, '|'); + size_t qlen = bar ? (size_t)(bar - got) : strlen(got); + size_t wlen = strlen(want_suffix); + if (qlen >= wlen && strncmp(got + qlen - wlen, want_suffix, wlen) == 0) { + return 1; + } + } + printf("\n %s -> %s: want one edge to ...%s, have %d:\n%s", caller, leaf, want_suffix, n, got); + return 0; +} + +/* No CALLS edge for (caller, leaf) reaches a node whose QN contains `bad`. */ +static int jt_expect_none_to(jt_index_t *ix, const char *caller, const char *leaf, + const char *bad) { + char got[4096]; + (void)jt_targets(ix, caller, leaf, got, sizeof(got)); + if (!strstr(got, bad)) { + return 1; + } + printf("\n %s -> %s: an edge reaches %s:\n%s", caller, leaf, bad, got); + return 0; +} + +/* At least one CALLS edge for (caller, leaf). */ +static int jt_expect_some_edge(jt_index_t *ix, const char *caller, const char *leaf) { + char got[4096]; + if (jt_targets(ix, caller, leaf, got, sizeof(got)) > 0) { + return 1; + } + printf("\n %s -> %s: want an edge, have none\n", caller, leaf); + return 0; +} + +/* No CALLS edge at all for (caller, leaf). */ +static int jt_expect_no_edge(jt_index_t *ix, const char *caller, const char *leaf) { + char got[4096]; + int n = jt_targets(ix, caller, leaf, got, sizeof(got)); + if (n == 0) { + return 1; + } + printf("\n %s -> %s: want no edge, have %d:\n%s", caller, leaf, n, got); + return 0; +} + +/* The CALLS edges out of the svc package that the Java resolver found by + * type, one sorted line each, with the project prefix dropped so two indexes + * compare. Edges of the textual registry are left out: its strategy names + * already differ between the two paths (a constructor is import_map on one + * and unique_name on the other), and so can its pick among same-named + * methods. */ +static char *jt_edge_set(jt_index_t *ix) { + const char *sql = "SELECT group_concat(x, char(10)) FROM (SELECT s.name || '|' || " + "coalesce(json_extract(e.properties,'$.callee'),'') || '|' || " + "substr(t.qualified_name, length(?1) + 2) || '|' || " + "json_extract(e.properties,'$.strategy') AS x FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.file_path LIKE '%/svc/%' " + "AND json_extract(e.properties,'$.strategy') LIKE 'lsp%' ORDER BY x)"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return NULL; + } + sqlite3_bind_text(st, 1, ix->project, -1, SQLITE_STATIC); + char *out = NULL; + if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0)) { + out = strdup((const char *)sqlite3_column_text(st, 0)); + } + sqlite3_finalize(st); + return out; +} + +/* ── Tests ───────────────────────────────────────────────────────── */ + +static int jt_check_external(jt_index_t *ix, int sequential) { + int ok = 1; + /* JDK and library receivers never bind a project method by name... */ + ok &= jt_expect_none_to(ix, "numbers", "filter", "Filters"); + ok &= jt_expect_none_to(ix, "numbers", "map", "Filters"); + ok &= jt_expect_none_to(ix, "touch", "select", "Filters"); + ok &= jt_expect_none_to(ix, "touch", "size", "Filters"); + /* ...and get no edge: the graph holds no node for a library method. */ + ok &= jt_expect_no_edge(ix, "numbers", "filter"); + ok &= jt_expect_no_edge(ix, "touch", "select"); + ok &= jt_expect_no_edge(ix, "touch", "size"); + /* A project receiver keeps its edge. Its type comes from an import, which + * only the name guess reads here, as on main; on Windows the parallel + * path's guess can pick Filters.getName. */ + ok &= sequential ? jt_expect(ix, "customerName", "getName", "com.acme.model.Customer.getName") + : jt_expect_some_edge(ix, "customerName", "getName"); + /* A project type spelled with its package and not imported binds by its + * type, where the name guess picks the caller's own label(). */ + ok &= jt_expect(ix, "qualifiedName", "label", "com.acme.other.Filters.label"); + /* An unknown type in a project package is not proven external. */ + ok &= jt_expect_some_edge(ix, "branchCity", "getCity"); + /* A project type named like a java.lang one, in the caller's package. */ + /* `new` on a project type binds its class node, as for any project type. */ + ok &= jt_expect(ix, "run", "Process", "com.acme.svc.Process"); + ok &= jt_expect(ix, "run", "start", "com.acme.svc.Process.start"); + ok &= jt_expect(ix, "start", "id", "Record.id"); + return ok; +} + +/* The one CALLS edge for (caller, leaf) reaches a node labelled `label`. */ +static int jt_expect_target_label(jt_index_t *ix, const char *caller, const char *leaf, + const char *label) { + const char *sql = "SELECT t.label, json_extract(e.properties,'$.callee') FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.name = ?1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC); + int hits = 0; + int others = 0; + size_t llen = strlen(leaf); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *have = (const char *)sqlite3_column_text(st, 0); + const char *callee = (const char *)sqlite3_column_text(st, 1); + if (!have || !callee) { + continue; + } + size_t clen = strlen(callee); + bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) || + (clen > llen && callee[clen - llen - 1] == '.' && + strcmp(callee + clen - llen, leaf) == 0); + if (!leaf_match) { + continue; + } + if (strcmp(have, label) == 0) { + hits++; + } else { + others++; + } + } + sqlite3_finalize(st); + if (hits == 1 && others == 0) { + return 1; + } + printf("\n %s -> %s: want one edge to a %s node, have %d to one and %d to others\n", caller, + leaf, label, hits, others); + return 0; +} + +/* `new Enum(..)` in com.acme.protogen binds the project class Enum (its + * class node, as `new` does for every project type), not java.lang.Enum. */ +static int jt_check_own_package_enum(jt_index_t *ix) { + int ok = 1; + ok &= jt_expect(ix, "addNestedEnum", "Enum", "com.acme.protogen.Enum"); + ok &= jt_expect_target_label(ix, "addNestedEnum", "Enum", "Class"); + return ok; +} + +static int jt_run_check(int filler, int enum_only) { + jt_index_t ix; + if (jt_index(&ix, filler) != 0) { + printf("\n indexing the fixture failed\n"); + jt_close(&ix); + return 0; + } + int ok = enum_only ? jt_check_own_package_enum(&ix) : jt_check_external(&ix, filler == 0); + jt_close(&ix); + return ok; +} + +static int jt_run_all(int filler) { + return jt_run_check(filler, 0); +} + +TEST(java_typed_calls_sequential) { + ASSERT_TRUE(jt_run_all(0)); + PASS(); +} + +TEST(java_typed_calls_parallel) { + ASSERT_TRUE(jt_run_all(60)); + PASS(); +} + +TEST(java_typed_calls_own_package_enum_sequential) { + ASSERT_TRUE(jt_run_check(0, 1)); + PASS(); +} + +TEST(java_typed_calls_own_package_enum_parallel) { + ASSERT_TRUE(jt_run_check(60, 1)); + PASS(); +} + +/* The sequential and the parallel path find the same svc edges by type. */ +TEST(java_typed_calls_paths_agree) { + jt_index_t seq; + jt_index_t par; + int seq_rc = jt_index(&seq, 0); + int par_rc = jt_index(&par, 60); + char *a = seq_rc == 0 ? jt_edge_set(&seq) : NULL; + char *b = par_rc == 0 ? jt_edge_set(&par) : NULL; + int same = a && b && strcmp(a, b) == 0; + if (!same) { + printf("\n sequential:\n%s\n parallel:\n%s\n", a ? a : "(none)", b ? b : "(none)"); + } + free(a); + free(b); + jt_close(&seq); + jt_close(&par); + ASSERT_TRUE(same); + PASS(); +} + +SUITE(java_typed_calls) { + RUN_TEST(java_typed_calls_sequential); + RUN_TEST(java_typed_calls_parallel); + RUN_TEST(java_typed_calls_own_package_enum_sequential); + RUN_TEST(java_typed_calls_own_package_enum_parallel); + RUN_TEST(java_typed_calls_paths_agree); +} diff --git a/tests/test_main.c b/tests/test_main.c index 0f36c9a7c3..e0d824ecd9 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1881,6 +1881,7 @@ extern void suite_py_lsp_scale(void); extern void suite_ts_lsp(void); extern void suite_java_lsp(void); extern void suite_java_lsp_coverage(void); +extern void suite_java_typed_calls(void); extern void suite_kotlin_lsp(void); extern void suite_kotlin_typed_calls(void); extern void suite_rust_lsp(void); @@ -2706,6 +2707,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(ts_lsp); RUN_SELECTED_SUITE(java_lsp); RUN_SELECTED_SUITE(java_lsp_coverage); + RUN_SELECTED_SUITE(java_typed_calls); /* Architecture + ADR + Louvain */ RUN_SELECTED_SUITE(store_arch); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index f884878e3a..f70fd0bb46 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -4144,6 +4144,36 @@ TEST(parallel_rust_external_target_contract) { PASS(); } +/* Contract for cbm_pipeline_java_external_target: only a Java row the + * resolver marked lsp_external, naming a qualified member, is external. The + * end-to-end probes live in test_java_typed_calls.c. */ +TEST(parallel_java_external_target_contract) { + CBMResolvedCall rc = {0}; + rc.callee_qn = "org.jooq.DSLContext.select"; + rc.strategy = "lsp_external"; + ASSERT_TRUE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* Every other strategy keeps the registry fallback. */ + rc.strategy = "lsp_type_dispatch"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + rc.strategy = "lsp_unresolved"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* A bare name is no member of any type. */ + rc.strategy = "lsp_external"; + rc.callee_qn = "select"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* Per-language: no other language is affected. */ + rc.callee_qn = "org.jooq.DSLContext.select"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_KOTLIN, &rc)); + /* Defensive NULL inputs. */ + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, NULL)); + rc.callee_qn = NULL; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + rc.callee_qn = "org.jooq.DSLContext.select"; + rc.strategy = NULL; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + PASS(); +} + /* Gate guard for the JVM-only unique-tail fallbacks (lsp_resolve.h). * * The tail fallbacks join LSP overrides across QN drift by unique @@ -6002,6 +6032,7 @@ SUITE(parallel) { RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges); RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges); RUN_TEST(parallel_rust_external_target_contract); + RUN_TEST(parallel_java_external_target_contract); RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm); RUN_TEST(parallel_calls_parity); RUN_TEST(parallel_defines_parity); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ce6a75741d..3398ae2c73 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -9733,6 +9733,108 @@ TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) { } #endif /* !_WIN32 */ +#ifdef _WIN32 +/* Create or delete a real file named nul in dir, as a Git Bash redirect such + * as `cmd > nul` leaves behind (#2646). The plain Win32 path /nul names + * the NUL device, so the file is reached only through the extended-length + * namespace, which needs an absolute path with backslashes (CI's TEMP uses + * forward slashes). */ +static bool pipeline_test_nul_file(const char *dir, bool create) { + enum { NUL_PATH_CAP = 1024, PREFIX_LEN = 4, LEAF_LEN = 4 }; + static const wchar_t prefix[] = L"\\\\?\\"; + static const wchar_t leaf[] = L"\\nul"; + wchar_t path[NUL_PATH_CAP]; + const DWORD room = NUL_PATH_CAP - PREFIX_LEN - LEAF_LEN; + wchar_t *wide_dir = cbm_utf8_to_wide(dir); + DWORD length = wide_dir ? GetFullPathNameW(wide_dir, room, path + PREFIX_LEN, NULL) : 0; + free(wide_dir); + if (length == 0 || length >= room) { + return false; + } + wmemcpy(path, prefix, PREFIX_LEN); + wmemcpy(path + PREFIX_LEN + length, leaf, LEAF_LEN + 1); + if (!create) { + return DeleteFileW(path) != 0; + } + HANDLE file = + CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) { + return false; + } + CloseHandle(file); + return true; +} + +/* #2646: the control-file walk cannot inspect a file named nul through the + * plain path, and it never needs to: only directories and control files can + * contribute to the manifest. Listing nul in .gitignore does not help, since + * the walk visits every entry. */ +TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_nul_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n"); + write_temp_file(tmp, ".gitignore", "nul\n"); + bool created = pipeline_test_nul_file(tmp, true); + + cbm_file_hash_t *manifest = NULL; + int manifest_count = -1; + int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-skip-nul", tmp, NULL, 0, NULL, 0, + NULL, NULL, &manifest, &manifest_count); + bool found_gitignore = false; + for (int i = 0; i < manifest_count; i++) { + if (manifest[i].rel_path && strcmp(manifest[i].rel_path, ".gitignore") == 0) { + found_gitignore = true; + } + } + cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0); + bool deleted = created && pipeline_test_nul_file(tmp, false); + th_rmtree(tmp); + + ASSERT_TRUE(created); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(found_gitignore); + ASSERT_TRUE(deleted); + PASS(); +} + +/* The user-visible half of #2646: one such file made every index run return + * CBM_PIPELINE_ABORT_PRESERVE_DB (aborted_previous_preserved), so a new + * project never got an index. */ +TEST(pipeline_full_index_survives_nul_device_file_issue2646) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_index_nul_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n"); + write_temp_file(tmp, ".gitignore", "nul\n"); + bool created = pipeline_test_nul_file(tmp, true); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run_rc = pipeline ? cbm_pipeline_run(pipeline) : -1; + char project[256] = ""; + if (pipeline) { + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + } + int indexed = -1; + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (store) { + indexed = named_node_count(store, project, "NulNeighbor"); + cbm_store_close(store); + } + bool deleted = created && pipeline_test_nul_file(tmp, false); + th_rmtree(tmp); + + ASSERT_TRUE(created); + ASSERT_EQ(run_rc, 0); + ASSERT_EQ(indexed, 1); + ASSERT_TRUE(deleted); + PASS(); +} +#endif /* _WIN32 */ + /* A fully validated staged graph must be able to recover from a definitely * non-SQLite destination without deleting evidence or overwriting an earlier * quarantine. The replacement happens only after the corrupt bytes are moved. */ @@ -32474,6 +32576,10 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); #ifndef _WIN32 RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); +#endif +#ifdef _WIN32 + RUN_TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646); + RUN_TEST(pipeline_full_index_survives_nul_device_file_issue2646); #endif RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite); RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 42502c2668..8fa057171a 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -2278,6 +2278,127 @@ TEST(watcher_sustained_failure_logs_once_issue2015) { PASS(); } +/* ── Success cooldown (#2835) ─────────────────────────────────────── */ + +TEST(success_delay_fast_index_keeps_interval) { + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 0), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1666), 5000); /* 3x < interval */ + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 20000), 60000); + PASS(); +} + +TEST(success_delay_slow_index_scales_by_factor) { + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1667), 5001); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 10000), 30000); + /* 90 s rebuild → next poll 4.5 min after it finishes. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 90000), 270000); + PASS(); +} + +TEST(success_delay_clamped_and_degenerate) { + /* Capped at 5 min (INDEX_FAIL_CEILING_MS) from 100 s of indexing up. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100000), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100001), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 300000), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MAX), 300000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, -1), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MIN), 5000); + ASSERT_EQ(cbm_watcher_success_delay_ms(-1, 0), 0); + /* Never below the caller's own cadence, even above the ceiling. */ + ASSERT_EQ(cbm_watcher_success_delay_ms(INT_MAX, INT64_MAX), INT_MAX); + PASS(); +} + +TEST(success_delay_is_monotonic) { + int previous = cbm_watcher_success_delay_ms(5000, 0); + for (int64_t e = 0; e <= 2000000; e += 997) { + int delay = cbm_watcher_success_delay_ms(5000, e); + ASSERT_TRUE(delay >= previous); + ASSERT_TRUE(delay >= 5000 && delay <= 300000); + previous = delay; + } + PASS(); +} + +/* Wire-up: the cooldown must be measured around the real index_fn call and + * counted from when the index finished. The watcher's clock is frozen via a + * test seam and the callback advances it, so an index "takes" a chosen time + * with no sleep and the scheduled poll time can be asserted exactly. */ +#define COOL_NS_PER_MS 1000000LL +static int64_t cool_clock_ns = 0; +static int64_t cool_index_ms = 0; +static int cool_index_calls = 0; +static int cool_index_callback(const char *name, const char *path, void *ud) { + (void)name; + (void)path; + (void)ud; + cool_index_calls++; + cool_clock_ns += cool_index_ms * COOL_NS_PER_MS; + cbm_watcher_test_set_clock_ns(cool_clock_ns); + return 0; +} + +/* Re-index once (touch + poll until the callback runs; a transient git probe + * failure makes a poll a no-op, see the sustained-failure test) and return + * the scheduled next poll relative to when the index finished, in ms. */ +static int64_t cool_reindex_delay_ms(cbm_watcher_t *w, const char *file, const char *line, + int64_t index_ms) { + cool_index_ms = index_ms; + th_append_file(file, line); + int target = cool_index_calls + 1; + while (cool_index_calls < target) { + cbm_watcher_touch(w, "cool-repo"); + cbm_watcher_poll_once(w); + } + return (cbm_watcher_test_next_poll_ns(w, "cool-repo") - cool_clock_ns) / COOL_NS_PER_MS; +} + +TEST(watcher_success_cooldown_measures_index_issue2835) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cool_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cool_clock_ns = 1000LL * 1000 * COOL_NS_PER_MS; /* any nonzero start */ + cbm_watcher_test_set_clock_ns(cool_clock_ns); + cool_index_calls = 0; + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, cool_index_callback, NULL); + cbm_watcher_watch(w, "cool-repo", tmpdir); + cbm_watcher_poll_once(w); /* baseline */ + + wt_path(p, sizeof(p), tmpdir, "file.txt"); + /* Instant index: plain cadence. */ + int64_t fast = cool_reindex_delay_ms(w, p, "a\n", 0); + /* 60 s index → 3 x 60 s, counted from the END of the index (anchoring at + * the start would give 120 s here). */ + int64_t slow = cool_reindex_delay_ms(w, p, "b\n", 60000); + /* 200 s index → 600 s, capped at 5 min. */ + int64_t capped = cool_reindex_delay_ms(w, p, "c\n", 200000); + + /* Restore the real clock before any assert can return early. */ + cbm_watcher_test_set_clock_ns(0); + + ASSERT_EQ(fast, cbm_watcher_poll_interval_ms(1)); + ASSERT_EQ(slow, 180000); + ASSERT_EQ(capped, 300000); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + TEST(watcher_index_failure_count_unknown_project) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); @@ -4073,6 +4194,11 @@ SUITE(watcher) { RUN_TEST(index_backoff_never_schedules_sooner_than_the_interval); RUN_TEST(watcher_index_failure_streak_increments_and_resets_issue2015); RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015); + RUN_TEST(success_delay_fast_index_keeps_interval); + RUN_TEST(success_delay_slow_index_scales_by_factor); + RUN_TEST(success_delay_clamped_and_degenerate); + RUN_TEST(success_delay_is_monotonic); + RUN_TEST(watcher_success_cooldown_measures_index_issue2835); RUN_TEST(watcher_sustained_failure_logs_once_issue2015); RUN_TEST(watcher_index_failure_count_unknown_project); RUN_TEST(watcher_project_info_publishes_scan_issue2167);