Merge origin/main into feat/path-allow-broad-too-shallow-override

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-26 02:27:02 +01:00
commit 0685110e22
118 files changed
+7275 -961

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# CodeQL scope: the repository's first-party source.
#
# Read by .github/workflows/codeql.yml (every scan, full and PR) and by
# scripts/ci/select-lanes.sh (which changed paths select which language's PR
# scan). Keep the lists plain: one path prefix per line, no globs.
#
# paths / paths-ignore narrow what the INTERPRETED languages extract
# (javascript-typescript, python, actions). For c-cpp they have no effect:
# CodeQL extracts exactly what the traced build compiles. A PR's c-cpp scan
# keeps vendored C out by compiling it before tracing starts (except vendored
# files the PR changes); the full scan on main, the dry run and the release
# traces everything, vendored C included.
name: cbm first-party source
paths:
- src
- internal
- graph-ui
- pkg
- scripts
- tests
- .github
paths-ignore:
- internal/cbm/vendored
- graph-ui/vendor
- graph-ui/fixtures
- graph-ui/verification
- tests/fixtures
+1 -1
View File
@@ -1,5 +1,5 @@
# Reusable: lint only (cppcheck + clang-format).
# Security-static and CodeQL gate are separate — see _security.yml.
# Security-static is separate — see _security.yml; CodeQL — see codeql.yml.
name: Lint & Security
on:
+3 -94
View File
@@ -1,4 +1,6 @@
# Reusable: security-static + CodeQL gate.
# Reusable: security-static + license gate.
# CodeQL is not here: PRs do not run it. It runs on push to main, weekly, and
# inside dry-run.yml / release.yml, which call codeql.yml directly.
# Runs independently from lint/test/build — does not block the main pipeline.
# Affects overall workflow success status.
name: Security Gate
@@ -52,96 +54,3 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: scripts/audit-license-provenance.py
codeql-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
# Without security-events:read this job inherits the workflow's
# contents:read, the code-scanning alert API answers 403, and the gate
# below reported "0 alerts" on every run it has ever made. actions:read is
# what the CodeQL-run poll above needs for the same reason.
permissions:
contents: read
security-events: read
actions: read
steps:
- name: Wait for CodeQL on current commit (max 150 min)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# On pull_request events github.sha is the synthetic merge commit;
# CodeQL runs are recorded against the PR head SHA.
CURRENT_SHA="${{ github.event.pull_request.head.sha || github.sha }}"
echo "Waiting for CodeQL to complete on $CURRENT_SHA..."
# 300 attempts x 30s = 150 min. The previous budget was 90 x 30s = 45 min,
# which is shorter than CodeQL actually takes on this repository: a measured
# run on PR #1426 (head 7b72652a) completed with conclusion=success after
# 124 min, 7 minutes AFTER this gate had already given up. That marked at
# least five contributor PRs red for a scan that passed. The job's own
# timeout-minutes is 240, so 150 still leaves headroom.
for attempt in $(seq 1 300); do
LATEST=$(gh api "repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?head_sha=$CURRENT_SHA&per_page=1" \
--jq '.workflow_runs[] | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
if [ -z "$LATEST" ]; then
echo " $attempt/300: no run yet..."; sleep 30; continue
fi
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
echo "=== CodeQL passed ==="; exit 0
elif [ "$STATUS" = "completed" ]; then
echo "BLOCKED: CodeQL $CONCLUSION"; exit 1
fi
echo " $attempt/300: $STATUS..."; sleep 30
done
echo "BLOCKED: CodeQL timeout"; exit 1
- name: Check for open code scanning alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`,
# which looks defensive and is the opposite. gh writes the API error
# BODY to stdout, so a 403 made ALERTS the string
# {"message":"Resource not accessible by integration",...,"status":"403"}0
# every `[` comparison then failed as non-integer, the `if` took its
# false branch, and the step printed "CodeQL gate passed (0 alerts)"
# and exited 0. That is exactly how a security gate should NEVER fail:
# silently, in the direction of "everything is fine". An unreadable
# alert list is now an error, not a zero.
# NOTE: this runs in a command substitution, so it must `return`, not
# `exit` -- an `exit` here would only end the subshell and let the
# caller continue with an empty count, recreating the very bug this
# replaces. The callers below check the status explicitly.
count_open_alerts() {
local body status
body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
--jq 'length' 2>/dev/null)
status=$?
if [ $status -ne 0 ]; then
echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2
echo " Refusing to treat an unreadable alert list as zero." >&2
echo " Most likely cause: the job lacks security-events:read." >&2
return 1
fi
case "$body" in
''|*[!0-9]*)
echo "BLOCKED: alert count was not a number: ${body:-<empty>}" >&2
return 1
;;
esac
printf '%s' "$body"
}
echo "Waiting 60s for alert API to settle..."
sleep 60
ALERTS=$(count_open_alerts) || exit 1
sleep 15
ALERTS2=$(count_open_alerts) || exit 1
[ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2
if [ "$ALERTS" -gt 0 ]; then
echo "BLOCKED: $ALERTS open alert(s)"
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
exit 1
fi
echo "=== CodeQL gate passed (0 alerts) ==="
+163 -16
View File
@@ -1,47 +1,194 @@
name: CodeQL SAST
# One analysis per language: c-cpp, javascript-typescript (graph-ui, pkg/npm),
# python, actions. Scope: .github/codeql/codeql-config.yml (first-party source).
#
# FULL scan -- push to main, weekly, dry run, release: every language; c-cpp
# traces the whole production build, vendored C included.
# PR scan -- pr.yml's `codeql` job (light: true), needed by ci-ok. One leg per
# language whose scope the PR changes (codeql-* lanes of
# scripts/ci/select-lanes.sh; an undeterminable diff or a change to the
# workflows, the build or the CodeQL machinery selects every language).
# Each leg analyses its language's WHOLE first-party scope, so cross-file
# data flow is kept; c-cpp compiles the vendored objects before tracing
# starts, except vendored files the PR changes. Diff-informed analysis then
# restricts the REPORTED alerts to the changed lines; what is extracted and
# analysed is never narrowed by the diff.
# There is no pull_request trigger and no job anywhere that polls for a CodeQL
# run: the former PR gate waited up to 150 min per PR for a full analysis and,
# with ~30 PRs pushed at once, starved PR CI for hours.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "23 5 * * 1"
workflow_dispatch:
inputs:
light:
description: "PR-style c-cpp build (vendored objects compiled untraced) -- to time it"
type: boolean
default: false
workflow_call:
inputs:
light:
description: "PR scan: vendored C compiled untraced except changed files; diff-informed results"
type: boolean
default: false
matrix:
description: 'The legs to run, {"include":[...]} (pr.yml: the selected codeql-* lanes); empty = every language'
type: string
default: ""
permissions:
contents: read
# One analysis per pull request and one per pushed branch. A newer push to main
# cancels the analysis of an older main commit; only the newest commit's
# results matter for main, and a merge-heavy day otherwise queues one 40-100
# minute analysis per merge ahead of every pull request run.
concurrency:
group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
runs-on: ubuntu-latest
# CodeQL needs security-events: write to upload results; scoped to this job so
# the workflow's top-level token stays read-only (Scorecard TokenPermissions).
strategy:
fail-fast: false
# The same legs (and keys) everywhere, so a PR's analysis and main's
# share one code-scanning configuration per language.
matrix: ${{ fromJSON(inputs.matrix || '{"include":[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]}') }}
# Superseded runs are always cancelled: a newer push to main cancels the
# analysis of an older main commit (batched merges -> only the newest main
# commit is scanned), a newer push to a PR cancels that PR's scan. Every
# other trigger gets a group of its own and is never cancelled: a dry run
# or a release tests ONE commit, and two dispatched together must not
# cancel each other's gate. Per language, so legs never cancel each other.
# In a called workflow `github` is the CALLER's context: event_name is
# pull_request for pr.yml, github.workflow names the dry run or release.
concurrency:
group: codeql-${{ matrix.language }}-${{ github.event_name == 'push' && github.ref || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
# CodeQL needs security-events: write to upload results (and read for the
# alert check); scoped to this job so the workflow's top-level token stays
# read-only (Scorecard TokenPermissions).
permissions:
security-events: write
actions: read
contents: read
env:
# One code-scanning configuration for every trigger. The action derives
# the key from the RUN's workflow file, which for a called workflow is
# the caller: pr.yml / dry-run.yml / release.yml would each open a
# configuration of their own, and on main an alert fixed by a later push
# would stay open in a stale one. The value is the key the push-to-main
# analysis has always used (the action documents this variable as
# settable for non-standard setups).
CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql.yml:analyze
# Diff-informed analysis: codeql-action v4.38.1 (CLI 2.27.0) reports a
# pull request's alerts only on its changed lines when the
# diff_informed_queries feature is on (default on; this variable forces
# it, src/feature-flags.ts). It applies only when the event payload is a
# pull request -- pr.yml's call -- and is inert on main, dry run, release.
CODEQL_ACTION_DIFF_INFORMED_QUERIES: ${{ inputs.light && 'true' || '' }}
# No compiler cache: a cache hit skips the compiler, and CodeQL extracts
# only what a traced compiler invocation actually compiles.
CBM_NO_CCACHE: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The PR scan reads the merge commit's first parent (the base) to
# find the vendored files the PR changes.
fetch-depth: ${{ inputs.light && 2 || 1 }}
- name: Install build dependencies
if: ${{ matrix.language == 'c-cpp' }}
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
# PR c-cpp: compile the third-party objects (tree-sitter grammars,
# sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of
# vendored C against ~45 MB first-party) BEFORE tracing starts, so the
# traced build below compiles first-party code only ...
- name: Build third-party objects (PR scan, untraced)
if: ${{ matrix.language == 'c-cpp' && inputs.light }}
run: scripts/build.sh --vendored-only
# ... except the vendored files this PR changes: those are rebuilt
# under tracing (an undeterminable diff leaves the list empty; the scan
# still covers all first-party C).
- name: Vendored files this PR changes (traced too)
if: ${{ matrix.language == 'c-cpp' && inputs.light }}
run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt"
- name: Initialize CodeQL
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
languages: c-cpp
build-mode: manual
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml
- name: Build for CodeQL analysis
- name: Build for CodeQL analysis (full)
if: ${{ matrix.language == 'c-cpp' && !inputs.light }}
run: scripts/build.sh
- name: Build for CodeQL analysis (PR scan, first-party + changed vendored)
if: ${{ matrix.language == 'c-cpp' && inputs.light }}
run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:c-cpp"
category: "/language:${{ matrix.language }}"
# Alert policy: the default branch carries ZERO open code-scanning
# alerts. Any open alert on the analysed ref fails this leg: on main the
# commit turns red and GitHub notifies whoever triggered the run; the
# dry run and the release cannot pass; a PR (refs/pull/<n>/merge: what
# its changed lines introduce) fails ci-ok. With a zero baseline "any
# open alert" is exactly "a new alert"; a dismissal (with a reason, in
# the Security tab) holds on every ref. Every leg checks after its own
# upload, so each language's alerts are seen by at least its own leg.
# Moved from _security.yml's former PR-CI codeql-gate job.
- name: Check for open code scanning alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALERT_REF: ${{ github.ref }}
run: |
# FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`,
# which looks defensive and is the opposite. gh writes the API error
# BODY to stdout, so a 403 made ALERTS the string
# {"message":"Resource not accessible by integration",...,"status":"403"}0
# every `[` comparison then failed as non-integer, the `if` took its
# false branch, and the step printed "CodeQL gate passed (0 alerts)"
# and exited 0. That is exactly how a security gate should NEVER fail:
# silently, in the direction of "everything is fine". An unreadable
# alert list is now an error, not a zero.
# NOTE: this runs in a command substitution, so it must `return`, not
# `exit` -- an `exit` here would only end the subshell and let the
# caller continue with an empty count, recreating the very bug this
# replaces. The callers below check the status explicitly.
count_open_alerts() {
local body status
body=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \
--jq 'length' 2>/dev/null)
status=$?
if [ $status -ne 0 ]; then
echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2
echo " Refusing to treat an unreadable alert list as zero." >&2
echo " Most likely cause: the job lacks security-events:read." >&2
return 1
fi
case "$body" in
''|*[!0-9]*)
echo "BLOCKED: alert count was not a number: ${body:-<empty>}" >&2
return 1
;;
esac
printf '%s' "$body"
}
echo "Waiting 60s for alert API to settle..."
sleep 60
ALERTS=$(count_open_alerts) || exit 1
sleep 15
ALERTS2=$(count_open_alerts) || exit 1
[ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2
if [ "$ALERTS" -gt 0 ]; then
echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on $ALERT_REF -- fix, or dismiss with a reason in the Security tab"
echo "BLOCKED: $ALERTS open alert(s)"
gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
exit 1
fi
echo "=== CodeQL gate passed (0 alerts) ==="
+17 -7
View File
@@ -2,9 +2,10 @@
# Each step can be skipped for faster iteration.
#
# Pipeline: lint → test → build + candidate scan/select → smoke/soak
# Security: security-static + codeql-gate (independent island)
# Security: security-static + license-gate (independent island)
# CodeQL: codeql.yml full scan of this commit + alert check (independent island)
#
# Security does NOT block lint/test/build/smoke/soak. All jobs must pass
# Security and CodeQL do NOT block lint/test/build/smoke/soak. All jobs must pass
# for the overall workflow to be green.
#
# Candidate VirusTotal scanning and tuple-local selection are part of the
@@ -62,15 +63,24 @@ jobs:
# ── Security (independent island — does not block main pipeline) ──
security:
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
# No job in _security.yml reads code scanning or the Actions API any more
# (the CodeQL gate moved to codeql.yml), so the token stays read-only.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
# ── CodeQL: full scan of this exact commit + the open-alert check ──
# Every language, vendored C included (PRs run the narrower PR scan).
# codeql.yml uploads results and reads the alerts: a called workflow
# cannot request more than its caller grants, so withholding either
# permission fails the whole run at startup.
codeql:
uses: ./.github/workflows/codeql.yml
permissions:
contents: read
security-events: write
actions: read
# ── Lint (cppcheck + clang-format) ────────────────────────────
lint:
if: ${{ inputs.skip_lint != true }}
+12 -4
View File
@@ -51,17 +51,25 @@ jobs:
run: |
set -euo pipefail
MESSAGE_FILE=.github/pr-acknowledgement.md
BODY_FILE="$RUNNER_TEMP/pr-acknowledgement-body.md"
if [ ! -f "$MESSAGE_FILE" ]; then
echo "No $MESSAGE_FILE in the base tree — acknowledgements are off."
exit 0
fi
# An empty or whitespace-only file is the documented off switch.
if [ -z "$(tr -d '[:space:]' < "$MESSAGE_FILE")" ]; then
echo "$MESSAGE_FILE is blank — acknowledgements are off."
# HTML comments in the template are notes for maintainers. Posted
# verbatim they become text a reader cannot see but a tool can, and
# our own PR screening holds every comment that carries hidden text.
# Strip them (an unterminated one runs to the end, as GitHub renders
# it) so the posted body is exactly what the contributor sees.
perl -0777 -pe 's/<!--.*?(?:-->|\z)[ \t]*\n?//gs' "$MESSAGE_FILE" > "$BODY_FILE"
# A body with no visible text is the documented off switch.
if [ -z "$(tr -d '[:space:]' < "$BODY_FILE")" ]; then
echo "$MESSAGE_FILE has no visible text — acknowledgements are off."
exit 0
fi
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$MESSAGE_FILE"
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$BODY_FILE"
echo "Acknowledged PR #${PR_NUMBER}."
+29 -6
View File
@@ -26,13 +26,10 @@ jobs:
security:
needs: [changes]
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
# No job in _security.yml reads code scanning or the Actions API any more
# (the CodeQL gate moved to codeql.yml), so the token stays read-only.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
with:
lanes: ${{ needs.changes.outputs.lanes }}
@@ -128,6 +125,7 @@ jobs:
full: ${{ steps.select.outputs.full }}
lanes: ${{ steps.select.outputs.lanes }}
smoke: ${{ steps.select.outputs.smoke }}
codeql: ${{ steps.select.outputs.codeql }}
steps:
- name: List the changed files
env:
@@ -180,6 +178,10 @@ jobs:
SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]'
LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt")
echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT"
# The CodeQL legs, tagged the same way (codeql.yml's full matrix has
# the same entries, so PR and main share a configuration).
CODEQL='[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]'
echo "codeql={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$CODEQL")}" >> "$GITHUB_OUTPUT"
# ── Light smoke: build the PRODUCTION binary and run the core smoke on the
# three RELIABLE NATIVE platforms. Catches built-binary regressions at PR
@@ -361,13 +363,34 @@ jobs:
- name: Contract steps only
run: scripts/test.sh --contracts-only
# ── CodeQL PR scan (lanes codeql-cpp / -js / -py / -actions): one leg per
# language whose first-party scope this PR changes; each analyses that
# language's whole first-party scope, c-cpp with the vendored objects
# compiled before tracing (except vendored files the PR changes), and
# reports alerts on the changed lines (diff-informed). A job of this run,
# needed by ci-ok -- nothing waits for a separate CodeQL run. Push to
# main, dry run and release run the full scan (codeql.yml). ──
codeql:
needs: [changes]
if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'codeql-cpp') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-js') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-py') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-actions')) }}
uses: ./.github/workflows/codeql.yml
# codeql.yml uploads results and reads alerts: a called workflow cannot
# request more than its caller grants (startup failure otherwise).
permissions:
contents: read
security-events: write
actions: read
with:
light: true
matrix: ${{ needs.changes.outputs.codeql }}
ci-ok:
# The one required context (besides dco) — fails unless every PR stage
# succeeded, so matrix renames can never silently deadlock merges, and
# every lane the selector chose ran green: `skipped` is OK only for a lane
# nobody selected (a selected lane that skipped is a silent gate loss).
# memwaste and the test-impact shadow are report-only and not needed here.
needs: [changes, security, lint, test, pr-smoke, contracts]
needs: [changes, security, lint, test, pr-smoke, contracts, codeql]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
+20 -10
View File
@@ -1,7 +1,8 @@
# Release pipeline: lint → test → build → smoke/soak → draft → verify → publish
#
# Security (security-static + codeql-gate) starts immediately and runs in
# parallel with lint/test/build/smoke/soak. It only blocks the final verify
# Security (security-static + license-gate) and CodeQL (codeql.yml: full scan
# of this commit + the open-alert check) start immediately and run in
# parallel with lint/test/build/smoke/soak. They only block the final verify
# step — everything else proceeds independently.
name: Release
@@ -44,15 +45,24 @@ jobs:
# ── Security (starts immediately, blocks only verify) ───────────
security:
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
# No job in _security.yml reads code scanning or the Actions API any more
# (the CodeQL gate moved to codeql.yml), so the token stays read-only.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
# ── CodeQL (starts immediately, blocks only verify) ──────────────
# Full scan of this exact commit (every language, vendored C included) +
# the open-alert check. codeql.yml uploads results and reads the alerts:
# a called workflow cannot request more than its caller grants, so
# withholding either permission fails the whole run at startup.
codeql:
uses: ./.github/workflows/codeql.yml
permissions:
contents: read
security-events: write
actions: read
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
# The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the
# installers can never resolve (they fetch releases/download/v<version>/...),
@@ -312,12 +322,12 @@ jobs:
body: ${{ inputs.release_notes || '' }}
generate_release_notes: ${{ inputs.release_notes == '' }}
# ── 7. Verify + Publish (requires security gate) ───────────────
# ── 7. Verify + Publish (requires the security and CodeQL gates) ─
verify:
needs: [release-draft, security]
needs: [release-draft, security, codeql]
# Explicit condition: a deliberately skipped test ancestor must not skip
# the publish chain; the draft itself proves smoke and soak both succeeded.
if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.release-draft.result == 'success' }}
if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.codeql.result == 'success' && needs.release-draft.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
+1 -1
View File
@@ -52,7 +52,7 @@ Operational authority is stricter than code review authority.
| Operation | Current authority | Project rule |
| --- | --- | --- |
| PR validation (`pr.yml`, DCO, CodeQL) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. |
| PR validation (`pr.yml`, DCO, PR CodeQL scan) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. |
| Dry run (`dry-run.yml`) | `@DeusData` | Delegated release operators may run dry runs after promotion. Dry-run delegation does not imply release authority. |
| Smoke/soak/repro manual runs | `@DeusData` | Area reviewers may operate these runs when delegated for diagnosis. Results are advisory. |
| Release workflow (`release.yml`) | `@DeusData` only | Owner-only until a release operator is explicitly promoted. Publishing, replacing releases, and tag movement remain owner-gated. |
+19 -2
View File
@@ -1299,6 +1299,16 @@ $(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c $(BUILD_DIR)/.build-co
OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ)
# The part of OBJS_VENDORED_PROD that holds no first-party code: everything but
# prod_lsp_all.o (first-party LSP unity) and prod_preprocessor.o (first-party
# code around vendored simplecpp). CodeQL's light PR scan builds these BEFORE
# it starts tracing (scripts/build.sh --vendored-only), so the traced build
# (--reuse-vendored) compiles first-party code only: the grammars alone are
# most of the 1.3 GB of vendored C, against ~45 MB of first-party code.
OBJS_THIRD_PARTY_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ)
.PHONY: cbm-third-party
cbm-third-party: $(OBJS_THIRD_PARTY_PROD)
MAIN_SRC = src/main.c
# Rebuild when the build CONFIGURATION changes, not only when sources do. The
@@ -1376,6 +1386,7 @@ LLVM_BIN := $(shell brew --prefix llvm 2>/dev/null)/bin
CLANG_TIDY ?= $(shell [ -x "$(LLVM_BIN)/clang-tidy" ] && echo "$(LLVM_BIN)/clang-tidy" || echo clang-tidy)
CLANG_FORMAT ?= $(shell [ -x "$(LLVM_BIN)/clang-format" ] && echo "$(LLVM_BIN)/clang-format" || echo clang-format)
CPPCHECK ?= cppcheck
CPPCHECK_JOBS ?= 2
# macOS SDK sysroot (needed for Homebrew LLVM to find system headers)
SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null)
@@ -1400,10 +1411,16 @@ lint-tidy:
lint-tidy-diff:
@scripts/lint-tidy-diff.sh "$(CLANG_TIDY)" $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG)
# cppcheck: complementary analysis (config in .cppcheck)
# cppcheck: complementary analysis (config in .cppcheck). A build directory
# retains whole-program analysis with parallel workers; each run owns its state.
lint-cppcheck:
@echo "=== cppcheck ==="
@$(CPPCHECK) --enable=warning,style,performance,portability \
@set -e; \
cppcheck_dir=$$(mktemp -d "$${TMPDIR:-/tmp}/cbm-cppcheck.XXXXXX"); \
trap 'status=$$?; rm -rf "$$cppcheck_dir" || :; exit "$$status"' EXIT; \
trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; \
$(CPPCHECK) -j$(CPPCHECK_JOBS) --cppcheck-build-dir="$$cppcheck_dir" \
--enable=warning,style,performance,portability \
--std=c11 --language=c \
--suppressions-list=.cppcheck \
--error-exitcode=1 \
+41 -36
View File
@@ -1,9 +1,9 @@
# codebase-memory-mcp: local code intelligence for developers and AI agents
# codebase-memory-mcp: local code intelligence for AI agents via MCP
[![GitHub Release](https://img.shields.io/github/v/release/DeusData/codebase-memory-mcp?style=flat&color=blue)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/DeusData/codebase-memory-mcp/release.yml?branch=main&label=main%20CI)](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/release.yml)
[![C tests](https://img.shields.io/badge/C_tests-9388_cases-blue)](#tests)
[![Native and frontend tests](https://img.shields.io/badge/tests-native_%2B_frontend-blue)](#tests)
[![MCP tools](https://img.shields.io/badge/MCP_tools-17-blue)](#mcp-tools)
[![Languages](https://img.shields.io/badge/languages-162-orange)](https://github.com/DeusData/codebase-memory-mcp)
[![Hybrid LSP](https://img.shields.io/badge/Hybrid_LSP-type_resolution-blue)](#hybrid-lsp)
@@ -14,27 +14,28 @@
[![SLSA 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-scanned_every_release-brightgreen?logo=virustotal)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![arXiv](https://img.shields.io/badge/arXiv-2603.27277-b31b1b?logo=arxiv)](https://arxiv.org/abs/2603.27277)
[![Join the community on Discord](https://img.shields.io/badge/Discord-Join_the_community-5865F2?logo=discord&logoColor=white)](https://discord.gg/KguGXxg2X)
**Understand your repository, from source code to the systems around it.** codebase-memory-mcp connects code, documentation, configuration, and infrastructure in a persistent local knowledge graph. Use it to find an unfamiliar feature, follow a call across services, trace a deployment back to its source, or assess which parts of a project a change could affect.
**Understand your repository, from source code to the systems around it.** codebase-memory-mcp is an MCP server that connects code, documentation, configuration, and infrastructure in a persistent local knowledge graph. Use it to find an unfamiliar feature, follow a call across services, trace a deployment back to its source, or assess which parts of a project a change could affect.
Give your coding agent structural context through [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), query the graph from the CLI, or explore it yourself in **CBM Lens Beta**. Lens brings together an IDE-style source reader, architecture maps, the Galaxy graph, coverage diagnostics, architecture decisions, and optional local code chat.
Give your coding agent structural context through [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), or query the graph directly from the CLI. **The MCP server is the core of the project.** Its optional visual companion, **CBM Lens, is in open beta (v0.0.1)**, with a source reader, architecture maps, the Galaxy graph, coverage diagnostics, architecture decisions, and local code chat.
The native C backend exposes **17 MCP tools**, parses [languages and file formats](#language-support) with vendored grammars, and adds [Hybrid LSP](#hybrid-lsp) type resolution for supported languages. The installer supports **45 automatic or conditional client integrations**, including client configuration, skills, agent profiles, and applicable hooks. Indexing and graph queries need no hosted service or API key.
The native C backend exposes **17 MCP tools**, parses [languages and file formats](#language-support) with vendored grammars, and adds [Hybrid LSP](#hybrid-lsp) type resolution for supported languages. The installer supports **45 automatic or conditional client integrations**, including client configuration, skills, agent profiles, and applicable hooks. Indexing and graph queries need no hosted service or API key. Focused graph queries can reduce repeated file reads and token use; see the [measured results](#performance).
[Quick start](#quick-start) · [Capabilities](#features) · [Documentation](#documentation-connected-to-code) · [Infrastructure](#infrastructure-and-deployment) · [Smart CI](#smart-ci-and-test-impact) · [Explore CBM Lens](#cbm-lens-beta-code-visualization-and-source-navigation) · [Agent setup](#agent-setup-skills-profiles-and-hooks) · [MCP tools](#mcp-tools)
> **Local processing:** Source analysis and graph storage run on your machine. CBM Lens can optionally download a model for browser AI after you enable it; inference stays on the device. The installer writes agent configuration for the clients you select. See [Security](#security) for release verification and [SECURITY.md](SECURITY.md) for reporting issues.
**Architecture overview.** Folders become nested platforms, files and symbols become selectable blocks, and arrows show indexed relationships. The recording zooms into the map of this repository, selects `src/pipeline`, opens the area, selects `artifact.c` and opens its source. Height can show source size; hotspot gravity reflects static fan-in. [Still image](docs/images/lens-architecture.png).
**See the graph with CBM Lens (open beta v0.0.1).** Folders become nested platforms, files and symbols become selectable blocks, and arrows show indexed relationships. The recording opens `src/foundation` in the map of this repository, selects `arena.c` and opens its source. Height can show source size; hotspot gravity reflects static fan-in. [Still image](docs/images/lens-architecture.png).
<p align="center">
<img src="docs/images/lens-overview.gif" alt="CBM Lens Beta architecture walkthrough: zoom into the source folders of this repository, open src/pipeline, select artifact.c and open its source" width="1000">
<img src="docs/images/lens-overview.gif" alt="CBM Lens Beta architecture walkthrough: open src/foundation, select arena.c and read its source beside the map" width="1000">
</p>
**Galaxy.** Explore the code knowledge graph in 3D. The recording searches `cbm_pipeline_run`, follows its outgoing calls one layer further, switches to the hierarchy and shows the path to `cbm_store_check_integrity`. Edge pulses show relationship direction; they do not represent live execution. [Current still: two dependency layers around `cbm_max_file_bytes`](docs/images/lens-galaxy.jpg).
**Galaxy.** Explore the code knowledge graph in 3D. The recording follows the outgoing relationships of `cbm_arena_alloc` one layer further, switches to the hierarchy and shows the two-hop call path to `cbm_alloc`. Edge pulses show relationship direction; they do not represent live execution. [Still: two dependency layers around `cbm_max_file_bytes`](docs/images/lens-galaxy.jpg).
<p align="center">
<img src="docs/images/lens-galaxy.gif" alt="Code knowledge graph in CBM Lens Beta Galaxy: search a function, expand its outgoing calls, switch to the hierarchy and follow a path" width="1000">
<img src="docs/images/lens-galaxy.gif" alt="Code knowledge graph in CBM Lens Beta Galaxy: expand cbm_arena_alloc, switch to the hierarchy and follow its call path through arena_grow to cbm_alloc" width="1000">
</p>
**Explore.** Browse files in an IDE-style workspace, read syntax-highlighted source in Monaco, and keep the code graph alongside it. File impact shows what depends on the open file, and Code details shows the callers and calls of the symbol under the caret. Select code to [ask the local chat about it](#chat-with-your-codebase-locally).
@@ -52,11 +53,11 @@ The native C backend exposes **17 MCP tools**, parses [languages and file format
</details>
These captures show CBM Lens Beta on 12–13 October 2026, using public source from this repository. See [capture details](docs/images/README.md) for each capture's build, indexed scope and recording setup.
The high-resolution GIFs show CBM Lens Beta on 20 October 2026; the still images were captured on 12–13 October. All show public source from this repository. See [capture details](docs/images/README.md) for each capture's build, indexed scope and recording setup.
## Why codebase-memory-mcp
- **Find the right code with context**: combine symbol, text, and semantic search with callers, dependencies, source snippets, and file outlines. Reduce repeated file exploration; see [Performance](#performance) for scoped measurements.
- **Find the right code with context**: combine symbol, text, and semantic search with callers, dependencies, source snippets, and file outlines. Targeted queries and compact, paged results can reduce token use and repeated file reads; see [Performance](#performance) for scoped measurements.
- **Connect documentation to implementation**: index document sections, PDF text pages, architecture decisions, and supported references into code. Find the explanation alongside the symbol.
- **Follow the system beyond source files**: trace supported relationships through Docker, Kubernetes, Helm, Kustomize, Terraform/OpenTofu, Terragrunt, Ansible, scripts, Make, and CI configuration.
- **Understand a change before running it**: inspect affected symbols, compare indexed snapshots, and find related tests. [Experimental Smart CI](#smart-ci-and-test-impact) combines static relationships and per-test coverage for supported C/C++ test harnesses, falling back to broader runs when evidence is insufficient.
@@ -160,15 +161,15 @@ Thin frontends still write immediate startup and session-specific errors to thei
All active CBM processes must run the exact same version, executable build, coordination ABI, and canonical cache root. Equivalent `CBM_CACHE_DIR` aliases resolve to the same root; a genuinely different root is rejected while any CBM process is active. MCP servers, hooks, one-shot CLI commands, temporary index workers, and the daemon share a crash-safe OS admission barrier; starting an ordinary conflicting process fails before doing work and records an explicit conflict in `${CBM_CACHE_DIR}/logs/daemon-conflicts.ndjson`.
The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in `${CBM_CACHE_DIR}/logs/activation-events.ndjson`, and a successful command tells you to restart open coding-agent sessions so they launch the activated build.
The native `install` and `uninstall` commands are the deliberate exception to that conflict rule. For installation, download, verification, and private same-filesystem staging finish before activation so a bad candidate never disrupts active work. The native `update` command prints the installer command that performs the update. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in `${CBM_CACHE_DIR}/logs/activation-events.ndjson`, and a successful command tells you to restart open coding-agent sessions so they launch the activated build.
Package-manager setup (npm, PyPI, or Go) verifies and caches the native executable for that package. It does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
The ordinary `cli` mode runs one command locally, but it is not disconnected from the daemon above: it connects to the shared coordination daemon (starting one if none is already running) for that same admission barrier plus per-project locks on graph mutations. Its connection is a `cli_session`: it never registers with the background watcher and never starts a UI, and it holds the admission lease only for the command's lifetime. If the command's own connection is what started the daemon, that daemon exits again once the command closes and no other session is attached; if a daemon was already running, the command simply joins and leaves it exactly as found. While the command is running, a temporary monitor separately lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
The ordinary `cli` mode runs one command locally, but it is not disconnected from the daemon above: it connects to the shared coordination daemon (starting one if none is already running) for that same admission barrier plus per-project locks on graph mutations. Its connection is a `cli_session`: it does not register a project watcher or open a browser, and it holds the admission lease only for the command's lifetime. The shared daemon may serve Lens according to its UI configuration and available assets. If the command's own connection is what started the daemon, that daemon exits again once the command closes and no other session is attached; if a daemon was already running, the command simply joins and leaves it exactly as found. While the command is running, a temporary monitor separately lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
### CBM Lens Beta: code visualization and source navigation
CBM Lens Beta is the browser interface included in standard native releases and UI-enabled source builds. It builds on [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) (design and tracking in #1964). Lens is served on loopback. Browser AI downloads its optional model only after you enable it; inference stays on-device. Each panel names where its facts come from, and what the index did not record is shown as a gap. Start the local server:
**CBM Lens is an optional visual companion to the MCP server, currently in open beta (v0.0.1).** Standard native releases include its browser interface, so you can launch Lens directly after installation; UI-enabled source builds include it too. It builds on [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) (design and tracking in #1964). Lens is served on loopback. Browser AI downloads its optional model only after you enable it; inference stays on-device. Each panel names where its facts come from, and what the index did not record is shown as a gap. Start the local server:
```bash
codebase-memory-mcp daemon start --open --port=9749
@@ -215,9 +216,9 @@ Open **Chat** with the panel button at the top right to ask about the code you a
Questions about callers and callees, such as "Who calls cbm_pipeline_run?", are answered from the loaded graph without the model, and the answer says so. When you select a symbol, the chat lists its indexed facts and the lines of its code, and the model adds one sentence; that sentence is left out when it claims something the source does not show. Text written by the model is labelled as such.
The recording shows a question about callers answered from the indexed graph, next to the automatic explanation of the selected symbol. [Still image](docs/images/lens-chat.png).
The recording asks who calls `arena_grow` and what it calls, with both answers listed directly from the indexed graph beside the selected symbol. [Still image of a model explanation and graph answer](docs/images/lens-chat.png).
<p align="center"><img src="docs/images/lens-chat.gif" alt="Chat with your codebase locally in CBM Lens Beta: a selected symbol, its listed facts and a caller question answered from the indexed graph" width="1000"></p>
<p align="center"><img src="docs/images/lens-chat.gif" alt="Chat with your codebase locally in CBM Lens Beta: callers and callees of arena_grow answered directly from the indexed graph" width="1000"></p>
The chat history is kept for 24 hours in this browser, per project. Each new message uses the source currently open in the reader; earlier source snapshots are not silently accumulated, and messages about another file or selection are not sent along. If a prompt is too long for the model, older messages are left out first; if it still does not fit, CBM Lens asks you to select less code instead of cutting the code. This chat explains the context you give it; it cannot edit files or run tools. Check its answers against the source. The small default model words things loosely, especially in languages other than English.
@@ -238,7 +239,7 @@ The Agents workspace and activity overlays are hidden in normal builds. To inclu
VITE_EXPERIMENTAL_AGENTS=true scripts/build.sh --with-ui
```
Activity comes from explicitly configured tool hooks. The bundled adapter supports Claude Code PostToolUse; other clients need their own adapter. Recorded events go to the local daemon, and the reader must enable activity before the browser polls them. This does not automatically observe every agent. See the [frontend setup guide](graph-ui/README.md#die-agentenebene-was-live-arbeitende-agenten-auf-dem-graphen-zeigen).
Activity comes from explicitly configured tool hooks. The bundled adapter supports Claude Code PostToolUse; other clients need their own adapter. Recorded events go to the local daemon, and the reader must enable activity before the browser polls them. This does not automatically observe every agent. See the [frontend setup guide](graph-ui/README.md#experimental-agent-activity).
</details>
@@ -252,13 +253,13 @@ Enable automatic indexing on MCP session start:
codebase-memory-mcp config set auto_index true
```
`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched independently through `auto_watch` (on by default), subject to the session rules below.
`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership.
The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched; re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher; useful when working across many projects and you want each session contained to explicit indexing.
Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches.
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch`, which is consulted per session, `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch`, which takes effect on the next refresh or registration request, `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules.
@@ -273,7 +274,7 @@ bash "<install-dir>/install.sh"
```powershell
# Windows
powershell -ExecutionPolicy Bypass -File "<install-dir>\install.ps1"
powershell -File "<install-dir>\install.ps1"
```
The install script is placed next to the binary at install time, so the printed path resolves beside the executable. It is idempotent, so re-running it *is* the update: it stops the daemon, retires the running binary, installs the new one, and cleans up.
@@ -401,7 +402,7 @@ In this repository, test narrowing is wired to the Linux x86_64 test leg when `T
### Distribution & operation
- **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/`
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
- **Auto-sync**: Background watcher detects file changes in projects registered by active MCP sessions, including successful explicit `index_repository` targets, and re-indexes them automatically.
- **Route nodes**: REST endpoints are first-class graph entities
- **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern ".*Handler.*"`
- **Available on**: npm, PyPI, Homebrew, Scoop, AUR, `go install`
@@ -467,15 +468,15 @@ Published backend benchmarks on Apple M3 Pro. These are separate from the CBM Le
| Dead code detection | ~150ms | Full graph scan with degree filtering |
| Trace call path (depth=5) | <10ms | BFS traversal |
**RAM-first pipeline**: All indexing runs in memory (LZ4 HC compressed read, in-memory SQLite, single dump at end). Memory is released back to the OS after indexing completes.
**Memory-aware pipeline**: Indexing keeps hot data in memory and can spill extraction results to disk when needed. Memory is released back to the OS after indexing completes.
**Token efficiency, within the reported workloads**: The project's [original README report](https://github.com/DeusData/codebase-memory-mcp/blob/4ff6feeab639c4668da0f491930326ed5ec63093/README.md#performance) lists ~3,400 tokens for five structural queries via codebase-memory-mcp versus ~412,000 via file-by-file exploration: approximately **120× fewer tokens** for that comparison. The separate [31-repository preprint](https://arxiv.org/html/2603.27277v1#S4.SS1) reports **10× lower token use** and **2.1× fewer tool calls**, with answer-quality scores of **83% for CBM versus 92% for file-by-file exploration**. These are scoped backend measurements, not a promised saving for every task or a benchmark of CBM Lens. Actual savings depend on the question, repository, agent, and required verification. The study's [system-performance measurements](https://arxiv.org/html/2603.27277v1#S4.SS3) include the Linux indexing and Cypher timings above.
**More focused context, fewer tokens**: The project's [31-repository preprint](https://arxiv.org/html/2603.27277v1#S4.SS1) reports **10× lower token use** and **2.1× fewer tool calls** compared with file-by-file exploration. These March 2026 backend measurements cover the study's workloads and agent setup; the paper documents the full evaluation. An earlier [five-query comparison](https://github.com/DeusData/codebase-memory-mcp/blob/4ff6feeab639c4668da0f491930326ed5ec63093/README.md#performance) reported ~3,400 versus ~412,000 tokens for structural queries. The study's [system-performance measurements](https://arxiv.org/html/2603.27277v1#S4.SS3) include the Linux indexing and Cypher timings above.
To measure comparable quality, latency, and agent-efficiency metrics on your own workload, see [Measuring quality, latency, and agent savings](docs/MEASURING_SAVINGS.md). Exact reproduction of the figures above requires the original inputs and raw artifacts.
Explore the [measurement guide](docs/MEASURING_SAVINGS.md) to evaluate quality, latency and token use on your own workload.
## Troubleshooting & Diagnostics
codebase-memory-mcp runs **100% locally and collects no telemetry**: your code, queries, environment, and usage never leave your machine. That privacy guarantee also means that when you hit something we can't reproduce on our side (a slow memory climb over hours, a performance regression, a leak that only appears after days of real use), **we have no data at all unless you choose to send it.** Here is how to capture it yourself.
codebase-memory-mcp **indexes and queries locally and collects no telemetry**. When you hit something we can't reproduce on our side (a slow memory climb over hours, a performance regression, a leak that only appears after days of real use), **we have no diagnostic data unless you choose to send it.** Here is how to capture it yourself.
### Capture a diagnostics log
@@ -614,7 +615,7 @@ Counts on `main` at `4d9592b5`, where CBM Lens arrived. Native and Go counts use
| npm package wrapper | 24 tests | Counted in the source |
| Python package wrapper | 43 tests | Counted in the source |
Run the test suite (9,388 tests across 189 suites):
Run the native test suite:
```bash
scripts/test.sh # full: clean sanitizer build + all suites + guards
@@ -864,7 +865,7 @@ codebase-memory-mcp cli search_graph --project my-project --label Function --for
codebase-memory-mcp cli list_projects --format json --detail stats | jq '.projects[].name'
```
JSON arguments can also be piped on stdin, for tools that take arguments. A tool whose input schema declares none; `list_projects`; never reads stdin, so it stays responsive when it inherits a pipe the caller never closes (the default for `child_process.spawn` and similar wrappers). Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
Tools requiring arguments accept JSON piped on stdin. Tools with only optional arguments, such as `list_projects`, accept flags, `--args-file`, or redirected JSON files without waiting on an inherited pipe. Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
## MCP Tools
@@ -872,8 +873,8 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| Tool | Description |
|------|-------------|
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List all indexed projects with node/edge counts. |
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. |
| `delete_project` | Remove a project and all its graph data. |
| `index_status` | Check indexing status of a project. |
| `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. |
@@ -966,7 +967,7 @@ See [docs/cbmignore.md](docs/cbmignore.md) for the full `.cbmignore` how-to: syn
codebase-memory-mcp config list # show all settings
codebase-memory-mcp config set auto_index true # auto-index on session start
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
codebase-memory-mcp config set auto_watch false # remove/disable all background watches (default: true)
codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast)
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
@@ -989,7 +990,7 @@ is preserved. See [Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
| `CBM_LOG_LEVEL` | role-aware | Set the minimum log level. Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and its supervised index workers default to `info` so lifecycle and liveness records remain available. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none`; or their numeric equivalents `0` to `4`. A physical worker retains INFO liveness records even under a stricter override because its private log drives the supervisor's no-progress timeout. Frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. |
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1 to 256; invalid values are ignored with a warning. |
| `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the `ram_fraction × total_RAM` default. Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). |
| `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the derived default (`ram_fraction × total_RAM`, adjusted for available memory and daemon worker caps). Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). |
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0 to 1; set `0` to disable. Invalid values are ignored with a warning. |
Environment used by daemon-owned components (such as diagnostics, daemon logging, and process-wide indexing resource limits) is captured from the first daemon-backed session that starts the daemon. Later sessions join that process and cannot replace those values. To change them, close all daemon-backed sessions, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and a one-shot CLI command is not exempt from the rule above: it connects to the coordination daemon like any other session, starting one if none is running. Its own environment becomes the captured daemon-owned environment only when its invocation is the one that starts the daemon; joining an already-running daemon, it inherits that daemon's already-captured values instead.
@@ -1019,9 +1020,9 @@ Need the full config-file reference? See [docs/CONFIGURATION.md](docs/CONFIGURAT
Each entry maps an extension (which **must** start with `.`) to a language name. Language names are matched **case-insensitively**. Accepted values (aliases in parentheses) are:
`bash` (`sh`), `c`, `c++` (`cpp`), `c#` (`csharp`), `clojure`, `cmake`, `cobol`, `common lisp` (`commonlisp`, `lisp`), `css`, `cuda`, `dart`, `dockerfile`, `elixir`, `elm`, `emacs lisp` (`emacslisp`), `erlang`, `f#` (`fsharp`), `form`, `fortran`, `glsl`, `go`, `graphql`, `groovy`, `haskell`, `hcl` (`terraform`), `html`, `ini`, `java`, `javascript`, `json`, `julia`, `kotlin`, `lean`, `lua`, `magma`, `makefile`, `markdown`, `matlab`, `meson`, `nix`, `objective-c` (`objc`), `ocaml`, `perl`, `php`, `protobuf`, `python`, `r`, `ruby`, `rust`, `scala`, `scss`, `sql`, `svelte`, `swift`, `toml`, `tsx`, `typescript`, `verilog`, `vimscript`, `vue`, `wolfram`, `xml`, `yaml`, `zig`.
`arkts`, `asciidoc`, `bash` (`sh`), `c`, `c++` (`cpp`), `c#` (`csharp`), `chialisp`, `clojure`, `cmake`, `cobol`, `common lisp` (`commonlisp`, `lisp`), `css`, `cuda`, `dart`, `diagram`, `dockerfile`, `elixir`, `elm`, `emacs lisp` (`emacslisp`), `erlang`, `f#` (`fsharp`), `form`, `fortran`, `glsl`, `go`, `graphql`, `groovy`, `haskell`, `hcl` (`terraform`), `html`, `ini`, `java`, `javascript`, `jinja2` (`jinja`), `json`, `julia`, `kotlin`, `lean`, `lua`, `magma`, `makefile`, `markdown`, `matlab`, `meson`, `mojo`, `nix`, `objective-c` (`objc`), `ocaml`, `openapi`, `pdf`, `perl`, `php`, `plsql`, `protobuf`, `python`, `r`, `ruby`, `rust`, `scala`, `scss`, `sql`, `svelte`, `swift`, `toml`, `tsx`, `typescript`, `verilog`, `vimscript`, `vue`, `wolfram`, `xml`, `yaml`, `zig`.
Project config overrides global for conflicting extensions. An entry whose language name is unknown, or whose extension does not start with `.`, is skipped and a warning is logged to stderr (shown at the default `info` log level). Missing config files are ignored.
Project config overrides global for conflicting extensions. An entry whose language name is unknown, or whose extension does not start with `.`, is skipped and logged as a warning. Missing config files are ignored.
## Persistence
@@ -1111,7 +1112,7 @@ Every release is verified through a multi-layer pipeline before publication:
- **Sigstore cosign**: keyless signatures on all artifacts; bundles included in every release
- **SHA-256 checksums**: `checksums.txt` published with every release; verified by both install scripts before extraction
- **CodeQL SAST**: blocks release pipeline if any open alerts remain
- **No language-runtime dependency chain**: the native backend uses libraries vendored at compile time, and the frontend is embedded by the UI build
- **No language-runtime dependency chain**: the native backend needs no language-runtime environment; UI builds embed the frontend
### VirusTotal release scans
@@ -1130,6 +1131,10 @@ The [v0.11.0 release notes](https://github.com/DeusData/codebase-memory-mcp/rele
The release also publishes [candidate scan results](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/virustotal-candidate-results.tsv), [binary provenance](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/release-candidates.tsv), and [selection records](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/release-selection.tsv). For subsequent versions, use the scan evidence linked from the [latest release](https://github.com/DeusData/codebase-memory-mcp/releases/latest).
## Help shape what comes next
The next idea could be yours. We invite the OSS community to bring ideas, tricky integration questions, and feedback to [our Discord community](https://discord.gg/KguGXxg2X). When a direct conversation works better, reach out to **DeusData** or the project's co-developers there. Let's build what comes next together.
## License
MIT
+1 -1
View File
@@ -111,7 +111,7 @@ This project implements multiple layers of security verification. Every release
- **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt`
- **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation)
- **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses)
- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release.
- **CodeQL SAST** — static application security testing of the C/C++, JavaScript/TypeScript, Python and GitHub Actions code (taint analysis, CWE detection, data flow tracking): a full scan on every push to main, weekly, and in every dry run and release; a first-party scan of the languages a pull request changes, with alerts reported on its changed lines. Any open alert fails the run and blocks the release.
- **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss.
- **Native antivirus scanning** on every platform (any detection fails the build):
- **Windows**: Windows Defender with ML heuristics — the same engine end users run
+14 -6
View File
@@ -87,9 +87,9 @@ Current keys:
|---|---|---|
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
| `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. |
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. |
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
@@ -102,15 +102,23 @@ has not run yet).
> **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the
> watcher *subsystem* starts at all (the background poll thread). `auto_watch` is
> narrower: it only controls whether a connecting session registers *its own*
> project with an already-running watcher. When `watcher_enabled=false`,
> narrower: it globally controls MCP session watch registration. On the next
> refresh or registration request, `false` removes all watches; `true` allows
> session roots and successful explicit `index_repository` targets to register.
> Watcher refreshes retain `mode`, `persistence`, and the resolved
> `doc_candidates` boolean from the latest successful explicit index registered
> for that target. This boolean can come from the request or its global setting.
> When neither supplied a boolean, refresh uses the current setting or mode default.
> A watcher callback also observes `auto_watch=false`: it removes all watches
> before admitting any new refresh work.
> When `watcher_enabled=false`,
> `auto_watch` has no effect — there is no watcher to register with.
>
> They also differ in **when they are read**, which matters because the watcher
> lives in the background daemon, not in your MCP client:
>
> - `auto_watch` is consulted each time a session would register its project, so
> a change applies to sessions that connect afterwards.
> - `auto_watch` takes effect on the next refresh or registration request and
> applies globally to session roots and successful `index_repository` targets.
> - `watcher_enabled` is read **once, when the daemon starts**, because it decides
> whether the watcher is built at all. The daemon is long-lived and outlives
> individual MCP sessions, so **reconnecting your client is not enough** — retire
+39 -35
View File
@@ -1,49 +1,53 @@
# CBM Lens Beta README captures
These are screenshots and browser recordings of the application, captured on 12–13 October 2026. The application data and text were not retouched. Bernhard Jackiewicz captured the recordings and PNG stills on 12 October. The Explorer and Galaxy JPEG stills were refreshed on 13 October. One addition is drawn on top of the recordings: a small mouse pointer and click ring, because a headless browser shows no pointer of its own.
These are screenshots and browser recordings of the real application using public source from this repository. The application data and text were not retouched. The GIFs were refreshed on 20 October 2026; the still images preserve the earlier captures from 12–13 October.
## Original captures: 12 October
## High-resolution recordings: 20 October
- Repository: [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp)
- Branch: `main`, after [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068) brought CBM Lens Beta in
- Commit: `4d9592b5`
- Build: `scripts/build.sh --with-ui`
- Browser: headless Chromium (Playwright) on macOS, English locale, viewport 1440 × 900, device scale factor 1
- Agents: standard build, experimental agent activity disabled
- Source commit: `4cf44147e639ae070ee44d9230244bfb9d9676e0`
- Build: `make -f Makefile.cbm cbm-with-ui`, with the standard frontend and experimental agent activity disabled
- Capture: built-in browser screenshots on macOS, English locale, native viewport **1600 × 1000**, **12 frames per second**
- Encoding: JPEG screenshot frames converted to GIF with one 256-color palette per recording, no dithering, infinite looping; no upscaling, sharpening or pointer overlays
The `v0.0.1` label in the application is the frontend package version. It is not the backend release version.
The `v0.0.1` label is the frontend package version, not the backend release version. Standard native releases include Lens; using a source build for these recordings is a capture-provenance detail, not an installation requirement.
### Data shown
### Data and interaction
The project `codebase-memory-mcp` is an export of the same public commit (`git archive`), indexed in `full` mode into an isolated local cache. No private repository was used. Because the export has no `.git` folder, Explore reports "Git unavailable" in its file impact line.
The project `codebase-memory-mcp` is a clean `git archive` export of the source commit, indexed in `full` mode into an isolated local cache. No private repository was used. Because the export has no `.git` folder, the interface reports "Git unavailable" where applicable.
The index reported 75,037 nodes and 361,349 edges. These are properties of this source scope, not a benchmark. The interface keeps its coverage and display-limit notices.
The index reported 81,190 nodes and 411,072 edges. These counts describe this source scope, not a benchmark or proof of complete repository coverage. The interface retains its coverage and display-limit notices. The clips join recorded interaction segments with pauses shortened between them; their displayed application states and answers are unchanged. Directional pulses represent indexed relationships, not observed execution.
## Refreshed stills: 13 October
| Recording | Duration | What it shows |
|-----------|----------|---------------|
| [lens-overview.gif](lens-overview.gif) | 12.5 seconds | Architecture overview, opening `src/foundation`, browsing its files, selecting `arena.c` and reading its source beside the map. |
| [lens-galaxy.gif](lens-galaxy.gif) | 12 seconds | Expanding outgoing relationships around `cbm_arena_alloc`, switching to the hierarchy and tracing the two-hop call path `cbm_arena_alloc → arena_grow → cbm_alloc`. |
| [lens-chat.gif](lens-chat.gif) | 11.5 seconds | `arena_grow` selected in Galaxy, with caller and callee questions answered directly from the indexed graph. |
All three GIFs are 1600 × 1000. Their frames and playback duration were decoded and checked after export. README and website use these same files.
### Local chat check
The optional Qwen2.5 Coder 0.5B model was downloaded and loaded through the application's own setup. Automatic explanations were disabled for this recording. Both displayed answers are deterministic graph answers, labelled "Listed from the indexed graph; not generated by the model." The clip shows one incoming `CALLS` relationship from `cbm_arena_alloc` and two outgoing `CALLS` relationships to `cbm_alloc` and `cbm_free`, alongside the other indexed relationship types. It does not demonstrate model-generated prose.
## Original still images: 12 October
Bernhard Jackiewicz captured the PNG stills on 12 October 2026, from commit `4d9592b5` on `main` after [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068). The build used `scripts/build.sh --with-ui`; headless Chromium on macOS used English locale, a 1440 × 900 viewport and device scale factor 1. Experimental agent activity was disabled.
The indexed project was a public `git archive` export of that commit in `full` mode, with 75,037 nodes and 361,349 edges. The PNG stills are 1440 × 900, except the chat still at 1440 × 1200 so the explanation and answer fit together. These are historical application views, also used as reduced-motion alternatives on the website; they are not extracted from the new GIFs.
| Still | What it shows |
|-------|---------------|
| [lens-architecture.png](lens-architecture.png) | The whole repository on the architecture map. |
| [lens-chat.png](lens-chat.png) | A local model explanation of `cbm_pipeline_pass_doclinks` and a caller answer listed from the graph. |
| [lens-system-structure.png](lens-system-structure.png) | Component groups and their dependencies in System structure, with `src` selected. |
| [lens-behavior.png](lens-behavior.png) | Possible direct calls of `compileCards`, with its declaration. |
## Refreshed still images: 13 October
`lens-explore.jpg` and `lens-galaxy.jpg` are unedited browser captures at 1280 × 720 from the locally served CBM Lens Beta UI. The running binary reports `dev`; its exact build commit is unavailable, so these captures do not certify an exact source revision. The local project is named `cbm-triage-main-20260919`; the screenshots show public CBM source and indexed relationships.
- **Explore:** `src/foundation/limits.c`, its compact file impact summary and direct dependencies (24 nodes, 26 edges). The UI's freshness, sampling and coverage caveats remain visible.
- **Galaxy:** `cbm_max_file_bytes`, both directions, all edge types, two layers (160 nodes, 218 edges).
- **[Explore](lens-explore.jpg):** `src/foundation/limits.c`, its compact file impact summary and direct dependencies (24 nodes, 26 edges). The UI's freshness, sampling and coverage notices remain visible.
- **[Galaxy](lens-galaxy.jpg):** `cbm_max_file_bytes`, both directions, all edge types, two layers (160 nodes, 218 edges).
These counts describe the displayed index snapshot, not a benchmark or proof of complete repository coverage. The original recordings and other stills are unchanged.
## Media
| File | What it shows |
|------|---------------|
| [lens-overview.gif](lens-overview.gif) | Architecture overview of the repository: zoom and a small orbit, selecting `src/pipeline`, opening that area, selecting `artifact.c` and opening its source. |
| [lens-architecture.png](lens-architecture.png) | Still image of the whole repository on the architecture map. |
| [lens-galaxy.gif](lens-galaxy.gif) | Galaxy: searching `cbm_pipeline_run`, following its outgoing calls one layer further, the hierarchy view and the path to `cbm_store_check_integrity`. |
| [lens-galaxy.jpg](lens-galaxy.jpg) | Two dependency layers around `cbm_max_file_bytes`, with direction and edge-type controls. |
| [lens-explore.jpg](lens-explore.jpg) | `src/foundation/limits.c` in the reader with its file impact summary, file tree and direct graph dependencies. |
| [lens-chat.gif](lens-chat.gif) | The chat with the local Qwen2.5 Coder 0.5B model: the explanation of `cbm_pipeline_pass_doclinks` and "Who calls cbm_pipeline_pass_doclinks?" answered from the graph. |
| [lens-chat.png](lens-chat.png) | Still image of the same explanation and listed answer. |
| [lens-system-structure.png](lens-system-structure.png) | Component groups and their dependencies in System structure, `src` selected. |
| [lens-behavior.png](lens-behavior.png) | Possible direct calls of `compileCards`, with its declaration. |
The recordings were captured as browser video, then encoded as GIF at 960 pixels wide and 10 frames per second with a palette per recording. While a search is typed, the Galaxy briefly shows "No matching indexed nodes." until the index search answers; those frames (under one second) were cut. The original PNG stills are 1440 × 900, the chat still 1440 × 1200 so that the explanation and the answer fit together. The refreshed JPEG stills are 1280 × 720. Directional pulses represent indexed relationships, not observed execution.
## Local chat check
The chat recording uses the actual browser model, downloaded through the application's own setup. The explanation lists the indexed facts and the declaration of the selected function; the one sentence after them was written by the model and is labelled as such. The caller answer is listed from the indexed graph without the model.
These counts describe the displayed index snapshot, not a benchmark or proof of complete repository coverage.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

+146 -82
View File
@@ -3,55 +3,103 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Codebase Memory — Connect your code, docs and infrastructure</title>
<meta name="description" content="Local code intelligence for developers and AI agents. Connect code, documentation and infrastructure in a persistent graph. Explore CBM Lens, query 17 MCP tools, configure agent integrations, and investigate change and test impact.">
<title>Codebase Memory — Local Code Intelligence &amp; MCP</title>
<meta name="description" content="A local MCP server for AI coding agents. Connect code, docs and infrastructure in a persistent knowledge graph, with 17 tools and native CLI access.">
<meta name="author" content="DeusData">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<meta name="theme-color" content="#0a0e0d">
<meta property="og:title" content="Codebase Memory — Understand the whole repository">
<meta property="og:description" content="Connect code, docs and infrastructure. Explore the graph in CBM Lens, give your agent structural context, and follow the impact of a change.">
<meta property="og:title" content="Codebase Memory — Local Code Intelligence &amp; MCP">
<meta property="og:description" content="A local MCP server for AI coding agents. Connect code, docs and infrastructure in a persistent knowledge graph, with 17 tools and native CLI access.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://deusdata.github.io/codebase-memory-mcp/">
<meta property="og:image" content="https://deusdata.github.io/codebase-memory-mcp/images/lens-architecture.png">
<meta property="og:image:alt" content="CBM Lens architecture view of the codebase-memory-mcp repository">
<meta property="og:image:width" content="1440">
<meta property="og:image:height" content="900">
<meta property="og:locale" content="en_US">
<meta property="og:site_name" content="Codebase Memory">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Codebase Memory — Understand the whole repository">
<meta name="twitter:description" content="Local code intelligence for developers and AI agents. Code, docs, infrastructure, CBM Lens and experimental Smart CI.">
<meta name="twitter:title" content="Codebase Memory — Local Code Intelligence &amp; MCP">
<meta name="twitter:description" content="A local MCP server for AI coding agents. Connect code, docs and infrastructure in a persistent knowledge graph, with 17 tools and native CLI access.">
<meta name="twitter:image" content="https://deusdata.github.io/codebase-memory-mcp/images/lens-architecture.png">
<meta name="twitter:image:alt" content="CBM Lens architecture view of the codebase-memory-mcp repository">
<link rel="canonical" href="https://deusdata.github.io/codebase-memory-mcp/">
<link rel="alternate" type="text/plain" href="llms.txt" title="Codebase Memory facts and documentation">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230a0e0d'/%3E%3Cg stroke='%2333ff99' stroke-width='2'%3E%3Cpath d='M9 9L23 16L10 24Z' fill='none'/%3E%3C/g%3E%3Cg fill='%2333ff99'%3E%3Ccircle cx='9' cy='9' r='3.5'/%3E%3Ccircle cx='23' cy='16' r='3.5'/%3E%3Ccircle cx='10' cy='24' r='3.5'/%3E%3C/g%3E%3C/svg%3E">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "codebase-memory-mcp",
"alternateName": "Codebase Memory",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux, Windows",
"description": "Local code intelligence connecting source code, documentation and infrastructure in a persistent knowledge graph, with CBM Lens, MCP tools, agent integrations and experimental C/C++ test selection.",
"url": "https://deusdata.github.io/codebase-memory-mcp/",
"downloadUrl": "https://github.com/DeusData/codebase-memory-mcp/releases/latest",
"codeRepository": "https://github.com/DeusData/codebase-memory-mcp",
"programmingLanguage": "C",
"license": "https://opensource.org/licenses/MIT",
"isAccessibleForFree": true,
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"featureList": [
"Persistent local graph of code, documentation and infrastructure",
"Structural, full-text and local semantic search",
"Hybrid LSP semantic type resolution for supported language families",
"17 MCP tools, CLI access and Cypher graph queries",
"CBM Lens source navigation, architecture maps and Galaxy graph",
"Documentation sections, PDF text pages and architecture decision records",
"Static infrastructure, deployment and configuration relationships",
"Cross-service and cross-repository linking",
"Change impact, coverage diagnostics and optional runtime trace ingestion",
"Experimental C/C++ test selection using static relationships and admitted per-test coverage",
"45 automatic/conditional client surfaces with client-specific setup",
"Optional browser AI with on-device inference"
],
"author": { "@type": "Organization", "name": "DeusData", "url": "https://github.com/DeusData" }
"@graph": [
{
"@type": "WebPage",
"@id": "https://deusdata.github.io/codebase-memory-mcp/#webpage",
"url": "https://deusdata.github.io/codebase-memory-mcp/",
"name": "Codebase Memory — Local Code Intelligence & MCP",
"description": "A local MCP server for AI coding agents. Connect code, docs and infrastructure in a persistent knowledge graph, with 17 tools and native CLI access.",
"inLanguage": "en",
"mainEntity": {
"@id": "https://deusdata.github.io/codebase-memory-mcp/#software"
}
},
{
"@type": "SoftwareApplication",
"name": "Codebase Memory",
"alternateName": [
"codebase-memory-mcp",
"CBM"
],
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux, Windows",
"description": "A local MCP server for AI coding agents. Connect code, docs and infrastructure in a persistent knowledge graph, with 17 tools and native CLI access.",
"url": "https://deusdata.github.io/codebase-memory-mcp/",
"downloadUrl": "https://github.com/DeusData/codebase-memory-mcp/releases/latest",
"license": "https://opensource.org/licenses/MIT",
"isAccessibleForFree": true,
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"Persistent local graph of code, documentation and infrastructure",
"Structural, full-text and local semantic search",
"Hybrid LSP semantic type resolution for supported language families",
"17 MCP tools, CLI access and Cypher graph queries",
"Optional CBM Lens visual companion: source navigation, architecture maps and Galaxy graph (open beta)",
"Documentation sections, PDF text pages and architecture decision records",
"Static infrastructure, deployment and configuration relationships",
"Cross-service and cross-repository linking",
"Change impact, coverage diagnostics and optional runtime trace ingestion",
"Experimental C/C++ test selection using static relationships and admitted per-test coverage",
"45 automatic/conditional client surfaces with client-specific setup",
"Optional browser AI with on-device inference"
],
"author": {
"@type": "Organization",
"name": "DeusData",
"url": "https://github.com/DeusData"
},
"@id": "https://deusdata.github.io/codebase-memory-mcp/#software",
"image": "https://deusdata.github.io/codebase-memory-mcp/images/lens-architecture.png",
"mainEntityOfPage": {
"@id": "https://deusdata.github.io/codebase-memory-mcp/#webpage"
},
"isBasedOn": {
"@id": "https://deusdata.github.io/codebase-memory-mcp/#source"
}
},
{
"@type": "SoftwareSourceCode",
"@id": "https://deusdata.github.io/codebase-memory-mcp/#source",
"name": "codebase-memory-mcp",
"codeRepository": "https://github.com/DeusData/codebase-memory-mcp",
"programmingLanguage": "C",
"license": "https://opensource.org/licenses/MIT",
"targetProduct": {
"@id": "https://deusdata.github.io/codebase-memory-mcp/#software"
}
}
]
}
</script>
<style>
@@ -85,10 +133,16 @@
.nav { display: flex; align-items: center; justify-content: space-between; min-height: 76px; gap: 28px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; color: var(--text); letter-spacing: -.025em; white-space: nowrap; }
.brand svg { width: 27px; height: 27px; color: var(--green); }
.nav-links { display: flex; gap: 28px; align-items: center; font-size: 13px; }
.nav-links { display: flex; gap: 24px; align-items: center; margin-left: auto; font-size: 13px; white-space: nowrap; }
.nav-links a { color: var(--muted); }
.nav-links a:hover { color: var(--green); }
.nav-links .nav-install { border: 1px solid var(--line); border-radius: 6px; padding: 8px 15px; color: var(--text); }
.nav-actions, .community-badges { display: flex; align-items: center; gap: 10px; }
.community-link, .nav-install { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 8px 13px; border: 1px solid var(--line); border-radius: 6px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.community-link { color: var(--muted); background: var(--panel); }
.community-link svg { width: 16px; height: 16px; flex: none; color: var(--green); }
.nav-install { color: var(--green); background: var(--selected); }
.community-link:hover, .nav-install:hover { color: var(--text); border-color: var(--green); text-decoration: none; }
@media (max-width: 950px) { .nav-links { display: none; } }
.eyebrow { font-family: var(--mono); font-size: 11px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase; color: var(--green); margin-bottom: 23px; }
.hero { padding: 80px 0 0; }
.hero-top { display: grid; grid-template-columns: 1fr 270px; gap: 65px; align-items: end; }
@@ -203,7 +257,6 @@
@media (max-width: 950px) {
.hero-top { grid-template-columns: 1fr; gap: 25px; }
.hero-aside { display: none; }
.nav-links { gap: 17px; }
.section-head { align-items: start; gap: 30px; }
.section-head p { max-width: 300px; }
.connection-grid { gap: 24px; }
@@ -215,12 +268,15 @@
}
@media (max-width: 680px) {
.container { width: calc(100% - 36px); }
.nav { min-height: 65px; gap: 12px; }
html { scroll-padding-top: 140px; }
.nav { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 12px 0; }
.nav .brand { grid-column: 1; grid-row: 1; }
.nav-actions { display: contents; }
.nav-install { grid-column: 2; grid-row: 1; }
.community-badges { grid-column: 1 / -1; grid-row: 2; }
.community-link { flex: 1; }
.brand { font-size: 14px; }
.brand svg { width: 23px; }
.nav-links { gap: 12px; font-size: 12px; }
.nav-links .desktop-link { display: none; }
.nav-links .nav-install { padding: 7px 12px; }
.hero { padding-top: 50px; }
h1 { font-size: clamp(42px, 10.5vw, 64px); }
.hero-intro { font-size: 17px; }
@@ -267,10 +323,15 @@
Codebase Memory
</a>
<div class="nav-links">
<a class="desktop-link" href="#features">Capabilities</a>
<a class="desktop-link" href="#lens">CBM Lens</a>
<a class="desktop-link" href="#smart-ci">Smart CI</a>
<a href="https://github.com/DeusData/codebase-memory-mcp">GitHub ↗</a>
<a href="#features">Capabilities</a>
<a href="#lens">Lens beta</a>
<a href="#smart-ci">Smart CI</a>
</div>
<div class="nav-actions">
<div class="community-badges" role="group" aria-label="Community links">
<a class="community-link" href="https://discord.gg/KguGXxg2X" title="Join the community on Discord"><svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2.5h10v8H7l-3.5 3v-3H3z"/><path d="M5.5 5.5h5M5.5 8h3"/></svg>Discord</a>
<a class="community-link" href="https://github.com/DeusData/codebase-memory-mcp" title="Star Codebase Memory on GitHub"><svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m8 1.5 2 4.1 4.5.7-3.3 3.2.8 4.5L8 11.9 4 14l.8-4.5L1.5 6.3l4.5-.7z"/></svg>Star on GitHub</a>
</div>
<a class="nav-install" href="#install">Get started</a>
</div>
</nav>
@@ -279,28 +340,29 @@
<section class="hero" id="what-is-it">
<div class="hero-top">
<div>
<p class="eyebrow">Local code intelligence · Open source</p>
<p class="eyebrow">Local MCP server · Open source</p>
<h1>Understand the<br><span>whole repository.</span></h1>
<p class="hero-intro">Your code, documentation and infrastructure, connected in one persistent graph. Give your agent structural context. Follow the evidence yourself in CBM Lens.</p>
<p class="hero-intro">Codebase Memory (CBM) is an open-source server for the Model Context Protocol (MCP). Give your AI coding agent a persistent knowledge graph of code, documentation and infrastructure. Search symbols, trace relationships and retrieve source evidence through 17 MCP tools.</p>
<p>Focused graph queries help reduce repeated code reading and token use. <a href="#token-savings">See the evidence →</a></p>
<div class="actions">
<a class="button primary" href="#install">Install Codebase Memory <span aria-hidden="true">↗</span></a>
<a class="button" href="#lens">Explore CBM Lens <span aria-hidden="true">↓</span></a>
<a class="button primary" href="#install">Install the MCP server <span aria-hidden="true">↗</span></a>
<a class="button" href="#features">Explore the MCP tools <span aria-hidden="true">↓</span></a>
</div>
<p class="hero-note">macOS · Linux · Windows &nbsp;/&nbsp; Native C · MIT licensed</p>
</div>
<div class="hero-aside">
<strong>For your agent</strong><p>Precise graph queries, source evidence and coverage checks through MCP.</p>
<strong>For you</strong><p>Source navigation, architecture maps and a visible path from question to code.</p>
<strong>For your agent</strong><p>17 MCP tools, source evidence and coverage checks, with client-specific skills, profiles and hooks.</p>
<strong>For your workflow</strong><p>Native CLI access, persistent indexes and optional visual exploration in Lens.</p>
</div>
</div>
<figure class="product-shot">
<div class="shot-bar"><span class="live">CBM LENS BETA</span><span class="shot-path">codebase-memory-mcp / architecture</span><span>LOCAL WORKSPACE</span></div>
<a href="images/lens-architecture.png" aria-label="View the full CBM Lens architecture screenshot"><img src="images/lens-architecture.png" alt="CBM Lens architecture map showing the source folders of codebase-memory-mcp" fetchpriority="high"></a>
<figcaption>CBM Lens, exploring this repository. Real product captures from October 2026. <a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/images/README.md">Capture details ↗</a></figcaption>
<div class="shot-bar"><span class="live">CBM LENS · OPEN BETA v0.0.1</span><span class="shot-path">codebase-memory-mcp / architecture</span><span>LOCAL WORKSPACE</span></div>
<a href="images/lens-overview.gif" aria-label="View the full CBM Lens architecture walkthrough"><picture><source media="(prefers-reduced-motion: reduce)" srcset="images/lens-architecture.png" width="1440" height="900"><img src="images/lens-overview.gif" width="1600" height="1000" alt="CBM Lens architecture walkthrough: open src/foundation, select arena.c and read its source beside the map" fetchpriority="high"></picture></a>
<figcaption>See the MCP server’s graph through CBM Lens, its optional visual companion. Open beta v0.0.1, captured in October 2026. <a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/images/README.md">Capture details ↗</a></figcaption>
</figure>
<div class="facts" aria-label="Project at a glance">
<div><strong>One connected graph</strong><span>Source, docs and infrastructure</span></div>
<div><strong>17 MCP tools</strong><span>Plus a CLI and CBM Lens</span></div>
<div><strong>17 MCP tools</strong><span>Plus native CLI access</span></div>
<div><strong>45 client surfaces</strong><span>Automatic or conditional setup</span></div>
<div><strong>Local processing</strong><span>Your index stays on your machine</span></div>
</div>
@@ -337,31 +399,10 @@
<p class="fine">Relationships come from supported source forms. Dynamic values and unresolved references can leave gaps; PDF extraction requires a text layer and does not perform OCR.</p>
</section>
<section class="section" id="lens">
<div class="section-head">
<div><p class="eyebrow">CBM Lens Beta</p><h2>A workspace for<br>understanding your code.</h2></div>
<p>Included in standard native releases. Open the local browser interface and move from the shape of a system to the source behind a symbol.</p>
</div>
<div class="lens-grid">
<figure><a href="images/lens-explore.jpg" aria-label="View the full CBM Lens source navigation screenshot"><img src="images/lens-explore.jpg" alt="Lens source reader beside the file impact summary and graph dependencies for limits.c" loading="lazy"></a><figcaption>Source navigation with indexed relationships and impact context.</figcaption></figure>
<div class="lens-copy">
<div><h3>Read code in context</h3><p>Browse files and symbols, inspect callers and dependencies, and keep the relevant source beside the graph.</p></div>
<div><h3>See the shape of the system</h3><p>Explore architecture, system structure and the Galaxy graph. Inspect coverage diagnostics and architecture decisions from the same workspace.</p></div>
</div>
</div>
<div class="gallery">
<figure><a href="images/lens-galaxy.jpg" aria-label="View the full Lens Galaxy screenshot"><img src="images/lens-galaxy.jpg" alt="CBM Lens Galaxy graph for exploring symbols and relationships" loading="lazy"></a><figcaption><strong>Follow the connections</strong>Search and expand the Galaxy graph.</figcaption></figure>
<figure><a href="images/lens-system-structure.png" aria-label="View the full Lens system structure screenshot"><img src="images/lens-system-structure.png" alt="CBM Lens system structure showing source groups and their dependencies" loading="lazy"></a><figcaption><strong>Inspect system structure</strong>See source groups and dependencies.</figcaption></figure>
<figure><a href="images/lens-chat.png" aria-label="View the full Lens local chat screenshot"><img src="images/lens-chat.png" alt="CBM Lens optional local chat explaining selected source using indexed facts" loading="lazy"></a><figcaption><strong>Ask about selected code</strong>Optional browser AI, with on-device inference.</figcaption></figure>
</div>
<p class="fine">Lens is served on loopback. Browser AI downloads its optional model after you enable it. Its explanations should be checked against the displayed source.</p>
<a class="source-link" href="https://github.com/DeusData/codebase-memory-mcp#cbm-lens-beta-code-visualization-and-source-navigation">Read the Lens guide and watch the walkthroughs →</a>
</section>
<section class="section" id="features">
<div class="section-head">
<div><p class="eyebrow">Code intelligence</p><h2>Ask better questions<br>of your repository.</h2></div>
<p>Use the same persistent graph through your coding agent, the CLI or Lens. Open source evidence whenever a result needs verification.</p>
<p>Give your coding agent structural context through 17 MCP tools. Query the same graph from the CLI and open source evidence whenever a result needs verification.</p>
</div>
<article class="feature-row" id="semantic-search"><h3>Find the right starting point</h3><div><p>Search by symbol, structure, text or meaning. Combine regex and graph filters, BM25 full-text search, and local semantic search to find relevant code even when the wording differs.</p><div class="tags"><span class="tag">search_graph</span><span class="tag">search_code</span><span class="tag">get_file_outline</span></div></div></article>
<article class="feature-row" id="hybrid-lsp"><h3>Follow actual relationships</h3><div><p>Tree-sitter parsing and Hybrid LSP resolution build call and dependency relationships for supported languages. Trace callers, callees, data flow and supported cross-service links; query more complex patterns with Cypher.</p><p>Language and framework coverage varies. <a href="https://github.com/DeusData/codebase-memory-mcp#language-support">Check the support matrix →</a></p></div></article>
@@ -399,8 +440,29 @@
</details>
</section>
<section class="section" id="lens">
<div class="section-head">
<div><p class="eyebrow">CBM Lens · Open beta v0.0.1</p><h2>A visual companion<br>to the MCP server.</h2></div>
<p>An optional way to explore the same graph your agent queries. Included in standard native releases, with source navigation, architecture maps and the Galaxy view.</p>
</div>
<div class="lens-grid">
<figure><a href="images/lens-explore.jpg" aria-label="View the full CBM Lens source navigation screenshot"><img src="images/lens-explore.jpg" width="1280" height="720" alt="Lens source reader beside the file impact summary and graph dependencies for limits.c" loading="lazy"></a><figcaption>Source navigation with indexed relationships and impact context.</figcaption></figure>
<div class="lens-copy">
<div><h3>Read code in context</h3><p>Browse files and symbols, inspect callers and dependencies, and keep the relevant source beside the graph.</p></div>
<div><h3>See the shape of the system</h3><p>Explore architecture, system structure and the Galaxy graph. Inspect coverage diagnostics and architecture decisions from the same workspace.</p></div>
</div>
</div>
<div class="gallery">
<figure><a href="images/lens-galaxy.gif" aria-label="View the full CBM Lens Galaxy walkthrough"><picture><source media="(prefers-reduced-motion: reduce)" srcset="images/lens-galaxy.jpg" width="1280" height="720"><img src="images/lens-galaxy.gif" width="1600" height="1000" alt="CBM Lens Galaxy walkthrough: expand cbm_arena_alloc and follow its call path through arena_grow to cbm_alloc" loading="lazy"></picture></a><figcaption><strong>Follow the connections</strong>Expand relationships and trace a call path.</figcaption></figure>
<figure><a href="images/lens-system-structure.png" aria-label="View the full Lens system structure screenshot"><img src="images/lens-system-structure.png" width="1440" height="900" alt="CBM Lens system structure showing source groups and their dependencies" loading="lazy"></a><figcaption><strong>Inspect system structure</strong>See source groups and dependencies.</figcaption></figure>
<figure><a href="images/lens-chat.gif" aria-label="View the full Lens local chat walkthrough"><picture><source media="(prefers-reduced-motion: reduce)" srcset="images/lens-chat.png" width="1440" height="1200"><img src="images/lens-chat.gif" width="1600" height="1000" alt="CBM Lens local chat walkthrough: callers and callees of arena_grow answered directly from the indexed graph" loading="lazy"></picture></a><figcaption><strong>Ask about selected code</strong>Caller and callee answers from the indexed graph.</figcaption></figure>
</div>
<p class="fine">Lens is served on loopback. Browser AI downloads its optional model after you enable it. Its explanations should be checked against the displayed source.</p>
<a class="source-link" href="https://github.com/DeusData/codebase-memory-mcp#cbm-lens-beta-code-visualization-and-source-navigation">Read the Lens guide and watch the walkthroughs →</a>
</section>
<section class="section" id="install">
<div class="section-head"><div><p class="eyebrow">Get started</p><h2>Install. Index. Explore.</h2></div><p>Standard native releases include CBM Lens. The native runtime needs no Docker or language runtime; the core indexer and graph queries need no API key.</p></div>
<div class="section-head"><div><p class="eyebrow">Get started</p><h2>Install. Index. Explore.</h2></div><p>Install the MCP server and configure your coding agent. The native runtime needs no Docker or language runtime; indexing and graph queries need no API key. Standard releases also include the optional Lens open beta.</p></div>
<div class="split">
<div><div class="terminal"><div class="terminal-head">TERMINAL · macOS / Linux</div><pre><code><span class="comment"># Install the native release and agent integrations</span>
<span class="command">curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash</span>
@@ -408,18 +470,20 @@
<span class="comment"># Restart your agent, then ask:</span>
"Index this project"
<span class="comment"># Open CBM Lens in your browser</span>
<span class="comment"># Optional: open CBM Lens (open beta)</span>
<span class="command">codebase-memory-mcp daemon start --open</span></code></pre></div><div class="install-links"><a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/install.sh">Inspect the installer ↗</a><a href="https://github.com/DeusData/codebase-memory-mcp#quick-start">Windows / PowerShell ↗</a><a href="https://github.com/DeusData/codebase-memory-mcp#installation">Other installation methods ↗</a></div></div>
<div class="install-copy"><h3>Your machine. Your index.</h3><p>Source analysis and graph storage run locally. Native releases contain the executable and authenticated release-owned assets, including the Lens frontend and agent integration templates.</p><p>Use native packages or the npm, PyPI and Go wrappers. Package wrappers use their respective runtimes. Nix and source builds also offer builds without the UI.</p><p>Choose the clients you want to configure, restart them after installation, then build your first index.</p><a class="button" href="https://github.com/DeusData/codebase-memory-mcp/releases/latest">Download the latest release <span aria-hidden="true">↗</span></a></div>
</div>
</section>
<section class="section" id="performance">
<div class="evidence" id="benchmark"><div><p class="eyebrow">Performance and research</p><h2>Useful context.<br>Measurable tradeoffs.</h2></div><div><p>Focused graph queries can reduce repeated file exploration. The result depends on the repository, the question, the agent and how much source verification the task needs.</p><p>Published benchmarks describe specific workloads, hardware and answer-quality tradeoffs. They are backend measurements, not a promise of a fixed saving or a benchmark of CBM Lens.</p><div class="evidence-links"><a href="https://github.com/DeusData/codebase-memory-mcp#performance">Results and limitations ↗</a><a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/BENCHMARK.md">Benchmark methodology ↗</a><a href="https://arxiv.org/abs/2603.27277">Research paper ↗</a></div></div></div>
<div class="evidence" id="benchmark"><div><p class="eyebrow">Performance and research</p><h2>More useful context.<br>Less repeated work.</h2></div><div><p id="token-savings">Reuse the graph to find relevant symbols, follow relationships and retrieve focused source snippets. Compact, paged results help keep repeated repository exploration out of your agent’s context window.</p><p>Our <a href="https://arxiv.org/abs/2603.27277v1">March 2026 preprint</a> reports <strong>10× lower token use</strong> and <strong>2.1× fewer tool calls</strong> across 31 repositories compared with file-by-file exploration.</p><p>These measurements cover the study’s backend workloads from March 2026. See the paper for the evaluation setup and full results.</p><div class="evidence-links"><a href="https://github.com/DeusData/codebase-memory-mcp#performance">Token-use results ↗</a><a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/BENCHMARK.md">Indexing benchmarks ↗</a><a href="https://arxiv.org/abs/2603.27277">Research paper ↗</a></div></div></div>
</section>
<section class="section" id="faq">
<div class="faq"><p class="eyebrow">A few practical details</p><h2>Before you start.</h2>
<div class="faq"><p class="eyebrow">Common questions</p><h2>Is Codebase Memory<br>right for your workflow?</h2>
<details><summary>When should I use Codebase Memory?</summary><p>Choose CBM when your coding agent needs a persistent local graph of code, docs and infrastructure. The MCP server brings structural search, source evidence, agent integrations and experimental test selection into one open-source package. The CLI provides direct access, and CBM Lens adds an optional visual workspace in open beta. Native releases run on macOS, Linux and Windows without Docker or a language runtime. <a href="#features">Explore the capabilities</a> and <a href="#smart-ci">Smart CI’s current scope</a>.</p></details>
<details><summary>How does Codebase Memory reduce AI token use?</summary><p>Your agent can query indexed relationships and retrieve the relevant symbols or source snippets instead of repeatedly exploring whole files. Compact results and pagination control how much context each query returns. The benefit depends on the task; <a href="#token-savings">explore the published token-use measurements</a>.</p></details>
<details><summary>Does Codebase Memory send my source code anywhere?</summary><p>The backend indexes source and stores and queries its graph locally. Your coding agent has its own data-handling settings. Lens can optionally download a browser model after you enable it, with inference on-device. Installers and update checks use the network to obtain releases and assets.</p></details>
<details><summary>How does the index stay current?</summary><p>Incremental indexing updates changed files. With auto-watch enabled, an eligible active MCP session can watch an already indexed project. Automatic first indexing is a separate opt-in setting. The local UI daemon is not a promise of a permanent background watcher.</p></details>
<details><summary>How much language coverage is available?</summary><p>Bundled tree-sitter grammars cover a broad range of languages and formats. Supported language families also have Hybrid LSP semantic resolution. Extraction depth, type resolution and framework relationships vary; the <a href="https://github.com/DeusData/codebase-memory-mcp#language-support">language support matrix</a> describes the details.</p></details>
@@ -436,7 +500,7 @@
<a class="source-link" href="https://github.com/DeusData/codebase-memory-mcp/releases">View all releases on GitHub →</a>
</section>
</main>
<footer><div class="container footer-inner"><div><a class="brand" href="#main">Codebase Memory</a><p>Local code intelligence. Open source, MIT licensed.</p></div><div class="footer-links"><a href="https://github.com/DeusData/codebase-memory-mcp">GitHub</a><a href="https://github.com/DeusData/codebase-memory-mcp#readme">Documentation</a><a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/SECURITY.md">Security</a><a href="llms.txt">llms.txt</a></div></div></footer>
<footer><div class="container footer-inner"><div><a class="brand" href="#main">Codebase Memory</a><p>Local code intelligence. Open source, MIT licensed.</p></div><div class="footer-links"><a href="https://discord.gg/KguGXxg2X">Discord</a><a href="https://github.com/DeusData/codebase-memory-mcp">GitHub</a><a href="https://github.com/DeusData/codebase-memory-mcp#readme">Documentation</a><a href="https://github.com/DeusData/codebase-memory-mcp/blob/main/SECURITY.md">Security</a><a href="llms.txt">llms.txt</a></div></div></footer>
<script>
(function () {
+8 -4
View File
@@ -1,6 +1,6 @@
# codebase-memory-mcp
> Local code intelligence for developers and AI agents. Connect code, documentation, configuration, and infrastructure in a persistent knowledge graph. Search for a feature, follow callers and dependencies, trace services and deployments, inspect change impact, and explore the evidence in CBM Lens. Access the same backend through MCP or the CLI. Indexing, graph queries, and semantic embeddings run locally without a hosted service or API key; Lens optionally downloads a browser model after opt-in for on-device code chat.
> Codebase Memory is an open-source local MCP server for AI coding agents. It connects code, documentation, configuration, and infrastructure in a persistent knowledge graph, with 17 MCP tools and native CLI access. Search for a feature, follow callers and dependencies, trace services and deployments, and inspect change impact. Indexing, graph queries, and semantic embeddings run locally without a hosted service or API key. CBM Lens is the optional visual companion, currently in open beta (v0.0.1); its optional browser model supports on-device code chat after an opt-in download.
## Capabilities
@@ -13,9 +13,10 @@
- Change impact: detect_changes maps diffs to affected symbols and investigates related tests with scope: "tests"; compare_graphs compares indexed snapshots.
- Smart CI (experimental): repository-specific changed-file rules select CI jobs; test-impact select combines static reachability, changed tests, and admitted per-test coverage to select tests. Narrowing supports C/C++ family code with supported test-harness conventions and an explicitly configured runner. Missing or doubtful evidence broadens the selection or runs all tests. Other indexed languages are not automatically supported for test narrowing. test-impact publish creates the baseline graph-and-coverage bundle. Start in shadow mode; consult the guide for version requirements.
- Architecture decisions and runtime evidence: manage_adr stores the project's decision record; ingest_traces adds separately collected observed call relationships alongside static analysis.
- CBM Lens Beta: local browser interface with source exploration, Galaxy graph navigation, architecture and behavior views, ADR editing, index-coverage diagnostics, daemon/system information, and optional local code chat. Static graph animations are not live execution traces. Activity overlays are experimental source-build features, not a standard-release promise.
- CBM Lens open beta (v0.0.1): optional visual companion to the MCP server, with a local browser interface with source exploration, Galaxy graph navigation, architecture and behavior views, ADR editing, index-coverage diagnostics, daemon/system information, and optional local code chat. Static graph animations are not live execution traces. Activity overlays are experimental source-build features, not a standard-release promise.
- Agent integrations: 45 automatic or conditional client surfaces. Installation configures supported MCP entries, durable instructions, skills, Scout/Verify/Auditor profiles, and applicable session, compaction, search, and read hooks. Coverage and activation vary by client and platform. Hooks provide context without blocking or replacing the user's tool call; some clients require trust or use parent-agent handoff instead.
- Persistent operation: a per-account daemon coordinates sessions and indexing. Existing indexes can stay synchronized during eligible active MCP sessions; initial auto-indexing is separately opt-in. Optional team graph artifacts share a baseline while each user keeps a local query database.
- Token-efficient retrieval: targeted graph queries, focused source snippets, and compact, paged results can reduce token use and repeated file reads during code exploration.
- Distribution: native C backend, MIT license, standard native releases for macOS, Linux, and Windows include CBM Lens; Linux portable archives provide static builds. Nix and development source builds can omit the UI. Package-manager options and release verification are documented in the README.
## Client coverage
@@ -24,7 +25,9 @@
## Measurement and limits
Published performance and token comparisons apply to specific workloads, hardware, and agent setups, not a universal percentage saving. The README Performance section preserves the historical comparison and research quality tradeoff. Check index generation, freshness, and exact path/scope coverage before relying on graph results, especially absence claims; read source when coverage is partial, skipped, excluded, stale, or unknown. Infrastructure relationships are static, and test-selection support is narrower than language-indexing support.
A March 2026 preprint across 31 real-world repositories reports 10× lower token use and 2.1× fewer tool calls compared with file-by-file exploration. These historical backend measurements cover the study’s workloads and agent setup. The paper documents the full evaluation; the README Performance section links to the research and earlier measurements.
Graph evidence depends on index generation, freshness, and exact path/scope coverage. Partial, skipped, excluded, stale, or unknown coverage requires source verification, especially for absence claims. Infrastructure relationships are static, and test-selection support is narrower than language-indexing support.
## Links
@@ -39,7 +42,8 @@ Published performance and token comparisons apply to specific workloads, hardwar
- Smart CI guide: https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/SMART_CI.md
- Configuration: https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/CONFIGURATION.md
- MCP tools: https://github.com/DeusData/codebase-memory-mcp#mcp-tools
- Performance and research limits: https://github.com/DeusData/codebase-memory-mcp#performance
- Token savings and evidence: https://deusdata.github.io/codebase-memory-mcp/#token-savings
- Performance and research: https://github.com/DeusData/codebase-memory-mcp#performance
- Benchmark report: https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/BENCHMARK.md
- Research paper: https://arxiv.org/abs/2603.27277
- Security and release verification: https://github.com/DeusData/codebase-memory-mcp#security
+1 -2
View File
@@ -2,7 +2,6 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://deusdata.github.io/codebase-memory-mcp/</loc>
<lastmod>2026-06-12</lastmod>
<priority>1.0</priority>
<lastmod>2026-10-20</lastmod>
</url>
</urlset>
+55 -55
View File
@@ -32,8 +32,8 @@
"playwright": "1.62.1",
"three-stdlib": "2.36.1",
"typescript": "5.9.3",
"vite": "6.4.3",
"vitest": "4.1.11"
"vite": "6.4.4",
"vitest": "4.1.12"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -1812,16 +1812,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1830,13 +1830,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1857,9 +1857,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1870,13 +1870,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -1884,14 +1884,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1900,9 +1900,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1910,13 +1910,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2135,9 +2135,9 @@
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4938,9 +4938,9 @@
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5277,9 +5277,9 @@
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5367,19 +5367,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5407,12 +5407,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+2 -2
View File
@@ -62,8 +62,8 @@
"playwright": "1.62.1",
"three-stdlib": "2.36.1",
"typescript": "5.9.3",
"vite": "6.4.3",
"vitest": "4.1.11"
"vite": "6.4.4",
"vitest": "4.1.12"
},
"overrides": {
"sharp": "$sharp",
@@ -343,3 +343,9 @@ describe('Spring service connection evidence', () => {
expect(links(result)).toEqual([]);
});
});
it.each(['$endpoint', 'endpoint$'])('tracks a literal identifier through a transport wrapper: %s', parameter => {
const compose = 'services:\n web:\n build: ./web\n api:\n image: example/api\n';
const source = `function load(${parameter}) { return fetch(${parameter}); }\nload("http://api:8080/items");`;
expect(links(buildContainerTopology([project(compose, { 'web/client.js': source })]))).toEqual(['web>api:http']);
});
@@ -300,7 +300,12 @@ function networkCall(name: string): boolean {
|| /^(?:requests|http|https|axios|urllib\.request|urllib3|grpc|redis|psycopg2?|pymysql|mysql|amqp|amqplib|net|socket|ConnectionMultiplexer|Dns)\./i.test(name)
&& /(?:get|post|put|patch|delete|request|urlopen|dial|newclient|connect|connectasync|createclient|createconnection|insecure_channel|secure_channel|gethostentry(?:async)?)$/i.test(name);
}
const wordIn = (code: string, name: string): boolean => new RegExp(`\\b${name.replace(/[$]/g, '\\$&')}\\b`).test(code);
function wordIn(code: string, name: string): boolean {
for (const [identifier] of code.matchAll(/[A-Za-z_$][\w$]*/g)) {
if (identifier === name) return true;
}
return false;
}
interface Wrapper { parameter: number; hint: string }
/** Bounded local argument propagation to known transport APIs, not a function-name dictionary. */
function connectionWrappers(code: string, calls: Call[]): Map<string, Wrapper> {
@@ -64,3 +64,10 @@ it('follows only actual call edges across components and retains their evidence'
expect(paths[0].edges[0]).toBe(map.evidence[0]);
expect(entryRoutes(graph.nodes[2], map.evidence)).toEqual([]);
});
it('keeps token boundaries when dropping markup from repository prose', () => {
const quote = sourceQuote('README.md', 'This repository provides useful source browsing and <<script>script> examples for developers.');
expect(quote?.text).not.toContain('<script>');
expect(sourceQuote('README.md', 'This repository provides useful source<b>browsing</b> for developers.')?.text)
.toBe('This repository provides useful source browsing for developers.');
});
@@ -41,7 +41,7 @@ export function sourceCandidates(area: RepositoryArea): string[] {
}
function plainMarkdown(text: string): string {
return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, '').trim();
return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/** Quote only repository-authored prose, never infer a responsibility from an identifier. */
@@ -102,3 +102,10 @@ describe('facts read from a configuration or text file (K12)', () => {
expect(readerFacts(reader('manage.py', 'import os\n'))).toEqual([]);
});
});
// Removing a comment must not create a root tag that was absent from the input.
it('does not join XML markup across removed declarations or comments', () => {
expect(fileFactLines('pom.xml', '<<!-- ignored -->fake/><project/>')).toContain('Root element: `<project>`.');
expect(fileFactLines('pom.xml', '<<?meta?>fake/><project/>')).toContain('Root element: `<project>`.');
expect(fileFactLines('pom.xml', '<!-- <hidden/> --><project/>')).toContain('Root element: `<project>`.');
});
+6 -2
View File
@@ -130,8 +130,12 @@ function patternFacts(text: string): string[] {
}
function xmlFacts(text: string): string[] {
const root = /<([A-Za-z_][\w:.-]*)[\s>/]/.exec(text.replace(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<![^>]*>/g, ''));
return root ? [words.root(quote(`<${root[1]}>`))] : [];
// Skip markup in the original input: deleting it could join fragments into a new tag.
const tokens = /<\?[\s\S]*?(?:\?>|$)|<!--[\s\S]*?(?:-->|$)|<![^>]*>|<([A-Za-z_][\w:.-]*)[\s>/]/g;
for (const match of text.matchAll(tokens)) {
if (match[1]) return [words.root(quote(`<${match[1]}>`))];
}
return [];
}
/** The structure of the text, by the kind its name gives. */
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import GalaxyPanel, { type GalaxyPanelProps } from './GalaxyPanel';
import type { GraphData, GraphNode } from './types';
import type { OrganicLayoutRequest, OrganicLayoutResponse } from './organic-layout.worker';
/** The layout can be capped; focused relationships come independently from RPC. */
function graphFetch(nodes: GraphNode[], edges: GraphData['edges'], total = nodes.length) {
@@ -102,10 +103,24 @@ it('opens a file hierarchy without a caret symbol and follows literal marked ran
expect(fetchLayout.mock.calls.filter(([url]) => String(url).includes('/api/layout'))).toHaveLength(1);
});
it('does not report a file missing from a capped layout while its indexed scope is loading', async () => {
it.each([true, false])('does not report a file missing while its scope or layout is loading (indexed: %s)', async indexed => {
let worker!: LayoutWorker;
class LayoutWorker {
onmessage: ((event: MessageEvent<OrganicLayoutResponse>) => void) | null = null;
requests: OrganicLayoutRequest[] = [];
constructor() { worker = this; }
postMessage(request: OrganicLayoutRequest) { this.requests.push(request); }
terminate() {}
reply() {
const { sequence, data } = this.requests.at(-1)!;
this.onmessage?.(new MessageEvent<OrganicLayoutResponse>('message', { data: { sequence,
result: { data, groups: [], groupByNode: new Map() } } }));
}
}
vi.stubGlobal('Worker', LayoutWorker);
const node: GraphNode = { id: 15, name: 'outside', qualified_name: 'sample.outside', label: 'Function',
file_path: 'src/a.ts', start_line: 1, end_line: 10, x: 0, y: 0, z: 0, size: 1, color: '#999999' };
const source = graphFetch([node], []);
const source = graphFetch(indexed ? [node] : [], []);
let release!: () => void;
const pending = new Promise<void>(resolve => { release = resolve; });
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
@@ -114,15 +129,27 @@ it('does not report a file missing from a capped layout while its indexed scope
return source(url, init);
});
await act(async () => root.render(<GalaxyPanel project="sample" visible focusFilePath="src/a.ts" onOpenNode={vi.fn()} fetch={fetchImpl} />));
// Hand test K8: the status names the layer that is loading instead of an endless "Loading relationships".
// It may first say "Checking index…" while the index generation is read; wait for the load itself.
await vi.waitFor(() => expect(host.textContent).toContain('Loading layer 1…'));
expect(host.textContent).toContain('Loading layer 1…');
// Finish the empty capped-layout preview while the indexed scope is held.
// This leaves a missing-file note in state, hidden until the scope finishes.
expect(worker.requests).toHaveLength(1);
expect(worker.requests[0]!.data.nodes).toEqual([]);
await act(async () => worker.reply());
expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull();
await act(async () => { release(); });
expect(host.textContent).toContain('All indexed direct dependencies included.');
// The scope is complete, but its new picture has not been arranged yet.
expect(worker.requests).toHaveLength(2);
expect(host.querySelector('[role="status"][data-state="arranging"]')).not.toBeNull();
expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull();
expect(host.textContent).not.toContain('not in the loaded graph');
await act(async () => worker.reply());
expect(host.querySelector('[role="status"][data-state="complete"]')).not.toBeNull();
expect(JSON.parse(host.querySelector('[data-testid="graph-selection"]')!.textContent!)).toEqual(indexed ? [15] : []);
if (indexed) expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull();
else expect(host.querySelector('[data-testid="atlas-galaxy-note"]')?.textContent).toBe('src/a.ts is not in the loaded graph layout.');
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; });
afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; vi.unstubAllGlobals(); });
it('selects the whole file, narrows only to marked code, and restores the file when the mark clears', async () => {
const common = { file_path: 'src/a.ts', x: 0, y: 0, z: 0, size: 1, color: '#999999' };
+1 -1
View File
@@ -3055,7 +3055,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<summary>Selection details</summary>
<div className="atlas-galaxy-selection-details-body">{selectionContent}</div>
</details>}
{note.length > 0 && !scope.loading && (
{note.length > 0 && !scope.loading && !organicTask.loading && (
<p className="atlas-galaxy-note" data-testid="atlas-galaxy-note">
{props.workspaceExpanded && note === GALAXY_NO_FOCUS_NOTE
? 'Select a node or entry point to attach graph context to chat.' : note}
@@ -0,0 +1,64 @@
import { afterEach, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Writable } from 'node:stream';
import { literalPattern } from './literal-pattern.mjs';
import { serveStaticAsset } from './smoke-static-assets.mjs';
const temporary = [];
afterEach(() => { for (const path of temporary.splice(0)) rmSync(path, { recursive: true, force: true }); });
it.each(['a.b', 'a+b', '(a+)+$', '[project]', 'a\\b'])('treats a project name as literal text: %s', name => {
const pattern = literalPattern(name, { start: true, end: true });
expect(pattern.test(name)).toBe(true);
expect(pattern.test(`prefix${name}`)).toBe(false);
expect(pattern.test(`${name}suffix`)).toBe(false);
});
it('does not expand project-name wildcards', () => {
expect(literalPattern('a.b', { start: true, end: true }).test('axb')).toBe(false);
});
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'cbm-static-assets-')); temporary.push(root);
mkdirSync(join(root, 'spike')); mkdirSync(join(root, 'node_modules')); mkdirSync(join(root, 'spike-extra'));
writeFileSync(join(root, 'spike', 'index.html'), 'fixture');
writeFileSync(join(root, 'outside.txt'), 'outside-root');
writeFileSync(join(root, 'spike-extra', 'outside.txt'), 'sibling-root');
return root;
}
function request(root, url) {
return new Promise((resolve, reject) => {
const chunks = []; let status;
const response = new Writable({ write(chunk, encoding, done) { chunks.push(Buffer.from(chunk)); done(); } });
response.writeHead = code => { status = code; };
response.on('error', reject);
response.on('finish', () => resolve({ status, body: Buffer.concat(chunks).toString() }));
try { serveStaticAsset(root, { url }, response); } catch (error) { reject(error); }
});
}
it('serves an allowed static fixture and rejects traversal and sibling prefixes', async () => {
const root = fixture();
expect(await request(root, '/spike/index.html')).toEqual({ status: 200, body: 'fixture' });
expect((await request(root, '/spike/../outside.txt')).status).toBe(404);
expect((await request(root, '/spike/%2e%2e/outside.txt')).status).toBe(404);
expect((await request(root, '/spike-extra/outside.txt')).status).toBe(404);
});
it('rejects a symlink escaping an allowed static root', async () => {
const root = fixture();
symlinkSync(join(root, 'outside.txt'), join(root, 'spike', 'escape.txt'));
expect((await request(root, '/spike/escape.txt')).status).toBe(404);
});
it('returns not found for malformed URL encoding instead of throwing', async () => {
expect((await request(fixture(), '/spike/%zz')).status).toBe(404);
});
it('allows a symlink whose real target stays inside the static root', async () => {
const root = fixture();
symlinkSync(join(root, 'spike', 'index.html'), join(root, 'spike', 'alias.html'));
expect(await request(root, '/spike/alias.html')).toEqual({ status: 200, body: 'fixture' });
});
@@ -33,6 +33,7 @@
*/
import { chromium } from 'playwright';
import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { inflateSync } from 'node:zlib';
@@ -446,7 +447,7 @@ async function hoverSweep(page) {
const hid = [];
let shown = false;
for (const id of ids.slice(0, 14)) {
const chip = page.locator(`button.architecture-node-label[data-node-id="${id.replace(/"/g, '\\"')}"]`).first();
const chip = page.locator(architectureNodeSelector(id)).first();
if (!(await chip.isVisible().catch(() => false))) continue;
await chip.hover({ timeout: 3000 }).catch(() => {});
await wait(450);
@@ -498,7 +499,7 @@ async function k21(page) {
async function switchProject(page, name) {
await page.locator('.atlas-shell details summary', { hasText: PROJECT }).first().click();
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${name}$`) }) }).first();
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: projectNamePattern(name) }) }).first();
await entry.waitFor({ timeout: 20000 });
await entry.click();
}
+3 -2
View File
@@ -22,6 +22,7 @@
* access to Hugging Face), to switch the project while the download runs; the cache is full again after it.
*/
import { literalPattern } from './literal-pattern.mjs';
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
@@ -843,7 +844,7 @@ const activeWorkspace = (page) => page.locator('[data-workspace-tab][aria-select
const sameText = (left, right) => left.replace(/\d[\d,.:]*/g, '#').replace(/\s+/g, ' ') === right.replace(/\d[\d,.:]*/g, '#').replace(/\s+/g, ' ');
async function switchTo(page, project) {
await page.locator('.atlas-project-switcher summary').first().click();
await page.locator('.atlas-project-picker button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${project}`) }) }).first().click();
await page.locator('.atlas-project-picker button', { has: page.locator('.atlas-project-result-name', { hasText: literalPattern(project, { start: true }) }) }).first().click();
}
const pageState = (page) => page.evaluate(() => ({ id: window.__probePage, origin: performance.timeOrigin, workers: window.__probeModelWorkers }));
@@ -1064,7 +1065,7 @@ async function k24(page) {
const downloads = modelRequests.length - requestsBefore;
await save('K24', 'legs.json', legs);
const legOk = (leg) => leg.samePage && leg.workspace.before === leg.workspace.after && leg.modelWorkersCreated === 0 && leg.prepares === 0 && leg.lamp.every((frame) => /Agent active/.test(frame.label ?? ''))
&& new RegExp(leg.to).test(leg.project ?? '') && leg.requestsNamingOldProject.length === 0 && leg.views.length === K24_VIEWS.length && leg.views.every((view) => !view.stale && !view.unexpected.length) && leg.galaxySameAsFresh
&& literalPattern(leg.to).test(leg.project ?? '') && leg.requestsNamingOldProject.length === 0 && leg.views.length === K24_VIEWS.length && leg.views.every((view) => !view.stale && !view.unexpected.length) && leg.galaxySameAsFresh
&& (leg.to !== CONTROL || leg.chatHasOldTurn === false) && (leg.to !== PROJECT || leg.how !== 'switcher' || leg.chatKeptOwnTurn === true);
check('K24', 'A project switch stays in the page: the model stays loaded (no reload, no new worker, lamp active), every workspace shows only the new project and the Galaxy its graph, no request names the old one; Back/Forward the same, all views checked',
loaded && downloads === 0 && legs.every(legOk),
+2 -1
View File
@@ -31,6 +31,7 @@
* wird im Browser beantwortet und navigator.sendBeacon bleibt in der Seite.
*/
import { literalPattern } from './literal-pattern.mjs';
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
@@ -460,7 +461,7 @@ try {
// Projektwechsel nach cbm: frischer Verlauf
await page.locator('.atlas-shell details summary', { hasText: PROJECT }).first().click();
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${CONTROL}$`) }) }).first();
const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: literalPattern(CONTROL, { start: true, end: true }) }) }).first();
await entry.waitFor({ timeout: 20000 });
await entry.click();
const fresh = { position: '1/1', back: { disabled: true, title: 'Nothing to go back to yet' }, forward: { disabled: true, title: 'Nothing to go forward to' }, recent: [] };
+14
View File
@@ -0,0 +1,14 @@
import { literalPattern } from './literal-pattern.mjs';
/** Match one architecture chip by its graph node ID. */
export function architectureNodeSelector(id) {
// CSS strings need escapes for backslashes, quotes, and control characters.
// Terminate each hex escape so the next character cannot extend its code point.
const escaped = id.replace(/[\x00-\x1f\x7f"\\]/g, char => `\\${char.codePointAt(0).toString(16)} `);
return `button.architecture-node-label[data-node-id="${escaped}"]`;
}
/** Match a complete project name in the project chooser. */
export function projectNamePattern(name) {
return literalPattern(name, { start: true, end: true });
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs';
it.each(['node', 'node"name', 'node\\name', 'node\\"name', 'node\nname', 'node\rname', 'node\fname', 'node], button'])('selects only the literal graph node ID: %j', id => {
const { document } = new JSDOM('<button class="architecture-node-label" data-node-id="other"></button>').window;
const target = document.createElement('button');
target.className = 'architecture-node-label';
target.setAttribute('data-node-id', id);
document.body.append(target);
expect(document.querySelectorAll(architectureNodeSelector(id))).toHaveLength(1);
expect(document.querySelector(architectureNodeSelector(id))).toBe(target);
});
it.each(['a.b', 'a+b', '(project)', '[project]', 'a\\b', '*', '(a+)+$'])('matches a complete literal project name: %j', name => {
const pattern = projectNamePattern(name);
expect(pattern.test(name)).toBe(true);
expect(pattern.test(`prefix${name}`)).toBe(false);
expect(pattern.test(`${name}suffix`)).toBe(false);
});
it('does not treat a project-name dot as a wildcard', () => {
expect(projectNamePattern('a.b').test('axb')).toBe(false);
});
+5
View File
@@ -0,0 +1,5 @@
/** Build a pattern for literal text with optional start/end anchoring. */
export function literalPattern(value, { start = false, end = false } = {}) {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`${start ? '^' : ''}${escaped}${end ? '$' : ''}`);
}
+4 -14
View File
@@ -31,9 +31,10 @@ import { createServer } from 'node:http';
import { request as httpRequest } from 'node:http';
import net from 'node:net';
import { mkdtemp, mkdir, writeFile, readFile, stat, rm } from 'node:fs/promises';
import { existsSync, createReadStream } from 'node:fs';
import { existsSync } from 'node:fs';
import { serveStaticAsset } from './smoke-static-assets.mjs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve, extname, normalize } from 'node:path';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
@@ -251,8 +252,6 @@ const MIME = {
};
function startStaticProxy(port, cPort) {
const allowedRoots = [join(ROOT, 'spike'), join(ROOT, 'node_modules')];
const server = createServer((req, res) => {
if (req.method === 'POST' && req.url.split('?')[0] === '/rpc') {
const chunks = [];
@@ -284,16 +283,7 @@ function startStaticProxy(port, cPort) {
return;
}
const urlPath = decodeURIComponent(req.url.split('?')[0]);
const candidate = resolve(join(ROOT, normalize(urlPath)));
const allowed = allowedRoots.some((root) => candidate === root || candidate.startsWith(root + '/'));
if (!allowed || !existsSync(candidate)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('not found');
return;
}
res.writeHead(200, { 'Content-Type': MIME[extname(candidate)] ?? 'application/octet-stream' });
createReadStream(candidate).pipe(res);
serveStaticAsset(ROOT, req, res, MIME);
});
return new Promise((resolveServer, rejectServer) => {
+39
View File
@@ -0,0 +1,39 @@
import { createReadStream, realpathSync, statSync } from 'node:fs';
import { resolve, extname, sep } from 'node:path';
/** Serve the smoke fixture and installed assets only, including after resolving symlinks. */
export function serveStaticAsset(root, req, res, mime = {}) {
const notFound = () => {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('not found');
};
let candidate;
try {
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
candidate = resolve(root, `.${urlPath}`);
} catch {
notFound();
return;
}
for (const directory of ['spike', 'node_modules']) {
const allowedRoot = resolve(root, directory);
if (!candidate.startsWith(allowedRoot + sep)) continue;
try {
const realRoot = realpathSync(allowedRoot);
const realCandidate = realpathSync(candidate);
if (!realCandidate.startsWith(realRoot + sep)) continue;
if (!statSync(realCandidate).isFile()) break;
const stream = createReadStream(realCandidate);
stream.once('error', () => {
if (!res.headersSent) notFound();
else res.destroy();
});
res.writeHead(200, { 'Content-Type': mime[extname(realCandidate)] ?? 'application/octet-stream' });
stream.pipe(res);
return;
} catch {
break;
}
}
notFound();
}
+32 -3
View File
@@ -277,8 +277,20 @@ try {
# Validate the zip namespace before extraction. Windows paths are
# case-insensitive, so two entries that differ only in case are ambiguous and
# must never be allowed to overwrite each other. The official five entries are
# must never be allowed to overwrite each other. The official four entries are
# required at the archive root with their exact release names.
#
# Two failure classes are reported differently (both still exit 1; nothing is
# relaxed). An unsafe entry path or a duplicate/case-conflicting entry is a
# hostile archive shape. A well-formed archive whose root file SET differs from
# $WindowsArchiveNames already passed checksum verification above, so it is
# the published artifact for $BaseUrl -- the usual cause is this script being
# newer or older than that release (e.g. install.ps1 run from main ahead of
# the latest release, #1218). Calling that "unsafe" sends the user hunting for
# tampering that is not there. Every entry is screened for the hostile class
# BEFORE any layout verdict, so a bad path can never hide behind a layout
# message just because an unexpected entry happened to come first.
$LayoutMismatch = $false
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive")
@@ -286,6 +298,7 @@ try {
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
$archiveCounts = @{}
foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 }
$layoutProblem = $null
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace('\', '/')
$isDirectory = $entryName.EndsWith('/')
@@ -304,10 +317,16 @@ try {
throw "duplicate or case-conflicting zip entry: $($entry.FullName)"
}
if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) {
throw "archive contains an unexpected root entry: $($entry.FullName)"
if ($null -eq $layoutProblem) {
$layoutProblem = "archive contains an unexpected root entry: $($entry.FullName)"
}
continue
}
$archiveCounts[$entryName] = $archiveCounts[$entryName] + 1
}
# Every entry passed the hostile-shape checks; only now judge the layout.
$LayoutMismatch = $true
if ($null -ne $layoutProblem) { throw $layoutProblem }
foreach ($archiveName in $WindowsArchiveNames) {
if ($archiveCounts[$archiveName] -ne 1) {
throw "archive must contain exactly one $archiveName"
@@ -316,11 +335,21 @@ try {
if ($seen.Count -ne $WindowsArchiveNames.Count) {
throw "archive does not match the exact Windows release allowlist"
}
$LayoutMismatch = $false
} finally {
$zip.Dispose()
}
} catch {
Write-Host "error: unsafe or incomplete release archive: $_" -ForegroundColor Red
if ($LayoutMismatch) {
Write-Host "error: release archive layout does not match this installer: $_" -ForegroundColor Red
Write-Host " The archive matched the checksum published with it at $BaseUrl, so it is"
Write-Host " the genuine release artifact, built for a different installer version."
Write-Host " This installer expects: $($WindowsArchiveNames -join ', ')"
Write-Host " Fix: run the install.ps1 shipped inside that same release archive, or"
Write-Host " re-run this script after the next release is published."
} else {
Write-Host "error: unsafe release archive: $_" -ForegroundColor Red
}
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
exit 1
}
+101
View File
@@ -416,6 +416,33 @@ static TSNode resolve_lean_func_name(TSNode node, TSNode name) {
return cbm_find_child_by_kind(node, "ident");
}
// Haskell infix-form definition head (`a <+> b`, ``x `plus` y``): the defined
// name is the operator, not the left operand that is the head's first child. A
// backticked `infix_id` yields its inner variable (`plus`); a symbolic `operator`
// is returned as-is and parenthesised by cbm_func_name_node_text, matching the
// prefix form `(<+>) a b = ...` (#2440).
static TSNode resolve_haskell_infix_head_name(TSNode head) {
enum { INFIX_MIN_CHILDREN = 3, INFIX_OP_IDX = 1 };
TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator"));
if (ts_node_is_null(op) && ts_node_child_count(head) >= INFIX_MIN_CHILDREN) {
op = ts_node_child(head, INFIX_OP_IDX);
}
if (!ts_node_is_null(op)) {
const char *ok = ts_node_type(op);
if (strcmp(ok, "operator") == 0) {
return op;
}
if (strcmp(ok, "infix_id") == 0) {
TSNode v = cbm_find_child_by_kind(op, "variable");
if (!ts_node_is_null(v)) {
return v;
}
}
}
TSNode null_node = {0};
return null_node;
}
// Haskell: resolve function name from first named child (variable/name).
static TSNode resolve_haskell_func_name(TSNode node) {
if (ts_node_named_child_count(node) > 0) {
@@ -424,6 +451,9 @@ static TSNode resolve_haskell_func_name(TSNode node) {
if (strcmp(hk, "variable") == 0 || strcmp(hk, "name") == 0) {
return head;
}
if (strcmp(hk, "infix") == 0) {
return resolve_haskell_infix_head_name(head);
}
if (ts_node_named_child_count(head) > 0) {
TSNode v = ts_node_named_child(head, 0);
const char *vk = ts_node_type(v);
@@ -8893,6 +8923,68 @@ static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, cons
}
}
/* True for a C# 14 extension block. The vendored tree-sitter-c-sharp has no
* `extension` production, so `extension(string text) { ... }` parses as a
* constructor_declaration named `extension` and nothing flags it: has_error
* stays 0. A constructor carries its own type's name, so this shape can only be
* a genuine constructor inside a type literally called `extension` (#2071). */
static bool csharp_is_extension_block(TSNode node, TSNode class_node, const char *source) {
if (strcmp(ts_node_type(node), "constructor_declaration") != 0) {
return false;
}
TSNode name = resolve_method_name(node, CBM_LANG_CSHARP);
if (ts_node_is_null(name)) {
return false;
}
const size_t kw_len = sizeof("extension") - 1;
uint32_t len = ts_node_end_byte(name) - ts_node_start_byte(name);
if (len != kw_len || strncmp(source + ts_node_start_byte(name), "extension", kw_len) != 0) {
return false;
}
TSNode type_name = ts_node_child_by_field_name(class_node, TS_FIELD("name"));
if (!ts_node_is_null(type_name)) {
const char *type_text = source + ts_node_start_byte(type_name);
uint32_t tlen = ts_node_end_byte(type_name) - ts_node_start_byte(type_name);
/* A verbatim identifier names the same type: class @extension may
* declare a genuine constructor spelled extension(). */
if (tlen > 0 && type_text[0] == '@') {
type_text++;
tlen--;
}
if (tlen == kw_len && strncmp(type_text, "extension", kw_len) == 0) {
return false;
}
}
return true;
}
/* Lift the members of a misparsed C# 14 extension block onto the enclosing
* type. They sit in the block the grammar took for a constructor body, which
* nothing recurses into, so every one of them was dropped. Methods land there
* as local_function_statement; a property in the block parses as ERROR and is
* beyond recovery without a grammar that knows the construct (#2071). */
static void extract_csharp_extension_members(CBMExtractCtx *ctx, TSNode ext_node, TSNode class_node,
const char *class_qn, const CBMLangSpec *spec) {
TSNode body = cbm_find_child_by_kind(ext_node, "block");
if (ts_node_is_null(body)) {
return;
}
member_iter_t it;
member_iter_init(&it, body, ctx->language, false);
TSNode member;
while (member_iter_next(&it, &member)) {
if (strcmp(ts_node_type(member), "local_function_statement") != 0) {
continue;
}
TSNode name = resolve_method_name(member, ctx->language);
if (ts_node_is_null(name)) {
continue;
}
push_method_def(ctx, member, class_node, class_qn, spec, name);
}
member_iter_done(&it);
}
// Extract methods inside a class body
static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn,
const CBMLangSpec *spec) {
@@ -8906,6 +8998,15 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c
TSNode child;
while (member_iter_next(&it, &child)) {
/* C# 14 extension block: emit the members it holds as methods of the
* enclosing type, rather than the block itself as a constructor named
* `extension` with its members buried in the body (#2071). */
if (ctx->language == CBM_LANG_CSHARP &&
csharp_is_extension_block(child, class_node, ctx->source)) {
extract_csharp_extension_members(ctx, child, class_node, class_qn, spec);
continue;
}
if (ctx->language == CBM_LANG_OBJC &&
strcmp(ts_node_type(child), "implementation_definition") == 0) {
extract_objc_impl_methods(ctx, child, class_qn, spec);
+8
View File
@@ -1213,6 +1213,14 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source,
if (text && lang == CBM_LANG_NIX) {
cbm_nix_strip_attr_quotes(text);
}
/* Haskell infix-form operator definition: `a <+> b = …` resolves to the bare
* `operator` node `<+>`, while the prefix form `(<+>) a b = …` is named by its
* `prefix_id` text `(<+>)`. Parenthesise here, not in the resolver, so both
* forms mint the same name and the def name and the call-scope QN, which both
* route through this function, cannot disagree (#2440). */
if (text && lang == CBM_LANG_HASKELL && strcmp(ts_node_type(name_node), "operator") == 0) {
return cbm_arena_sprintf(a, "(%s)", text);
}
return text;
}
+227 -69
View File
@@ -20,7 +20,7 @@
* cbm_batch_ts_lsp_cross.
*
* Conventions (mirrors c_lsp / go_lsp):
* - All allocations go through ctx->arena; no malloc / free.
* - Persistent results use ctx->arena; temporary walk/index buffers use tracked allocations.
* - ts_emit_resolved_call requires a non-NULL enclosing_func_qn — calls outside any
* function (module top-level) are not emitted.
* - eval_depth caps at TS_LSP_MAX_EVAL_DEPTH to defend against pathological recursion.
@@ -28,6 +28,10 @@
#include "ts_lsp.h"
#include "../../../src/foundation/platform.h"
#include "../helpers.h"
#include "../../../src/foundation/log.h"
#include "../../../src/foundation/mem_core.h"
#include <limits.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
@@ -60,6 +64,7 @@ void cbm_ts_full_registry_builds_reset(void) {
static _Thread_local long g_ts_type_budget = -1;
static _Thread_local bool g_ts_type_budget_warned;
#ifdef CBM_ENABLE_TEST_SEAMS
static _Thread_local unsigned long g_ts_signature_lookup_work;
static _Thread_local int g_ts_max_member_lookup_steps;
static _Thread_local int g_ts_max_method_lookup_steps;
#endif
@@ -174,6 +179,9 @@ long cbm_ts_lsp_test_budget_remaining(void) {
bool cbm_ts_lsp_test_budget_warned(void) {
return g_ts_type_budget_warned;
}
unsigned long cbm_ts_lsp_test_signature_lookup_work(void) {
return g_ts_signature_lookup_work;
}
int cbm_ts_lsp_test_max_member_lookup_steps(void) {
return g_ts_max_member_lookup_steps;
}
@@ -3956,6 +3964,7 @@ void ts_lsp_init(TSLSPContext *ctx, CBMArena *arena, const char *source, int sou
if (!ctx)
return;
#ifdef CBM_ENABLE_TEST_SEAMS
g_ts_signature_lookup_work = 0;
g_ts_max_member_lookup_steps = 0;
g_ts_max_method_lookup_steps = 0;
#endif
@@ -4805,16 +4814,77 @@ static void register_file_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMFileRes
}
}
// Find the first `return EXPR` inside a body and evaluate EXPR's type. Returns
// UNKNOWN if the body has no return or every return is unresolvable.
// AST walk stacks for the passes below. The first chunk lives on the C stack, the
// same 256 slots the old fixed arrays used; a wider pending set grows on the heap
// through cbm_walk_stack_reserve and is released when the walk ends, so nothing
// is cut from the per-file result arena. A fixed cap used to stop pushing without
// a word, which skipped every top-level declaration past the 256th and left the
// return walk short of returns it had never reached (#2388).
enum { TS_WALK_STACK_INLINE = 256 };
static int ts_walk_pending_count(int top, uint32_t count, const char *walker) {
if (top < 0 || count > (uint32_t)(INT_MAX - top)) {
char pending[24];
snprintf(pending, sizeof(pending), "%llu", (unsigned long long)count + (unsigned)top);
cbm_log_error("extract.walk_stack_alloc_failed", "walker", walker, "pending", pending);
return -1;
}
return top + (int)count;
}
#ifdef CBM_ENABLE_TEST_SEAMS
int cbm_ts_lsp_test_walk_pending_count(int top, uint32_t count) {
return ts_walk_pending_count(top, count, "ts_lsp_test");
}
#endif
// Push every child of n in cursor order (one O(N) pass). The walk pops the stack,
// so children are visited last-child first: the order these walks have always
// used. False means the stack could not grow; cbm_walk_stack_reserve has logged it.
static bool ts_walk_push_children(TSNode **stack, int *cap, int *top, TSNode n,
const TSNode *inline_buf, const char *walker) {
uint32_t count = ts_node_child_count(n);
if (count == 0)
return true;
int need = ts_walk_pending_count(*top, count, walker);
if (need < 0 ||
!cbm_walk_stack_reserve((void **)stack, cap, need, sizeof(TSNode), inline_buf, walker))
return false;
TSTreeCursor cursor = ts_tree_cursor_new(n);
if (ts_tree_cursor_goto_first_child(&cursor)) {
uint32_t pushed = 0;
do {
(*stack)[(*top)++] = ts_tree_cursor_current_node(&cursor);
pushed++;
} while (pushed < count && ts_tree_cursor_goto_next_sibling(&cursor));
}
ts_tree_cursor_delete(&cursor);
return true;
}
static bool ts_walk_push(TSNode **stack, int *cap, int *top, TSNode n, const TSNode *inline_buf,
const char *walker) {
int need = ts_walk_pending_count(*top, 1, walker);
if (need < 0 ||
!cbm_walk_stack_reserve((void **)stack, cap, need, sizeof(TSNode), inline_buf, walker))
return false;
(*stack)[(*top)++] = n;
return true;
}
// Find a `return EXPR` inside a body and evaluate EXPR's type. Children are visited
// last-child first, so of several returns the one latest in the body is tried
// first; the first one that resolves wins. Returns UNKNOWN if the body has no
// return, every return is unresolvable, or the walk stack could not grow.
static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body) {
if (ts_node_is_null(body))
return cbm_type_unknown();
// Iterative DFS using a small fixed-size stack to avoid C-stack blowup on big bodies.
enum { STACK_CAP = 256 };
TSNode stack[STACK_CAP];
TSNode inline_stack[TS_WALK_STACK_INLINE];
TSNode *stack = inline_stack;
int cap = TS_WALK_STACK_INLINE;
int top = 0;
const CBMType *found = cbm_type_unknown();
stack[top++] = body;
while (top > 0) {
TSNode n = stack[--top];
@@ -4830,8 +4900,10 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body
if (ts_node_is_null(expr))
continue;
const CBMType *t = ts_eval_expr_type(ctx, expr);
if (t && !cbm_type_is_unknown(t))
return t;
if (t && !cbm_type_is_unknown(t)) {
found = t;
break;
}
continue;
}
// Don't recurse into nested function bodies — their returns belong to them.
@@ -4839,14 +4911,96 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body
strcmp(k, "arrow_function") == 0 || strcmp(k, "method_definition") == 0)
continue;
uint32_t cnt = ts_node_child_count(n);
for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) {
stack[top++] = ts_node_child(n, i);
}
if (!ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_return_walk"))
break; // "unknown" is the safe answer for a walk that could not finish
}
return cbm_type_unknown();
cbm_walk_stack_release(stack, inline_stack);
return found;
}
// These two AST passes patch the FIRST matching local registration, even when
// the general registry index prefers the last duplicate. Do not follow fallback
// or copy its entries into the overlay. The snapshot lives only across these
// passes, which mutate signatures but never append or rename registered funcs.
typedef struct {
CBMRegisteredFunc **slots;
size_t capacity;
} TSSignatureIndex;
static void ts_signature_work(void) {
#ifdef CBM_ENABLE_TEST_SEAMS
g_ts_signature_lookup_work++;
#endif
}
static size_t ts_signature_slot(const TSSignatureIndex *index, const char *qn) {
uint64_t hash = 0xcbf29ce484222325ULL;
for (const unsigned char *p = (const unsigned char *)qn; *p; p++) {
hash ^= *p;
hash *= 0x100000001b3ULL;
}
size_t slot = (size_t)hash & (index->capacity - 1);
for (;;) {
ts_signature_work();
if (!index->slots[slot] || strcmp(index->slots[slot]->qualified_name, qn) == 0)
return slot;
slot = (slot + 1) & (index->capacity - 1);
}
}
static TSSignatureIndex ts_signature_index(CBMTypeRegistry *reg) {
TSSignatureIndex index = {0};
if (reg->func_count <= 0 || (size_t)reg->func_count > SIZE_MAX / 2)
return index;
size_t capacity = 16;
while (capacity < (size_t)reg->func_count * 2) {
if (capacity > SIZE_MAX / 2)
return index;
capacity *= 2;
}
if (capacity > SIZE_MAX / sizeof(*index.slots))
return index;
index.slots = cbm_alloc(CBM_MEM_CLASS_EXTRACT, capacity * sizeof(*index.slots));
if (!index.slots)
return index; // Preserve the original lookup on allocation failure.
index.capacity = capacity;
memset(index.slots, 0, capacity * sizeof(*index.slots));
for (int i = 0; i < reg->func_count; i++) {
ts_signature_work();
CBMRegisteredFunc *func = &reg->funcs[i];
if (!func->qualified_name)
continue;
size_t slot = ts_signature_slot(&index, func->qualified_name);
if (!index.slots[slot])
index.slots[slot] = func;
}
return index;
}
static CBMRegisteredFunc *ts_signature_func(CBMTypeRegistry *reg, const TSSignatureIndex *index,
const char *qn) {
if (!qn)
return NULL;
if (index->slots)
return index->slots[ts_signature_slot(index, qn)];
for (int i = 0; i < reg->func_count; i++) {
ts_signature_work();
if (reg->funcs[i].qualified_name && strcmp(reg->funcs[i].qualified_name, qn) == 0)
return &reg->funcs[i];
}
return NULL;
}
#ifdef CBM_ENABLE_TEST_SEAMS
int cbm_ts_lsp_test_signature_func_index(CBMTypeRegistry *reg, const char *qn) {
TSSignatureIndex index = ts_signature_index(reg);
CBMRegisteredFunc *func = ts_signature_func(reg, &index, qn);
int result = func ? (int)(func - reg->funcs) : -1;
cbm_free(CBM_MEM_CLASS_EXTRACT, index.slots);
return result;
}
#endif
// Rebuild a function's CBMRegisteredFunc signature from the AST directly. extract_defs
// only handles type_identifier / generic_type / predefined_type for params, so function
// types (`(x: Foo) => void`), object types, tuple types etc. are missing from
@@ -4854,18 +5008,22 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body
//
// Called after `register_file_defs` so the func entries already exist; we only mutate
// in place.
static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) {
static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg,
const TSSignatureIndex *index) {
if (ts_node_is_null(root) || !reg || !ctx->module_qn)
return;
enum { STACK_CAP = 256 };
TSNode stack[STACK_CAP];
// Seeded with every top-level child, so a file with more than 256 of them
// needs the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the
// walk stops and the declarations not yet reached keep extract_defs' signature.
TSNode inline_stack[TS_WALK_STACK_INLINE];
TSNode *stack = inline_stack;
int cap = TS_WALK_STACK_INLINE;
int top = 0;
uint32_t nc = ts_node_child_count(root);
for (uint32_t i = 0; i < nc && top < STACK_CAP; i++)
stack[top++] = ts_node_child(root, i);
bool walking =
ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_rebuild_signatures");
while (top > 0) {
while (walking && top > 0) {
TSNode n = stack[--top];
if (ts_node_is_null(n))
continue;
@@ -4873,16 +5031,15 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR
// Recurse into export_statement and class bodies.
if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) {
uint32_t cnt = ts_node_child_count(n);
for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) {
stack[top++] = ts_node_child(n, i);
}
walking = ts_walk_push_children(&stack, &cap, &top, n, inline_stack,
"ts_lsp_rebuild_signatures");
continue;
}
if (strcmp(k, "class_declaration") == 0) {
TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body"));
if (!ts_node_is_null(body) && top < STACK_CAP)
stack[top++] = body;
if (!ts_node_is_null(body))
walking = ts_walk_push(&stack, &cap, &top, body, inline_stack,
"ts_lsp_rebuild_signatures");
continue;
}
@@ -4993,43 +5150,42 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR
// Build new signature and patch in registry.
const CBMType *rets[2] = {ret, NULL};
const CBMType *new_sig = cbm_type_func(ctx->arena, p_names, p_types, rets);
for (int fi = 0; fi < reg->func_count; fi++) {
if (!reg->funcs[fi].qualified_name)
continue;
if (strcmp(reg->funcs[fi].qualified_name, fqn) != 0)
continue;
CBMRegisteredFunc *func = ts_signature_func(reg, index, fqn);
if (func) {
// Preserve the ret type if AST gave UNKNOWN but the existing reg sig had one.
if (cbm_type_is_unknown(ret) && reg->funcs[fi].signature &&
reg->funcs[fi].signature->kind == CBM_TYPE_FUNC &&
reg->funcs[fi].signature->data.func.return_types &&
reg->funcs[fi].signature->data.func.return_types[0] &&
!cbm_type_is_unknown(reg->funcs[fi].signature->data.func.return_types[0])) {
const CBMType *preserved[2] = {reg->funcs[fi].signature->data.func.return_types[0],
NULL};
if (cbm_type_is_unknown(ret) && func->signature &&
func->signature->kind == CBM_TYPE_FUNC && func->signature->data.func.return_types &&
func->signature->data.func.return_types[0] &&
!cbm_type_is_unknown(func->signature->data.func.return_types[0])) {
const CBMType *preserved[2] = {func->signature->data.func.return_types[0], NULL};
new_sig = cbm_type_func(ctx->arena, p_names, p_types, preserved);
}
reg->funcs[fi].signature = new_sig;
break;
func->signature = new_sig;
}
}
cbm_walk_stack_release(stack, inline_stack);
}
// Convert NAMED references to TYPE_PARAM for any function whose declaration has a
// `type_parameters` block. Without this, generic functions like `function id<T>(x: T): T`
// register T as NAMED("module.T") instead of TYPE_PARAM("T"), defeating substitution.
static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) {
static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg,
const TSSignatureIndex *index) {
if (ts_node_is_null(root) || !reg || !ctx->module_qn)
return;
// Walk: function_declaration, class_declaration { method_definition }, plus exported.
enum { STACK_CAP = 256 };
TSNode stack[STACK_CAP];
// Seeded with every top-level child, so a file with more than 256 of them needs
// the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the walk stops
// and the generics not yet reached keep their NAMED parameters.
TSNode inline_stack[TS_WALK_STACK_INLINE];
TSNode *stack = inline_stack;
int cap = TS_WALK_STACK_INLINE;
int top = 0;
uint32_t nc = ts_node_child_count(root);
for (uint32_t i = 0; i < nc && top < STACK_CAP; i++)
stack[top++] = ts_node_child(root, i);
bool walking =
ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_type_params");
while (top > 0) {
while (walking && top > 0) {
TSNode n = stack[--top];
if (ts_node_is_null(n))
continue;
@@ -5037,16 +5193,15 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp
// Recurse into export_statement and class_body.
if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) {
uint32_t cnt = ts_node_child_count(n);
for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) {
stack[top++] = ts_node_child(n, i);
}
walking =
ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_type_params");
continue;
}
if (strcmp(k, "class_declaration") == 0) {
TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body"));
if (!ts_node_is_null(body) && top < STACK_CAP)
stack[top++] = body;
if (!ts_node_is_null(body))
walking =
ts_walk_push(&stack, &cap, &top, body, inline_stack, "ts_lsp_type_params");
continue;
}
@@ -5122,17 +5277,14 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp
}
// Patch the registered func's signature: rewrite NAMED → TYPE_PARAM.
for (int fi = 0; fi < reg->func_count; fi++) {
if (!reg->funcs[fi].qualified_name)
continue;
if (strcmp(reg->funcs[fi].qualified_name, fqn) != 0)
continue;
const CBMType *old_sig = reg->funcs[fi].signature;
CBMRegisteredFunc *func = ts_signature_func(reg, index, fqn);
if (func) {
const CBMType *old_sig = func->signature;
if (!old_sig)
break;
continue;
const CBMType *new_sig = cbm_type_substitute(ctx->arena, old_sig, names, args);
if (new_sig)
reg->funcs[fi].signature = new_sig;
func->signature = new_sig;
// Also store the type_param_names for later overload-by-types use.
const char **stored = (const char **)cbm_arena_alloc(
ctx->arena, (size_t)(tpc + 1) * sizeof(const char *));
@@ -5140,11 +5292,20 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp
for (int ti = 0; ti < tpc; ti++)
stored[ti] = names[ti];
stored[tpc] = NULL;
reg->funcs[fi].type_param_names = stored;
func->type_param_names = stored;
}
break;
}
}
cbm_walk_stack_release(stack, inline_stack);
}
// Build once after shape registration, then release before later passes may
// grow the registry. This keeps per-file lookup storage out of the result arena.
static void refine_ast_signatures(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) {
TSSignatureIndex index = ts_signature_index(reg);
rebuild_signatures_from_ast(ctx, root, reg, &index);
convert_signature_type_params(ctx, root, reg, &index);
cbm_free(CBM_MEM_CLASS_EXTRACT, index.slots);
}
// Parse a JSDoc comment block for `@param {T} name` and `@returns {T}` tags. For each
@@ -5789,8 +5950,7 @@ void cbm_run_ts_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
}
ast_sweep_shapes(&ctx, root, &reg);
rebuild_signatures_from_ast(&ctx, root, &reg);
convert_signature_type_params(&ctx, root, &reg);
refine_ast_signatures(&ctx, root, &reg);
apply_jsdoc_signatures(&ctx, root, &reg);
infer_implicit_returns(&ctx, root, &reg);
@@ -6043,8 +6203,7 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int
}
ast_sweep_shapes(&ctx, root, &overlay);
rebuild_signatures_from_ast(&ctx, root, &overlay);
convert_signature_type_params(&ctx, root, &overlay);
refine_ast_signatures(&ctx, root, &overlay);
apply_jsdoc_signatures(&ctx, root, &overlay);
infer_implicit_returns(&ctx, root, &overlay);
@@ -6259,8 +6418,7 @@ void cbm_run_ts_lsp_cross(CBMArena *arena, const char *source, int source_len,
}
ast_sweep_shapes(&ctx, root, &reg);
rebuild_signatures_from_ast(&ctx, root, &reg);
convert_signature_type_params(&ctx, root, &reg);
refine_ast_signatures(&ctx, root, &reg);
apply_jsdoc_signatures(&ctx, root, &reg);
infer_implicit_returns(&ctx, root, &reg);
+5
View File
@@ -72,6 +72,11 @@ typedef struct {
// for the calling thread, valid after a cbm_run_ts_lsp on the same thread.
long cbm_ts_lsp_test_budget_remaining(void);
bool cbm_ts_lsp_test_budget_warned(void);
// Registry entries/probes visited by the two AST signature passes in the last run.
unsigned long cbm_ts_lsp_test_signature_lookup_work(void);
// Inspect signature-index selection and checked walker addition without huge ASTs.
int cbm_ts_lsp_test_signature_func_index(CBMTypeRegistry *reg, const char *qn);
int cbm_ts_lsp_test_walk_pending_count(int top, uint32_t count);
// Maximum recursive calls in one member/method lookup during the last TS LSP run,
// including attempts rejected by the depth or visited-type guard.
int cbm_ts_lsp_test_max_member_lookup_steps(void);
+3 -3
View File
@@ -15,9 +15,9 @@
build==1.3.0 \
--hash=sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397 \
--hash=sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
certifi==2026.10.19 \
--hash=sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae \
--hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b
cffi==2.1.1 \
--hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
--hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
+56 -5
View File
@@ -32,6 +32,15 @@ Options:
--with-ui Build the web UI as a content-addressed sidecar (needs node).
--version V Stamp the version string (release venue passes the tag).
--arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta.
--vendored-only Build only the third-party objects (make cbm-third-party)
into a clean BUILD_DIR, no binary.
--reuse-vendored Do NOT clean BUILD_DIR: build the binary on top of a
--vendored-only run with the same arguments, compiling
first-party code only. The pair exists for CodeQL's light
PR scan (codeql.yml): the first runs before tracing starts.
--retrace-from F With --reuse-vendored: F lists vendored paths (one per
line) whose objects must be rebuilt anyway (make -W), so a
vendored file the PR changes is compiled under tracing too.
-h, --help This text.
Make passthrough (VAR=VAL, forwarded verbatim):
@@ -79,6 +88,8 @@ source "$ROOT/scripts/path-safety.sh"
# containerized legs can build in their own directory instead of deleting and
# clobbering the host's native build/c artifacts.
WITH_UI=false
PHASE=all
RETRACE_FROM=""
VERSION=""
BUILD_DIR="build/c"
EXTRA_MAKE_ARGS=()
@@ -94,7 +105,13 @@ for arg in "$@"; do
--with-ui)
WITH_UI=true
;;
--version)
--vendored-only)
PHASE=vendored
;;
--reuse-vendored)
PHASE=reuse
;;
--version|--retrace-from)
prev_arg="$arg"
continue
;;
@@ -122,6 +139,8 @@ for arg in "$@"; do
# a usage error, not a silent make argument.
if [[ "${prev_arg:-}" == "--version" ]]; then
VERSION="$arg"
elif [[ "${prev_arg:-}" == "--retrace-from" ]]; then
RETRACE_FROM="$arg"
else
echo "build.sh: unexpected argument '$arg'. Please consult --help." >&2
exit 2
@@ -144,15 +163,47 @@ echo " ui=$WITH_UI version=${VERSION:-dev}"
# Verify compiler supports target arch
verify_compiler "$CC"
# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that)
cbm_remove_build_dir "$ROOT" "$BUILD_DIR"
if [[ "$PHASE" != all ]] && $WITH_UI; then
echo "build.sh: --vendored-only / --reuse-vendored do not combine with --with-ui. Please consult --help." >&2
exit 2
fi
# --retrace-from: each listed path becomes `make -W <path>` (treated as changed
# now -- the retrace helper supplies direct compilation entrypoints, so nested
# includes do not depend on Makefile wildcard coverage; no timestamp race).
RETRACE_ARGS=()
if [[ -n "$RETRACE_FROM" ]]; then
if [[ "$PHASE" != reuse || ! -f "$RETRACE_FROM" ]]; then
echo "build.sh: --retrace-from needs --reuse-vendored and an existing list file. Please consult --help." >&2
exit 2
fi
while IFS= read -r retrace_path || [[ -n "$retrace_path" ]]; do
[[ -n "$retrace_path" ]] && RETRACE_ARGS+=(-W "$retrace_path")
done < "$RETRACE_FROM"
fi
# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that).
# --reuse-vendored keeps the third-party objects of the --vendored-only run.
if [[ "$PHASE" == reuse ]]; then
if [[ ! -d "$BUILD_DIR" ]]; then
echo "build.sh: --reuse-vendored needs a --vendored-only run in $BUILD_DIR first." >&2
exit 2
fi
else
cbm_remove_build_dir "$ROOT" "$BUILD_DIR"
fi
# Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS)
if $WITH_UI; then
if [[ "$PHASE" == vendored ]]; then
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 \
CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}"
else
make -j"$NPROC" -f Makefile.cbm cbm \
make -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \
CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}"
fi
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# codeql-retrace-vendored.sh — prebuilt C/C++ translation units to retrace,
# for the PR c-cpp CodeQL scan (.github/workflows/codeql.yml).
#
# A PR's c-cpp scan compiles the third-party objects BEFORE CodeQL starts
# tracing (scripts/build.sh --vendored-only), so vendored C stays out of the
# analysis, while ALL first-party C is traced -- except the vendored files the
# PR itself changes, including grammar/runtime wrappers. This lists their
# compilation entrypoints; scripts/build.sh --reuse-vendored --retrace-from
# <list> then rebuilds their objects under tracing. Use direct prerequisites
# for make -W: its shallow wildcard dependencies miss nested headers/sources,
# and compiler include paths can contain aliases such as ../unix/prim.c.
#
# The changed files come from the PR merge commit (HEAD^1 is the base it was
# merged onto, as in pr.yml's file-list fallback; needs fetch-depth 2) unless
# --changed FILE gives them. An undeterminable diff (no pull request, no
# parent, git failing, an unreadable FILE) is NOT an error and never narrows
# the scan: the list is empty and the scan still covers all first-party C.
#
# Usage: scripts/ci/codeql-retrace-vendored.sh [--changed FILE] OUT
# OUT is written with the entrypoints to retrace, one per line (may be
# empty). The scope test is scripts/ci/select-lanes.sh --codeql-scope c-cpp.
# Exit: 0 = list written · 2 = usage error.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CHANGED_FILE=""
OUT=""
while [ $# -gt 0 ]; do
case "$1" in
-h | --help)
sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
--changed)
CHANGED_FILE="${2:-}"
[ -n "$CHANGED_FILE" ] || {
echo "codeql-retrace-vendored.sh: --changed needs a file. Please consult --help." >&2
exit 2
}
shift
;;
-*)
echo "codeql-retrace-vendored.sh: unknown option '$1'. Please consult --help." >&2
exit 2
;;
*)
if [ -n "$OUT" ]; then
echo "codeql-retrace-vendored.sh: more than one output file. Please consult --help." >&2
exit 2
fi
OUT="$1"
;;
esac
shift
done
if [ -z "$OUT" ]; then
echo "codeql-retrace-vendored.sh: no output file. Please consult --help." >&2
exit 2
fi
changed=""
determined=false
if [ -n "$CHANGED_FILE" ]; then
if [ -r "$CHANGED_FILE" ]; then
changed=$(cat "$CHANGED_FILE")
determined=true
fi
elif [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then
if changed=$(git -C "$ROOT" diff --name-only --no-renames HEAD^1 HEAD 2>/dev/null); then
determined=true
fi
fi
: >"$OUT"
if ! $determined; then
echo "diff undetermined: no vendored file to retrace; the scan covers all first-party C"
exit 0
fi
printf '%s\n' "$changed" |
bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp |
while IFS= read -r path; do
# Native Windows Python writes CRLF; read removes only the LF.
path="${path%$'\r'}"
entrypoints=()
case "$path" in
internal/cbm/grammar_*.c | internal/cbm/ts_runtime.c)
entrypoints=("$path")
;;
vendored/mimalloc/*) entrypoints=(vendored/mimalloc/src/static.c) ;;
vendored/sqlite3/*) entrypoints=(vendored/sqlite3/sqlite3.c) ;;
vendored/tre/*) entrypoints=(vendored/tre/tre_all.c) ;;
internal/cbm/vendored/lz4/*)
entrypoints=(internal/cbm/vendored/lz4/lz4.c internal/cbm/vendored/lz4/lz4hc.c)
;;
internal/cbm/vendored/zstd/*) entrypoints=(internal/cbm/vendored/zstd/zstd.c) ;;
internal/cbm/vendored/ts_runtime/*) entrypoints=(internal/cbm/ts_runtime.c) ;;
internal/cbm/vendored/grammars/*)
family="${path#internal/cbm/vendored/grammars/}"
wrapper="internal/cbm/grammar_${family%%/*}.c"
if [ -f "$ROOT/$wrapper" ]; then
entrypoints=("$wrapper")
else
# Shared or unfamiliar grammar code can have many consumers.
entrypoints=("$ROOT"/internal/cbm/grammar_*.c)
fi
;;
internal/cbm/vendored/common/*)
entrypoints=("$ROOT"/internal/cbm/grammar_*.c)
;;
internal/cbm/vendored/* | vendored/*)
# Other vendored code is compiled with the first-party build.
entrypoints=("$path")
;;
esac
for entrypoint in "${entrypoints[@]+"${entrypoints[@]}"}"; do
entrypoint="${entrypoint#"$ROOT"/}"
if [ -f "$ROOT/$entrypoint" ]; then printf '%s\n' "$entrypoint"; fi
done
done | LC_ALL=C sort -u >"$OUT"
count=$(wc -l <"$OUT" | tr -d ' ')
echo "prebuilt translation units to retrace: $count"
cat "$OUT"
+3 -2
View File
@@ -66,12 +66,13 @@ if 'LANES' in os.environ:
single = {
'lint / lint': 'lint', 'lint / lint-mem': 'lint-mem',
'security / security-static': 'security-static',
'security / license-gate': 'license-gate', 'security / codeql-gate': 'codeql-gate',
'security / license-gate': 'license-gate',
'test / test-diag': 'diag', 'test / test-msan': 'msan',
'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards',
'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts',
}
matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (')
matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (',
'codeql / analyze (')
def lane_of(name):
if name in single:
return single[name]
+112 -8
View File
@@ -30,13 +30,19 @@
# replay's single process).
# scripts/ci/select-lanes.sh --list-lanes
# Every lane name, one per line (the universe ci-ok checks against).
# scripts/ci/select-lanes.sh --codeql-scope LANGUAGE [FILE]
# The paths of FILE (default stdin) in that CodeQL language's PR
# scope (c-cpp, javascript-typescript, python, actions), one per line.
# The codeql-* lanes are selected by these scopes; the interpreted
# ones follow .github/codeql/codeql-config.yml (paths/paths-ignore).
# Exit: 0 = decided · 2 = usage error.
set -euo pipefail
usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; }
usage() { sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; }
FORMAT=json
MODE="select"
CODEQL_LANGUAGE=""
INPUT=""
EXPECT=""
while [ $# -gt 0 ]; do
@@ -56,6 +62,11 @@ while [ $# -gt 0 ]; do
[ $# -gt 1 ] && shift
;;
--list-lanes) MODE="list" ;;
--codeql-scope)
MODE="codeql-scope"
CODEQL_LANGUAGE="${2:-}"
[ $# -gt 1 ] && shift
;;
--expect-files)
# An empty value (a workflow whose count went missing) must fail
# loudly, never quietly skip the truncation check.
@@ -101,20 +112,29 @@ fi
# The heredoc below is the program, so stdin input is staged in a file first.
STAGED=""
if [ "$MODE" = select ] && [ -z "$INPUT" ]; then
case "$MODE:$CODEQL_LANGUAGE" in
codeql-scope:c-cpp | codeql-scope:javascript-typescript | codeql-scope:python | codeql-scope:actions | select:* | batch:* | list:*) ;;
*)
echo "select-lanes.sh: --codeql-scope takes c-cpp, javascript-typescript, python or actions. Please consult --help." >&2
exit 2
;;
esac
if { [ "$MODE" = select ] || [ "$MODE" = codeql-scope ]; } && [ -z "$INPUT" ]; then
STAGED=$(mktemp "${TMPDIR:-/tmp}/cbm-select-lanes.XXXXXX")
trap 'rm -f "$STAGED"' EXIT
cat >"$STAGED"
INPUT="$STAGED"
fi
python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" <<'PY'
CODEQL_CONFIG="$(cd "$(dirname "$0")/../.." && pwd)/.github/codeql/codeql-config.yml"
python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" "$CODEQL_CONFIG" "$CODEQL_LANGUAGE" <<'PY'
import json
import re
import sys
mode, fmt, source = sys.argv[1:4]
expect = int(sys.argv[4]) if sys.argv[4] else None
codeql_config, codeql_language = sys.argv[5:7]
# ── Path -> category (first matching rule wins; order is part of the rule) ──
GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml",
@@ -192,6 +212,8 @@ def category(path):
return "grammar"
if path.startswith(".github/workflows/"):
return "ci-gate" if base in GATE_WORKFLOWS else "ci-other"
if path.startswith(".github/codeql/"):
return "ci-gate"
if path.startswith(".github/"):
if path.endswith(".md") or ("ISSUE_TEMPLATE" in path and path.endswith((".yml", ".yaml"))):
return "docs"
@@ -293,11 +315,16 @@ def tier_of(cats):
# ── Tier -> lanes (R1) ──
FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos",
# The PR CodeQL scans, one lane per language (pr.yml's `codeql` matrix). Each
# is selected by a change in its scope (codeql_scope below) and, like every
# lane, by any full run: an undeterminable diff scans every language.
CODEQL_LANES = {"c-cpp": "codeql-cpp", "javascript-typescript": "codeql-js",
"python": "codeql-py", "actions": "codeql-actions"}
FULL = {"diag", "license-gate", "lint", "lint-mem", "lsan-macos",
"memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness",
"smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86",
"unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows",
"windows-guards"}
"windows-guards"} | set(CODEQL_LANES.values())
UNIVERSE = FULL | {"contracts"} # contracts: the T0a/T0b job; every test leg runs them too
LINT = {"lint", "lint-mem"}
CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"}
@@ -308,9 +335,9 @@ R1 = {
# The contract steps police test-infrastructure/ and lint config too.
"T0b-nonproduct": LINT | {"contracts"},
"T1-tests": LINT | CORE_LEGS,
"T2-extract": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"},
"T3-mid": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"},
"T4-platform": FULL - {"msan", "diag", "memwaste"},
"T2-extract": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"},
"T3-mid": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"},
"T4-platform": FULL - {"msan", "diag", "memwaste"} - set(CODEQL_LANES.values()),
"T5-core": FULL,
}
# Category add-ons: (categories, lanes). pkg adds nothing to a docs-only set
@@ -328,6 +355,73 @@ HARNESS = {"scripts/test.sh", "scripts/run-tests-parallel.sh", "scripts/run-test
"tests/test_main.c", "tests/test_framework.h"}
# ── CodeQL PR scope (the codeql-* lanes) ──
# c-cpp: any C/C++ the production build can compile -- first-party AND
# vendored (a vendored-only change is scanned: codeql.yml traces the vendored
# files a PR changes). Tests are not in the analysed build.
# Interpreted languages: their extensions inside the CodeQL config's
# paths minus paths-ignore (.github/codeql/codeql-config.yml, read below).
# A change to the CodeQL machinery itself scans every language (fail closed);
# those paths are full runs anyway, this keeps that true if a tier moves.
CODEQL_C_EXT = (".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".inc")
CODEQL_C_ROOTS = ("src/", "internal/", "vendored/")
CODEQL_EXT = {"javascript-typescript": (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx",
".mts", ".cts", ".vue", ".html", ".htm"),
"python": (".py",)}
CODEQL_FAIL_CLOSED_PREFIXES = (".github/workflows/", ".github/codeql/")
CODEQL_FAIL_CLOSED = {"Makefile.cbm", "scripts/build.sh", "scripts/ci/select-lanes.sh",
"scripts/ci/codeql-retrace-vendored.sh"}
def config_lists(path):
"""paths / paths-ignore of the CodeQL config: `key:` then ` - item` lines.
None when the file is unreadable -- every interpreted scope then matches
by extension alone (fail closed)."""
try:
lines = open(path, encoding="utf-8").read().splitlines()
except OSError:
return None
lists, key = {"paths": [], "paths-ignore": []}, None
for line in lines:
if not line.strip() or line.lstrip().startswith("#"):
continue
m = re.match(r"^([A-Za-z-]+):\s*$", line)
if m:
key = m.group(1) if m.group(1) in lists else None
continue
m = re.match(r"^\s+-\s+['\"]?([^'\"#]+?)['\"]?\s*$", line)
if m and key:
lists[key].append(m.group(1).rstrip("/"))
elif not line.startswith((" ", "\t")):
key = None
return lists
CODEQL_PATHS = config_lists(codeql_config)
def under(path, prefixes):
return any(path == p or path.startswith(p + "/") for p in prefixes)
def codeql_fail_closed(path):
return path in CODEQL_FAIL_CLOSED or path.startswith(CODEQL_FAIL_CLOSED_PREFIXES)
def codeql_scope(language, path):
if codeql_fail_closed(path):
return True
if language == "c-cpp":
return path.startswith(CODEQL_C_ROOTS) and path.endswith(CODEQL_C_EXT)
if CODEQL_PATHS is not None and (not under(path, CODEQL_PATHS["paths"])
or under(path, CODEQL_PATHS["paths-ignore"])):
return False
if language == "actions":
return path.startswith(".github/workflows/") or \
path.rsplit("/", 1)[-1] in ("action.yml", "action.yaml")
return path.endswith(CODEQL_EXT[language])
def triggers(path, cat):
found = []
if path == "scripts/ci/select-lanes.sh":
@@ -380,6 +474,12 @@ def select(paths, expect=None):
lanes = lanes | extra
if lanes & TEST_LEGS:
lanes = lanes | {"shard-completeness"}
for language, lane in sorted(CODEQL_LANES.items()):
hits = [p for p in paths if codeql_scope(language, p)]
if hits and lane not in lanes:
more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else ""
reasons.append(f"codeql {language}: {hits[0]}{more}")
lanes = lanes | {lane}
for trigger, hits in by_trigger.items():
more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else ""
reasons.append(f"full: {trigger}: {hits[0]}{more}")
@@ -407,6 +507,10 @@ def read_paths(path):
if mode == "list":
print("\n".join(sorted(UNIVERSE)))
elif mode == "codeql-scope":
scoped = sorted({p for p in read_paths(source) if p and codeql_scope(codeql_language, p)})
if scoped:
print("\n".join(scoped))
elif mode == "batch":
with open(source, encoding="utf-8") as fh:
for line in fh:
@@ -2,12 +2,14 @@
# pushes, from the lane analysis ground truth: which lane caught each. The
# replay asserts the selector selects that lane for that push.
# Columns (TAB): run_id, attempt, pr, head_sha, lane, classification.
# The three codeql-action-bump failures were caught by the codeql-gate lane;
# CodeQL now runs inside PR CI as the codeql-* lanes, so their lane is codeql-cpp.
34263688699 1 2115 013e1f7ec7d4b981a1cd28bf35d5767c195f8447 unix-arm64 real: cli suite hang on slow runners (arm64, then intel)
34288397854 1 2115 92cb3b9a4bebbc79bd283e447104669ca068ea64 unix-arm64 real: cli suite hang on slow runners (arm64, then intel)
34649406520 1 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel)
34649406520 2 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel)
34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
34696991657 1 2115 9a460b389ce3002ac164605c3627bb86aa3b9be3 unix-macos-intel real: cli suite hang on slow runners (arm64, then intel)
34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 lsan-macos real: macOS-only LSan leaks (memory-core)
34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 msan real?: MSan stack overflow (memory-core, branch-specific)
@@ -23,4 +25,4 @@
35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-mac real: cache CLI broken on macOS/Windows
35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-win real: cache CLI broken on macOS/Windows
35493947102 1 2236 2caafc0c41bd083092afeb692824e718a8e1e007 smoke-mac real: cache CLI broken on macOS/Windows
36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze)
1 # The 22 real (non-flake, non-infra) job failures of the September 2026 PR
2 # pushes, from the lane analysis ground truth: which lane caught each. The
3 # replay asserts the selector selects that lane for that push.
4 # Columns (TAB): run_id, attempt, pr, head_sha, lane, classification.
5 # The three codeql-action-bump failures were caught by the codeql-gate lane;
6 # CodeQL now runs inside PR CI as the codeql-* lanes, so their lane is codeql-cpp.
7 34263688699
8 34288397854
9 34649406520
10 34649406520
11 34676336284
12 34676429093
13 34696991657
14 34805101784
15 34805101784
25 35483768695
26 35483768695
27 35493947102
28 36242675954
+2
View File
@@ -383,6 +383,8 @@ bash "$ROOT/tests/test_setup_scripts_contract.sh"
# every September 2026 PR push and real failure against it.
echo "=== Step 0z: PR lane selector contract ==="
bash "$ROOT/tests/test_select_lanes.sh"
echo "=== Step 0z1: CodeQL retraces affected prebuilt objects ==="
bash "$ROOT/tests/test_codeql_retrace.sh"
echo "=== Step 0z2: lane selector history replay ==="
bash "$ROOT/scripts/test-impact/replay-selector.sh"
echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ==="
+141 -41
View File
@@ -23,6 +23,7 @@
#include "foundation/platform.h"
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/mem_core.h"
#include "foundation/sha256.h"
#include "foundation/str_util.h"
#include "cli/client_adapter.h"
@@ -1125,36 +1126,50 @@ static bool is_executable(const char *path) {
#endif
}
/* Windows ships CLIs as extension-bearing shims -- .cmd from npm, .ps1 from
* mise, .exe from scoop -- so a bare-name probe never matches there. One
* definition serves both the PATH scan and the fallback-directory probe (#221). */
#ifdef _WIN32
static const char *const WIN_EXEC_EXTS[] = {".exe", ".cmd", ".bat", ".ps1", NULL};
#endif
/* Search for an executable named `name` in the PATH environment variable.
* Returns the full path in `out` (max out_sz) if found, else empty string. */
* Returns the full path in `out` (max out_sz) if found, else empty string.
*
* The PATH copy is sized to the REAL value, not to a fixed 4 KB stack buffer.
* That buffer was the first root cause of #221: a Windows dev machine's PATH
* routinely runs past 4096 characters, and cbm_safe_getenv() REFUSES a value it
* cannot fit rather than truncating it -- so find_in_path() returned false
* before looking at a single directory, and opencode (installed through
* mise/npm/scoop and present on PATH) read as "not installed". The same 4 KB
* cap hid entries on macOS/Linux too; the fix is not Windows-specific. */
static bool find_in_path(const char *name, char *out, size_t out_sz) {
char path_copy[CLI_BUF_4K];
if (!cbm_safe_getenv("PATH", path_copy, sizeof(path_copy), NULL)) {
char *path_copy = cbm_env_dup("PATH");
if (!path_copy) {
return false;
}
char *saveptr;
/* One exit. PATH is a full live copy of the environment, the scan has
* several ways to stop, and the previous shape returned straight out of the
* loop -- which is exactly what leaked a PATH-sized allocation per call. */
bool found = false;
char *saveptr = NULL;
char *dir = strtok_r(path_copy, PATH_DELIM, &saveptr);
while (dir) {
while (dir != NULL && !found) {
snprintf(out, out_sz, "%s/%s", dir, name);
if (is_executable(out)) {
return true;
}
found = is_executable(out);
#ifdef _WIN32
/* On Windows executables carry an extension (PATHEXT). A CLI like
* opencode is often installed as a .cmd / .ps1 / .exe shim (e.g. via
* mise or npm), so the bare-name probe above misses it (#221). Try the
* common executable extensions before moving to the next PATH entry. */
static const char *const win_exts[] = {".exe", ".cmd", ".bat", ".ps1", NULL};
for (int i = 0; win_exts[i]; i++) {
snprintf(out, out_sz, "%s/%s%s", dir, name, win_exts[i]);
if (is_executable(out)) {
return true;
}
/* PATHEXT variants for THIS entry, before moving to the next one. */
for (int i = 0; !found && WIN_EXEC_EXTS[i] != NULL; i++) {
snprintf(out, out_sz, "%s/%s%s", dir, name, WIN_EXEC_EXTS[i]);
found = is_executable(out);
}
#endif
dir = strtok_r(NULL, PATH_DELIM, &saveptr);
}
return false;
cbm_free(CBM_MEM_CLASS_OTHER, path_copy);
return found;
}
const char *cbm_find_cli(const char *name, const char *home_dir) {
@@ -1168,22 +1183,66 @@ const char *cbm_find_cli(const char *name, const char *home_dir) {
if (!home_dir || !home_dir[0]) {
return "";
}
/* PATH-independent fallbacks, tried in order. Every home-derived entry is
* built from the `home_dir` ARGUMENT and not from cbm_get_home_dir(): a
* dry-run scan or a test passes a synthetic home and must not be shown the
* host's agents. `/` separators are fine on Windows -- the wide-safe file
* layer accepts them, and the rest of this file already composes paths so. */
enum { NUM_PATHS = 5 };
char paths[NUM_PATHS][CLI_BUF_512];
snprintf(paths[0], sizeof(paths[0]), "/usr/local/bin/%s", name);
snprintf(paths[1], sizeof(paths[1]), "%s/.npm/bin/%s", home_dir, name);
snprintf(paths[2], sizeof(paths[2]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[3], sizeof(paths[3]), "%s/.cargo/bin/%s", home_dir, name);
#ifdef __APPLE__
snprintf(paths[4], sizeof(paths[4]), "/opt/homebrew/bin/%s", name);
memset(paths, 0, sizeof(paths));
int n = 0;
#ifdef _WIN32
/* Locations that were already probed on Windows before #221 and are real
* there: pipx installs into ~/.local/bin and cargo into ~/.cargo/bin, so
* replacing the list rather than extending it would make pipx- and
* cargo-installed agents disappear. */
snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name);
/* Windows-shaped locations (issue #221, second root cause): npm puts its
* global bin under %APPDATA%\npm, and mise and scoop expose shims under
* %LOCALAPPDATA%\mise\shims and %USERPROFILE%\scoop\shims. Each is read
* through its own environment seam, so the list stays testable. */
const char *appdata = cbm_app_config_dir();
if (appdata) {
snprintf(paths[n++], sizeof(paths[0]), "%s/npm/%s", appdata, name);
}
const char *localapp = cbm_app_local_dir();
if (localapp) {
snprintf(paths[n++], sizeof(paths[0]), "%s/mise/shims/%s", localapp, name);
}
snprintf(paths[n++], sizeof(paths[0]), "%s/scoop/shims/%s", home_dir, name);
#else
paths[4][0] = '\0';
snprintf(paths[n++], sizeof(paths[0]), "/usr/local/bin/%s", name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.npm/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name);
#ifdef __APPLE__
snprintf(paths[n++], sizeof(paths[0]), "/opt/homebrew/bin/%s", name);
#endif
for (int i = 0; i < NUM_RETRIES; i++) {
if (paths[i][0] && is_executable(paths[i])) {
#endif
/* NUM_PATHS, not NUM_RETRIES: both are 5 today, so the old bound only
* looked right by coincidence -- what the loop means is the list length. */
for (int i = 0; i < NUM_PATHS; i++) {
if (!paths[i][0]) {
continue;
}
if (is_executable(paths[i])) {
snprintf(buf, sizeof(buf), "%s", paths[i]);
return buf;
}
#ifdef _WIN32
/* The npm/mise/scoop shims carry an extension, so every fallback
* directory needs the same PATHEXT probe the PATH scan got. */
for (int e = 0; WIN_EXEC_EXTS[e] != NULL; e++) {
char extpath[CLI_BUF_512];
snprintf(extpath, sizeof(extpath), "%s%s", paths[i], WIN_EXEC_EXTS[e]);
if (is_executable(extpath)) {
snprintf(buf, sizeof(buf), "%s", extpath);
return buf;
}
}
#endif
}
return "";
}
@@ -4053,13 +4112,14 @@ static int cbm_reconcile_codex_hooks_command_detailed(const char *config_path, c
const char *command_windows,
cbm_toml_codex_hook_action_t action,
bool check_only,
cbm_toml_codex_hook_failure_t *failure) {
cbm_toml_codex_hook_failure_t *failure,
int *pretool_written) {
if (!config_path || !command || !command_windows) {
return CLI_ERR;
}
return cbm_toml_reconcile_codex_hooks_detailed(config_path, CODEX_HOOK_BEGIN, CODEX_HOOK_END,
command, command_windows, action,
check_only ? 1 : 0, failure) == 0
return cbm_toml_reconcile_codex_hooks_detailed(
config_path, CODEX_HOOK_BEGIN, CODEX_HOOK_END, command, command_windows, action,
check_only ? 1 : 0, failure, pretool_written) == 0
? CLI_OK
: CLI_ERR;
}
@@ -4067,7 +4127,7 @@ static int cbm_reconcile_codex_hooks_command(const char *config_path, const char
const char *command_windows,
cbm_toml_codex_hook_action_t action, bool check_only) {
return cbm_reconcile_codex_hooks_command_detailed(config_path, command, command_windows, action,
check_only, NULL);
check_only, NULL, NULL);
}
static int cbm_upsert_codex_hooks_command(const char *config_path, const char *command,
const char *command_windows) {
@@ -6509,6 +6569,35 @@ static int cbm_remove_paired_lifecycle_hooks_json(const char *settings_path,
return session_result == CLI_OK && subagent_result == CLI_OK ? CLI_OK : CLI_ERR;
}
/* Codex names its shell tool Bash and passes {"command": "..."} as tool_input,
* the payload hook-augment already augments for Claude Code. Only the entry whose
* command is exactly ours is touched; the user's own PreToolUse hooks stay. */
static int cbm_upsert_codex_pretool_hooks_json(const char *settings_path, const char *command,
const char *command_windows) {
return upsert_hooks_json((hooks_upsert_args_t){
.settings_path = settings_path,
.hook_event = "PreToolUse",
.matcher_str = "Bash",
.command_str = command,
.command_windows = command_windows,
.timeout_value = CMM_HOOK_TIMEOUT_SEC,
.match_command_exact = command,
});
}
static int cbm_remove_codex_pretool_hooks_json(const char *settings_path,
const char *canonical_command) {
if (!canonical_command) {
return CLI_ERR;
}
return remove_hooks_json((hooks_remove_args_t){
.settings_path = settings_path,
.hook_event = "PreToolUse",
.matcher_str = "Bash",
.match_command_exact = canonical_command,
});
}
static int cbm_remove_qwen_lifecycle_hooks(const char *settings_path, const char *binary_path,
bool windows) {
char command[CLI_BUF_8K];
@@ -9745,7 +9834,7 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
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,
true, &preflight_failure)
true, &preflight_failure, NULL)
: CLI_ERR;
if (preflight_result != CLI_OK) {
if (!g_install_plan) {
@@ -9788,19 +9877,29 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
plan_record("Codex CLI", "hook", hook_target);
} else {
bool hook_ok = true;
int pretool_written = 1;
if (!dry_run && use_hooks_json) {
hook_ok =
cbm_upsert_paired_lifecycle_hooks_json(hooks_json, command, command_windows,
NULL, CMM_HOOK_TIMEOUT_SEC) == CLI_OK &&
cbm_upsert_codex_pretool_hooks_json(hooks_json, command, command_windows) ==
CLI_OK &&
cbm_reconcile_codex_hooks_command(cp, command, command_windows,
CBM_TOML_CODEX_HOOK_REMOVE, false) == CLI_OK;
} else if (!dry_run) {
hook_ok = cbm_upsert_codex_hooks_command(cp, command, command_windows) == CLI_OK;
hook_ok = cbm_reconcile_codex_hooks_command_detailed(
cp, command, command_windows, CBM_TOML_CODEX_HOOK_UPSERT, false, NULL,
&pretool_written) == CLI_OK;
}
if (!hook_ok) {
record_agent_config_error(false, "Codex CLI", "hook_install", hook_target);
} else {
printf(" hooks: SessionStart + SubagentStart (dynamic graph context)\n");
printf(pretool_written
? " hooks: SessionStart + SubagentStart + PreToolUse Bash (dynamic "
"graph context)\n"
: " hooks: SessionStart + SubagentStart (dynamic graph context); "
"PreToolUse skipped: config.toml already has an inline "
"hooks.PreToolUse\n");
printf(" note: non-managed hooks require /hooks trust; definition changes "
"require re-trust\n");
}
@@ -12226,10 +12325,10 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char
installed_binary, hook_command_windows,
sizeof(hook_command_windows)) == CLI_OK;
cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE;
bool hook_preflight_ok =
hook_commands_ok && cbm_reconcile_codex_hooks_command_detailed(
cp, hook_command, hook_command_windows,
CBM_TOML_CODEX_HOOK_REMOVE, true, &preflight_failure) == CLI_OK;
bool hook_preflight_ok = hook_commands_ok && cbm_reconcile_codex_hooks_command_detailed(
cp, hook_command, hook_command_windows,
CBM_TOML_CODEX_HOOK_REMOVE, true,
&preflight_failure, NULL) == CLI_OK;
if (!hook_preflight_ok) {
printf("Codex CLI:\n");
fflush(stdout);
@@ -12264,7 +12363,8 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char
snprintf(hooks_json, sizeof(hooks_json), "%s/hooks.json", config_dir);
if (cbm_file_exists(hooks_json) &&
(!hook_command_ok ||
cbm_remove_paired_lifecycle_hooks_json(hooks_json, hook_command) != CLI_OK)) {
cbm_remove_paired_lifecycle_hooks_json(hooks_json, hook_command) != CLI_OK ||
cbm_remove_codex_pretool_hooks_json(hooks_json, hook_command) != CLI_OK)) {
record_agent_config_error(true, "Codex CLI", "json_hook_uninstall", hooks_json);
}
}
+79 -32
View File
@@ -3379,7 +3379,8 @@ static int toml_codex_inline_is_owned(const char *data, size_t start, size_t end
toml_string_t type = {0};
toml_string_t command = {0};
toml_string_t windows = {0};
const char *expected_matcher = event == 0 ? "startup|resume|clear|compact" : "*";
const char *expected_matcher =
event == 0 ? "startup|resume|clear|compact" : (event == 1 ? "*" : "Bash");
int result = 0;
if (toml_codex_take(data, end, &pos, "[") != TOML_EDIT_OK ||
toml_codex_take(data, end, &pos, "{") != TOML_EDIT_OK)
@@ -3432,11 +3433,15 @@ done:
toml_string_dispose(&windows);
return result;
}
/* include_pretool renders the current block: the lifecycle pair plus a PreToolUse
* Bash hook, so Codex shell searches get graph context in flow. Without it the
* function renders the previous block (lifecycle pair only), which installs up to
* v0.12 wrote and which upgrades and uninstalls must still recognize as owned. */
static int toml_codex_build_block(const char *command, const char *command_windows,
toml_buffer_t *block) {
int include_pretool, toml_buffer_t *block) {
char escaped[8192];
char escaped_windows[8192];
char rendered[24576];
char rendered[32768];
if (cbm_toml_escape_basic_string(command, escaped, sizeof(escaped)) != TOML_EDIT_OK ||
cbm_toml_escape_basic_string(command_windows, escaped_windows, sizeof(escaped_windows)) !=
TOML_EDIT_OK ||
@@ -3455,6 +3460,16 @@ static int toml_codex_build_block(const char *command, const char *command_windo
"type = \"command\"\ncommand = \"%s\"\n"
"command_windows = \"%s\"\ntimeout = 5\n",
escaped, escaped_windows, escaped, escaped_windows);
if (written > 0 && (size_t)written < sizeof(rendered) && include_pretool) {
int more = snprintf(rendered + written, sizeof(rendered) - (size_t)written,
"\n[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n\n"
"[[hooks.PreToolUse.hooks]]\n"
"type = \"command\"\ncommand = \"%s\"\n"
"command_windows = \"%s\"\ntimeout = 5\n",
escaped, escaped_windows);
written =
more > 0 && (size_t)more < sizeof(rendered) - (size_t)written ? written + more : -1;
}
return written > 0 && (size_t)written < sizeof(rendered)
? toml_buffer_append(block, rendered, (size_t)written)
: TOML_EDIT_ERR;
@@ -3485,19 +3500,24 @@ static size_t toml_codex_owned_span(const char *data, size_t len, const char *be
}
toml_assignment_dispose(&assignment);
}
toml_buffer_t block = {0};
toml_buffer_t expected = {0};
if (toml_codex_current_command_is_owned(values[0].data, values[0].len) &&
toml_codex_current_command_is_owned(values[1].data, values[1].len) &&
toml_codex_build_block(values[0].data, values[1].data, &block) == TOML_EDIT_OK &&
(marked ? toml_append_managed(&expected, begin_marker, end_marker, block.data, newline)
: toml_append_normalized_text(&expected, block.data, block.len, newline)) ==
TOML_EDIT_OK &&
expected.len <= len && memcmp(expected.data, data, expected.len) == 0) {
result = expected.len;
/* Either generation is ours: the current block with PreToolUse first, then the
* previous lifecycle-only block, so upgrade and uninstall never strand it. */
for (int include_pretool = 1; include_pretool >= 0 && result == 0U; --include_pretool) {
toml_buffer_t block = {0};
toml_buffer_t expected = {0};
if (toml_codex_current_command_is_owned(values[0].data, values[0].len) &&
toml_codex_current_command_is_owned(values[1].data, values[1].len) &&
toml_codex_build_block(values[0].data, values[1].data, include_pretool, &block) ==
TOML_EDIT_OK &&
(marked ? toml_append_managed(&expected, begin_marker, end_marker, block.data, newline)
: toml_append_normalized_text(&expected, block.data, block.len, newline)) ==
TOML_EDIT_OK &&
expected.len <= len && memcmp(expected.data, data, expected.len) == 0) {
result = expected.len;
}
toml_buffer_dispose(&expected);
toml_buffer_dispose(&block);
}
toml_buffer_dispose(&expected);
toml_buffer_dispose(&block);
done:
toml_string_dispose(&values[0]);
toml_string_dispose(&values[1]);
@@ -3509,14 +3529,21 @@ static int toml_codex_add_edit(toml_codex_edit_t *edits, size_t *count, size_t s
edits[(*count)++] = (toml_codex_edit_t){.start = start, .end = end};
return TOML_EDIT_OK;
}
/* Inline forms Codex may normalize our hooks to (#1432). SessionStart and
* SubagentStart must be ours or the edit is refused. A foreign inline PreToolUse
* is the user's own hook: it is kept, and *foreign_pretool tells the caller to
* leave PreToolUse out of the block, because TOML cannot hold an inline array and
* array-of-tables entries for the same key. */
static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_t *edits,
size_t *edit_count, int found[2],
size_t *edit_count, int found[3], int *foreign_pretool,
cbm_toml_codex_hook_failure_t *failure) {
toml_key_path_t wanted[2] = {{0}};
toml_key_path_t wanted[3] = {{0}};
toml_key_path_t scope = {0};
if (toml_parse_key_path("hooks.SessionStart", 0U, strlen("hooks.SessionStart"), &wanted[0]) !=
TOML_EDIT_OK ||
toml_parse_key_path("hooks.SubagentStart", 0U, strlen("hooks.SubagentStart"), &wanted[1]) !=
TOML_EDIT_OK ||
toml_parse_key_path("hooks.PreToolUse", 0U, strlen("hooks.PreToolUse"), &wanted[2]) !=
TOML_EDIT_OK) {
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_EDIT_BUILD);
goto error;
@@ -3554,9 +3581,12 @@ static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_EDIT_BUILD);
goto error;
}
int event = toml_key_path_equal(&full_key, &wanted[0])
? 0
: (toml_key_path_equal(&full_key, &wanted[1]) ? 1 : -1);
int event = -1;
for (int candidate = 0; candidate < 3 && event < 0; ++candidate) {
if (toml_key_path_equal(&full_key, &wanted[candidate])) {
event = candidate;
}
}
toml_key_path_dispose(&full_key);
if (event >= 0) {
size_t tail = assignment.value_end;
@@ -3564,12 +3594,14 @@ static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_
size_t payload_start = toml_codex_payload_start(data, len);
size_t edit_start = line.start == 0U ? payload_start : line.start;
cbm_toml_codex_hook_failure_t event_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE;
++found[event];
if (found[event] > 1) {
int owned = !assignment.multiline_value && tail == line.content_end &&
toml_codex_inline_is_owned(data, assignment.value_start,
assignment.value_end, event);
if (event == 2 && !owned) {
*foreign_pretool = 1;
} else if (++found[event] > 1) {
event_failure = CBM_TOML_CODEX_HOOK_FAILURE_CONFLICTING_HOOKS;
} else if (assignment.multiline_value || tail != line.content_end ||
!toml_codex_inline_is_owned(data, assignment.value_start,
assignment.value_end, event)) {
} else if (!owned) {
event_failure = CBM_TOML_CODEX_HOOK_FAILURE_AMBIGUOUS_OWNERSHIP;
} else if (toml_codex_add_edit(edits, edit_count, edit_start, line.full_end) !=
TOML_EDIT_OK) {
@@ -3600,14 +3632,19 @@ done:
toml_key_path_dispose(&scope);
toml_key_path_dispose(&wanted[0]);
toml_key_path_dispose(&wanted[1]);
toml_key_path_dispose(&wanted[2]);
return result;
}
int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *begin_marker,
const char *end_marker, const char *command,
const char *command_windows,
cbm_toml_codex_hook_action_t action, int check_only,
cbm_toml_codex_hook_failure_t *failure) {
cbm_toml_codex_hook_failure_t *failure,
int *pretool_written) {
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_NONE);
if (pretool_written) {
*pretool_written = 0;
}
if (!toml_valid_path(file_path) || !toml_valid_marker(begin_marker) ||
!toml_valid_marker(end_marker) || strcmp(begin_marker, end_marker) == 0 || !command ||
!command_windows ||
@@ -3616,7 +3653,7 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b
return TOML_EDIT_ERR;
}
toml_buffer_t block = {0};
if (toml_codex_build_block(command, command_windows, &block) != TOML_EDIT_OK) {
if (toml_codex_build_block(command, command_windows, 1, &block) != TOML_EDIT_OK) {
toml_buffer_dispose(&block);
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_COMMAND_RENDER);
return TOML_EDIT_ERR;
@@ -3687,15 +3724,23 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b
pos += owned_len - 1U;
}
}
int inline_found[2] = {0};
if (toml_codex_scan_inline(existing, existing_len, edits, &edit_count, inline_found, failure) !=
TOML_EDIT_OK) {
int inline_found[3] = {0};
int foreign_pretool = 0;
if (toml_codex_scan_inline(existing, existing_len, edits, &edit_count, inline_found,
&foreign_pretool, failure) != TOML_EDIT_OK) {
goto error;
}
if (markerless_count && (has_pair || inline_found[0] || inline_found[1])) {
if (markerless_count && (has_pair || inline_found[0] || inline_found[1] || inline_found[2])) {
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_CONFLICTING_HOOKS);
goto error;
}
if (foreign_pretool) {
toml_buffer_dispose(&block);
if (toml_codex_build_block(command, command_windows, 0, &block) != TOML_EDIT_OK) {
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_COMMAND_RENDER);
goto error;
}
}
for (size_t i = 0U; i < edit_count; ++i) {
for (size_t j = i + 1U; j < edit_count; ++j) {
if (edits[j].start < edits[i].start) {
@@ -3737,6 +3782,8 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b
output.len, &snapshot);
if (result != TOML_EDIT_OK) {
toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_CONFIG_WRITE);
} else if (pretool_written) {
*pretool_written = action == CBM_TOML_CODEX_HOOK_UPSERT && !foreign_pretool;
}
error:
toml_buffer_dispose(&output);
@@ -3749,5 +3796,5 @@ int cbm_toml_reconcile_codex_hooks(const char *file_path, const char *begin_mark
const char *command_windows, cbm_toml_codex_hook_action_t action,
int check_only) {
return cbm_toml_reconcile_codex_hooks_detailed(file_path, begin_marker, end_marker, command,
command_windows, action, check_only, NULL);
command_windows, action, check_only, NULL, NULL);
}
+6 -2
View File
@@ -98,12 +98,16 @@ typedef enum {
/* Returns a stable, content-free diagnostic name for a failure category. */
const char *cbm_toml_codex_hook_failure_name(cbm_toml_codex_hook_failure_t failure);
/* `failure` may be NULL. When non-NULL, it is reset to NONE on entry and remains
* NONE on success; failure categories never contain configuration content. */
* NONE on success; failure categories never contain configuration content.
* `pretool_written` may be NULL. When non-NULL it is set to 1 after a successful
* upsert that wrote the PreToolUse Bash hook, and to 0 when a foreign inline
* hooks.PreToolUse made the upsert keep only the lifecycle pair. */
int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *begin_marker,
const char *end_marker, const char *command,
const char *command_windows,
cbm_toml_codex_hook_action_t action, int check_only,
cbm_toml_codex_hook_failure_t *failure);
cbm_toml_codex_hook_failure_t *failure,
int *pretool_written);
int cbm_toml_reconcile_codex_hooks(const char *file_path, const char *begin_marker,
const char *end_marker, const char *command,
const char *command_windows, cbm_toml_codex_hook_action_t action,
+158 -45
View File
@@ -791,6 +791,8 @@ static void expr_free(cbm_expr_t *e) {
safe_str_free(&cur->cond.op);
safe_str_free(&cur->cond.value);
safe_str_free(&cur->cond.coalesce_default);
safe_str_free(&cur->cond.rhs_variable);
safe_str_free(&cur->cond.rhs_property);
for (int i = 0; i < cur->cond.in_value_count; i++) {
safe_str_free(&cur->cond.in_values[i]);
}
@@ -1081,9 +1083,49 @@ static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) {
return expr_leaf(c);
}
/* Free what a condition parser owns when it gives up mid-condition. */
static void cond_parse_fail_free(cbm_condition_t *c) {
cond_func_fields_free(c);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
safe_str_free(&c->coalesce_default);
safe_str_free(&c->rhs_variable);
safe_str_free(&c->rhs_property);
}
enum { COND_RHS_REF = 1 };
/* RHS operand that names a row value instead of a literal: var.prop, or a bare
* alias such as a WITH projection (#2591). Returns COND_RHS_REF when one was
* parsed, 0 when the next token is not one, CBM_NOT_FOUND with p->error set
* when it is malformed or a function call. */
static int parse_condition_rhs_ref(parser_t *p, cbm_condition_t *c) {
if (p->pos + SKIP_ONE < p->count && p->tokens[p->pos + SKIP_ONE].type == TOK_LPAREN) {
snprintf(p->error, sizeof(p->error),
"unsupported function '%s' on the right side of a WHERE comparison "
"(only literals and var.prop are supported)",
peek(p)->text);
return CBM_NOT_FOUND;
}
if (!check(p, TOK_IDENT)) {
return 0;
}
c->rhs_variable = heap_strdup(advance(p)->text);
if (match(p, TOK_DOT)) {
const cbm_token_t *prop = expect(p, TOK_IDENT);
if (!prop) {
return CBM_NOT_FOUND;
}
c->rhs_property = heap_strdup(prop->text);
}
return COND_RHS_REF;
}
/* Parse the operator + value tail shared by every condition subject
* (var[.prop] and multi-arg functions like coalesce(...)): IS [NOT] NULL,
* IN [...], or a comparison operator with a literal value. */
* IN [...], or a comparison operator with a literal value or a property
* reference (a.x = b.y). */
static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) {
/* IS NULL / IS NOT NULL */
if (check(p, TOK_IS)) {
@@ -1111,10 +1153,7 @@ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) {
c->op = parse_comparison_op(p);
if (!c->op) {
snprintf(p->error, sizeof(p->error), "unexpected operator at pos %d", peek(p)->pos);
cond_func_fields_free(c);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->coalesce_default);
cond_parse_fail_free(c);
return NULL;
}
@@ -1127,13 +1166,11 @@ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) {
} else if (check(p, TOK_FALSE)) {
advance(p);
c->value = heap_strdup("false");
} else {
snprintf(p->error, sizeof(p->error), "expected value at pos %d", peek(p)->pos);
cond_func_fields_free(c);
safe_str_free(&c->variable);
safe_str_free(&c->property);
safe_str_free(&c->op);
safe_str_free(&c->coalesce_default);
} else if (parse_condition_rhs_ref(p, c) <= 0) {
if (!p->error[0]) {
snprintf(p->error, sizeof(p->error), "expected value at pos %d", peek(p)->pos);
}
cond_parse_fail_free(c);
return NULL;
}
@@ -2227,6 +2264,8 @@ static void free_where(cbm_where_clause_t *w) {
free(w->conditions[i].in_values);
safe_str_free(&w->conditions[i].func);
func_args_free(w->conditions[i].args, w->conditions[i].arg_count);
safe_str_free(&w->conditions[i].rhs_variable);
safe_str_free(&w->conditions[i].rhs_property);
}
free(w->conditions);
safe_str_free(&w->op);
@@ -2732,6 +2771,59 @@ static bool eval_comparison_op(const char *op, const char *actual, const char *e
return false;
}
enum { EXISTS_DIR_OUT = 0, EXISTS_DIR_IN = 1 };
/* EXISTS { (var)-[:TYPE]->() }: does the bound node have any edge of the
* given type in the requested direction? (dir 0=out, 1=in, 2=any) */
static bool eval_exists_condition(const cbm_condition_t *c, binding_t *b) {
cbm_node_t *n = binding_get(b, c->variable);
bool result = false;
if (n && b->store) {
cbm_edge_t *edges = NULL;
int cnt = 0;
if (c->exists_dir != EXISTS_DIR_IN) { /* outbound or any */
if (c->value) {
cbm_store_find_edges_by_source_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_source(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
if (!result && c->exists_dir != EXISTS_DIR_OUT) { /* inbound or any */
edges = NULL;
cnt = 0;
if (c->value) {
cbm_store_find_edges_by_target_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_target(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
}
return c->negated ? !result : result;
}
/* a.x <op> b.y (#2591): the RHS operand is read from the same row. An unbound
* RHS alias does not filter, like an unbound LHS. A missing (empty) property
* on either side is NULL and the comparison is false, so two nodes that both
* lack a property do not compare equal. */
static bool eval_property_comparison(const cbm_condition_t *c, const char *actual, binding_t *b) {
cbm_condition_t rhs = {0};
rhs.variable = c->rhs_variable;
rhs.property = c->rhs_property;
const char *expected = resolve_condition_value(&rhs, b);
if (!expected) {
return true;
}
if (actual[0] == '\0' || expected[0] == '\0') {
return false;
}
bool result = eval_comparison_op(c->op, actual, expected);
return c->negated ? !result : result;
}
/* Evaluate a WHERE condition against a binding */
static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
/* Label test: WHERE n:Label (#241) — compare the bound node's label
@@ -2742,36 +2834,8 @@ static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
return c->negated ? !result : result;
}
/* EXISTS { (var)-[:TYPE]->() }: does the bound node have any edge of the
* given type in the requested direction? (dir 0=out, 1=in, 2=any) */
if (strcmp(c->op, "EXISTS") == 0) {
cbm_node_t *n = binding_get(b, c->variable);
bool result = false;
if (n && b->store) {
cbm_edge_t *edges = NULL;
int cnt = 0;
if (c->exists_dir != 1) { /* outbound or any */
if (c->value) {
cbm_store_find_edges_by_source_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_source(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
if (!result && c->exists_dir != 0) { /* inbound or any */
edges = NULL;
cnt = 0;
if (c->value) {
cbm_store_find_edges_by_target_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_target(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
}
return c->negated ? !result : result;
return eval_exists_condition(c, b);
}
const char *actual = resolve_condition_value(c, b);
@@ -2808,6 +2872,9 @@ static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
return c->negated ? !result : result;
}
if (c->rhs_variable) {
return eval_property_comparison(c, actual, b);
}
result = eval_comparison_op(c->op, actual, c->value);
return c->negated ? !result : result;
}
@@ -2899,12 +2966,24 @@ static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) {
: CYP_PARTIAL_UNKNOWN;
}
/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS
* (func condition with no variable arg) has nothing to wait for. */
static bool partial_alias_unbound(binding_t *b, const char *alias) {
return alias && !binding_get(b, alias) && !binding_get_edge(b, alias);
}
/* A condition is UNKNOWN while any alias it reads is not bound yet: its own
* variable, the RHS of a property comparison (the b in a.x = b.y), and every
* function argument's -- a func condition is filed under its first argument's
* alias, so the b in coalesce(a.x, b.name) is only seen here. A literal-only
* LHS has nothing to wait for. */
static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) {
if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) {
if (partial_alias_unbound(b, c->variable) || partial_alias_unbound(b, c->rhs_variable)) {
return CYP_PARTIAL_UNKNOWN;
}
for (int i = 0; i < c->arg_count; i++) {
if (partial_alias_unbound(b, c->args[i].variable)) {
return CYP_PARTIAL_UNKNOWN;
}
}
return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
}
@@ -5359,6 +5438,34 @@ static void ret_agg_emit_row(cbm_return_clause_t *ret, ret_agg_entry_t *agg, res
rb_add_row(rb, row);
}
/* True when every RETURN item is an aggregate, so the whole input is one group. */
static bool ret_agg_is_ungrouped(const cbm_return_clause_t *ret) {
for (int ci = 0; ci < ret->count; ci++) {
if (!is_aggregate_func(ret->items[ci].func)) {
return false;
}
}
return true;
}
/* The one row an ungrouped aggregation answers when no binding reached it
* (#2742): count and sum are 0, collect is an empty list, and avg/min/max have
* no value, rendered as "" like any other null. */
static void ret_agg_emit_empty_row(cbm_return_clause_t *ret, result_builder_t *rb) {
const char *row[CBM_SZ_32];
for (int ci = 0; ci < ret->count && ci < CBM_SZ_32; ci++) {
const char *func = ret->items[ci].func;
if (strcmp(func, "COUNT") == 0 || strcmp(func, "SUM") == 0) {
row[ci] = "0";
} else if (strcmp(func, "COLLECT") == 0) {
row[ci] = "[]";
} else {
row[ci] = "";
}
}
rb_add_row(rb, row);
}
static void ret_sink_feed_agg(ret_sink_t *s, binding_t *bindings, int bind_count) {
cbm_return_clause_t *ret = s->ret;
for (int bi = 0; bi < bind_count && !s->done; bi++) {
@@ -5575,6 +5682,11 @@ static void ret_sink_finish(ret_sink_t *s) {
for (int a = 0; a < s->agg_count; a++) {
ret_agg_emit_row(ret, &s->aggs[a], rb);
}
/* Without grouping keys the input is a single group even when it is
* empty; a grouped aggregation over no rows has no group to report. */
if (s->agg_count == 0 && ret_agg_is_ungrouped(ret)) {
ret_agg_emit_empty_row(ret, rb);
}
}
ret_sink_free(s);
@@ -5874,7 +5986,8 @@ static int cypher_cond_selectivity(const cbm_condition_t *c, const char *var) {
if (!c || !c->variable || !var || strcmp(c->variable, var) != 0 || c->negated) {
return 0;
}
if (!c->op || strcmp(c->op, "=") != 0 || !c->property) {
/* a.x = b.y pins no value on either alias, so it seeds nothing. */
if (!c->op || strcmp(c->op, "=") != 0 || !c->property || c->rhs_variable) {
return 0;
}
return (strcmp(c->property, "name") == 0 || strcmp(c->property, "qualified_name") == 0) ? 3 : 2;
+5
View File
@@ -210,6 +210,11 @@ typedef struct {
const char *func;
cbm_func_arg_t *args;
int arg_count;
/* Property reference on the RHS, e.g. a.file_path = b.file_path (#2591).
* When rhs_variable is set the comparison reads its operand from the row
* instead of `value`; rhs_property NULL = bare alias. */
const char *rhs_variable;
const char *rhs_property;
} cbm_condition_t;
/* Expression tree for WHERE clause */
+390 -137
View File
@@ -102,10 +102,25 @@ typedef enum {
struct cbm_daemon_application_watch {
char *project;
char *root;
char *index_mode;
bool persistence;
bool has_doc_candidates;
bool doc_candidates;
size_t subscribers;
cbm_daemon_application_watch_t *next;
};
typedef struct application_watch_membership {
cbm_daemon_application_watch_t *watch;
struct application_watch_membership *next;
} application_watch_membership_t;
typedef struct application_index_watch_owner {
cbm_daemon_application_session_t *session;
bool async;
struct application_index_watch_owner *next;
} application_index_watch_owner_t;
struct cbm_daemon_application_session {
cbm_daemon_application_t *application;
cbm_mcp_server_t *mcp;
@@ -119,7 +134,7 @@ struct cbm_daemon_application_session {
bool request_active;
cbm_daemon_runtime_application_token_t active_request_token;
cbm_daemon_runtime_application_token_t request_cancel_token;
cbm_daemon_application_watch_t *watch;
application_watch_membership_t *watches;
cbm_daemon_application_job_t *active_job;
bool active_job_subscribed;
cbm_daemon_application_job_t *auto_index_job;
@@ -159,6 +174,7 @@ struct cbm_daemon_application_job {
* job outlives the request and session that started it. */
bool async_owned;
bool worker_started;
application_index_watch_owner_t *watch_owners;
cbm_daemon_application_job_t *next;
};
@@ -232,7 +248,7 @@ struct cbm_daemon_application {
static void application_job_unsubscribe_locked(cbm_daemon_application_job_t *job);
static void application_watch_job_unsubscribe_session_locked(
cbm_daemon_application_session_t *session);
cbm_daemon_application_session_t *session, const cbm_daemon_application_watch_t *watch);
static bool application_watch_job_subscribe_late_session_locked(
cbm_daemon_application_session_t *session, cbm_daemon_application_watch_t *watch);
static bool application_unique_recovery_file(char out[APPLICATION_PATH_CAP], const char *kind);
@@ -449,8 +465,8 @@ static bool application_regular_db_exists(const char *project) {
if (written <= 0 || (size_t)written >= sizeof(path)) {
return false;
}
struct stat status;
return stat(path, &status) == 0 && S_ISREG(status.st_mode);
cbm_path_info_t status;
return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular;
}
static bool application_canonical_directory_exists(const char *path) {
@@ -458,6 +474,35 @@ static bool application_canonical_directory_exists(const char *path) {
return cbm_path_info_utf8(path, &info) == 0 && info.is_directory;
}
/* Compare canonical roots without changing the native spelling used by workspace policy. */
static bool application_watch_roots_equal(const char *left, const char *right, bool windows_paths) {
while (*left && *right) {
char left_char = *left++;
char right_char = *right++;
if (windows_paths) {
left_char = left_char == '\\' ? '/' : left_char;
right_char = right_char == '\\' ? '/' : right_char;
}
if (left_char != right_char) {
return false;
}
}
return *left == *right;
}
static bool application_watch_root_matches(const char *left, const char *right) {
#ifdef _WIN32
return application_watch_roots_equal(left, right, true);
#else
return application_watch_roots_equal(left, right, false);
#endif
}
bool cbm_daemon_application_watch_roots_equal_for_test(const char *left, const char *right,
bool windows_paths) {
return application_watch_roots_equal(left, right, windows_paths);
}
static cbm_daemon_application_watch_t *application_find_watch_locked(
cbm_daemon_application_t *application, const char *project) {
for (cbm_daemon_application_watch_t *watch = application->watches; watch; watch = watch->next) {
@@ -475,15 +520,21 @@ static void application_remove_watch_entry_locked(cbm_daemon_application_t *appl
while (*cursor && *cursor != watch) {
cursor = &(*cursor)->next;
}
if (*cursor != watch) {
if (!watch || *cursor != watch) {
return;
}
*cursor = watch->next;
for (cbm_daemon_application_session_t *session = application->sessions; session;
session = session->next) {
if (session->watch == watch) {
application_watch_job_unsubscribe_session_locked(session);
session->watch = NULL;
application_watch_membership_t **membership = &session->watches;
while (*membership && (*membership)->watch != watch) {
membership = &(*membership)->next;
}
if (*membership) {
application_watch_job_unsubscribe_session_locked(session, watch);
application_watch_membership_t *removed = *membership;
*membership = removed->next;
cbm_free(CBM_MEM_CLASS_OTHER, removed);
}
}
if (unregister_physical_watch && application->watcher) {
@@ -491,7 +542,8 @@ static void application_remove_watch_entry_locked(cbm_daemon_application_t *appl
}
free(watch->project);
free(watch->root);
free(watch);
cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode);
cbm_free(CBM_MEM_CLASS_OTHER, watch);
}
static void application_remove_watch_locked(cbm_daemon_application_t *application,
@@ -499,18 +551,32 @@ static void application_remove_watch_locked(cbm_daemon_application_t *applicatio
application_remove_watch_entry_locked(application, watch, true);
}
/* Registration and refresh can each be the first observer of the global
* switch. Drain memberships and physical watches together under the mutex. */
static bool application_watches_disabled_locked(cbm_daemon_application_t *application) {
if (!application->config ||
cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) {
return false;
}
while (application->watches) {
application_remove_watch_locked(application, application->watches);
}
return true;
}
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) {
cbm_daemon_application_watch_t *watch = session->watch;
if (!watch) {
return;
}
application_watch_job_unsubscribe_session_locked(session);
session->watch = NULL;
if (watch->subscribers > 0) {
watch->subscribers--;
}
if (watch->subscribers == 0) {
application_remove_watch_locked(session->application, watch);
application_watch_job_unsubscribe_session_locked(session, NULL);
while (session->watches) {
application_watch_membership_t *membership = session->watches;
cbm_daemon_application_watch_t *watch = membership->watch;
session->watches = membership->next;
cbm_free(CBM_MEM_CLASS_OTHER, membership);
if (watch->subscribers > 0) {
watch->subscribers--;
if (watch->subscribers == 0) {
application_remove_watch_locked(session->application, watch);
}
}
}
}
@@ -531,87 +597,198 @@ static bool application_session_workspace_allowed(const cbm_daemon_application_s
}
/* Caller holds application->mutex. */
static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) {
cbm_daemon_application_t *application = session->application;
static bool application_session_has_watch_locked(const cbm_daemon_application_session_t *session,
const cbm_daemon_application_watch_t *watch) {
for (application_watch_membership_t *membership = session->watches; membership;
membership = membership->next) {
if (membership->watch == watch) {
return true;
}
}
return false;
}
static bool application_watch_set_options(cbm_daemon_application_watch_t *watch,
const char *args_json) {
if (!args_json) {
return true;
}
yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0);
yyjson_val *args = doc ? yyjson_doc_get_root(doc) : NULL;
const char *mode = yyjson_get_str(yyjson_obj_get(args, "mode"));
char *mode_copy = mode ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, mode) : NULL;
if (!yyjson_is_obj(args) || (mode && !mode_copy)) {
cbm_free(CBM_MEM_CLASS_OTHER, mode_copy);
yyjson_doc_free(doc);
return false;
}
cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode);
watch->index_mode = mode_copy;
watch->persistence = yyjson_get_bool(yyjson_obj_get(args, "persistence"));
yyjson_val *doc_candidates = yyjson_obj_get(args, "doc_candidates");
watch->has_doc_candidates = yyjson_is_bool(doc_candidates);
watch->doc_candidates = yyjson_get_bool(doc_candidates);
yyjson_doc_free(doc);
return true;
}
static bool application_watch_registration_allowed(const cbm_daemon_application_session_t *session,
const char *project, const char *root) {
const cbm_daemon_application_t *application = session->application;
if (!application->watcher || !session->context_set ||
session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event ||
session->hook_dialect || session->auto_index_subscribed) {
session->hook_dialect || session->session_cancelled || application->stopping || !project ||
!project[0] || !root || !root[0]) {
return false;
}
char boundary_error[CBM_SZ_1K];
return cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(),
cbm_mcp_server_allowed_root(session->mcp), boundary_error,
sizeof(boundary_error));
}
static void application_register_watch_locked(cbm_daemon_application_session_t *session,
const char *project, const char *root,
const char *args_json) {
cbm_daemon_application_t *application = session->application;
if (application_watches_disabled_locked(application) ||
!application_watch_registration_allowed(session, project, root)) {
return;
}
const char *project = cbm_mcp_server_session_project(session->mcp);
const char *root = cbm_mcp_server_session_root(session->mcp);
if (!project || !project[0] || !root || !root[0]) {
cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project);
if (!application_regular_db_exists(project)) {
if (watch) {
application_remove_watch_locked(application, watch);
}
return;
}
if (watch && !application_watch_root_matches(watch->root, root)) {
cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root",
watch->root, "requested_root", root);
return;
}
if (watch && application_session_has_watch_locked(session, watch)) {
(void)application_watch_set_options(watch, args_json);
return;
}
application_watch_membership_t *membership =
cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*membership));
if (!membership) {
return;
}
if (watch) {
if (!application_watch_set_options(watch, args_json) ||
!application_watch_job_subscribe_late_session_locked(session, watch)) {
cbm_free(CBM_MEM_CLASS_OTHER, membership);
cbm_log_warn("daemon.watch.late_owner_allocation_failed", "project", project, "action",
"retry");
return;
}
} else {
watch = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*watch));
if (watch) {
watch->project = strdup(project);
watch->root = strdup(root);
}
if (!watch || !watch->project || !watch->root ||
!application_watch_set_options(watch, args_json) ||
!cbm_watcher_watch(application->watcher, project, root)) {
if (watch) {
free(watch->project);
free(watch->root);
cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode);
cbm_free(CBM_MEM_CLASS_OTHER, watch);
}
cbm_free(CBM_MEM_CLASS_OTHER, membership);
cbm_log_warn("daemon.watch.registration_failed", "project", project, "action", "retry");
return;
}
watch->next = application->watches;
application->watches = watch;
}
watch->subscribers++;
membership->watch = watch;
membership->next = session->watches;
session->watches = membership;
}
static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) {
cbm_daemon_application_t *application = session->application;
if (application_watches_disabled_locked(application)) {
return;
}
if (session->auto_index_subscribed || application_request_cancelled_locked(session)) {
return;
}
if (!application_session_workspace_allowed(session, "watch")) {
application_release_session_watch_locked(session);
return;
}
bool enabled = !application->config ||
cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true);
bool db_exists = application_regular_db_exists(project);
application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp),
cbm_mcp_server_session_root(session->mcp), NULL);
}
/* Disconnect cancellation is the logical ownership boundary. An
* in-flight request may reach this refresh after cancel returned but
* before runtime can join it and call session_close; it must never
* recreate that session's watch. */
if (application->stopping || application_request_cancelled_locked(session)) {
/* Pending registrations do not own an async job: the index may outlive its
* caller, but only a still-connected MCP session can acquire its watch. */
static void application_index_watch_subscribe_locked(cbm_daemon_application_session_t *session,
cbm_daemon_application_job_t *job,
bool async) {
if (session->one_shot_request || !session->background_eligible || session->session_cancelled ||
(session->application->config &&
!cbm_config_get_bool(session->application->config, CBM_CONFIG_AUTO_WATCH, true))) {
return;
}
cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project);
if (!enabled || !db_exists) {
/* A delete/config transition is global for this project. Remove the
* physical watch and clear every logical subscriber in one step. */
if (watch) {
application_remove_watch_locked(application, watch);
if (job->terminal) {
if (job->successful) {
application_register_watch_locked(session, job->project_key, job->root_path,
job->args_json);
}
return;
}
if (session->watch) {
return;
for (application_index_watch_owner_t *owner = job->watch_owners; owner; owner = owner->next) {
if (owner->session == session) {
owner->async = owner->async || async;
return;
}
}
if (watch) {
if (strcmp(watch->root, root) == 0) {
if (!application_watch_job_subscribe_late_session_locked(session, watch)) {
cbm_log_warn("daemon.watch.late_owner_allocation_failed", "project", project,
"action", "retry");
return;
application_index_watch_owner_t *owner = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*owner));
if (owner) {
owner->session = session;
owner->async = async;
owner->next = job->watch_owners;
job->watch_owners = owner;
}
}
static void application_index_watch_unsubscribe_locked(cbm_daemon_application_session_t *session,
cbm_daemon_application_job_t *target) {
for (cbm_daemon_application_job_t *job = session->application->jobs; job; job = job->next) {
if (target && job != target) {
continue;
}
application_index_watch_owner_t **cursor = &job->watch_owners;
while (*cursor) {
application_index_watch_owner_t *owner = *cursor;
if (owner->session == session && (!target || !owner->async)) {
*cursor = owner->next;
cbm_free(CBM_MEM_CLASS_OTHER, owner);
} else {
cursor = &owner->next;
}
watch->subscribers++;
session->watch = watch;
} else {
cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root",
watch->root, "requested_root", root);
}
return;
}
}
watch = calloc(1, sizeof(*watch));
if (watch) {
watch->project = strdup(project);
watch->root = strdup(root);
}
if (!watch || !watch->project || !watch->root) {
if (watch) {
free(watch->project);
free(watch->root);
free(watch);
static void application_index_watch_publish_locked(cbm_daemon_application_job_t *job) {
while (job->watch_owners) {
application_index_watch_owner_t *owner = job->watch_owners;
job->watch_owners = owner->next;
if (job->successful) {
application_register_watch_locked(owner->session, job->project_key, job->root_path,
job->args_json);
}
return;
cbm_free(CBM_MEM_CLASS_OTHER, owner);
}
/* Physical registration is the commit point. Never publish a logical
* subscription that the shared watcher failed to install. */
if (!cbm_watcher_watch(application->watcher, project, root)) {
cbm_log_warn("daemon.watch.registration_failed", "project", project, "action", "retry");
free(watch->project);
free(watch->root);
free(watch);
return;
}
watch->subscribers = 1;
watch->next = application->watches;
application->watches = watch;
session->watch = watch;
}
static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) {
@@ -628,8 +805,8 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate
}
/* index_status watch visibility (#2167). The physical watcher is the truth for
* "watched"; the reasons explain the documented scope: only an MCP session's
* own project is watched, and only while that session is open. Takes the
* "watched"; session roots and successful explicit targets stay watched while
* at least one owning MCP session remains open. Takes the
* application mutex and the watcher lock one after the other, never nested. */
static void application_watch_status(void *context, const char *project,
cbm_mcp_watch_status_t *out) {
@@ -678,6 +855,11 @@ static void application_job_free(cbm_daemon_application_job_t *job) {
if (!job) {
return;
}
while (job->watch_owners) {
application_index_watch_owner_t *owner = job->watch_owners;
job->watch_owners = owner->next;
cbm_free(CBM_MEM_CLASS_OTHER, owner);
}
free(job->project_key);
free(job->root_path);
free(job->args_json);
@@ -1787,6 +1969,7 @@ static void application_job_publish(cbm_daemon_application_job_t *job,
!execution->last_result.cancellation_requested);
job->terminal = true;
job->thread_done = true;
application_index_watch_publish_locked(job);
application_index_record_finish_locked(job);
if (job->async_owned) {
/* The async reference ends with the job; terminal, so no cancel. */
@@ -2500,7 +2683,8 @@ static bool application_watch_job_subscribe_late_session_locked(
cbm_daemon_application_t *application = session->application;
cbm_daemon_application_job_t *job =
application_find_active_job_locked(application, watch->project);
if (!job || job->watcher_waiters == 0 || strcmp(job->root_path, watch->root) != 0 ||
if (!job || job->watcher_waiters == 0 ||
!application_watch_root_matches(job->root_path, watch->root) ||
application_watch_job_subscription_exists_locked(application, session, job)) {
return true;
}
@@ -2526,14 +2710,15 @@ static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_applicati
size_t *matched_out) {
*matched_out = 0;
if (!watch || !job || strcmp(watch->project, job->project_key) != 0 ||
strcmp(watch->root, job->root_path) != 0) {
!application_watch_root_matches(watch->root, job->root_path)) {
return true;
}
cbm_daemon_application_watch_job_subscription_t *pending = NULL;
for (cbm_daemon_application_session_t *session = application->sessions; session;
session = session->next) {
if (session->session_cancelled || !session->context_set || session->watch != watch) {
if (session->session_cancelled || !session->context_set ||
!application_session_has_watch_locked(session, watch)) {
continue;
}
(*matched_out)++;
@@ -2567,7 +2752,7 @@ static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_applicati
}
static void application_watch_job_unsubscribe_session_locked(
cbm_daemon_application_session_t *session) {
cbm_daemon_application_session_t *session, const cbm_daemon_application_watch_t *watch) {
if (!session || !session->application) {
return;
}
@@ -2575,7 +2760,10 @@ static void application_watch_job_unsubscribe_session_locked(
&session->application->watch_job_subscriptions;
while (*cursor) {
cbm_daemon_application_watch_job_subscription_t *subscription = *cursor;
if (subscription->session != session) {
if (subscription->session != session ||
(watch &&
(strcmp(subscription->job->project_key, watch->project) != 0 ||
!application_watch_root_matches(subscription->job->root_path, watch->root)))) {
cursor = &subscription->next;
continue;
}
@@ -2693,6 +2881,7 @@ static char *application_index_execute_sync(cbm_daemon_application_session_t *se
}
session->active_job = job;
session->active_job_subscribed = true;
application_index_watch_subscribe_locked(session, job, false);
cbm_mutex_unlock(&session->application->mutex);
return application_job_wait_for_session(session, job);
}
@@ -2803,6 +2992,7 @@ static char *application_index_start_async(cbm_daemon_application_session_t *ses
bool joined = false;
const char *state = "queued";
if (job) {
application_index_watch_subscribe_locked(session, job, true);
joined = job->async_owned || job->subscribers > 1;
if (job->async_owned) {
application_job_unsubscribe_locked(job); /* one async reference per job */
@@ -3414,6 +3604,7 @@ static void application_request_cancel(void *context,
cbm_daemon_application_job_t *job = session->active_job;
session->active_job = NULL;
session->active_job_subscribed = false;
application_index_watch_unsubscribe_locked(session, job);
application_index_record_cut_short_locked(job);
application_job_unsubscribe_locked(job);
}
@@ -3455,6 +3646,7 @@ static void application_session_cancel(void *context,
reap_update =
reap_update || (session->background_eligible && application->update_thread_started &&
application->update_owners == 0);
application_index_watch_unsubscribe_locked(session, NULL);
application_release_session_watch_locked(session);
bool final_live_session = newly_cancelled;
for (cbm_daemon_application_session_t *other = application->sessions;
@@ -3514,6 +3706,7 @@ static void application_session_close(void *context,
reap_update =
reap_update || (session->background_eligible && application->update_thread_started &&
application->update_owners == 0);
application_index_watch_unsubscribe_locked(session, NULL);
application_release_session_watch_locked(session);
cbm_mutex_unlock(&application->mutex);
application_auto_index_cancel_join(application, join_auto_index);
@@ -3722,6 +3915,11 @@ bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *applicat
application_index_records_free(index_records);
while (sessions) {
cbm_daemon_application_session_t *next = sessions->next;
while (sessions->watches) {
application_watch_membership_t *membership = sessions->watches;
sessions->watches = membership->next;
cbm_free(CBM_MEM_CLASS_OTHER, membership);
}
cbm_mcp_server_free(sessions->mcp);
free(sessions);
sessions = next;
@@ -3733,7 +3931,8 @@ bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *applicat
}
free(watches->project);
free(watches->root);
free(watches);
cbm_free(CBM_MEM_CLASS_OTHER, watches->index_mode);
cbm_free(CBM_MEM_CLASS_OTHER, watches);
watches = next;
}
while (watch_job_subscriptions) {
@@ -4033,57 +4232,53 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augme
timeout_ms);
}
static int application_background_index(cbm_daemon_application_t *application,
const char *project_name, const char *root_path,
bool require_live_watch) {
if (!application || !project_name || !root_path) {
return -1;
}
char canonical_root[APPLICATION_PATH_CAP];
if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) ||
!application_canonical_directory_exists(canonical_root)) {
return -1;
}
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL;
if (!document || !root) {
yyjson_mut_doc_free(document);
return -1;
}
yyjson_mut_doc_set_root(document, root);
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) &&
application_index_args_add_policy(application, document, root);
char *default_project = cbm_project_name_from_path(canonical_root);
bool custom_project =
project_name[0] && (!default_project || strcmp(default_project, project_name) != 0);
if (encoded && custom_project) {
encoded = yyjson_mut_obj_add_strcpy(document, root, "name", project_name);
}
free(default_project);
char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
yyjson_mut_doc_free(document);
if (!args) {
return -1;
}
char *project_key = application_index_project_key(canonical_root, args);
if (!project_key) {
free(args);
return -1;
}
enum {
APPLICATION_BACKGROUND_INDEX_FAILED = -1,
APPLICATION_BACKGROUND_INDEX_SUCCESS = 0,
APPLICATION_BACKGROUND_INDEX_SKIPPED = 1,
};
/* Caller holds application->mutex through option encoding and job admission. */
static void application_watch_encode_index_args_locked(const cbm_daemon_application_watch_t *watch,
yyjson_mut_doc *document,
yyjson_mut_val *root, char **args) {
bool encoded =
!watch->index_mode || yyjson_mut_obj_add_strcpy(document, root, "mode", watch->index_mode);
encoded = encoded && yyjson_mut_obj_add_bool(document, root, "persistence", watch->persistence);
if (watch->has_doc_candidates) {
(void)yyjson_mut_obj_remove_key(root, "doc_candidates");
encoded = encoded &&
yyjson_mut_obj_add_bool(document, root, "doc_candidates", watch->doc_candidates);
}
free(*args);
*args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
}
static cbm_daemon_application_job_t *application_background_index_admit(
cbm_daemon_application_t *application, const char *project_name, const char *canonical_root,
const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args,
bool require_live_watch, int *no_job_result) {
application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE;
application_jobs_reap_completed(application);
cbm_mutex_lock(&application->mutex);
if (require_live_watch) {
(void)application_watches_disabled_locked(application);
}
cbm_daemon_application_watch_t *watch =
require_live_watch ? application_find_watch_locked(application, project_name) : NULL;
bool watch_live = !require_live_watch ||
(watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0);
bool watch_live =
!require_live_watch || (watch && watch->subscribers > 0 &&
application_watch_root_matches(watch->root, canonical_root));
if (require_live_watch && watch_live) {
application_watch_encode_index_args_locked(watch, document, root, args);
}
size_t watch_owner_count = 0;
bool watch_subscriptions_ok = true;
cbm_daemon_application_job_t *job =
watch_live ? application_job_subscribe_locked(application, project_key, canonical_root,
args, &subscribe_status)
: NULL;
watch_live && *args
? application_job_subscribe_locked(application, project_key, canonical_root, *args,
&subscribe_status)
: NULL;
if (job && require_live_watch) {
watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked(
application, watch, job, &watch_owner_count);
@@ -4101,22 +4296,27 @@ static int application_background_index(cbm_daemon_application_t *application,
}
}
cbm_mutex_unlock(&application->mutex);
free(project_key);
free(args);
if (!job) {
if (require_live_watch &&
(!watch_live || (watch_subscriptions_ok && watch_owner_count == 0))) {
/* The physical watcher can retain a poll snapshot after the last
* owning session unwatches it. Treat that stale callback as a
* harmless skip; no job was admitted. */
return 1;
*no_job_result = APPLICATION_BACKGROUND_INDEX_SKIPPED;
return NULL;
}
return subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT ||
subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY ||
subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING
? 1
: -1;
*no_job_result = subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT ||
subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY ||
subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING
? APPLICATION_BACKGROUND_INDEX_SKIPPED
: APPLICATION_BACKGROUND_INDEX_FAILED;
}
return job;
}
static int application_background_index_wait(cbm_daemon_application_t *application,
cbm_daemon_application_job_t *job,
bool require_live_watch) {
bool successful = false;
bool cancelled = false;
for (;;) {
@@ -4139,7 +4339,60 @@ static int application_background_index(cbm_daemon_application_t *application,
cbm_usleep(APPLICATION_JOB_POLL_US);
}
application_jobs_reap_completed(application);
return successful ? 0 : (cancelled ? 1 : -1);
if (successful) {
return APPLICATION_BACKGROUND_INDEX_SUCCESS;
}
return cancelled ? APPLICATION_BACKGROUND_INDEX_SKIPPED : APPLICATION_BACKGROUND_INDEX_FAILED;
}
static int application_background_index(cbm_daemon_application_t *application,
const char *project_name, const char *root_path,
bool require_live_watch) {
if (!application || !project_name || !root_path) {
return APPLICATION_BACKGROUND_INDEX_FAILED;
}
char canonical_root[APPLICATION_PATH_CAP];
if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) ||
!application_canonical_directory_exists(canonical_root)) {
return APPLICATION_BACKGROUND_INDEX_FAILED;
}
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL;
if (!document || !root) {
yyjson_mut_doc_free(document);
return APPLICATION_BACKGROUND_INDEX_FAILED;
}
yyjson_mut_doc_set_root(document, root);
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) &&
application_index_args_add_policy(application, document, root);
char *default_project = cbm_project_name_from_path(canonical_root);
bool custom_project =
project_name[0] && (!default_project || strcmp(default_project, project_name) != 0);
if (encoded && custom_project) {
encoded = yyjson_mut_obj_add_strcpy(document, root, "name", project_name);
}
free(default_project);
char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
if (!args) {
yyjson_mut_doc_free(document);
return APPLICATION_BACKGROUND_INDEX_FAILED;
}
char *project_key = application_index_project_key(canonical_root, args);
if (!project_key) {
free(args);
yyjson_mut_doc_free(document);
return APPLICATION_BACKGROUND_INDEX_FAILED;
}
int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED;
cbm_daemon_application_job_t *job = application_background_index_admit(
application, project_name, canonical_root, project_key, document, root, &args,
require_live_watch, &no_job_result);
free(project_key);
free(args);
yyjson_mut_doc_free(document);
return job ? application_background_index_wait(application, job, require_live_watch)
: no_job_result;
}
int cbm_daemon_application_index(cbm_daemon_application_t *application, const char *project_name,
+5
View File
@@ -51,4 +51,9 @@ char *cbm_daemon_application_framable_response_for_test(char *response,
* on Windows, while the fold itself runs on every platform. */
bool cbm_daemon_application_index_args_equal_for_test(const char *left, const char *right);
/* Exercise the production root comparison's Windows branch on any host;
* POSIX backslashes remain literal filename characters. */
bool cbm_daemon_application_watch_roots_equal_for_test(const char *left, const char *right,
bool windows_paths);
#endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */
+98 -2
View File
@@ -52,6 +52,9 @@ enum {
HOST_HTTP_RETRY_INITIAL_MS = 1000,
HOST_HTTP_RETRY_MAX_MS = 30000,
HOST_WATCH_INTERVAL_MS = 5000,
/* Age-based temp cleaners need days of inactivity (macOS tmp_cleaner: 3);
* a minute bounds the blind window after a long sleep (#2178). */
HOST_COORDINATION_TOUCH_MS = 60000,
HOST_CONFLICT_LOG_CAP = 1024 * 1024,
HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024,
HOST_PATH_CAP = 4096,
@@ -875,10 +878,58 @@ static bool host_background_start(host_state_t *host) {
return true;
}
/* Coordination handles the host retains for the generation's whole lifetime. */
typedef struct {
const cbm_daemon_ipc_endpoint_t *endpoint;
cbm_version_cohort_lease_t *cohort_lease;
cbm_version_cohort_daemon_claim_t *daemon_claim;
cbm_daemon_ipc_participant_guard_t *participant_guard;
} host_coordination_t;
typedef struct {
const char *lost; /* first held file whose path no longer names it */
const char *transient; /* first still-valid file that could not be refreshed */
} host_touch_result_t;
static void host_touch_note(host_touch_result_t *result, const char *file, int status) {
if (status == 0 && !result->lost) {
result->lost = file;
} else if (status < 0 && !result->transient) {
result->transient = file;
}
}
static int host_cohort_touch_status(cbm_version_cohort_status_t status) {
return status == CBM_VERSION_COHORT_OK ? 1 : (status == CBM_VERSION_COHORT_IO ? -1 : 0);
}
/* Refresh every long-held runtime file. A held lock whose path now names
* another inode coordinates nothing: forked index workers read the daemon as
* uncoordinated and a peer can start a second generation (#2178). Every file
* is visited even after one fails so the rest keep being refreshed. */
static host_touch_result_t host_coordination_touch(const host_coordination_t *coordination,
cbm_daemon_runtime_service_t *service) {
host_touch_result_t result = {0};
host_touch_note(&result, "cohort_daemon_claim",
host_cohort_touch_status(
cbm_version_cohort_daemon_claim_touch(coordination->daemon_claim)));
host_touch_note(
&result, "cohort_lease",
host_cohort_touch_status(cbm_version_cohort_lease_touch(coordination->cohort_lease)));
host_touch_note(&result, "participant_guard",
cbm_daemon_ipc_participant_guard_touch(coordination->endpoint,
coordination->participant_guard));
host_touch_note(&result, "listener", cbm_daemon_runtime_service_touch_listener(service));
return result;
}
static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service,
atomic_int *stop_requested, host_state_t *host, bool permanent) {
atomic_int *stop_requested, host_state_t *host, bool permanent,
const host_coordination_t *coordination) {
uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS;
uint64_t stopping_deadline = 0;
uint64_t next_touch = 0;
bool touch_degraded = false;
for (;;) {
cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(service);
if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) {
@@ -910,6 +961,28 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service,
stop ? "stop_requested" : "initial_window_expired");
return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS);
}
/* Permanent generations too: exiting lets the next client start a
* coordinated generation, while re-claiming would race that client. */
if (cbm_now_ms() >= next_touch) {
next_touch = cbm_now_ms() + HOST_COORDINATION_TOUCH_MS;
host_touch_result_t touched = host_coordination_touch(coordination, service);
if (touched.lost) {
cbm_log_warn("daemon.lifetime_end", "reason", "coordination_file_lost", "file",
touched.lost);
return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS);
}
/* A refresh failure on a still-valid file (read-only remount,
* EMFILE) is not loss: keep serving, retry next tick, and log
* only transitions so a persistent condition cannot flood. */
if ((touched.transient != NULL) != touch_degraded) {
touch_degraded = touched.transient != NULL;
if (touch_degraded) {
cbm_log_warn("daemon.coordination_touch_failed", "file", touched.transient);
} else {
cbm_log_info("daemon.coordination_touch_recovered");
}
}
}
host_http_reconcile_at(host, cbm_now_ms(), false);
/* Retire an ephemeral generation that lingered for cold-storm cohort
* participants once they drain, or once its bounded linger elapses.
@@ -919,6 +992,22 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service,
}
}
bool cbm_daemon_host_wait_for_lifetime_for_test(cbm_daemon_runtime_service_t *service,
const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_version_cohort_lease_t *cohort_lease,
cbm_version_cohort_daemon_claim_t *daemon_claim,
cbm_daemon_ipc_participant_guard_t *guard) {
host_state_t host = {0};
atomic_int stop_requested = 0;
host_coordination_t coordination = {
.endpoint = endpoint,
.cohort_lease = cohort_lease,
.daemon_claim = daemon_claim,
.participant_guard = guard,
};
return host_wait_for_lifetime(service, &stop_requested, &host, false, &coordination);
}
static bool host_application_shutdown(host_state_t *host) {
if (cbm_daemon_application_shutdown(host->application, HOST_APPLICATION_SHUTDOWN_MS)) {
return true;
@@ -1130,7 +1219,14 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) {
: "unavailable",
"memory_budget_bytes", memory_budget, "physical_job_limit", physical_job_limit,
"worker_memory_budget_bytes", worker_memory_budget);
if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent)) {
host_coordination_t coordination = {
.endpoint = config->endpoint,
.cohort_lease = cohort_lease,
.daemon_claim = daemon_claim,
.participant_guard = participant_guard,
};
if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent,
&coordination)) {
host_force_terminate("runtime");
}
+14
View File
@@ -9,6 +9,10 @@
#include <stdint.h>
struct cbm_daemon_ipc_endpoint;
struct cbm_daemon_ipc_participant_guard;
struct cbm_daemon_runtime_service;
struct cbm_version_cohort_daemon_claim;
struct cbm_version_cohort_lease;
typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context);
@@ -68,4 +72,14 @@ bool cbm_daemon_host_http_reconcile_free_refusal_for_test(
* the failure, the adapter must cancel so the final free succeeds. */
bool cbm_daemon_host_http_thread_create_failure_lifecycle_for_test(void);
/* Run the production lifetime loop over caller-held coordination handles with
* no HTTP host state and no stop request. The generation is ephemeral, so a
* loop that misses a loss still ends at the initial client window instead of
* hanging the test. Returns the loop's own result. */
bool cbm_daemon_host_wait_for_lifetime_for_test(
struct cbm_daemon_runtime_service *service, const struct cbm_daemon_ipc_endpoint *endpoint,
struct cbm_version_cohort_lease *cohort_lease,
struct cbm_version_cohort_daemon_claim *daemon_claim,
struct cbm_daemon_ipc_participant_guard *guard);
#endif /* CBM_DAEMON_HOST_INTERNAL_H */
+109
View File
@@ -3510,6 +3510,94 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) {
free(listener);
}
/* Touch results: 1 refreshed, 0 lost (the path no longer names the owned
* file), -1 transient (a still-valid file could not be refreshed). Touch
* through the held descriptor only after proving the path still names it:
* refreshing by path would keep a replacement inode fresh and hide the loss
* (#2178). */
static int posix_touch_fold(int result, int next) {
return result == 0 || next == 0 ? 0 : (result < 0 || next < 0 ? -1 : 1);
}
/* Whether base_name still names the regular file (device, inode): 1 yes,
* 0 proven not (absent, another inode, a symlink or other non-regular
* entry), -1 undetermined (any other lookup failure, e.g. EIO or ESTALE). */
static int posix_path_names_inode(int directory_fd, const char *base_name, dev_t device,
ino_t inode) {
struct stat by_path;
if (fstatat(directory_fd, base_name, &by_path, AT_SYMLINK_NOFOLLOW) != 0) {
return errno == ENOENT ? 0 : -1;
}
return S_ISREG(by_path.st_mode) && by_path.st_dev == device && by_path.st_ino == inode ? 1 : 0;
}
static int posix_held_file_touch(int directory_fd, const char *base_name, int fd) {
struct stat by_handle;
if (fstat(fd, &by_handle) != 0) {
return -1;
}
int identity =
by_handle.st_nlink == 0
? 0
: posix_path_names_inode(directory_fd, base_name, by_handle.st_dev, by_handle.st_ino);
if (identity <= 0) {
return identity;
}
return futimens(fd, NULL) == 0 ? 1 : -1;
}
static int posix_held_lock_touch(int directory_fd, const process_lock_entry_t *entry, int fd) {
return entry ? posix_held_file_touch(directory_fd, entry->lock_name, fd) : 0;
}
static int posix_identity_marker_touch(const cbm_daemon_ipc_listener_t *listener) {
/* The marker is not held open. Classify by path first, so a symlink or
* other replacement is loss; only then reopen it to refresh, where a
* failure (EMFILE, EACCES) says nothing about the file and is transient. */
int identity = posix_path_names_inode(listener->dir_fd, listener->socket_identity_name,
listener->identity_device, listener->identity_inode);
if (identity <= 0) {
return identity;
}
int marker_fd = openat(listener->dir_fd, listener->socket_identity_name,
O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (marker_fd < 0) {
return errno == ENOENT ? 0 : -1;
}
struct stat marker_status;
int result = -1;
if (fstat(marker_fd, &marker_status) == 0) {
/* Replaced between the lookup and the open. */
result = marker_status.st_dev != listener->identity_device ||
marker_status.st_ino != listener->identity_inode
? 0
: (futimens(marker_fd, NULL) == 0 ? 1 : -1);
}
(void)close(marker_fd);
return result;
}
int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener,
cbm_daemon_ipc_participant_guard_t *external_guard) {
if (!listener || listener->dir_fd < 0 || listener->owner_pid != getpid() ||
!listener->lifetime_reservation) {
return 0;
}
int result =
posix_held_lock_touch(listener->dir_fd, listener->lifetime_reservation->process_entry,
listener->lifetime_reservation->fd);
const cbm_daemon_ipc_participant_guard_t *guards[] = {listener->participant_guard,
external_guard};
for (size_t i = 0; i < sizeof(guards) / sizeof(guards[0]); i++) {
if (guards[i]) {
result = posix_touch_fold(result, posix_held_lock_touch(listener->dir_fd,
guards[i]->legacy_process_entry,
guards[i]->legacy_fd));
}
}
return posix_touch_fold(result, posix_identity_marker_touch(listener));
}
int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms,
cbm_daemon_ipc_connection_t **connection_out) {
if (connection_out) {
@@ -3893,6 +3981,14 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t
return true;
}
int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_daemon_ipc_participant_guard_t *guard) {
if (!endpoint || !guard || guard->owner_pid != getpid()) {
return 0;
}
return posix_held_lock_touch(endpoint->dir_fd, guard->legacy_process_entry, guard->legacy_fd);
}
int cbm_daemon_ipc_local_transition_try_acquire(
const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) {
if (transition_out) {
@@ -7090,6 +7186,19 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t
return true;
}
/* Windows has no age-based cleaner of the private runtime directory, and its
* held handles deny deletion; the #2178 heartbeat has nothing to refresh. */
int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener,
cbm_daemon_ipc_participant_guard_t *external_guard) {
(void)external_guard;
return listener ? 1 : 0;
}
int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_daemon_ipc_participant_guard_t *guard) {
return endpoint && guard ? 1 : 0;
}
int cbm_daemon_ipc_local_transition_try_acquire(
const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) {
if (transition_out) {
+17
View File
@@ -76,6 +76,19 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved(
cbm_daemon_ipc_lifetime_reservation_t **reservation_io);
void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener);
/* Heartbeat for the daemon's long-lived runtime artifacts: refresh the held
* lifetime reservation, the listener's participant guard (if it owns one),
* external_guard (optional, a guard the caller retains beside this listener)
* and the published identity marker so an age-based temp cleaner never
* deletes them (#2178). Returns 1 when all were refreshed, 0 once any no
* longer names the file this listener owns (a lost lifetime file lets a peer
* start a second generation; a lost identity marker makes listener close
* leave the socket pair behind), and -1 when a still-valid file could not be
* refreshed (transient). Only the thread that closes the listener may call it
* while the listener is open. */
int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener,
cbm_daemon_ipc_participant_guard_t *external_guard);
/* Create or validate one private current-user directory at an already
* canonical local path. Ancestors are handle-validated without mutation:
* POSIX permits only root/current-user owners and no group/other write (apart
@@ -290,6 +303,10 @@ bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io
int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_daemon_ipc_participant_guard_t **guard_out);
bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io);
/* Same heartbeat contract and 1/0/-1 result as cbm_daemon_ipc_listener_touch,
* for a guard the caller retains itself. */
int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_daemon_ipc_participant_guard_t *guard);
/* Standalone CLI work joins the legacy-compatible current participant group
* without becoming a daemon client. Acquisition retains startup-v2 only for
+6
View File
@@ -2724,6 +2724,12 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_
return count;
}
int cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service) {
return service && service->listener
? cbm_daemon_ipc_listener_touch(service->listener, service->owned_participant_guard)
: 0;
}
void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service) {
if (!service) {
return;
+6
View File
@@ -347,6 +347,12 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_
* an unbounded idle hang. The host lifetime loop calls this every tick; it is a
* no-op unless a last-committed-client linger is armed. */
void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service);
/* #2178 heartbeat over the listener's runtime artifacts, plus the participant
* guard the service owns when it started itself; see
* cbm_daemon_ipc_listener_touch for the 1/0/-1 result. Call only from the
* thread that stops and frees the service, which is the only thread that
* closes the listener. */
int cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service);
size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service,
const char *project_key);
uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service,
+31
View File
@@ -363,6 +363,37 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release(
return result;
}
/* Folds one lock's touch into an accumulated result: a lost file (UNSAFE)
* outranks a transient refresh failure (IO), which outranks OK. A lease holds
* only lifetime after admission, so absent locks are skipped. */
static cbm_version_cohort_status_t version_cohort_lock_touch(cbm_private_file_lock_t *lock,
cbm_version_cohort_status_t result) {
if (!lock || result == CBM_VERSION_COHORT_UNSAFE) {
return result;
}
cbm_private_file_lock_status_t status = cbm_private_file_lock_touch(lock);
if (status == CBM_PRIVATE_FILE_LOCK_UNSAFE) {
return CBM_VERSION_COHORT_UNSAFE;
}
return status == CBM_PRIVATE_FILE_LOCK_OK ? result : CBM_VERSION_COHORT_IO;
}
cbm_version_cohort_status_t cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease) {
if (!lease || !lease->lifetime) {
return CBM_VERSION_COHORT_UNSAFE;
}
cbm_version_cohort_status_t result =
version_cohort_lock_touch(lease->lifetime, CBM_VERSION_COHORT_OK);
result = version_cohort_lock_touch(lease->admission, result);
return version_cohort_lock_touch(lease->maintenance, result);
}
cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_touch(
cbm_version_cohort_daemon_claim_t *claim) {
return claim && claim->marker ? version_cohort_lock_touch(claim->marker, CBM_VERSION_COHORT_OK)
: CBM_VERSION_COHORT_UNSAFE;
}
static cbm_version_cohort_status_t version_cohort_failed(cbm_version_cohort_lease_t *lease,
cbm_version_cohort_status_t status,
cbm_version_cohort_lease_t **lease_out) {
+11
View File
@@ -145,6 +145,17 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence_under_tr
cbm_private_file_lock_status_t cbm_version_cohort_lease_release(
cbm_version_cohort_lease_t **lease_io);
/* Heartbeat for a long-lived holder: refresh every lock file the lease or
* claim holds. An age-based temp cleaner deleting a held file silently ends
* coordination, because the next participant creates and locks a fresh inode
* (#2178). Returns OK when all held files were refreshed, UNSAFE once any is
* lost (its path no longer names the held file), and IO when a still-valid
* file could not be refreshed. The only safe response to UNSAFE is an orderly
* exit; re-claiming would race that participant. IO is transient. */
cbm_version_cohort_status_t cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease);
cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_touch(
cbm_version_cohort_daemon_claim_t *claim);
/* Refuses teardown while a lease or retryable cleanup handle remains. */
cbm_private_file_lock_status_t cbm_version_cohort_manager_free(
cbm_version_cohort_manager_t **manager_io);
+4
View File
@@ -135,8 +135,12 @@ static const ext_entry_t EXT_TABLE[] = {
{".F95", CBM_LANG_FORTRAN},
/* GLSL */
{".comp", CBM_LANG_GLSL},
{".frag", CBM_LANG_GLSL},
{".geom", CBM_LANG_GLSL},
{".glsl", CBM_LANG_GLSL},
{".tesc", CBM_LANG_GLSL},
{".tese", CBM_LANG_GLSL},
{".vert", CBM_LANG_GLSL},
/* Go */
+4 -1
View File
@@ -56,6 +56,7 @@ void *__real_malloc(size_t size);
void *__real_calloc(size_t count, size_t size);
void *__real_realloc(void *block, size_t size);
void __real_free(void *block);
void __real__aligned_free(void *block);
char *__real_strdup(const char *text);
size_t __real__msize(void *block);
/* Wrappers must call __real_* for anything that is itself wrapped: a plain
@@ -155,7 +156,9 @@ void __wrap__aligned_free(void *block) {
mi_free(block);
return;
}
__real_free(block);
/* A CRT aligned block has its own bookkeeping header. Plain free is not
* its matching deallocator. */
__real__aligned_free(block);
}
void *__wrap_realloc(void *block, size_t size) {
+77
View File
@@ -7,6 +7,7 @@
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "foundation/mem_core.h"
#include "foundation/platform_internal.h"
#include <ctype.h>
#include <errno.h>
@@ -467,6 +468,82 @@ const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const
return NULL;
}
/* See platform.h. The long-value sibling of cbm_safe_getenv: there is no caller
* buffer to overrun, so a PATH past 4 KB is read in full instead of being
* refused (#221). Both the wide staging buffer and the UTF-8 result are
* allocated through the memory core, which is also the only allocator the
* caller may release them with -- keeping the memory-core ratchet honest means
* this file adds no raw allocation site to pay for the new read. */
char *cbm_env_dup(const char *name) {
if (!name || !name[0]) {
return NULL;
}
/* The same view cbm_safe_getenv gives: the startup settings resolver
* answers first, and a handled NULL masks the variable. */
const char *resolved = NULL;
if (environment_resolver && environment_resolver(name, &resolved)) {
return resolved ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, resolved) : NULL;
}
#ifdef _WIN32
/* Same wide route as cbm_safe_getenv: _environ holds ANSI-code-page bytes,
* so a non-ASCII PATH would arrive mojibake'd or with '?' substituted --
* invalid in a Windows path and fatal to every file API downstream. */
wchar_t wname[CBM_SZ_256];
int wn = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wname, CBM_SZ_256);
if (wn <= 0) {
return NULL;
}
SetLastError(ERROR_SUCCESS);
DWORD needed = GetEnvironmentVariableW(wname, NULL, 0U);
DWORD lookup_error = GetLastError();
if (needed == 0U) {
if (lookup_error != ERROR_ENVVAR_NOT_FOUND) {
/* Present but empty. Distinct from unset, and callers are entitled
* to tell the two apart, so return "" and not NULL. */
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, "");
}
return NULL;
}
wchar_t *wvalue = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)needed * sizeof(*wvalue));
if (!wvalue) {
return NULL;
}
SetLastError(ERROR_SUCCESS);
DWORD got = GetEnvironmentVariableW(wname, wvalue, needed);
DWORD read_error = GetLastError();
if (got >= needed || (got == 0U && read_error != ERROR_SUCCESS)) {
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return NULL;
}
int utf8_size =
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, NULL, 0, NULL, NULL);
char *utf8 = utf8_size > 0 ? cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)utf8_size) : NULL;
int converted = utf8 ? WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, utf8,
utf8_size, NULL, NULL)
: 0;
if (!utf8 || converted != utf8_size) {
cbm_free(CBM_MEM_CLASS_OTHER, utf8);
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return NULL;
}
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return utf8;
#else
/* Same environ walk as cbm_safe_getenv: getenv() returns a pointer another
* thread can invalidate with setenv(), so the value is copied out here. */
char **env = CBM_ENVIRON;
if (env) {
size_t name_len = strlen(name);
for (; *env; env++) {
if (strncmp(*env, name, name_len) == 0 && (*env)[name_len] == '=') {
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, *env + name_len + SKIP_ONE);
}
}
}
return NULL;
#endif
}
/* See platform.h. The shape here is the one src/main.c:1104 already uses for
* --port=: an end pointer says where the read stopped, errno catches a number
* too large, and *end == '\0' catches anything left over. */
+15
View File
@@ -147,6 +147,21 @@ const char *cbm_runtime_getenv(const char *name);
/* Explicit inherited environment access for bootstrap/settings provenance. */
const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback);
/* Allocating sibling of cbm_safe_getenv, for a value that must not be capped at
* a caller-chosen size. A Windows PATH routinely runs past the 4096-byte buffer
* cbm_safe_getenv's callers pass, and the copy helper REPORTS that as failure
* rather than truncating -- so the whole PATH was skipped and every directory
* past the cut became invisible (#221).
*
* Returns a UTF-8 copy owned by the memory core: release it with
* cbm_free(CBM_MEM_CLASS_OTHER, ptr), never free(). NULL when the variable is
* unset or cannot be read; a variable that exists but is empty yields "".
* On Windows the value is read with the wide API and converted, the same route
* cbm_safe_getenv takes, so a non-ASCII value survives intact. Like
* cbm_safe_getenv, an installed settings resolver answers before the process
* environment does. */
char *cbm_env_dup(const char *name);
/* Read an environment variable as a whole number.
*
* Answers true only when the variable is set, is not empty, and reads cleanly
+78
View File
@@ -38,6 +38,9 @@ struct cbm_private_file_lock {
int fd;
pid_t owner_pid;
cbm_private_file_lock_mode_t mode;
/* Borrowed; the directory outlives every lock acquired through it. */
const cbm_private_lock_directory_t *directory;
char base_name[NAME_MAX + 1];
struct cbm_private_file_lock *next_tracked;
bool unlocked;
bool test_fail_unlock_once;
@@ -345,6 +348,8 @@ cbm_private_file_lock_status_t cbm_private_file_lock_try_acquire(
lock->fd = fd;
lock->owner_pid = getpid();
lock->mode = mode;
lock->directory = directory;
memcpy(lock->base_name, base_name, strlen(base_name) + 1);
int operation = mode == CBM_PRIVATE_FILE_LOCK_SH ? LOCK_SH : LOCK_EX;
if (private_flock_set(fd, operation | LOCK_NB) != 0) {
@@ -482,6 +487,59 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f
return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO;
}
/* Whether the lock's canonical path still names the held inode: 1 yes, 0 no
* (proven lost), -1 undetermined. Only proof counts as loss: unlink leaves
* st_nlink 0, a rename-away or replacement makes the path name another inode
* or nothing, and a replaced directory changes the directory's identity. Any
* other syscall failure (EIO, ESTALE, ENOMEM) says nothing about the file and
* is reported as undetermined so the caller retries instead of exiting. */
static int private_held_identity(const cbm_private_file_lock_t *lock) {
const cbm_private_lock_directory_t *directory = lock->directory;
struct stat by_handle;
struct stat directory_by_path;
struct stat by_path;
if (fstat(lock->fd, &by_handle) != 0) {
return -1;
}
if (by_handle.st_nlink == 0) {
return 0;
}
if (lstat(directory->path, &directory_by_path) != 0) {
return errno == ENOENT || errno == ENOTDIR ? 0 : -1;
}
if (!S_ISDIR(directory_by_path.st_mode) || directory_by_path.st_dev != directory->device ||
directory_by_path.st_ino != directory->inode) {
return 0;
}
if (fstatat(directory->fd, lock->base_name, &by_path, AT_SYMLINK_NOFOLLOW) != 0) {
return errno == ENOENT ? 0 : -1;
}
return S_ISREG(by_path.st_mode) && by_path.st_dev == by_handle.st_dev &&
by_path.st_ino == by_handle.st_ino
? 1
: 0;
}
cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) {
if (!lock) {
return CBM_PRIVATE_FILE_LOCK_UNSAFE;
}
if (!cbm_private_file_lock_fork_guard_enter()) {
return CBM_PRIVATE_FILE_LOCK_IO;
}
cbm_private_file_lock_status_t status = CBM_PRIVATE_FILE_LOCK_UNSAFE;
if (lock->fd >= 0 && !lock->unlocked && lock->owner_pid == getpid() && lock->directory &&
private_lock_is_tracked(lock)) {
int identity = private_held_identity(lock);
status = identity > 0 ? (futimens(lock->fd, NULL) == 0 ? CBM_PRIVATE_FILE_LOCK_OK
: CBM_PRIVATE_FILE_LOCK_IO)
: identity < 0 ? CBM_PRIVATE_FILE_LOCK_IO
: CBM_PRIVATE_FILE_LOCK_UNSAFE;
}
cbm_private_file_lock_fork_guard_leave();
return status;
}
cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) {
if (!lock_io || !*lock_io) {
return CBM_PRIVATE_FILE_LOCK_IO;
@@ -1450,6 +1508,26 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f
return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO;
}
cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) {
/* Windows has no age-based cleaner of the private lock directory; only
* confirm the held handle still names a linked file. */
if (!lock || lock->handle == INVALID_HANDLE_VALUE || lock->unlocked) {
return CBM_PRIVATE_FILE_LOCK_UNSAFE;
}
if (!cbm_private_file_lock_fork_guard_enter()) {
return CBM_PRIVATE_FILE_LOCK_IO;
}
BY_HANDLE_FILE_INFORMATION information;
bool queried = GetFileInformationByHandle(lock->handle, &information) != 0;
cbm_private_file_lock_fork_guard_leave();
/* A failed query says nothing about the file; only a proven unlink is loss. */
if (!queried) {
return CBM_PRIVATE_FILE_LOCK_IO;
}
return information.nNumberOfLinks == 0 ? CBM_PRIVATE_FILE_LOCK_UNSAFE
: CBM_PRIVATE_FILE_LOCK_OK;
}
static bool private_win_release_unlock(cbm_private_file_lock_t *lock) {
lock->test_unlock_attempts++;
if (lock->test_fail_unlock_once) {
@@ -61,6 +61,16 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f
const void *buffer,
size_t length);
/* Long-lived holders call this periodically. It refreshes the held file's
* timestamps through the validated handle so age-based temp cleaners (macOS
* tmp_cleaner, systemd-tmpfiles) never select it. Returns UNSAFE once the
* lock's path in its directory no longer names the held file (unlinked,
* replaced, renamed away, or the directory itself replaced): a peer opening
* the path would then lock a different inode and the lock coordinates nothing
* (#2178). IO reports a refresh failure on a still-valid lock, which callers
* should treat as transient. The directory must outlive the lock. */
cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock);
/* Forces the next successfully acquired native lock down the post-lock
* validation cleanup path and injects pre-call release failures there. */
bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test(
+4
View File
@@ -1134,6 +1134,10 @@ static void print_help(void) {
printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run] [--delete-indexes]\n");
printf(" codebase-memory-mcp update [-y|-n]\n");
printf(" codebase-memory-mcp config <list|get|set|reset>\n");
printf(" codebase-memory-mcp daemon <start|stop|status>\n");
printf(" start keeps a daemon warm: CLI calls skip\n");
printf(" the per-command start-up, and hooks add\n");
printf(" graph context only while a daemon runs\n");
printf(" codebase-memory-mcp --version Print version\n");
printf(" codebase-memory-mcp --help Print this help\n");
printf("\nCLI output options:\n");
+8 -6
View File
@@ -1468,8 +1468,10 @@ static const char MCP_SERVER_INSTRUCTIONS[] =
"query_graph runs multi-hop Cypher queries; get_architecture summarizes structure; "
"detect_changes maps the current diff to affected symbols. Grep and file reads fit literal "
"text, config values, comments and non-code files better; search_code combines text search "
"with graph context. The session project's index refreshes automatically; other projects "
"need index_repository. check_index_coverage tells whether a cited file was fully indexed. "
"with graph context. With auto_watch enabled, an index registered by an active MCP session "
"refreshes automatically; sessions register their root and successful explicit "
"index_repository targets. CLI calls do not add watches. check_index_coverage tells whether "
"a cited file was fully indexed. "
"Results are paginated.";
static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] =
@@ -7379,8 +7381,8 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
}
/* index_status "watch" object (#2167): is this project kept fresh by the
* background watcher right now, and if not, why. Auto-sync covers the active
* MCP session's project only; anything else needs index_repository again. */
* background watcher right now, and if not, why. Active MCP sessions register
* their root on connect and successful explicit index_repository targets. */
static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root,
const char *project) {
cbm_mcp_watch_status_t status = {0};
@@ -7406,8 +7408,8 @@ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yy
} else {
yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown");
yyjson_mut_obj_add_str(doc, watch, "hint",
"Auto-sync watches only the active MCP session's project; "
"re-run index_repository to refresh this index.");
"To register this project for auto-sync, successfully "
"re-index it from an active MCP session with auto_watch enabled.");
}
yyjson_mut_obj_add_val(doc, root, "watch", watch);
}
+44 -7
View File
@@ -16,16 +16,18 @@
* document wrote the path.
*
* Unresolved:
* missing the path is not in the graph, but its directory is: the
* document names something that is not there (doc rot)
* missing the path is not in the graph, but its directory is, or a
* directory above it that the reference wrote is: the document
* names something that is not there (doc rot; a moved or
* renamed directory)
* ambiguous a file name alone that is not next to the document but exists
* elsewhere in the repository: the document does not say which
* Not a reference to this repository at all (LOCAL: no edge, no row): a link
* back into the same document; a path that is neither indexed nor of a type
* the indexer reads (an image, an archive, an extensionless page); a path
* whose directory is not in the repository either (a file a tutorial asks
* the reader to create, a system path, a path above the root); and, in a
* code span, a rooted single segment without a file extension, which is a
* none of whose directories is in the repository either (a file a tutorial
* asks the reader to create, a system path, a path above the root); and, in
* a code span, a rooted single segment without a file extension, which is a
* URL route (`/docs`, `/health`), not the directory of that name.
*/
#include "pipeline/doc_links.h"
@@ -408,6 +410,25 @@ static const cbm_gbuf_node_t *mdr_folder(const mdr_index_t *x, const cbm_gbuf_t
return path[0] ? (const cbm_gbuf_node_t *)cbm_ht_get(x->folders, path) : NULL;
}
/* Whether a directory above `path` that the reference itself wrote, one
* longer than the `base_len` bytes it was read from, is a Folder. */
static bool mdr_written_ancestor(const mdr_index_t *x, const cbm_gbuf_t *graph, const char *path,
size_t base_len) {
char dir[MDR_PATH_CAP];
size_t n = strlen(path);
if (n >= sizeof(dir)) {
return false;
}
memcpy(dir, path, n + SKIP_ONE);
for (char *s = strrchr(dir, '/'); s && (size_t)(s - dir) > base_len; s = strrchr(dir, '/')) {
*s = '\0';
if (mdr_folder(x, graph, dir)) {
return true;
}
}
return false;
}
static bool mdr_code_label(const char *label) {
for (size_t i = 0; label && MDR_CODE_LABELS[i]; i++) {
if (strcmp(label, MDR_CODE_LABELS[i]) == 0) {
@@ -1126,8 +1147,19 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
char tries[MDR_TRIES][MDR_PATH_CAP];
int nt = 0;
int attempted = 0;
size_t base_len[MDR_TRIES]; /* the directory each try reads from, before what p wrote */
if (!rooted) {
attempted++;
char lead[MDR_PATH_CAP]; /* p's leading `./` and `../` segments */
char base[MDR_PATH_CAP];
size_t ll = 0;
while (ll < sizeof(lead) - PAIR_LEN - 1 && (strncmp(p + ll, "./", PAIR_LEN) == 0 ||
strncmp(p + ll, "../", PAIR_LEN + 1) == 0)) {
ll += p[ll + 1] == '/' ? PAIR_LEN : PAIR_LEN + 1;
}
memcpy(lead, p, ll);
lead[ll] = '\0';
base_len[nt] = mdr_join(doc_dir, lead, base, sizeof(base)) ? strlen(base) : 0;
nt += mdr_join(doc_dir, p, tries[nt], MDR_PATH_CAP);
}
if (!relative && !r->name_only) {
@@ -1137,7 +1169,7 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
q++;
}
if (mdr_join("", q, tries[nt], MDR_PATH_CAP) && (nt == 0 || strcmp(tries[0], tries[nt]))) {
nt++;
base_len[nt++] = 0;
}
}
for (int i = 0; i < nt; i++) {
@@ -1174,7 +1206,8 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
return;
}
/* doc rot when the reference points into the repository: its directory is
* there (a bare name in a link: the document's own directory) */
* there (a bare name in a link: the document's own directory), or a
* directory above it that the reference wrote is (one moved or renamed) */
for (int i = 0; i < nt; i++) {
char parent[MDR_PATH_CAP];
const char *ps = strrchr(tries[i], '/');
@@ -1185,6 +1218,10 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
}
if (pl > 0 && strchr(p, '/') && mdr_written_ancestor(x, graph, tries[i], base_len[i])) {
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
}
if (pl == 0 && r->link && !rooted) {
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
+12
View File
@@ -1143,6 +1143,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
/* The member guard's one exemption (Python, self/cls-rooted receiver,
* unique_name, not a builtin type's method) — see
* cbm_weak_member_unique_name_exempt. MUST match pass_parallel.c exactly. */
/* #1355: `import { eq } from "drizzle-orm"` binds `eq` to a package that is
* not in the indexed tree, so a project-wide same-name guess must not turn
* `eq(...)` into a CALLS edge to an unrelated project `eq`. Joined to
* drop_plain_call for the reason spelled out above: dropping the call here
* would also skip route/HTTP/CONFIG classification, and a route
* registration reached through a static import (`import static
* spark.Spark.get` + `get("/x", handler)`) is exactly a bare call bound by
* a package specifier. Suppressing only the plain-CALLS fall-through keeps
* every Route node and service edge main-identical. */
bool drop_plain_call =
(cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) &&
!cbm_weak_member_unique_name_exempt(lang == CBM_LANG_PYTHON,
@@ -1159,6 +1168,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
(lang == CBM_LANG_PYTHON && cbm_suppress_weak_import_bound_call(true, true, res.strategy) &&
cbm_python_import_binding_contradicts(imports, call->callee_name, res.qualified_name,
ctx->gbuf, ctx->project_name, rel)) ||
cbm_suppress_external_import_shadow(call->callee_name, res.strategy, imports, imp_keys,
imp_count, cbm_pipeline_get_pkgmap(),
cbm_pipeline_get_nsmap()) ||
/* MUST match pass_parallel.c exactly. */
(lang == CBM_LANG_PHP && cbm_suppress_weak_member_match(true, true, res.strategy) &&
cbm_pipeline_php_receiver_type_unindexed(lsp_calls, call, ctx->registry));
+3 -1
View File
@@ -1184,7 +1184,9 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
cbm_pipeline_diagram_arrows(ctx, result);
cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path);
}
cbm_pipeline_namespace_map_free(namespace_map);
/* Publish instead of free — see the twin in pass_parallel.c (#1355). */
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(namespace_map);
if (owns_local_cache) {
for (int i = 0; i < file_count; i++) {
if (local_cache[i]) {
+13 -1
View File
@@ -2041,7 +2041,11 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
}
}
cbm_pipeline_namespace_map_free(namespace_map);
/* Publish instead of free: the call pass needs the same declared-package
* set to tell an in-tree package path from a third-party one (#1355).
* The pipeline owns it from here and frees it in its cleanup. */
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(namespace_map);
cbm_log_info("parallel.registry.done", "entries", itoa_log(reg_entries), "defines",
itoa_log(defines_edges), "imports", itoa_log(imports_edges));
@@ -3406,6 +3410,11 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* This gate MUST stay identical to the one there. */
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
/* The member guard's one exemption — MUST match pass_calls.c exactly. */
/* #1355: same guard as pass_calls.c, and joined to drop_plain_call for
* the same reason — a `continue` here would also skip route/HTTP/CONFIG
* classification, and a static-import route registration is a bare call
* bound by a package specifier. Suppress only the plain-CALLS
* fall-through so every Route node stays main-identical. */
bool drop_plain_call =
(cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) &&
!cbm_weak_member_unique_name_exempt(lang == CBM_LANG_PYTHON,
@@ -3422,6 +3431,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
cbm_python_import_binding_contradicts(&result->imports, call->callee_name,
res.qualified_name, rc->main_gbuf,
rc->project_name, rel)) ||
cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports,
imp_keys, imp_count, cbm_pipeline_get_pkgmap(),
cbm_pipeline_get_nsmap()) ||
/* MUST match pass_calls.c exactly. */
(lang == CBM_LANG_PHP && cbm_suppress_weak_member_match(true, true, res.strategy) &&
cbm_pipeline_php_receiver_type_unindexed(&result->resolved_calls, call, rc->registry));
+52 -40
View File
@@ -408,61 +408,58 @@ void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefin
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm,
cbm_route_canon_path(def->route_path, cpath, sizeof(cpath)));
char rprops[CBM_SZ_256];
snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm);
char esc_m[CBM_SZ_128];
cbm_json_escape(esc_m, sizeof(esc_m), rm);
(void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m);
int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", def->route_path, route_qn,
def->file_path ? def->file_path : rel, 0, 0, rprops);
char hprops[CBM_SZ_512];
char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the
closing brace */
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name);
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
(void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
cbm_gbuf_insert_edge(gb, func_id, route_id, "HANDLES", hprops);
}
/* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges.
* During incremental indexing, only changed files get Route nodes from extraction.
* This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */
/* Extract a JSON string property value into buf. Returns true if found. */
static bool extract_json_prop(const char *json, const char *key, char *buf, int bufsz) {
if (!json) {
/* Extract the string property `key` of a parsed properties object into buf,
* with its escapes decoded. Returns true if found: a non-empty string with no
* NUL that fits buf. Nothing is copied otherwise. */
static bool extract_json_prop(yyjson_val *root, const char *key, char *buf, size_t bufsz) {
yyjson_val *val = yyjson_obj_get(root, key);
const char *str = yyjson_get_str(val);
size_t len = yyjson_get_len(val);
if (!str || len == 0 || len >= bufsz || memchr(str, '\0', len)) {
return false;
}
char pattern[CBM_SZ_64];
snprintf(pattern, sizeof(pattern), "\"%s\":\"", key);
const char *p = strstr(json, pattern);
if (!p) {
return false;
}
p += strlen(pattern);
const char *end = strchr(p, '"');
if (!end || end <= p) {
return false;
}
int len = (int)(end - p);
if (len >= bufsz) {
return false;
}
memcpy(buf, p, (size_t)len);
buf[len] = '\0';
memcpy(buf, str, len + SKIP_ONE);
return true;
}
/* Process a single Function/Method node: create Route+HANDLES if it has route_path.
* Returns 1 if a new HANDLES edge was created, 0 otherwise. */
static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func) {
if (!func->properties_json) {
/* Most nodes carry no route: skip them before parsing their properties. */
if (!func->properties_json || !strstr(func->properties_json, "\"route_path\"")) {
return 0;
}
/* Properties hold source text as its bytes, which need not be UTF-8. */
yyjson_doc *doc = yyjson_read(func->properties_json, strlen(func->properties_json),
YYJSON_READ_ALLOW_INVALID_UNICODE);
yyjson_val *root = yyjson_doc_get_root(doc);
char path[CBM_SZ_256];
if (!extract_json_prop(func->properties_json, "route_path", path, sizeof(path))) {
return 0;
}
if (path[0] != '/') {
return 0;
}
char method[CBM_SZ_16] = "ANY";
extract_json_prop(func->properties_json, "route_method", method, sizeof(method));
bool routed = extract_json_prop(root, "route_path", path, sizeof(path)) && path[0] == '/';
if (routed) {
extract_json_prop(root, "route_method", method, sizeof(method));
}
yyjson_doc_free(doc);
if (!routed) {
return 0;
}
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
@@ -471,7 +468,9 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun
const cbm_gbuf_node_t *existing = cbm_gbuf_find_by_qn(gb, route_qn);
char rprops[CBM_SZ_256];
snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", method);
char esc_m[CBM_SZ_128];
cbm_json_escape(esc_m, sizeof(esc_m), method);
(void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m);
int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", path, route_qn,
func->file_path ? func->file_path : "", 0, 0, rprops);
@@ -486,9 +485,12 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun
}
}
char hprops[CBM_SZ_512];
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}",
func->qualified_name ? func->qualified_name : "");
/* The same properties cbm_pipeline_emit_def_route gives the edge. */
char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the
closing brace */
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h), func->qualified_name ? func->qualified_name : "");
(void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
cbm_gbuf_insert_edge(gb, func->id, route_id, "HANDLES", hprops);
return SKIP_ONE;
}
@@ -1180,6 +1182,17 @@ typedef struct {
int files_seen;
} sveltekit_ctx_t;
/* The properties of a SvelteKit handler's HANDLES edge: its QN, escaped as
* every HANDLES edge's handler is. */
static void sveltekit_handles_props(char *out, size_t outsz, const cbm_gbuf_node_t *child,
bool is_actions) {
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h),
child->qualified_name ? child->qualified_name : child->name);
(void)snprintf(out, outsz, "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}", esc_h,
is_actions ? ",\"via\":\"actions_object\"" : "");
}
/* Process one File node: if it matches a SvelteKit server-side file,
* synthesise a Route node and HANDLES edges from any handler functions
* (or actions Variable) defined in the file. */
@@ -1256,10 +1269,9 @@ static void sveltekit_file_visitor(const cbm_gbuf_node_t *node, void *userdata)
}
ctx->routes_created++;
char hprops[CBM_SZ_256];
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}",
child->qualified_name ? child->qualified_name : child->name,
is_actions ? ",\"via\":\"actions_object\"" : "");
char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the
closing brace */
sveltekit_handles_props(hprops, sizeof(hprops), child, is_actions);
cbm_gbuf_insert_edge(ctx->gb, child->id, route_id, "HANDLES", hprops);
ctx->handles_created++;
}
+14
View File
@@ -792,6 +792,18 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map) {
g_pkgmap = map;
}
/* ── Global declared-package map (one active pipeline at a time) ── */
static CBMHashTable *g_nsmap = NULL;
CBMHashTable *cbm_pipeline_get_nsmap(void) {
return g_nsmap;
}
void cbm_pipeline_set_nsmap(CBMHashTable *map) {
g_nsmap = map;
}
bool cbm_pipeline_had_format_migration(const cbm_pipeline_t *p) {
return p && p->format_migration;
}
@@ -4095,6 +4107,8 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) {
cleanup:
cbm_pkgmap_free(cbm_pipeline_get_pkgmap());
cbm_pipeline_set_pkgmap(NULL);
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(NULL);
cbm_discover_free(files, file_count);
if (p->frozen) {
p->frozen->baseline = baseline_manifest;
+22 -2
View File
@@ -19,8 +19,9 @@
#include <stdint.h>
#include <stdatomic.h>
#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
#include "foundation/constants.h" /* CBM_SZ_512 */
#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
#include "foundation/constants.h" /* CBM_SZ_512 */
#include "foundation/hash_table.h" /* CBMHashTable (#1355 pkgmap lookup) */
/* Forward declarations */
typedef struct cbm_store cbm_store_t;
@@ -411,6 +412,25 @@ bool cbm_weak_member_unique_name_exempt(bool is_python, bool receiver_is_self_at
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy);
/* #1355: drop a project-wide same-name guess (suffix_match / unique_name /
* field_type_hint / fuzzy) for a BARE call whose name the calling file binds to
* a NON-RELATIVE (package) import that resolved to nothing in the graph —
* `import { eq } from "drizzle-orm"` must not make `eq(...)` a CALLS edge to an
* unrelated project `eq`. A name the import map does bind, a relative
* specifier, a member/qualified callee, and every import-/receiver-aware
* strategy are all kept. `indexed_packages` is the pipeline package map: a
* specifier naming a package the tree itself declares (a workspace sibling)
* counts as in-tree and is kept too; NULL disables that check.
* `declared_packages` is the pipeline namespace map and does the same job for
* package-path specifiers, which have no in-tree/external shape of their own:
* `import org.example.util.assertThing` is kept when some indexed file
* declares `package org.example.util`; NULL disables that check.
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy,
const CBMImportArray *file_imports,
const char **import_map_keys, int import_map_count,
const CBMHashTable *indexed_packages,
const CBMHashTable *declared_packages);
/* Go analog of the TS/JS guard, same failure class: a selector call whose
* receiver the Go LSP could not type must not be bound by a receiver-blind
+12 -2
View File
@@ -426,8 +426,12 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder,
if (depth >= MANIFEST_WALK_MAX_DEPTH)
return 0;
cbm_dir_t *dir = cbm_opendir(abs_dir);
if (!dir)
return CBM_NOT_FOUND;
if (!dir) {
/* A nested directory we can't open has no control files to
* contribute; skip it like discover.c's walk_dir already does.
* The root call (depth 0) still fails closed. */
return depth == 0 ? CBM_NOT_FOUND : 0;
}
int rc = 0;
cbm_dirent_t *entry;
while (rc == 0 && (entry = cbm_readdir(dir)) != NULL) {
@@ -2312,6 +2316,8 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
* leaked one map per probed run (LSan, macOS CI). */
cbm_pkgmap_free(cbm_pipeline_get_pkgmap());
cbm_pipeline_set_pkgmap(NULL);
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(NULL);
cbm_path_alias_collection_free(aliases);
cbm_gbuf_free(probe_gbuf);
return rc;
@@ -3537,6 +3543,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
}
cbm_pkgmap_free(cbm_pipeline_get_pkgmap());
cbm_pipeline_set_pkgmap(NULL);
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(NULL);
if (phase_rc != 0) {
cbm_log_error("delta.err", "phase", "extract_resolve", "rc", itoa_buf(phase_rc));
if (phase_rc == CBM_PIPELINE_ABORT_PRESERVE_DB) {
@@ -4293,6 +4301,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
* full pipeline's ownership boundary on both success and failure. */
cbm_pkgmap_free(cbm_pipeline_get_pkgmap());
cbm_pipeline_set_pkgmap(NULL);
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
cbm_pipeline_set_nsmap(NULL);
if (phase_rc != 0) {
cbm_log_error("incremental.err", "phase", "extract_resolve", "rc", itoa_buf(phase_rc));
+10
View File
@@ -760,6 +760,16 @@ typedef enum {
cbm_psr4_member_t cbm_pipeline_psr4_member_qn(const cbm_gbuf_t *gbuf, const char *class_fqn,
const char *member, const char **out_qn);
/* Get the current pipeline's declared-package map (NULL if none): the
* namespace/package → File-QN map built by cbm_pipeline_namespace_map_build
* from every indexed file's own `package`/`namespace`/`using` declaration.
* The import passes build it to resolve namespace imports; it is published
* here so the call passes can ask whether a package-path specifier names a
* package the tree itself declares (#1355). Set before resolve starts, read
* only afterwards — same single-writer contract as the pkgmap above. */
CBMHashTable *cbm_pipeline_get_nsmap(void);
void cbm_pipeline_set_nsmap(CBMHashTable *map);
/* Unified module resolver: relative → pkgmap → fqn_module fallback.
* Handles bare specifiers via pkgmap lookup with prefix matching.
* Caller must free() the returned string. */
+236
View File
@@ -33,6 +33,7 @@ enum { REG_MAX_CANDIDATES = 256 };
#include "foundation/platform.h"
#include "callable_sig.h" /* cbm_qn_callable_base_len_named: overloads share the name key */
#include <ctype.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
@@ -750,6 +751,241 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b
return weak_short_name_strategy(strategy);
}
/* A module specifier that names a path inside the indexed tree ("./x", "../x",
* "/abs/x", and on Windows "C:\x", "C:/x", "\\server\share\x") rather than an
* external package. Package specifiers are everything else — "drizzle-orm",
* "rxjs/operators", "@scope/pkg". Windows paths must count as in-tree here:
* pr-smoke runs this pipeline on Windows, and misclassifying a drive-letter or
* UNC specifier as "external" is exactly the false-external edge this guard
* exists to prevent (#1355). */
static bool specifier_is_relative(const char *module_path) {
if (!module_path || !module_path[0]) {
return false;
}
if (module_path[0] == '.' || module_path[0] == '/') {
return true;
}
/* UNC share: \\server\share\... */
if (module_path[0] == '\\' && module_path[1] == '\\') {
return true;
}
/* Drive-letter absolute path: C:\repo\... or C:/repo/... */
if (isalpha((unsigned char)module_path[0]) && module_path[1] == ':' &&
(module_path[2] == '\\' || module_path[2] == '/')) {
return true;
}
return false;
}
/* True when the specifier names a package that the indexed tree DECLARES —
* a workspace sibling, not a third-party dependency. `indexed_packages` is the
* pipeline's package map, keyed by the `name` of every manifest found in the
* tree (package.json, go.mod, Cargo.toml, ...), so a pnpm/npm/cargo workspace
* registers each of its own packages here.
*
* The key is the bare package name, so a subpath specifier is walked back one
* slash at a time: "drizzle-orm/pg-core" -> "drizzle-orm". Scoped names keep
* their leading segment ("@scope/pkg/sub" -> "@scope/pkg" -> "@scope").
*
* Deliberately a NAME test, not a resolution test. A workspace package usually
* points `main`/`module` at a build artifact ("./index.cjs") that is not
* checked in, so asking whether the entry file exists in the graph answers
* "no" for exactly the monorepos this has to protect. Whether the tree claims
* the name is decidable from the manifest alone. */
static bool specifier_names_indexed_package(const CBMHashTable *indexed_packages,
const char *module_path) {
if (!indexed_packages || !module_path || !module_path[0]) {
return false;
}
if (cbm_ht_has(indexed_packages, module_path)) {
return true;
}
char buf[CBM_SZ_512];
if (strlen(module_path) >= sizeof(buf)) {
return false;
}
snprintf(buf, sizeof(buf), "%s", module_path);
for (char *slash = strrchr(buf, '/'); slash != NULL; slash = strrchr(buf, '/')) {
*slash = '\0';
if (cbm_ht_has(indexed_packages, buf)) {
return true;
}
}
return false;
}
/* True when the specifier is a package PATH the indexed tree itself DECLARES.
* `declared_packages` is the pipeline's namespace map, keyed by the
* dot-normalized `package` / `namespace` / `use` declaration of every indexed
* file (cbm_pipeline_namespace_map_build), so this is the JVM/CLR/PHP
* counterpart of the manifest-name test above: those languages have no
* specifier shape that separates in-tree from third-party, and their manifests
* (pom.xml, build.gradle) name artifacts, not packages.
*
* A package-path specifier names a member inside a package, so the trailing
* segment is walked off one at a time, exactly like the namespace-map lookup in
* cbm_pipeline_resolve_import_node: `org.jetbrains.exposed.v1.tests.shared
* .assertEquals` -> `org.jetbrains.exposed.v1.tests.shared`, which Exposed's
* own Assert.kt declares, so the call keeps its edge. `java.net.URL` walks to
* `java.net` then `java`, which no project file declares, so it stays external
* and the guard still cuts its bind to an in-tree `URL` class.
*
* Separator normalization mirrors the map builder's: PHP writes `\`, some
* grammars `::` or `/`. NULL disables the check. */
static bool specifier_names_declared_package(const CBMHashTable *declared_packages,
const char *module_path) {
if (!declared_packages || !module_path || !module_path[0]) {
return false;
}
char buf[CBM_SZ_512];
if (strlen(module_path) >= sizeof(buf)) {
return false;
}
snprintf(buf, sizeof(buf), "%s", module_path);
char *brace = strchr(buf, '{');
if (brace) {
*brace = '\0'; /* grouped import — the prefix is the package */
}
char *alias = strstr(buf, " as ");
if (alias) {
*alias = '\0';
}
for (char *p = buf; *p; p++) {
if (*p == '\\' || *p == ':' || *p == '/') {
*p = '.';
}
}
/* Collapse the empty segments "::" just produced, then strip glob/
* separator noise off the tail. */
for (;;) {
char *dd = strstr(buf, "..");
if (!dd) {
break;
}
memmove(dd, dd + 1, strlen(dd + 1) + 1);
}
size_t len = strlen(buf);
while (len > 0 && (buf[len - 1] == '*' || buf[len - 1] == '.' || buf[len - 1] == ' ')) {
buf[--len] = '\0';
}
if (buf[0] == '\0') {
return false;
}
if (cbm_ht_has(declared_packages, buf)) {
return true;
}
/* The walk has no floor, and that is a deliberate false-NEGATIVE: once the
* tree declares `com.example`, EVERY specifier under `com.example.**` reads
* as in-tree, including a third-party library that happens to publish under
* a declared prefix. Such a call keeps the same-name fallback it has on
* main, so the cost is a fabricated edge this guard could have removed —
* never a real edge lost. Stopping earlier would need a rule for where a
* package path ends and a type begins, and getting that wrong fails the
* other way. Pinned by external_import_shadow_declared_prefix_is_permissive
* in test_registry.c. */
for (char *dot = strrchr(buf, '.'); dot != NULL; dot = strrchr(buf, '.')) {
*dot = '\0';
if (cbm_ht_has(declared_packages, buf)) {
return true;
}
}
return false;
}
static bool import_map_binds(const char **import_map_keys, int import_map_count,
const char *local_name) {
if (!import_map_keys || import_map_count <= 0) {
return false;
}
for (int i = 0; i < import_map_count; i++) {
if (import_map_keys[i] && strcmp(import_map_keys[i], local_name) == 0) {
return true;
}
}
return false;
}
/* #1355: a bare call whose name the calling file binds to an EXTERNAL package
* import must not fall back to a project-wide same-name guess.
*
* `import { eq } from "drizzle-orm"` binds `eq` in this file's scope. When the
* package is not part of the indexed tree it materializes no IMPORTS edge, so
* the import map has no entry, strategies 1-2 miss, and strategy 3/4 attach
* `eq(users.id, id)` to whatever project symbol happens to share the simple
* name — a CALLS edge into an unrelated local helper. The explicit import
* statement is positive evidence, taken from the caller's own source, that the
* identifier does NOT denote that symbol.
*
* Fires only when ALL of:
* - the callee is a BARE identifier: member (`x.foo()`) and package/namespace
* qualified callees are the receiver-aware guards' business, not this one;
* - the match came from a project-wide guess (suffix_match / unique_name;
* field_type_hint / fuzzy listed for the same defensive reason as the TS/JS
* guard) — every import-, receiver- or module-aware strategy is KEPT;
* - the file imports that exact local name from a NON-RELATIVE specifier;
* - no import-map key binds the name, i.e. that import resolved to nothing in
* the graph. A name the map does bind already had its chance at strategy 1
* and is import-aware by construction.
*
* Relative specifiers are deliberately excluded: they name a path inside the
* indexed tree, so a missing IMPORTS edge is an in-project resolution gap and
* the same-name fallback can still be right. A name imported from both a
* relative and a package specifier keeps the edge — the relative binding wins
* the tie explicitly, so the outcome does not depend on import order.
*
* `indexed_packages` extends that same reasoning to workspace monorepos, where
* a BARE specifier also names in-tree code: `import { eq } from "drizzle-orm"`
* inside the drizzle-orm repository is a sibling package, not a dependency. A
* specifier the tree's own manifests claim is treated exactly like a relative
* one. NULL disables the check and restores the specifier-shape-only contract.
*
* `declared_packages` closes the same hole for the package-path languages,
* where NO specifier shape says in-tree: `import
* org.jetbrains.exposed.v1.tests.shared.assertEquals` is spelled exactly like
* `import kotlin.test.assertEquals`, and only the tree's own `package`
* declarations tell them apart. It is permissive by construction — any
* specifier UNDER a declared package prefix counts as in-tree, see the note on
* the walk — so it can leave a fabricated edge in place, never remove a real
* one. NULL disables the check.
*
* Pure + side-effect-free so the contract is unit-testable without a pipeline. */
bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy,
const CBMImportArray *file_imports,
const char **import_map_keys, int import_map_count,
const CBMHashTable *indexed_packages,
const CBMHashTable *declared_packages) {
if (!callee_name || !callee_name[0] || !strategy || !strategy[0]) {
return false;
}
if (strcmp(strategy, "suffix_match") != 0 && strcmp(strategy, "unique_name") != 0 &&
strcmp(strategy, "field_type_hint") != 0 && strcmp(strategy, "fuzzy") != 0) {
return false;
}
if (strchr(callee_name, '.') != NULL || strstr(callee_name, "::") != NULL) {
return false;
}
if (!file_imports || file_imports->count <= 0) {
return false;
}
if (import_map_binds(import_map_keys, import_map_count, callee_name)) {
return false;
}
bool external_binding = false;
for (int i = 0; i < file_imports->count; i++) {
const CBMImport *imp = &file_imports->items[i];
if (!imp->local_name || !imp->module_path || strcmp(imp->local_name, callee_name) != 0) {
continue;
}
if (specifier_is_relative(imp->module_path) ||
specifier_names_indexed_package(indexed_packages, imp->module_path) ||
specifier_names_declared_package(declared_packages, imp->module_path)) {
return false; /* an in-tree binding for this name — never suppress */
}
external_binding = true;
}
return external_binding;
}
bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy,
double confidence) {
/* Go analog of the TS/JS guard above, same failure class: a selector call
+65 -44
View File
@@ -575,6 +575,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje
/* 3. Query edges — filter during fetch via binary search (O(e log n)) */
int *deg = calloc((size_t)n, sizeof(int));
bool *framework_entry = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)n * sizeof(bool));
int mapped = 0;
int edge_cap = CBM_SZ_256;
cbm_edge_t *all_edges = malloc((size_t)edge_cap * sizeof(cbm_edge_t));
@@ -582,52 +583,69 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje
int *ed = malloc((size_t)edge_cap * sizeof(int));
cbm_schema_info_t schema;
memset(&schema, 0, sizeof(schema));
if (deg && all_edges && es && ed &&
cbm_store_get_schema(store, project, &schema) == CBM_STORE_OK) {
for (int t = 0; t < schema.edge_type_count; t++) {
cbm_edge_t *te = NULL;
int tc = 0;
if (cbm_store_find_edges_by_type(store, project, schema.edge_types[t].type, &te, &tc) ==
CBM_STORE_OK) {
for (int e = 0; e < tc; e++) {
int si = find_node_index(id_map, n, te[e].source_id);
int di = find_node_index(id_map, n, te[e].target_id);
if (si >= 0 && di >= 0) {
if (mapped >= edge_cap) {
int nc = edge_cap * PAIR_LEN;
cbm_edge_t *te2 = realloc(all_edges, (size_t)nc * sizeof(cbm_edge_t));
int *ts = realloc(es, (size_t)nc * sizeof(int));
int *td = realloc(ed, (size_t)nc * sizeof(int));
if (!te2 || !ts || !td) {
if (te2)
all_edges = te2;
if (ts)
es = ts;
if (td)
ed = td;
free_edge_array(te + e, tc - e);
goto edges_done;
bool framework_entries_valid = deg && framework_entry && all_edges && es && ed;
if (framework_entries_valid) {
if (cbm_store_get_schema(store, project, &schema) == CBM_STORE_OK) {
for (int t = 0; t < schema.edge_type_count; t++) {
cbm_edge_t *te = NULL;
int tc = 0;
if (cbm_store_find_edges_by_type(store, project, schema.edge_types[t].type, &te,
&tc) == CBM_STORE_OK) {
for (int e = 0; e < tc; e++) {
int si = find_node_index(id_map, n, te[e].source_id);
int di = find_node_index(id_map, n, te[e].target_id);
if (si >= 0 && te[e].type && strcmp(te[e].type, "HANDLES") == 0) {
cbm_node_t route = {0};
if (cbm_store_find_node_by_id(store, te[e].target_id, &route) ==
CBM_STORE_OK) {
if (route.label && strcmp(route.label, "Route") == 0)
framework_entry[si] = true;
cbm_node_free_fields(&route);
} else
framework_entries_valid = false;
}
if (si >= 0 && di >= 0) {
if (mapped >= edge_cap) {
int nc = edge_cap * PAIR_LEN;
cbm_edge_t *te2 =
realloc(all_edges, (size_t)nc * sizeof(cbm_edge_t));
int *ts = realloc(es, (size_t)nc * sizeof(int));
int *td = realloc(ed, (size_t)nc * sizeof(int));
if (!te2 || !ts || !td) {
framework_entries_valid = false;
if (te2)
all_edges = te2;
if (ts)
es = ts;
if (td)
ed = td;
free_edge_array(te + e, tc - e);
goto edges_done;
}
all_edges = te2;
es = ts;
ed = td;
edge_cap = nc;
}
all_edges = te2;
es = ts;
ed = td;
edge_cap = nc;
all_edges[mapped] = te[e];
memset(&te[e], 0, sizeof(cbm_edge_t));
es[mapped] = si;
ed[mapped] = di;
deg[si]++;
deg[di]++;
mapped++;
} else {
free((void *)te[e].project);
free((void *)te[e].type);
free((void *)te[e].properties_json);
}
all_edges[mapped] = te[e];
memset(&te[e], 0, sizeof(cbm_edge_t));
es[mapped] = si;
ed[mapped] = di;
deg[si]++;
deg[di]++;
mapped++;
} else {
free((void *)te[e].project);
free((void *)te[e].type);
free((void *)te[e].properties_json);
}
}
free(te);
free(te);
} else
framework_entries_valid = false;
}
} else {
framework_entries_valid = false;
}
edges_done:
cbm_store_schema_free(&schema);
@@ -651,6 +669,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje
if (!result || !bodies) {
free(bodies);
free(deg);
cbm_free(CBM_MEM_CLASS_OTHER, framework_entry);
free(es);
free(ed);
free(cdepth);
@@ -757,11 +776,12 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje
status = "structural";
else if (testish)
status = "test";
else if (nf.is_entry || nf.is_route)
else if (nf.is_entry || nf.is_route ||
(framework_entries_valid && framework_entry && framework_entry[i]))
status = "entry";
else if (nf.is_exported)
status = "exported";
else if (ic == 0 && iu == 0)
else if (framework_entries_valid && ic == 0 && iu == 0)
status = "dead";
else if (ic == 1)
status = "single";
@@ -795,6 +815,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje
free(bodies);
free(deg);
cbm_free(CBM_MEM_CLASS_OTHER, framework_entry);
free(es);
free(ed);
free(cdepth);
+27 -9
View File
@@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s
* a `.git` file (the gitlink form used by linked worktrees and initialized
* submodules). Deliberately a filesystem check, not a git invocation — the whole
* point is to learn something `rev-parse` cannot tell us, because it walks up. */
/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */
static int watcher_path_info(const char *path, cbm_path_info_t *info) {
int status = cbm_path_info_utf8(path, info);
if (status == CBM_PATH_INFO_OK && info->is_symlink) {
char canonical[CBM_SZ_4K];
if (!cbm_canonical_path(path, canonical, sizeof(canonical))) {
return CBM_PATH_INFO_UNAVAILABLE;
}
status = cbm_path_info_utf8(canonical, info);
}
return status;
}
static bool git_has_own_dot_git(const char *root_path) {
char path[CBM_SZ_4K];
int written = snprintf(path, sizeof(path), "%s/.git", root_path);
if (written <= 0 || (size_t)written >= sizeof(path)) {
return false;
}
struct stat st;
return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode));
cbm_path_info_t info;
return watcher_path_info(path, &info) == CBM_PATH_INFO_OK &&
(info.is_directory || info.is_regular);
}
/* Does the ancestor repository actually track anything inside this directory?
@@ -990,13 +1004,17 @@ static root_status_t root_status(const char *root_path, int *out_errno) {
if (!root_path) {
return ROOT_UNCERTAIN;
}
struct stat st;
if (stat(root_path, &st) == 0) {
/* Exists but is no longer a directory → the root directory is gone. */
return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING;
cbm_path_info_t info;
int status = watcher_path_info(root_path, &info);
if (status == CBM_PATH_INFO_OK) {
return info.is_directory ? ROOT_PRESENT : ROOT_MISSING;
}
#ifdef _WIN32
*out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO;
#else
*out_errno = errno;
return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN;
#endif
return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN;
}
/* Sustained-absence window (seconds) before a missing root may be pruned.
@@ -1338,8 +1356,8 @@ static void publish_status(project_state_t *s, bool scanned) {
/* Init baseline for a project: check if git, get HEAD, count files */
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
struct stat st;
if (stat(s->root_path, &st) != 0) {
cbm_path_info_t info;
if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) {
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
s->baseline_done = true;
s->is_git = false;
+20 -16
View File
@@ -10,7 +10,7 @@ npm-readme-runs-the-test-legs pkg/npm/README.md T1-tests false lint lint-mem sec
issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static
local-infra test-infrastructure/run.sh T0b-nonproduct false contracts lint lint-mem security-static
lint-config scripts/memory-core-baseline.txt T0b-nonproduct false contracts lint lint-mem security-static
graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards
graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards codeql-js
memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false contracts lint lint-mem memwaste security-static
non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86
test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86
@@ -19,19 +19,19 @@ test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem securit
contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
lsp-language internal/cbm/lsp/go_lsp.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
lsp-core internal/cbm/lsp/type_registry.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
extraction internal/cbm/extract_calls.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
pipeline src/pipeline/pass_calls.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false codeql-gate lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows
daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste
main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste
foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste
windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste
smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste
package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste
lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp
mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp codeql-py
daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions
main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions
foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions
windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions
smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions
package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions
memory-core src/foundation/mem_core.c T5-core true FULL
gate-script scripts/ci/require-all-green.sh T5-core true FULL
the-selector-itself scripts/ci/select-lanes.sh T5-core true FULL
@@ -50,8 +50,8 @@ lint-config-file .cppcheck T0b-nonproduct false contracts lint lint-mem security
script-c-tests-read scripts/security-vendored.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
engine-config-c-tests-read .codebase-memory.json T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
test-failure-report scripts/suite-failure-report.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows
smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste
shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste
smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions
shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions
non-gate-workflow-forces-full .github/workflows/stale.yml T0b-nonproduct true FULL
harness-main-forces-full tests/test_main.c T1-tests true FULL
harness-framework-forces-full tests/test_framework.h T1-tests true FULL
@@ -59,3 +59,7 @@ harness-driver-forces-full scripts/test.sh T5-core true FULL
harness-scheduler-forces-full scripts/run-test-wave.py T5-core true FULL
lower-tier-plus-trigger-is-full docs/x.md src/pipeline/a.c tests/test_main.c T3-mid true FULL
empty-list-fails-safe - T0a-docs true FULL
graph-ui-python-agent graph-ui/agents/agent.py T0b-nonproduct false codeql-py contracts lint lint-mem security-static smoke-ubuntu windows-guards
vendored-js-is-not-scanned graph-ui/vendor/lib.js T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards
vendored-only-c-scans-c vendored/sqlite3/sqlite3.c T5-core true FULL
codeql-config-is-full .github/codeql/codeql-config.yml T5-core true FULL
Can't render this file because it contains an unexpected character in line 3 and column 58.
+38 -6
View File
@@ -115,8 +115,9 @@
* does (".ts" → TypeScript, ".tsx" → TSX). Returns 0 on PASS (GREEN), non-zero
* on FAIL (RED) — the redness is the documented per-pass status.
*/
static int assert_lsp_strategy_files(const RFile *files, int nfiles,
const char *strategy) {
static int assert_lsp_strategy_files_expected(const RFile *files, int nfiles,
const char *strategy,
int expected_module_sourced) {
RProj lp;
cbm_store_t *store = rh_index_files(&lp, files, nfiles);
if (!store) {
@@ -135,12 +136,13 @@ static int assert_lsp_strategy_files(const RFile *files, int nfiles,
int rc = 0;
/* (a) callable-sourcing floor: zero Module/File-sourced CALLS edges. */
if (module_sourced != 0) {
/* (a) keep module-sourced calls explicit so top-level constructors are
* allowed only in fixtures that expect them. */
if (module_sourced != expected_module_sourced) {
printf(" %sFAIL%s %s:%d: strategy %s: %d Module-sourced CALLS "
"(expected 0)\n",
"(expected %d)\n",
tf_red(), tf_reset(), __FILE__, __LINE__, strategy,
module_sourced);
module_sourced, expected_module_sourced);
rc = 1;
}
/* There must be a callable-sourced CALLS edge, else the fixture produced no
@@ -165,6 +167,11 @@ static int assert_lsp_strategy_files(const RFile *files, int nfiles,
return rc;
}
static int assert_lsp_strategy_files(const RFile *files, int nfiles,
const char *strategy) {
return assert_lsp_strategy_files_expected(files, nfiles, strategy, 0);
}
/* Single-file convenience wrapper. */
static int assert_lsp_strategy(const char *filename, const char *src,
const char *strategy) {
@@ -297,6 +304,22 @@ static const RFile kTsImport[] = {
"function caller(v: number): number { return helper(v); }\n"},
};
/* Keep the module-level inferred receiver shape from issue #1974: a local
* initialized from an imported constructor is used by a later function.
*/
static const RFile kTsInferredImportedMethod[] = {
{"store.ts",
"export class WidgetStore {\n"
" findById(id: string): string { return id; }\n"
"}\n"},
{"service.ts",
"import { WidgetStore } from \"./store\";\n"
"const inferred = new WidgetStore();\n"
"function load(id: string): string {\n"
" return inferred.findById(id);\n"
"}\n"},
};
/* lsp_ts_jsx — <Comp/> JSX element whose tag is a module-local component
* function (ts_lsp.c:2643-2647). TSX only (jsx_mode); the tag's first letter is
* uppercase so it is NOT treated as an intrinsic HTML element; it resolves via
@@ -370,6 +393,14 @@ TEST(repro_lsp_ts_import) {
"lsp_ts_import");
}
TEST(repro_lsp_ts_inferred_import_module_receiver) {
return assert_lsp_strategy_files_expected(
kTsInferredImportedMethod,
(int)(sizeof(kTsInferredImportedMethod) /
sizeof(kTsInferredImportedMethod[0])),
"lsp_ts_method", 1);
}
TEST(repro_lsp_ts_jsx) {
return assert_lsp_strategy("app.tsx", kTsxJsx, "lsp_ts_jsx");
}
@@ -403,6 +434,7 @@ SUITE(repro_lsp_ts) {
RUN_TEST(repro_lsp_ts_method);
RUN_TEST(repro_lsp_ts_namespace);
RUN_TEST(repro_lsp_ts_import);
RUN_TEST(repro_lsp_ts_inferred_import_module_receiver);
RUN_TEST(repro_lsp_ts_jsx);
RUN_TEST(repro_lsp_ts_jsx_import);
RUN_TEST(repro_lsp_ts_default);
+336 -1
View File
@@ -4245,6 +4245,212 @@ TEST(cli_find_cli_fallback_paths) {
PASS();
}
/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */
/* Snapshot/restore one environment variable. The suite swaps PATH and the
* Windows profile roots, and a leaked swap would poison every later suite. */
static char *cli_issue221_save_env(const char *name) {
const char *value = getenv(name);
return value ? strdup(value) : NULL;
}
static void cli_issue221_restore_env(const char *name, char *saved) {
if (saved) {
cbm_setenv(name, saved, 1);
free(saved);
} else {
cbm_unsetenv(name);
}
}
/*
* cli_find_cli_long_path_beyond_four_kb_issue221
*
* find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through
* cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it
* cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole
* PATH was skipped and every agent on it read as "not installed". That is the
* layout the #221 reporter had: mise, npm and scoop each append entries.
*
* The fixture directory is the LAST PATH entry, so any truncation of the copy
* hides exactly the directory under test and nothing else. This case is not
* Windows-specific -- the same 4 KB cap hid entries on macOS and Linux.
*
* RED before the fix (empty result), GREEN after it.
*/
TEST(cli_find_cli_long_path_beyond_four_kb_issue221) {
const char *created = th_mktempdir("repro221long");
if (!created) {
FAIL("th_mktempdir failed");
}
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s", created);
#ifdef _WIN32
const char *fixture_name = "cbm-repro221-long.cmd";
const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n";
const char path_delim = ';';
#else
const char *fixture_name = "cbm-repro221-long";
const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n";
const char path_delim = ':';
#endif
char fixture_path[1024];
snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name);
if (th_write_file(fixture_path, fixture_body) != 0) {
th_cleanup(tmpdir);
FAIL("could not write the long-PATH fixture");
}
th_make_executable(fixture_path);
/*
* Filler entries name directories that do not exist: the scan has to walk
* past every one of them to reach the fixture, which is appended after the
* filler so that it always sits beyond the 4096-byte mark.
*/
enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 };
char long_path[PATH_CAP];
size_t used = 0U;
while (used < (size_t)FILLER_TARGET) {
int written = snprintf(long_path + used, sizeof(long_path) - used,
"/nonexistent-repro221-filler%05zu%c", used, path_delim);
if (written <= 0 || (size_t)written >= sizeof(long_path) - used) {
th_cleanup(tmpdir);
FAIL("could not build the long PATH filler");
}
used += (size_t)written;
}
ASSERT_GT(used, 4096);
int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir);
if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) {
th_cleanup(tmpdir);
FAIL("could not append the fixture directory to the long PATH");
}
char *saved_path = cli_issue221_save_env("PATH");
cbm_setenv("PATH", long_path, 1);
/* A home that does not exist keeps the PATH-independent fallbacks out of
* the way: the fixture's directory is the only place that can satisfy it. */
const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home");
cli_issue221_restore_env("PATH", saved_path);
ASSERT_NOT_NULL(result);
ASSERT(result[0] != '\0');
ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0);
ASSERT(strstr(result, "cbm-repro221-long") != NULL);
th_cleanup(tmpdir);
PASS();
}
/* ── #221 regression: the Windows fallback shim locations ───────────────── */
/*
* cli_find_cli_windows_fallback_shims_issue221
*
* An agent the PATH scan cannot see must still be found where Windows actually
* puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims,
* %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin,
* ~\.cargo\bin) that were already probed before #221 passed and must not be
* dropped by the fix.
*
* Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or
* the home_dir argument -- so this never reads the developer's real profile.
* Every shim carries a .cmd extension, which is why the fallback probe needs
* the same PATHEXT treatment the PATH scan got.
*
* Windows-only: the directory layout it asserts is the one Windows installers
* produce. */
TEST(cli_find_cli_windows_fallback_shims_issue221) {
#ifndef _WIN32
SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout");
#else
const char *created = th_mktempdir("repro221fb");
if (!created) {
FAIL("th_mktempdir failed");
}
char root[512];
snprintf(root, sizeof(root), "%s", created);
char *saved_appdata = cli_issue221_save_env("APPDATA");
char *saved_localappdata = cli_issue221_save_env("LOCALAPPDATA");
char *saved_userprofile = cli_issue221_save_env("USERPROFILE");
char *saved_path = cli_issue221_save_env("PATH");
/* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The
* scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so
* USERPROFILE here mirrors what a real profile would supply. */
cbm_setenv("APPDATA", root, 1);
cbm_setenv("LOCALAPPDATA", root, 1);
cbm_setenv("USERPROFILE", root, 1);
cbm_setenv("PATH", "/nonexistent-repro221-path", 1);
/* One shim at a time, so the returned path proves which entry matched
* rather than merely that something did. Each case removes its shim before
* the next one runs, so the earlier entries cannot shadow the later ones. */
const struct {
const char *subdir;
const char *label;
} cases[] = {
{".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"},
{"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"},
};
const char *shim_name = "cbm-repro221-fb.cmd";
const size_t case_count = sizeof(cases) / sizeof(cases[0]);
for (size_t i = 0; i < case_count; i++) {
char dir[512];
snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir);
if (th_mkdir_p(dir) != 0) {
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("could not create the fallback fixture directory");
}
char shim[1024];
snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name);
if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) {
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("could not write the fallback shim");
}
const char *result = cbm_find_cli("cbm-repro221-fb", root);
char expected_prefix[512];
snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir);
bool matched = result != NULL && result[0] != '\0' &&
strncmp(result, expected_prefix, strlen(expected_prefix)) == 0;
(void)remove(shim);
if (!matched) {
printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir);
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("a Windows fallback shim location was not probed");
}
}
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
PASS();
#endif
}
/* ═══════════════════════════════════════════════════════════════════
* Dry-run flag parsing (port of TestDryRun)
* ═══════════════════════════════════════════════════════════════════ */
@@ -12197,6 +12403,89 @@ TEST(cli_codex_session_hook_issue330) {
PASS();
}
/* The block installs up to v0.12 wrote: the lifecycle pair only, inside the
* markers. Upgrades and uninstalls must still recognize it as ours. */
static const char test_codex_previous_hook_block[] =
"# >>> codebase-memory-mcp SessionStart >>>\n"
"[[hooks.SessionStart]]\n"
"matcher = \"startup|resume|clear|compact\"\n\n"
"[[hooks.SessionStart.hooks]]\n"
"type = \"command\"\ncommand = \"codebase-memory-mcp hook-augment\"\n"
"command_windows = \"codebase-memory-mcp hook-augment\"\ntimeout = 5\n\n"
"[[hooks.SubagentStart]]\nmatcher = \"*\"\n\n"
"[[hooks.SubagentStart.hooks]]\n"
"type = \"command\"\ncommand = \"codebase-memory-mcp hook-augment\"\n"
"command_windows = \"codebase-memory-mcp hook-augment\"\ntimeout = 5\n"
"# <<< codebase-memory-mcp SessionStart <<<\n";
/* Codex shell searches get graph context in flow: the managed block adds a
* PreToolUse Bash hook, upgrades the previous block in place, removes either
* generation, keeps a user's own inline hooks.PreToolUse (TOML cannot mix an
* inline array with array-of-tables entries for one key), and replaces the
* inline form Codex may normalize ours to. */
TEST(cli_codex_pretooluse_hook_lifecycle) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codexpre-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/config.toml", tmpdir);
/* fresh install: one PreToolUse Bash hook running hook-augment, idempotent */
write_test_file(cfg, "[mcp_servers.other]\ncommand = \"x\"\n");
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]\nmatcher = \"Bash\""), 1U);
ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse.hooks]]"), 1U);
ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U);
ASSERT_EQ(cbm_remove_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "hooks.PreToolUse"));
ASSERT_NOT_NULL(strstr(d, "[mcp_servers.other]"));
/* upgrade: the previous block is replaced, never refused as ambiguous */
char previous[2048];
snprintf(previous, sizeof(previous), "[mcp_servers.other]\ncommand = \"x\"\n\n%s",
test_codex_previous_hook_block);
write_test_file(cfg, previous);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U);
ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]"), 1U);
/* uninstall of the previous generation */
write_test_file(cfg, previous);
ASSERT_EQ(cbm_remove_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "hooks.SessionStart"));
ASSERT_NULL(strstr(d, "hook-augment"));
/* a user's own inline PreToolUse stays, and our block leaves PreToolUse out */
const char *foreign = "[hooks]\nPreToolUse = [{ matcher = \"Bash\", hooks = [{ type = "
"\"command\", command = \"/usr/local/bin/audit-shell\" }] }]\n";
write_test_file(cfg, foreign);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NOT_NULL(strstr(d, "audit-shell"));
ASSERT_NULL(strstr(d, "[[hooks.PreToolUse]]"));
ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U);
/* Codex's inline form of our own PreToolUse is replaced, not duplicated */
write_test_file(cfg, "[hooks]\nPreToolUse = [{ matcher = \"Bash\", hooks = [{ type = "
"\"command\", command = \"codebase-memory-mcp hook-augment\", "
"command_windows = \"codebase-memory-mcp hook-augment\", "
"timeout = 5 }] }]\n");
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "PreToolUse = ["));
ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]"), 1U);
test_rmdir_r(tmpdir);
PASS();
}
/* Gemini/Antigravity SessionStart reminder parity (settings.json JSON path). */
TEST(cli_gemini_session_hook_parity) {
char tmpdir[256];
@@ -13221,7 +13510,8 @@ TEST(cli_codex_migrates_to_single_hook_representation) {
bool lifecycle_ok = first_rc == 0 && repeat_rc == 0 && dry_rc == 0 && first && repeated &&
after_dry && strcmp(first, repeated) == 0 && strcmp(first, after_dry) == 0;
bool migrated = migration_rc == 0 && toml && !strstr(toml, "SessionStart") && hooks &&
strstr(hooks, "SessionStart") && strstr(hooks, "SubagentStart");
strstr(hooks, "SessionStart") && strstr(hooks, "SubagentStart") &&
strstr(hooks, "PreToolUse");
free(first);
free(repeated);
free(after_dry);
@@ -13580,6 +13870,47 @@ TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter) {
PASS();
}
/* Codex sends PreToolUse for its shell tool as tool_name "Bash" with a string
* tool_input.command, plus its own fields (turn_id, model, permission_mode,
* tool_use_id, a null transcript_path). The default dialect must augment it. */
TEST(cli_hook_augment_codex_pretooluse_payload) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
char *project = cbm_project_name_from_path("/tmp/hookproj");
ASSERT_NOT_NULL(project);
cbm_mcp_server_set_project(srv, project);
cbm_store_upsert_project(st, project, "/tmp/hookproj");
char qualified_name[256];
snprintf(qualified_name, sizeof(qualified_name), "%s.mod.someIndexedSymbol", project);
cbm_node_t node = {.project = project,
.label = "Function",
.name = "someIndexedSymbol",
.qualified_name = qualified_name,
.file_path = "mod.py",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
const char *input = "{\"session_id\":\"0199a1b2-codex\",\"turn_id\":\"turn-1\","
"\"transcript_path\":null,\"cwd\":\"/tmp/hookproj\","
"\"hook_event_name\":\"PreToolUse\",\"model\":\"gpt-5.6\","
"\"permission_mode\":\"default\",\"tool_name\":\"Bash\","
"\"tool_input\":{\"command\":\"rg -n someIndexedSymbol .\"},"
"\"tool_use_id\":\"call_1\"}";
char *output = cbm_hook_augment_process(srv, input);
bool reached = output && strstr(output, "\"hookEventName\":\"PreToolUse\"") &&
strstr(output, "additionalContext") && strstr(output, "someIndexedSymbol");
free(output);
cbm_mcp_server_free(srv);
free(project);
if (!reached)
FAIL("a Codex-shaped PreToolUse Bash payload must reach graph search augmentation");
PASS();
}
TEST(cli_hook_augment_bash_pattern_extractor) {
char out[256];
@@ -18307,6 +18638,8 @@ SUITE(cli) {
RUN_TEST(cli_find_cli_result_is_thread_local);
RUN_TEST(cli_find_cli_on_path);
RUN_TEST(cli_find_cli_fallback_paths);
RUN_TEST(cli_find_cli_long_path_beyond_four_kb_issue221);
RUN_TEST(cli_find_cli_windows_fallback_shims_issue221);
/* Dry-run flag parsing (1 test — install_test.go) */
RUN_TEST(cli_dry_run_flags);
@@ -18500,6 +18833,7 @@ SUITE(cli) {
RUN_TEST(cli_hook_metadata_rejects_truncated_utf8_without_oob);
RUN_TEST(cli_aider_config_loads_installed_conventions);
RUN_TEST(cli_codex_session_hook_issue330);
RUN_TEST(cli_codex_pretooluse_hook_lifecycle);
RUN_TEST(cli_gemini_session_hook_parity);
RUN_TEST(cli_claude_subagent_hook);
RUN_TEST(cli_claude_hook_mutation_converges_mixed_owned_duplicates);
@@ -18525,6 +18859,7 @@ SUITE(cli) {
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
RUN_TEST(cli_hook_augment_context_names_graph_follow_ups);
RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter);
RUN_TEST(cli_hook_augment_codex_pretooluse_payload);
RUN_TEST(cli_hook_augment_bash_pattern_extractor);
RUN_TEST(cli_hook_augment_coverage_requests_machine_json);
RUN_TEST(cli_hook_augment_lifecycle_output_contract);
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Exercise the real production Make rules with tiny C fixtures. CodeQL starts
# tracing after cbm-third-party, so every affected object must compile again,
# while unrelated objects must remain reused. No dry-run/timestamp assertions.
set -euo pipefail
ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
python3 - "$ROOT" "$BASH" <<'PY'
import os
import pathlib
import re
import subprocess
import sys
import tempfile
root, bash = pathlib.Path(sys.argv[1]), sys.argv[2]
failures = []
with tempfile.TemporaryDirectory(prefix="cbm-codeql-retrace-") as directory:
work = pathlib.Path(directory)
def write(path, text):
dest = work / path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(text, encoding="utf-8")
for path in ("Makefile.cbm", "scripts/ci/codeql-retrace-vendored.sh",
"scripts/ci/select-lanes.sh", ".github/codeql/codeql-config.yml"):
write(path, (root / path).read_text(encoding="utf-8"))
# Nested and aliased includes deliberately escape the Makefile's shallow
# wildcard dependencies. The compiler really compiles these translation units.
write("vendored/mimalloc/src/static.c",
'#include "mimalloc/internal.h"\n#include "prim/osx/prim.c"\n')
write("vendored/mimalloc/include/mimalloc/internal.h", "#define MI_VALUE 7\n")
write("vendored/mimalloc/src/prim/osx/prim.c", '#include "../unix/prim.c"\n')
write("vendored/mimalloc/src/prim/unix/prim.c", "int mi_value(void) { return MI_VALUE; }\n")
write("internal/cbm/vendored/common/scanner.h", "#define SHARED_VALUE 3\n")
for language in ("go", "json"):
write(f"internal/cbm/grammar_{language}.c",
f'#include "vendored/grammars/{language}/parser.c"\n')
write(f"internal/cbm/vendored/grammars/{language}/parser.c",
'#include "nested/value.h"\n#include "../../common/scanner.h"\n'
f'int grammar_{language}(void) {{ return VALUE + SHARED_VALUE; }}\n')
write(f"internal/cbm/vendored/grammars/{language}/nested/value.h", "#define VALUE 2\n")
write("internal/cbm/vendored/grammars/shared/shared.h", "/* future shared grammar helper */\n")
write("internal/cbm/ts_runtime.c", '#include "vendored/ts_runtime/src/lib.c"\n')
write("internal/cbm/vendored/ts_runtime/src/lib.c", "int runtime_value(void) { return 1; }\n")
for path, symbol in (("vendored/sqlite3/sqlite3.c", "sqlite_value"),
("vendored/tre/tre_all.c", "tre_value"),
("internal/cbm/vendored/lz4/lz4.c", "lz4_value"),
("internal/cbm/vendored/lz4/lz4hc.c", "lz4hc_value"),
("internal/cbm/vendored/zstd/zstd.c", "zstd_value")):
write(path, f"int {symbol}(void) {{ return 1; }}\n")
write("internal/cbm/vendored/lz4/lz4.h", "/* shared LZ4 header */\n")
write("vendored/sqlite3/sqlite3.h", "/* sqlite header */\n")
write("internal/cbm/vendored/zstd/zstd.h", "/* zstd header */\n")
write("vendored/nomic/code_vectors_blob.S", ".text\n")
write("vendored/nomic/code_vectors.bin", "")
write("src/main.c", "int main(void) { return 0; }\n")
def run(args):
proc = subprocess.run(args, cwd=work, capture_output=True, text=True)
if proc.returncode:
raise RuntimeError(f"{args}: exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}")
return proc.stdout
make = ["make", "--no-print-directory", "-f", "Makefile.cbm", "cbm-third-party",
"BUILD_DIR=build/c", "CC=" + os.environ.get("CC", "cc")]
def compiled(output):
return set(re.findall(r" -c -o build/c/([^\s]+\.o)(?:\s|$)", output))
prebuilt = compiled(run(make))
expected = {"prod_mimalloc.o", "prod_sqlite3.o", "prod_grammar_go.o",
"prod_grammar_json.o", "prod_ts_runtime.o", "prod_lz4.o",
"prod_lz4hc.o", "prod_zstd.o", "unixcoder_blob.o"}
# TRE is a Windows-only production object. All other objects are mandatory.
if prebuilt not in (expected, expected | {"prod_tre.o"}):
raise RuntimeError(f"incomplete prebuild: {sorted(prebuilt)}")
if compiled(run(make)):
raise RuntimeError("unchanged prebuild is not reused")
cases = [
("nested allocator header", ["vendored/mimalloc/include/mimalloc/internal.h"], {"prod_mimalloc.o"}),
("aliased allocator source", ["vendored/mimalloc/src/prim/unix/prim.c"], {"prod_mimalloc.o"}),
("nested grammar header", ["internal/cbm/vendored/grammars/go/nested/value.h"], {"prod_grammar_go.o"}),
("grammar wrapper", ["internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}),
("runtime wrapper", ["internal/cbm/ts_runtime.c"], {"prod_ts_runtime.o"}),
("runtime source", ["internal/cbm/vendored/ts_runtime/src/lib.c"], {"prod_ts_runtime.o"}),
("shared grammar header", ["internal/cbm/vendored/common/scanner.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}),
("unknown grammar family", ["internal/cbm/vendored/grammars/shared/shared.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}),
("LZ4 header", ["internal/cbm/vendored/lz4/lz4.h"], {"prod_lz4.o", "prod_lz4hc.o"}),
("SQLite header", ["vendored/sqlite3/sqlite3.h"], {"prod_sqlite3.o"}),
("zstd header", ["internal/cbm/vendored/zstd/zstd.h"], {"prod_zstd.o"}),
("first-party only", ["src/main.c"], set()),
("duplicate changes", ["internal/cbm/grammar_go.c", "internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}),
]
for label, paths, expected_objects in cases:
write("changed.txt", "\n".join(paths) + "\n")
run([bash, "scripts/ci/codeql-retrace-vendored.sh", "--changed", "changed.txt", "retrace.txt"])
retrace = (work / "retrace.txt").read_text(encoding="utf-8").splitlines()
actual = compiled(run(make + [arg for path in retrace for arg in ("-W", path)]))
if actual != expected_objects:
failures.append(f"{label}: expected {sorted(expected_objects)}, compiled {sorted(actual)}")
else:
print(f"PASS {label}: {len(actual)}/{len(prebuilt)} third-party objects retraced")
if failures:
print("CODEQL RETRACE CONTRACT VIOLATED:\n " + "\n ".join(failures))
sys.exit(1)
print("CodeQL retrace contract OK (real compiler + production Make rules)")
PY
+10 -3
View File
@@ -57,7 +57,8 @@ static int cte_codex_edit_commands_detailed(const char *path, const char *comman
cbm_toml_codex_hook_action_t action, int check_only,
cbm_toml_codex_hook_failure_t *failure) {
return cbm_toml_reconcile_codex_hooks_detailed(path, CTE_CODEX_BEGIN, CTE_CODEX_END, command,
command_windows, action, check_only, failure);
command_windows, action, check_only, failure,
NULL);
}
static int cte_codex_edit_detailed(const char *path, cbm_toml_codex_hook_action_t action,
@@ -1456,14 +1457,18 @@ TEST(config_toml_codex_accepts_v0102_managed_windows_crlf) {
ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0);
ASSERT_STR_EQ(actual, before);
/* The v0.10.2 lifecycle-only block is ours: the upsert upgrades it in place to
* the current block (which adds PreToolUse Bash) and keeps the BOM, CRLF and
* the surrounding tables; a second upsert is then byte-identical. */
ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows,
CBM_TOML_CODEX_HOOK_UPSERT, 0, &failure),
0);
ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0);
ASSERT_STR_EQ(actual, before);
ASSERT_EQ(strncmp(actual, "\xEF\xBB\xBF", 3U), 0);
ASSERT_EQ(cte_occurrences(actual, CTE_CODEX_BEGIN), 1);
ASSERT_EQ(cte_occurrences(actual, "[[hooks.SessionStart]]"), 1);
ASSERT_EQ(cte_occurrences(actual, "[[hooks.SubagentStart]]"), 1);
ASSERT_EQ(cte_occurrences(actual, "[[hooks.PreToolUse]]"), 1);
ASSERT_NOT_NULL(strstr(actual, "[mcp_servers.other]"));
ASSERT_NOT_NULL(strstr(actual, "keep = true"));
for (const char *cursor = actual; *cursor; ++cursor) {
@@ -1471,12 +1476,14 @@ TEST(config_toml_codex_accepts_v0102_managed_windows_crlf) {
ASSERT(cursor > actual && cursor[-1] == '\r');
}
}
char upgraded[CTE_FILE_CAP];
ASSERT_GT(snprintf(upgraded, sizeof(upgraded), "%s", actual), 0);
ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows,
CBM_TOML_CODEX_HOOK_UPSERT, 0, &failure),
0);
ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0);
ASSERT_STR_EQ(actual, before);
ASSERT_STR_EQ(actual, upgraded);
ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows,
CBM_TOML_CODEX_HOOK_REMOVE, 0, &failure),
+374
View File
@@ -1917,6 +1917,32 @@ TEST(cypher_exists_has_outgoing_calls) {
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE b.name <> \"SubmitOrder\" "
"RETURN a.name, b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE b.name = \"SubmitOrder\" OR "
"NOT b.name = \"SubmitOrder\" RETURN count(*)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "3");
cbm_cypher_result_free(&r);
rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = "
"\"SubmitOrder\" RETURN a.name, b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
@@ -3378,6 +3404,240 @@ TEST(cypher_exec_where_mixed_alias_xor) {
PASS();
}
TEST(cypher_exec_where_not_target_node) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = \"SubmitOrder\" "
"RETURN a.name, b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not_target_relationship) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE NOT r.url_path = "
"\"/health\" RETURN r.url_path",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "/api/orders");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not_source_node) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = "
"\"HandleOrder\" RETURN a.name, b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not_target_node_count) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = "
"\"SubmitOrder\" RETURN count(*)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "2");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* The early WHERE pass must wait for every alias a condition reads. A function
* condition is filed under its first argument's alias, so in
* coalesce(a.no_such_prop, b.name) the target b hid behind the bound a: the
* seed row read coalesce(NULL, <unbound b>) as a definite mismatch and pruned
* every row before CALLS expansion, while the same predicate after WITH was
* right. */
TEST(cypher_exec_where_func_arg_on_unbound_target) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = "
"\"SubmitOrder\" RETURN a.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #2591: a WHERE comparison whose RHS is another property (a.file_path =
* b.file_path) failed to parse with "expected value at pos N". Fixture: Caller
* calls Helper in the same file and Remote in another one. */
static cbm_store_t *setup_issue2591_store(void) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t caller = {.project = "test",
.label = "Function",
.name = "Caller",
.qualified_name = "test.Caller",
.file_path = "a.go",
.start_line = 1,
.end_line = 3};
cbm_node_t helper = {.project = "test",
.label = "Function",
.name = "Helper",
.qualified_name = "test.Helper",
.file_path = "a.go",
.start_line = 5,
.end_line = 7};
cbm_node_t remote = {.project = "test",
.label = "Function",
.name = "Remote",
.qualified_name = "test.Remote",
.file_path = "b.go",
.start_line = 1,
.end_line = 2};
int64_t id_caller = cbm_store_upsert_node(s, &caller);
int64_t id_helper = cbm_store_upsert_node(s, &helper);
int64_t id_remote = cbm_store_upsert_node(s, &remote);
cbm_edge_t e1 = {
.project = "test", .source_id = id_caller, .target_id = id_helper, .type = "CALLS"};
cbm_edge_t e2 = {
.project = "test", .source_id = id_caller, .target_id = id_remote, .type = "CALLS"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
return s;
}
/* Run `query` on the #2591 fixture and expect exactly one row whose last
* column is `want`. */
static int issue2591_expect_one(const char *query, const char *want) {
cbm_store_t *s = setup_issue2591_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
int ok = rc == 0 && r.row_count == 1 && r.col_count > 0 &&
strcmp(r.rows[0][r.col_count - 1], want) == 0;
if (!ok) {
printf(" query: %s\n rc=%d rows=%d err=%s\n", query, rc, r.row_count,
r.error ? r.error : "(none)");
}
cbm_cypher_result_free(&r);
cbm_store_close(s);
return ok;
}
TEST(cypher_issue2591_parse_where_property_rhs) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (a)-[:CALLS]->(b) WHERE a.file_path <> b.file_path RETURN a",
&q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.variable, "a");
ASSERT_STR_EQ(q->where->root->cond.property, "file_path");
ASSERT_STR_EQ(q->where->root->cond.op, "<>");
ASSERT_STR_EQ(q->where->root->cond.rhs_variable, "b");
ASSERT_STR_EQ(q->where->root->cond.rhs_property, "file_path");
ASSERT_NULL(q->where->root->cond.value);
cbm_query_free(q);
PASS();
}
TEST(cypher_issue2591_where_property_eq_and_neq) {
ASSERT_TRUE(issue2591_expect_one("MATCH (a)-[:CALLS]->(b) WHERE a.file_path = b.file_path "
"RETURN a.name, b.name",
"Helper"));
ASSERT_TRUE(issue2591_expect_one("MATCH (a)-[:CALLS]->(b) WHERE a.file_path <> b.file_path "
"RETURN a.name, b.name",
"Remote"));
/* Operand order does not matter, and the RHS can sit inside an AND chain
* next to a literal comparison. */
ASSERT_TRUE(issue2591_expect_one("MATCH (a)-[:CALLS]->(b) WHERE b.file_path = a.file_path "
"AND a.name = \"Caller\" RETURN b.name",
"Helper"));
PASS();
}
TEST(cypher_issue2591_where_property_after_with) {
ASSERT_TRUE(issue2591_expect_one("MATCH (a)-[:CALLS]->(b) WITH a, b "
"WHERE a.file_path = b.file_path RETURN b.name",
"Helper"));
/* Bare projected aliases on both sides. */
ASSERT_TRUE(issue2591_expect_one("MATCH (a)-[:CALLS]->(b) WITH a.file_path AS af, "
"b.file_path AS bf, b.name AS callee "
"WHERE af <> bf RETURN callee",
"Remote"));
PASS();
}
/* The early WHERE pass binds only the seed alias a. The RHS b is not bound
* there, so the leaf must be UNKNOWN; reading it as a match would let the
* outer NOT prune every seed before CALLS expansion. */
TEST(cypher_issue2591_where_property_rhs_unbound_in_early_pass) {
ASSERT_TRUE(issue2591_expect_one(
"MATCH (a)-[:CALLS]->(b) WHERE NOT (a.file_path = b.file_path) RETURN b.name", "Remote"));
ASSERT_TRUE(issue2591_expect_one(
"MATCH (a)-[:CALLS]->(b) WHERE NOT a.file_path = b.file_path RETURN b.name", "Remote"));
PASS();
}
TEST(cypher_issue2591_where_property_numeric) {
/* Caller starts on line 1, Helper on 5, Remote on 1. */
ASSERT_TRUE(issue2591_expect_one(
"MATCH (a)-[:CALLS]->(b) WHERE a.start_line < b.start_line RETURN b.name", "Helper"));
ASSERT_TRUE(issue2591_expect_one(
"MATCH (a)-[:CALLS]->(b) WHERE a.start_line >= b.start_line RETURN b.name", "Remote"));
PASS();
}
/* A missing property is NULL, and a comparison with NULL never matches: two
* absent properties must not compare equal. */
TEST(cypher_issue2591_where_property_missing_is_null) {
const char *queries[] = {
"MATCH (a)-[:CALLS]->(b) WHERE a.no_such_prop = b.no_such_prop RETURN b.name",
"MATCH (a)-[:CALLS]->(b) WHERE a.no_such_prop <> b.file_path RETURN b.name",
};
for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) {
cbm_store_t *s = setup_issue2591_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, queries[i], "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
}
PASS();
}
TEST(cypher_issue2591_where_function_rhs_unsupported) {
cbm_query_t *q = NULL;
char *err = NULL;
const char *query = "MATCH (a)-[:CALLS]->(b) WHERE a.name = toLower(b.name) RETURN a";
int rc = cbm_cypher_parse(query, &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT_TRUE(strstr(err, "unsupported function 'toLower' on the right side of a WHERE "
"comparison") != NULL);
free(err);
PASS();
}
TEST(cypher_exec_where_in) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -4852,6 +5112,107 @@ TEST(cypher_issue305_count_star_alias) {
PASS();
}
/* Runs one query and asserts it answers exactly one row with `want` in the
* first column. */
static int expect_one_row(cbm_store_t *s, const char *project, const char *query,
const char *want) {
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, project, 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], want);
cbm_cypher_result_free(&r);
return 0;
}
/* #2742: an ungrouped aggregation is one row even when MATCH/WHERE produced no
* input rows. count(*) over nothing is 0, not an empty result: a caller asking
* "how many?" must not have to read "no rows" as "zero". */
TEST(cypher_count_star_empty_input_returns_zero) {
/* Empty graph: the project exists but holds no nodes. */
cbm_store_t *empty = cbm_store_open_memory();
cbm_store_upsert_project(empty, "empty", "/tmp/empty");
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(empty, "MATCH (n) RETURN count(*) AS total", "empty", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.col_count, 1);
ASSERT_STR_EQ(r.columns[0], "total");
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "0");
cbm_cypher_result_free(&r);
cbm_store_close(empty);
/* Nonempty graph, every row filtered out. */
cbm_store_t *s = setup_cypher_store();
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) AS total",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (a)-[:CALLS]->(b) WHERE b.name = \"NoSuchFunction\" "
"RETURN count(*)",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test", "MATCH (n:NoSuchLabel) RETURN count(*)", "0"), 0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(f) AS n",
"0"),
0);
ASSERT_EQ(expect_one_row(s, "test",
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(DISTINCT f.name) AS n",
"0"),
0);
/* The other aggregates in the same row take their empty-input values:
* sum 0, collect [], and avg/min/max null (rendered as ""). */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) AS c, sum(f.start_line) AS s, "
"avg(f.start_line) AS a, min(f.start_line) AS lo, "
"max(f.start_line) AS hi, collect(f.name) AS names",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "0");
ASSERT_STR_EQ(r.rows[0][1], "0");
ASSERT_STR_EQ(r.rows[0][2], "");
ASSERT_STR_EQ(r.rows[0][3], "");
ASSERT_STR_EQ(r.rows[0][4], "");
ASSERT_STR_EQ(r.rows[0][5], "[]");
cbm_cypher_result_free(&r);
/* A grouped aggregation has no group to report: still no rows. */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN f.label, count(*)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
/* SKIP still applies to the single aggregate row. */
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"NoSuchFunction\" "
"RETURN count(*) SKIP 1",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
/* Nonempty input is unchanged. */
ASSERT_EQ(expect_one_row(s, "test", "MATCH (f:Function) RETURN count(*) AS total", "4"), 0);
cbm_store_close(s);
PASS();
}
/* Regression: projecting several computed/JSON properties in one row must yield
* DISTINCT values. node_prop previously returned a single shared static buffer,
* so every such column aliased the last property read — and because the search
@@ -6154,6 +6515,7 @@ SUITE(cypher) {
RUN_TEST(cypher_issue873_distinct_order_skip_limit_dedupes_before_skip);
RUN_TEST(cypher_issue252_tointeger);
RUN_TEST(cypher_issue305_count_star_alias);
RUN_TEST(cypher_count_star_empty_input_returns_zero);
RUN_TEST(cypher_exec_where_eq);
RUN_TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196);
RUN_TEST(cypher_exec_aggregate_sees_all_edges_beyond_expansion_cap_issue1196);
@@ -6243,6 +6605,18 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_where_mixed_alias_and);
RUN_TEST(cypher_exec_where_mixed_alias_or);
RUN_TEST(cypher_exec_where_mixed_alias_xor);
RUN_TEST(cypher_exec_where_not_target_node);
RUN_TEST(cypher_exec_where_not_target_relationship);
RUN_TEST(cypher_exec_where_not_source_node);
RUN_TEST(cypher_exec_where_not_target_node_count);
RUN_TEST(cypher_exec_where_func_arg_on_unbound_target);
RUN_TEST(cypher_issue2591_parse_where_property_rhs);
RUN_TEST(cypher_issue2591_where_property_eq_and_neq);
RUN_TEST(cypher_issue2591_where_property_after_with);
RUN_TEST(cypher_issue2591_where_property_rhs_unbound_in_early_pass);
RUN_TEST(cypher_issue2591_where_property_numeric);
RUN_TEST(cypher_issue2591_where_property_missing_is_null);
RUN_TEST(cypher_issue2591_where_function_rhs_unsupported);
RUN_TEST(cypher_exec_where_in);
RUN_TEST(cypher_exec_where_not_in);
RUN_TEST(cypher_exec_where_is_null);
+688 -5
View File
@@ -1997,13 +1997,15 @@ typedef struct {
app_fake_worker_context_t fake;
cbm_store_t *store;
cbm_watcher_t *watcher;
cbm_config_t *config;
cbm_daemon_application_t *application;
cbm_daemon_runtime_application_callbacks_t callbacks;
cbm_daemon_runtime_application_session_t *session;
} app_watch_race_fixture_t;
static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
cbm_daemon_client_id_t client_id) {
static bool app_watch_race_fixture_init_with_auto_watch(app_watch_race_fixture_t *fixture,
cbm_daemon_client_id_t client_id,
bool auto_watch) {
memset(fixture, 0, sizeof(*fixture));
app_fake_worker_context_init(&fixture->fake);
const char *old_cache = getenv("CBM_CACHE_DIR");
@@ -2022,6 +2024,9 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
fixture->project);
}
bool db_ok = fixture->project && app_test_create_empty_file(fixture->db_path);
fixture->config = cbm_config_open(fixture->cache);
bool config_ok = fixture->config && cbm_config_set(fixture->config, CBM_CONFIG_AUTO_WATCH,
auto_watch ? "true" : "false") == 0;
cbm_daemon_application_worker_ops_t worker_ops = {
.context = &fixture->fake,
.start = app_fake_worker_start,
@@ -2034,6 +2039,7 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
fixture->watcher = cbm_watcher_new(fixture->store, app_test_index_noop, NULL);
cbm_daemon_application_config_t config = {
.watcher = fixture->watcher,
.config = fixture->config,
.worker_ops = &worker_ops,
};
fixture->application = cbm_daemon_application_new(&config);
@@ -2049,8 +2055,8 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
uint8_t *response = NULL;
uint32_t response_length = 0;
bool encoded =
env_ok && dirs_ok && db_ok && fixture->store && fixture->watcher && fixture->application &&
fixture->session &&
env_ok && dirs_ok && db_ok && config_ok && fixture->store && fixture->watcher &&
fixture->application && fixture->session &&
app_test_context_request(fixture->root, fixture->root, &context, &context_length) &&
app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
"{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"ping\"}", &ping,
@@ -2067,7 +2073,45 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
free(response);
free(context);
free(ping);
return initialized && cbm_watcher_watch_count(fixture->watcher) == 1;
return initialized && cbm_watcher_watch_count(fixture->watcher) == (auto_watch ? 1 : 0);
}
static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture,
cbm_daemon_client_id_t client_id) {
return app_watch_race_fixture_init_with_auto_watch(fixture, client_id, true);
}
static bool app_test_initialize_profile(const cbm_daemon_runtime_application_callbacks_t *callbacks,
cbm_daemon_runtime_application_session_t *session,
const char *root, cbm_mcp_tool_profile_t profile,
const char *hook_event, const char *hook_dialect);
static bool app_test_initialize_existing_context(
const cbm_daemon_runtime_application_callbacks_t *callbacks,
cbm_daemon_runtime_application_session_t *session) {
uint8_t *initialize = NULL;
uint32_t initialize_length = 0;
bool encoded = session && app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
"{\"jsonrpc\":\"2.0\",\"id\":4100,"
"\"method\":\"initialize\",\"params\":{}}",
&initialize, &initialize_length);
uint8_t *response = NULL;
uint32_t response_length = 0;
cbm_daemon_runtime_application_status_t status =
encoded ? app_test_request(callbacks, session, initialize, initialize_length, &response,
&response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR;
bool initialized = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response &&
strstr((char *)response, "\"result\"");
free(initialize);
free(response);
return initialized;
}
static bool app_watch_race_fixture_init_mcp(app_watch_race_fixture_t *fixture,
cbm_daemon_client_id_t client_id, bool auto_watch) {
bool ready = app_watch_race_fixture_init_with_auto_watch(fixture, client_id, auto_watch);
return ready && app_test_initialize_existing_context(&fixture->callbacks, fixture->session);
}
static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) {
@@ -2086,6 +2130,10 @@ static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) {
cbm_store_close(fixture->store);
fixture->store = NULL;
}
if (stopped && fixture->config) {
cbm_config_close(fixture->config);
fixture->config = NULL;
}
free(fixture->project);
fixture->project = NULL;
(void)th_rmtree(fixture->root);
@@ -4589,6 +4637,628 @@ TEST(daemon_application_late_watcher_session_owns_active_watcher_job) {
* Any ADR write accepted during that lifetime can therefore be silently lost
* (and can make replacement fail on Windows). The daemon job must keep the
* project reserved against mutations until the physical worker is terminal. */
static bool app_test_mcp_index_request(const char *root, const char *name, const char *extra_args,
uint8_t **request_out, uint32_t *request_length_out) {
char message[APP_TEST_PATH_CAP + 256];
(void)snprintf(message, sizeof(message),
"{\"jsonrpc\":\"2.0\",\"id\":9300,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_repository\",\"arguments\":{"
"\"repo_path\":\"%s\",\"name\":\"%s\"%s}}}",
root, name, extra_args ? extra_args : "");
return app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, message, request_out,
request_length_out);
}
static bool app_test_explicit_index_with_options(
cbm_daemon_runtime_application_callbacks_t *callbacks,
cbm_daemon_runtime_application_session_t *session, const char *root, const char *name,
const char *extra_args) {
uint8_t *request = NULL;
uint32_t request_length = 0;
uint8_t *response = NULL;
uint32_t response_length = 0;
bool encoded = app_test_mcp_index_request(root, name, extra_args, &request, &request_length);
cbm_daemon_runtime_application_status_t status =
encoded ? app_test_request(callbacks, session, request, request_length, &response,
&response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
bool indexed = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response &&
strstr((char *)response, "indexed") != NULL;
free(request);
free(response);
return indexed;
}
static bool app_test_explicit_index(cbm_daemon_runtime_application_callbacks_t *callbacks,
cbm_daemon_runtime_application_session_t *session,
const char *root, const char *name) {
return app_test_explicit_index_with_options(callbacks, session, root, name, NULL);
}
TEST(daemon_application_watch_roots_match_windows_separators_only) {
ASSERT_TRUE(
cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm", "C:/repos/cbm", true));
ASSERT_TRUE(cbm_daemon_application_watch_roots_equal_for_test("\\\\server\\share\\cbm",
"//server/share/cbm", true));
ASSERT_FALSE(
cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm", "C:/repos/cbm2", true));
ASSERT_FALSE(cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm",
"C:/repos/cbm/sub", true));
ASSERT_FALSE(
cbm_daemon_application_watch_roots_equal_for_test("/repos/a\\b", "/repos/a/b", false));
ASSERT_TRUE(
cbm_daemon_application_watch_roots_equal_for_test("/repos/a\\b", "/repos/a\\b", false));
PASS();
}
/* A held async worker completes after the global switch changes. No subsequent
* request may hide target-only cleanup by refreshing the session root. */
TEST(daemon_application_async_completion_disables_all_watches) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9320, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-disable-async-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/disable-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
atomic_store(&fixture.fake.allow_completion, true);
bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target,
"disable-target");
int watches_before = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
atomic_store(&fixture.fake.allow_completion, false);
uint8_t *request = NULL;
uint32_t request_length = 0;
uint8_t *response = NULL;
uint32_t response_length = 0;
bool encoded =
indexed && app_test_mcp_index_request(target, "disable-target", ",\"async\":true", &request,
&request_length);
cbm_daemon_runtime_application_status_t status =
encoded ? app_test_request(&fixture.callbacks, fixture.session, request, request_length,
&response, &response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
bool started = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response &&
strstr((char *)response, "\"async\":true") &&
app_wait_for_atomic_int(&fixture.fake.starts, 2);
bool disabled = started && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
atomic_store(&fixture.fake.allow_completion, true);
bool drained = started && app_wait_for_active_jobs(fixture.application, 0);
int watches_after = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
free(request);
free(response);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(indexed);
ASSERT_EQ(watches_before, 2);
ASSERT_TRUE(started);
ASSERT_TRUE(disabled);
ASSERT_TRUE(drained);
ASSERT_EQ(watches_after, 0);
ASSERT_TRUE(cleaned);
PASS();
}
/* The callback can be the first observer of a config change, without a
* client request. It must drain every target before admitting refresh work. */
TEST(daemon_application_watcher_callback_disables_all_watches) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9322, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-disable-callback-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/callback-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
atomic_store(&fixture.fake.allow_completion, true);
bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target,
"callback-target");
int watches_before = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool disabled = indexed && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
int refreshed = disabled ? cbm_daemon_application_watcher_index("callback-target", target,
fixture.application)
: -1;
int watches_after = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
int starts = atomic_load(&fixture.fake.starts);
bool cleaned = app_watch_race_fixture_finish(&fixture);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(indexed);
ASSERT_EQ(watches_before, 2);
ASSERT_TRUE(disabled);
ASSERT_EQ(refreshed, 1);
ASSERT_EQ(watches_after, 0);
ASSERT_EQ(starts, 1);
ASSERT_TRUE(cleaned);
PASS();
}
static int app_watch_preserves_doc_candidates(int explicit_value) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9321, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-watch-options-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/options-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
/* The opposite global setting must be replaced, not duplicated in JSON. */
bool configured = db_ready && cbm_config_set(fixture.config, CBM_CONFIG_DOC_CANDIDATES,
explicit_value < 0 ? "auto"
: explicit_value ? "false"
: "true") == 0;
atomic_store(&fixture.fake.allow_completion, true);
bool indexed =
configured &&
app_test_explicit_index_with_options(
&fixture.callbacks, fixture.session, target, "options-target",
explicit_value < 0 ? ",\"mode\":\"fast\",\"persistence\":true"
: explicit_value ? ",\"mode\":\"fast\",\"persistence\":true,\"doc_candidates\":true"
: ",\"mode\":\"fast\",\"persistence\":true,\"doc_candidates\":false");
bool default_updated =
explicit_value >= 0 ||
(indexed && cbm_config_set(fixture.config, CBM_CONFIG_DOC_CANDIDATES, "true") == 0);
int refreshed =
indexed && default_updated
? cbm_daemon_application_watcher_index("options-target", target, fixture.application)
: -1;
const char *args = fixture.fake.args_json[1];
yyjson_doc *doc = refreshed == 0 ? yyjson_read(args, strlen(args), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_val *choice = yyjson_obj_get(root, "doc_candidates");
int forwarded = !choice ? -1 : !yyjson_is_bool(choice) ? -2 : yyjson_get_bool(choice) ? 1 : 0;
bool mode_preserved = yyjson_equals_str(yyjson_obj_get(root, "mode"), "fast");
bool persistence_preserved = yyjson_get_bool(yyjson_obj_get(root, "persistence"));
size_t choices = 0;
size_t idx, max;
yyjson_val *key, *value;
yyjson_obj_foreach(root, idx, max, key, value) {
if (yyjson_equals_str(key, "doc_candidates")) {
choices++;
}
}
yyjson_doc_free(doc);
bool cleaned = app_watch_race_fixture_finish(&fixture);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(configured);
ASSERT_TRUE(indexed);
ASSERT_TRUE(default_updated);
ASSERT_EQ(refreshed, 0);
ASSERT_TRUE(cleaned);
ASSERT_TRUE(mode_preserved);
ASSERT_TRUE(persistence_preserved);
ASSERT_EQ(choices, 1);
ASSERT_EQ(forwarded, explicit_value != 0);
PASS();
}
TEST(daemon_application_watch_refresh_uses_current_doc_candidates_default) {
return app_watch_preserves_doc_candidates(-1);
}
TEST(daemon_application_watch_refresh_preserves_doc_candidates_true) {
return app_watch_preserves_doc_candidates(true);
}
TEST(daemon_application_watch_refresh_preserves_doc_candidates_false) {
return app_watch_preserves_doc_candidates(false);
}
TEST(daemon_application_explicit_index_registers_target_watch_for_live_session) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9301, true);
char targets[2][APP_TEST_PATH_CAP];
(void)snprintf(targets[0], sizeof(targets[0]), "%s/cbm-app-explicit-target-a-XXXXXX",
fixture.root);
(void)snprintf(targets[1], sizeof(targets[1]), "%s/cbm-app-explicit-target-b-XXXXXX",
fixture.root);
bool targets_ready = fixture_ready && cbm_mkdtemp(targets[0]) && cbm_mkdtemp(targets[1]);
char target_dbs[2][APP_TEST_PATH_CAP];
(void)snprintf(target_dbs[0], sizeof(target_dbs[0]), "%s/custom-target.db", fixture.cache);
(void)snprintf(target_dbs[1], sizeof(target_dbs[1]), "%s/second-target.db", fixture.cache);
bool target_dbs_ready = targets_ready && app_test_create_empty_file(target_dbs[0]) &&
app_test_create_empty_file(target_dbs[1]);
cbm_daemon_runtime_application_session_t *second =
target_dbs_ready ? app_test_open(&fixture.callbacks, 9302) : NULL;
bool second_ready =
second && app_test_initialize_profile(&fixture.callbacks, second, fixture.root,
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
atomic_store(&fixture.fake.allow_completion, true);
bool first_indexed =
second_ready && app_test_explicit_index_with_options(
&fixture.callbacks, fixture.session, targets[0], "custom-target",
",\"mode\":\"fast\",\"persistence\":true");
int after_first = cbm_watcher_watch_count(fixture.watcher);
bool second_indexed =
first_indexed &&
app_test_explicit_index(&fixture.callbacks, fixture.session, targets[1], "second-target");
int after_second = cbm_watcher_watch_count(fixture.watcher);
bool shared_indexed =
second_indexed && app_test_explicit_index_with_options(
&fixture.callbacks, second, targets[0], "custom-target",
",\"mode\":\"fast\",\"persistence\":true");
int after_shared = cbm_watcher_watch_count(fixture.watcher);
app_watcher_index_thread_t watcher_request = {
.application = fixture.application,
.project = "custom-target",
.root = targets[0],
.result = -1,
};
atomic_init(&watcher_request.ready, false);
atomic_init(&watcher_request.proceed, true);
atomic_init(&watcher_request.done, false);
cbm_thread_t watcher_thread;
bool watcher_thread_started =
shared_indexed &&
cbm_thread_create(&watcher_thread, 0, app_watcher_index_thread, &watcher_request) == 0;
bool watcher_worker_started =
watcher_thread_started && app_wait_for_atomic_int_at_least(&fixture.fake.starts, 4);
bool watcher_done =
watcher_worker_started && app_wait_for_atomic_bool(&watcher_request.done, true);
bool watcher_thread_joined = watcher_thread_started && cbm_thread_join(&watcher_thread) == 0;
bool saved_options = watcher_worker_started &&
strstr(fixture.fake.args_json[3], "\"mode\":\"fast\"") &&
strstr(fixture.fake.args_json[3], "\"persistence\":true");
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int after_first_close = cbm_watcher_watch_count(fixture.watcher);
if (second) {
fixture.callbacks.session_close(fixture.callbacks.context, second);
second = NULL;
}
int after_last_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (targets_ready) {
(void)th_rmtree(targets[0]);
(void)th_rmtree(targets[1]);
}
if (targets_ready) {
(void)cbm_unlink(target_dbs[0]);
(void)cbm_unlink(target_dbs[1]);
}
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(targets_ready);
ASSERT_TRUE(target_dbs_ready);
ASSERT_TRUE(second_ready);
ASSERT_TRUE(first_indexed);
ASSERT_TRUE(second_indexed);
ASSERT_TRUE(shared_indexed);
ASSERT_TRUE(watcher_worker_started);
ASSERT_TRUE(watcher_done);
ASSERT_TRUE(watcher_thread_joined);
ASSERT_EQ(watcher_request.result, 0);
ASSERT_TRUE(saved_options);
ASSERT_TRUE(strstr(fixture.fake.args_json[0], "custom-target") != NULL);
ASSERT_EQ(after_first, 2);
ASSERT_EQ(after_second, 3);
ASSERT_EQ(after_shared, 3);
ASSERT_EQ(after_first_close, 2);
ASSERT_EQ(after_last_close, 0);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_failed_explicit_index_does_not_register_target_watch) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9303, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-explicit-failed-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/failed-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
atomic_store(&fixture.fake.scripted, true);
for (size_t i = 0; i < APP_FAKE_MAX_ATTEMPTS; i++) {
fixture.fake.outcomes[i] = CBM_PROC_CRASH;
}
bool request_failed = db_ready && !app_test_explicit_index(&fixture.callbacks, fixture.session,
target, "failed-target");
int watches_after_failure = cbm_watcher_watch_count(fixture.watcher);
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(request_failed);
ASSERT_EQ(watches_after_failure, 1);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_auto_watch_false_suppresses_explicit_target_watch) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9304, false);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-explicit-no-watch-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/no-watch-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
atomic_store(&fixture.fake.allow_completion, true);
bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target,
"no-watch-target");
int watches_after_index = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(indexed);
ASSERT_EQ(watches_after_index, 0);
ASSERT_EQ(watches_after_close, 0);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_one_shot_cli_index_does_not_register_target_watch) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9305, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-one-shot-target-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/one-shot-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
char args[APP_TEST_PATH_CAP + 96];
(void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"one-shot-target\"}",
target);
uint8_t *tool = NULL;
uint32_t tool_length = 0;
uint8_t *response = NULL;
uint32_t response_length = 0;
bool encoded = db_ready && app_test_tool_request("index_repository", args, &tool, &tool_length);
atomic_store(&fixture.fake.allow_completion, true);
cbm_daemon_runtime_application_status_t status =
encoded ? app_test_request(&fixture.callbacks, fixture.session, tool, tool_length,
&response, &response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
bool indexed = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response &&
strstr((char *)response, "indexed") != NULL;
int watches_after_one_shot = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
free(tool);
free(response);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(encoded);
ASSERT_TRUE(indexed);
ASSERT_EQ(watches_after_one_shot, 1);
ASSERT_EQ(watches_after_close, 0);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_async_index_interest_survives_cancelled_sync_join) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9306, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-async-interest-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/async-interest.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
uint8_t *async_request = NULL;
uint32_t async_request_length = 0;
bool async_encoded =
db_ready && app_test_mcp_index_request(target, "async-interest", ",\"async\":true",
&async_request, &async_request_length);
uint8_t *async_response = NULL;
uint32_t async_response_length = 0;
cbm_daemon_runtime_application_status_t async_status =
async_encoded
? app_test_request(&fixture.callbacks, fixture.session, async_request,
async_request_length, &async_response, &async_response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
bool worker_started = async_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && async_response &&
strstr((char *)async_response, "\"async\":true") != NULL &&
app_wait_for_atomic_int(&fixture.fake.starts, 1);
uint8_t *sync_request = NULL;
uint32_t sync_request_length = 0;
bool sync_encoded =
worker_started && app_test_mcp_index_request(target, "async-interest", NULL, &sync_request,
&sync_request_length);
app_request_thread_t sync_call = {
.callbacks = fixture.callbacks,
.session = fixture.session,
.request_token = UINT64_C(9307),
.request = sync_request,
.request_length = sync_request_length,
.status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR,
};
atomic_init(&sync_call.done, false);
cbm_thread_t sync_thread;
bool sync_thread_started =
sync_encoded && cbm_thread_create(&sync_thread, 0, app_request_thread, &sync_call) == 0;
bool sync_joined_job =
sync_thread_started && app_wait_for_subscribers(fixture.application, "async-interest", 2);
if (sync_joined_job) {
fixture.callbacks.request_cancel(fixture.callbacks.context, fixture.session,
sync_call.request_token);
}
bool sync_cancelled = sync_joined_job && app_wait_for_atomic_bool(&sync_call.done, true);
atomic_store(&fixture.fake.allow_completion, true);
bool sync_thread_joined = sync_thread_started && cbm_thread_join(&sync_thread) == 0;
bool drained = worker_started && app_wait_for_active_jobs(fixture.application, 0);
int watches_after_success = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
free(async_request);
free(async_response);
free(sync_request);
free(sync_call.response);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(async_encoded);
ASSERT_TRUE(worker_started);
ASSERT_TRUE(sync_encoded);
ASSERT_TRUE(sync_thread_started);
ASSERT_TRUE(sync_joined_job);
ASSERT_TRUE(sync_cancelled);
ASSERT_EQ(sync_call.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED);
ASSERT_TRUE(sync_thread_joined);
ASSERT_TRUE(drained);
ASSERT_EQ(watches_after_success, 2);
ASSERT_EQ(watches_after_close, 0);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_auto_watch_false_removes_existing_secondary_watches) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9308, true);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-toggle-watch-XXXXXX", fixture.root);
bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/toggle-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
atomic_store(&fixture.fake.allow_completion, true);
bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target,
"toggle-target");
int watches_before_toggle = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
bool disabled =
fixture.config && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
uint8_t *ping = NULL;
uint32_t ping_length = 0;
uint8_t *response = NULL;
uint32_t response_length = 0;
bool ping_encoded = app_test_text_request(
CBM_DAEMON_APPLICATION_REQUEST_MCP, "{\"jsonrpc\":\"2.0\",\"id\":9309,\"method\":\"ping\"}",
&ping, &ping_length);
cbm_daemon_runtime_application_status_t ping_status =
disabled && ping_encoded ? app_test_request(&fixture.callbacks, fixture.session, ping,
ping_length, &response, &response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
int watches_after_ping = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
free(ping);
free(response);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(indexed);
ASSERT_EQ(watches_before_toggle, 2);
ASSERT_TRUE(disabled);
ASSERT_EQ(ping_status, CBM_DAEMON_RUNTIME_APPLICATION_OK);
ASSERT_EQ(watches_after_ping, 0);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_async_index_drops_watch_interest_on_requester_disconnect) {
app_watch_race_fixture_t fixture;
bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9310, true);
cbm_daemon_runtime_application_session_t *observer =
fixture_ready ? app_test_open(&fixture.callbacks, 9311) : NULL;
bool observer_ready =
observer && app_test_initialize_profile(&fixture.callbacks, observer, fixture.root,
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
char target[APP_TEST_PATH_CAP];
(void)snprintf(target, sizeof(target), "%s/cbm-app-disconnect-async-XXXXXX", fixture.root);
bool target_ready = observer_ready && cbm_mkdtemp(target) != NULL;
char db_path[APP_TEST_PATH_CAP];
(void)snprintf(db_path, sizeof(db_path), "%s/disconnect-target.db", fixture.cache);
bool db_ready = target_ready && app_test_create_empty_file(db_path);
uint8_t *request = NULL;
uint32_t request_length = 0;
bool encoded =
db_ready && app_test_mcp_index_request(target, "disconnect-target", ",\"async\":true",
&request, &request_length);
uint8_t *async_response = NULL;
uint32_t async_response_length = 0;
cbm_daemon_runtime_application_status_t async_status =
encoded ? app_test_request(&fixture.callbacks, fixture.session, request, request_length,
&async_response, &async_response_length)
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
bool worker_started = async_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && async_response &&
app_wait_for_atomic_int(&fixture.fake.starts, 1);
if (fixture.session) {
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
fixture.session = NULL;
}
int watches_after_disconnect = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
atomic_store(&fixture.fake.allow_completion, true);
bool drained = worker_started && app_wait_for_active_jobs(fixture.application, 0);
int watches_after_completion = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1;
if (observer) {
fixture.callbacks.session_close(fixture.callbacks.context, observer);
observer = NULL;
}
bool cleaned = app_watch_race_fixture_finish(&fixture);
if (target_ready) {
(void)th_rmtree(target);
(void)cbm_unlink(db_path);
}
free(request);
free(async_response);
ASSERT_TRUE(fixture_ready);
ASSERT_TRUE(observer_ready);
ASSERT_TRUE(target_ready);
ASSERT_TRUE(db_ready);
ASSERT_TRUE(encoded);
ASSERT_TRUE(worker_started);
ASSERT_EQ(watches_after_disconnect, 1);
ASSERT_TRUE(drained);
ASSERT_EQ(watches_after_completion, 1);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(daemon_application_serializes_adr_mutation_with_index_job) {
const char *old_cache = getenv("CBM_CACHE_DIR");
bool had_cache = old_cache != NULL;
@@ -6975,6 +7645,19 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job);
RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners);
RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job);
RUN_TEST(daemon_application_watch_roots_match_windows_separators_only);
RUN_TEST(daemon_application_async_completion_disables_all_watches);
RUN_TEST(daemon_application_watcher_callback_disables_all_watches);
RUN_TEST(daemon_application_watch_refresh_uses_current_doc_candidates_default);
RUN_TEST(daemon_application_watch_refresh_preserves_doc_candidates_true);
RUN_TEST(daemon_application_watch_refresh_preserves_doc_candidates_false);
RUN_TEST(daemon_application_explicit_index_registers_target_watch_for_live_session);
RUN_TEST(daemon_application_failed_explicit_index_does_not_register_target_watch);
RUN_TEST(daemon_application_auto_watch_false_suppresses_explicit_target_watch);
RUN_TEST(daemon_application_one_shot_cli_index_does_not_register_target_watch);
RUN_TEST(daemon_application_async_index_interest_survives_cancelled_sync_join);
RUN_TEST(daemon_application_auto_watch_false_removes_existing_secondary_watches);
RUN_TEST(daemon_application_async_index_drops_watch_interest_on_requester_disconnect);
RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job);
RUN_TEST(daemon_application_reserved_mutation_delays_worker_start);
RUN_TEST(daemon_application_mutation_does_not_block_other_project);
+103
View File
@@ -2211,6 +2211,108 @@ TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen) {
PASS();
}
/* #2178: an age-based temp cleaner unlinked the live daemon's lifetime lock
* and identity marker. The heartbeat must keep every listener-owned file
* young, and report loss (unlink, or replacement by a fresh file at the same
* name) so the daemon exits instead of serving under a lifetime file that
* coordinates nothing or leaving an unremovable socket pair behind at close. */
TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) {
#ifdef _WIN32
SKIP_PLATFORM("unlink-while-held of runtime files is POSIX behavior");
#else
enum { TOUCH_CASES = 5 };
enum { LOSS_UNLINK, LOSS_REPLACE_FILE, LOSS_REPLACE_SYMLINK };
static const char key[] = "2178a2178a2178a0";
static const struct {
const char *suffix;
int loss;
} cases[TOUCH_CASES] = {
{"lifetime.lock", LOSS_UNLINK}, /* lifetime reservation */
{"lock", LOSS_UNLINK}, /* listener-owned participant guard */
{"sock.identity", LOSS_UNLINK}, /* identity marker, unlinked */
{"sock.identity", LOSS_REPLACE_FILE}, /* ... replaced by a fresh inode */
{"sock.identity", LOSS_REPLACE_SYMLINK} /* ... replaced by a symlink */,
};
const time_t stale = 1000000000;
char parent[TEST_PATH_CAP] = {0};
char runtime_dir[TEST_PATH_CAP] = {0};
cbm_daemon_ipc_endpoint_t *endpoint = NULL;
bool started[TOUCH_CASES] = {false};
int touched_intact[TOUCH_CASES] = {0};
bool refreshed[TOUCH_CASES] = {false};
bool lost[TOUCH_CASES] = {false};
int touched_after_loss[TOUCH_CASES] = {1, 1, 1, 1, 1};
int unreadable_touch = 0;
int readable_again_touch = 0;
if (ipc_test_parent_new(parent, "listener-touch")) {
endpoint = cbm_daemon_ipc_endpoint_new(key, parent);
}
if (endpoint) {
ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint));
}
/* A still-valid marker that merely cannot be reopened (EACCES) is a
* transient refresh failure, not loss: the daemon must keep serving. */
if (endpoint) {
cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint);
char marker[TEST_PATH_CAP];
int written = snprintf(marker, sizeof(marker), "%s/cbm-%s.sock.identity", runtime_dir, key);
if (listener && written > 0 && written < (int)sizeof(marker) && chmod(marker, 0) == 0) {
unreadable_touch = cbm_daemon_ipc_listener_touch(listener, NULL);
readable_again_touch =
chmod(marker, 0600) == 0 ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2;
}
cbm_daemon_ipc_listener_close(listener);
}
for (size_t i = 0; endpoint && i < TOUCH_CASES; i++) {
cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint);
started[i] = listener != NULL;
char path[TEST_PATH_CAP];
int written =
snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, cases[i].suffix);
bool path_ok = started[i] && written > 0 && written < (int)sizeof(path);
struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}};
bool aged = path_ok && utimensat(AT_FDCWD, path, stale_times, AT_SYMLINK_NOFOLLOW) == 0;
touched_intact[i] = aged ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2;
struct stat after;
refreshed[i] = touched_intact[i] == 1 && stat(path, &after) == 0 &&
after.st_atime > stale && after.st_mtime > stale;
lost[i] = refreshed[i] && unlink(path) == 0;
if (lost[i] && cases[i].loss == LOSS_REPLACE_FILE) {
int fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
lost[i] = fd >= 0 && close(fd) == 0;
} else if (lost[i] && cases[i].loss == LOSS_REPLACE_SYMLINK) {
lost[i] = symlink("/dev/null", path) == 0;
}
touched_after_loss[i] = lost[i] ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2;
cbm_daemon_ipc_listener_close(listener);
/* A tampered marker makes close (correctly) keep the socket pair; clear
* this case's simulated leftovers so the next case can listen. */
static const char *const leftovers[] = {"sock.identity", "sock", "anc"};
for (size_t j = 0; j < sizeof(leftovers) / sizeof(leftovers[0]); j++) {
written = snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, leftovers[j]);
if (written > 0 && written < (int)sizeof(path)) {
(void)unlink(path);
}
}
}
cbm_daemon_ipc_endpoint_free(endpoint);
ipc_test_remove_tree(runtime_dir, parent);
/* Root bypasses mode 0, so the reopen succeeds there and the touch is 1. */
ASSERT_EQ(unreadable_touch, geteuid() == 0 ? 1 : -1);
ASSERT_EQ(readable_again_touch, 1);
for (size_t i = 0; i < TOUCH_CASES; i++) {
ASSERT_TRUE(started[i]);
ASSERT_EQ(touched_intact[i], 1);
ASSERT_TRUE(refreshed[i]);
ASSERT_TRUE(lost[i]);
ASSERT_EQ(touched_after_loss[i], 0);
}
PASS();
#endif
}
TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) {
static const char key[] = "1029384756abcdef";
char parent[TEST_PATH_CAP] = {0};
@@ -5868,6 +5970,7 @@ SUITE(daemon_ipc) {
RUN_TEST(daemon_ipc_rejects_uppercase_instance_key);
RUN_TEST(daemon_ipc_no_spawn_probe_distinguishes_absent_active_and_busy);
RUN_TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen);
RUN_TEST(daemon_ipc_listener_touch_detects_lost_runtime_files);
RUN_TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window);
RUN_TEST(daemon_ipc_local_frame_roundtrip);
RUN_TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload);
+132
View File
@@ -1272,6 +1272,20 @@ static bool runtime_test_fixture_start_failed(const char *tag, const char *stage
static bool runtime_test_fixture_permanent = false;
typedef struct {
cbm_version_cohort_manager_t *manager;
cbm_version_cohort_lease_t *lease;
cbm_daemon_ipc_participant_guard_t *guard;
cbm_version_cohort_daemon_claim_t *claim;
bool held;
} runtime_test_coordination_t;
/* When set, the fixture takes the host's coordination handles in the host's
* order before starting the service: cohort admission refuses once a
* generation is already serving. Unlike the host's start_reserved, the
* fixture's convenience start also joins a service-owned participant guard. */
static runtime_test_coordination_t *runtime_test_fixture_coordination = NULL;
static bool runtime_test_fixture_start_configured(
runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity,
uint32_t max_clients, uint64_t lease_timeout_ms,
@@ -1327,6 +1341,19 @@ static bool runtime_test_fixture_start_configured(
if (application) {
config.application = *application;
}
runtime_test_coordination_t *coordination = runtime_test_fixture_coordination;
if (coordination) {
cbm_daemon_conflict_t conflict;
coordination->manager = cbm_version_cohort_manager_new(fixture->endpoint);
coordination->held =
coordination->manager &&
cbm_version_cohort_acquire(coordination->manager, identity, cbm_now_ms() + 5000,
&coordination->lease, &conflict) == CBM_VERSION_COHORT_OK &&
cbm_daemon_ipc_participant_guard_try_join(fixture->endpoint, &coordination->guard) ==
1 &&
cbm_version_cohort_daemon_claim_acquire(coordination->manager, &coordination->claim) ==
CBM_VERSION_COHORT_OK;
}
fixture->service = cbm_daemon_runtime_service_start(&config);
if (!fixture->service) {
return runtime_test_fixture_start_failed(tag, "service-start",
@@ -1380,6 +1407,110 @@ TEST(daemon_runtime_convenience_service_owns_participant_guard) {
PASS();
}
#ifndef _WIN32
/* The runtime's threads log concurrently while the loop stops the service, so
* the sink only sets flags. */
static atomic_bool runtime_test_coordination_lost_logged;
static atomic_bool runtime_test_window_expired_logged;
static void runtime_test_lifetime_log_sink(const char *line) {
/* Substrings, not key=value spelling, so text and JSON formats both match. */
if (line && strstr(line, "daemon.lifetime_end") && strstr(line, "coordination_file_lost") &&
strstr(line, "cohort_lease")) {
atomic_store(&runtime_test_coordination_lost_logged, true);
}
if (line && strstr(line, "initial_window_expired")) {
atomic_store(&runtime_test_window_expired_logged, true);
}
}
#endif
/* #2178: the host lifetime loop must refresh every held coordination file on
* its tick and stop the generation once one is lost. The claim marker is aged
* and the cohort lifetime lock unlinked: the tick must refresh the marker
* through its held handle, name the lease (touched after the claim) as lost,
* and stop the runtime. */
TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss) {
#ifdef _WIN32
SKIP_PLATFORM("unlinking a held lock file is POSIX behavior");
#else
cbm_daemon_build_identity_t identity =
runtime_test_identity("2.4.0", runtime_test_self_build());
identity.cache_fingerprint = RUNTIME_CACHE_A; /* cohort admission requires one */
runtime_test_fixture_t fixture;
runtime_test_coordination_t coordination = {0};
runtime_test_fixture_coordination = &coordination;
bool started = runtime_test_fixture_start(&fixture, "coordination-loss", &identity);
runtime_test_fixture_coordination = NULL;
bool held = coordination.held;
char marker[RUNTIME_TEST_PATH_CAP];
char lifetime[RUNTIME_TEST_PATH_CAP];
int marker_written = snprintf(marker, sizeof(marker), "%s/cbm-version-cohort-daemon-v1.lock",
fixture.runtime_dir);
int lifetime_written = snprintf(lifetime, sizeof(lifetime),
"%s/cbm-version-cohort-lifetime-v1.lock", fixture.runtime_dir);
const time_t stale = 1000000000;
struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}};
bool staged = held && marker_written > 0 && marker_written < (int)sizeof(marker) &&
lifetime_written > 0 && lifetime_written < (int)sizeof(lifetime) &&
utimensat(AT_FDCWD, marker, stale_times, 0) == 0 && unlink(lifetime) == 0;
bool loop_ok = false;
if (staged) {
atomic_store(&runtime_test_coordination_lost_logged, false);
atomic_store(&runtime_test_window_expired_logged, false);
CBMLogLevel previous_log_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_sink(runtime_test_lifetime_log_sink);
loop_ok = cbm_daemon_host_wait_for_lifetime_for_test(fixture.service, fixture.endpoint,
coordination.lease, coordination.claim,
coordination.guard);
cbm_log_set_sink(NULL);
cbm_log_set_level(previous_log_level);
}
cbm_daemon_runtime_service_state_t state =
started ? cbm_daemon_runtime_service_state(fixture.service)
: CBM_DAEMON_RUNTIME_SERVICE_RUNNING;
struct stat marker_status = {0};
bool marker_refreshed = staged && stat(marker, &marker_status) == 0 &&
marker_status.st_mtime > stale && marker_status.st_atime > stale;
/* Host teardown order: service first, then guard, claim and lease. Every
* handle references the endpoint, which the fixture frees. */
if (started && state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) {
(void)cbm_daemon_runtime_service_stop(fixture.service, RUNTIME_TEST_TIMEOUT_MS);
}
if (fixture.service && cbm_daemon_runtime_service_free(fixture.service)) {
fixture.service = NULL;
}
(void)cbm_daemon_ipc_participant_guard_release(&coordination.guard);
while (coordination.claim && cbm_version_cohort_daemon_claim_release(&coordination.claim) !=
CBM_PRIVATE_FILE_LOCK_OK) {
cbm_usleep(1000);
}
while (coordination.lease &&
cbm_version_cohort_lease_release(&coordination.lease) != CBM_PRIVATE_FILE_LOCK_OK) {
cbm_usleep(1000);
}
while (coordination.manager &&
cbm_version_cohort_manager_free(&coordination.manager) != CBM_PRIVATE_FILE_LOCK_OK) {
cbm_usleep(1000);
}
runtime_test_fixture_finish(&fixture);
ASSERT_TRUE(started);
ASSERT_TRUE(held);
ASSERT_TRUE(staged);
ASSERT_TRUE(loop_ok);
ASSERT_EQ(state, CBM_DAEMON_RUNTIME_SERVICE_EXITED);
ASSERT_TRUE(marker_refreshed);
ASSERT_TRUE(atomic_load(&runtime_test_coordination_lost_logged));
ASSERT_FALSE(atomic_load(&runtime_test_window_expired_logged));
PASS();
#endif
}
static bool runtime_test_fixture_start_application(runtime_test_fixture_t *fixture, const char *tag,
const cbm_daemon_build_identity_t *identity,
runtime_application_context_t *context) {
@@ -5288,6 +5419,7 @@ SUITE(daemon_runtime) {
RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path);
#endif
RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard);
RUN_TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss);
RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent);
RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity);
RUN_TEST(daemon_runtime_image_rejection_reaches_client_issue1383);
+50
View File
@@ -444,6 +444,55 @@ TEST(doc_links_md_incremental) {
PASS();
}
/* #2588: a reference into a directory that was moved or renamed named a
* path whose directory is gone, so it counted as no reference to this
* repository: no edge and no row. A directory above it that the reference
* wrote is still there, so it is doc rot. A path none of whose directories
* the repository has stays no reference. */
TEST(doc_links_md_moved_directory_issue2588) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_mv_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
th_write_file(TH_PATH(repo, "docs/notes.md"), "# Notes\n"
"\n"
"See `src/util/rounding.py::clamp` and "
"[rounding](../src/util/rounding.py).\n"
"Create `scratch/new/thing.py` first.\n");
th_write_file(TH_PATH(repo, "src/ledger.py"), "def balance(ledger):\n"
" return sum(ledger)\n");
th_write_file(TH_PATH(repo, "src/util/rounding.py"), "def clamp(x, lo, hi):\n"
" return max(lo, min(hi, x))\n");
char db[512];
char full_db[512];
snprintf(db, sizeof(db), "%s/inc.db", tmp);
snprintf(full_db, sizeof(full_db), "%s/full.db", tmp);
ASSERT_EQ(dm_index(repo, db, NULL), 0);
char props[512];
char reason[64];
ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 1);
/* src/util moves to src/helpers: both references are missing, on the
* incremental route as in a full index */
th_write_file(TH_PATH(repo, "src/helpers/rounding.py"), "def clamp(x, lo, hi):\n"
" return max(lo, min(hi, x))\n");
ASSERT_EQ(th_rmtree(TH_PATH(repo, "src/util")), 0);
ASSERT_EQ(dm_step(repo, db, full_db, "directory moved", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0);
ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 0);
dm_row(db, "docs/notes.md", "src/util/rounding.py::clamp", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
dm_row(db, "docs/notes.md", "../src/util/rounding.py", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
/* no directory of it in the repository: still no reference */
dm_row(db, "docs/notes.md", "scratch/new/thing.py", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "");
th_cleanup(tmp);
PASS();
}
/* ── ADRs ────────────────────────────────────────────────────────── */
static void md_write_adrs(const char *tmp, const char *first_status) {
@@ -866,6 +915,7 @@ SUITE(doc_links_md) {
RUN_TEST(doc_links_md_classify_span);
RUN_TEST(doc_links_md_resolve);
RUN_TEST(doc_links_md_incremental);
RUN_TEST(doc_links_md_moved_directory_issue2588);
RUN_TEST(doc_links_md_adr);
RUN_TEST(doc_links_md_adr_label_choice);
RUN_TEST(doc_links_md_adr_reported);
+115
View File
@@ -879,6 +879,89 @@ TEST(csharp_interface) {
PASS();
}
/* Issue #2071: a C# 14 extension block parses as a constructor_declaration
* named `extension` whose body holds the members, and has_error stays 0, so the
* members were dropped without any signal. They belong to the enclosing type. */
TEST(csharp_extension_block_members) {
CBMFileResult *r = extract("namespace App;\n"
"public static class NumberExtensions\n"
"{\n"
" extension(IEnumerable<int> numbers)\n"
" {\n"
" public int SumPositive() => 0;\n"
" public static int Zero() => 0;\n"
" }\n"
"}\n",
CBM_LANG_CSHARP, "t", "NumberExtensions.cs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Class", "NumberExtensions"));
ASSERT(has_def(r, "Method", "SumPositive"));
ASSERT(has_def(r, "Method", "Zero"));
ASSERT_FALSE(has_def_any(r, "extension"));
cbm_free_result(r);
PASS();
}
/* The same shape is a genuine constructor inside a type actually called
* `extension`, and must still be emitted (#2071). */
TEST(csharp_constructor_of_type_named_extension) {
CBMFileResult *r =
extract("namespace App { public class extension { public extension() { } } }",
CBM_LANG_CSHARP, "t", "extension.cs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Method", "extension"));
cbm_free_result(r);
PASS();
}
/* Escaping a type identifier does not change its constructor's identity. */
TEST(csharp_constructor_of_escaped_type_named_extension) {
const char *src = "namespace App { public class @extension { "
"public extension() { int ConstructorLocal() => 1; } } }";
CBMFileResult *r = extract(src, CBM_LANG_CSHARP, "t", "extension.cs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Method", "extension"));
ASSERT_FALSE(has_def(r, "Method", "ConstructorLocal"));
cbm_free_result(r);
PASS();
}
/* All conditional branches expose extension members, but method-local
* functions must never be promoted to members of the enclosing type. */
TEST(csharp_extension_block_conditional_members) {
const char *src = "public static class TextExtensions\n"
"{\n"
" extension(string text)\n"
" {\n"
"#if FIRST\n"
" public int First() { int Nested() => 1; return Nested(); }\n"
"#elif SECOND\n"
" public int Second() => 2;\n"
"#else\n"
"#if NESTED\n"
" public int Third() => 3;\n"
"#else\n"
" public int Fourth() => 4;\n"
"#endif\n"
"#endif\n"
" }\n"
"}\n";
CBMFileResult *r = extract(src, CBM_LANG_CSHARP, "t", "TextExtensions.cs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Method", "First"));
ASSERT(has_def(r, "Method", "Second"));
ASSERT(has_def(r, "Method", "Third"));
ASSERT(has_def(r, "Method", "Fourth"));
ASSERT_FALSE(has_def_any(r, "extension"));
ASSERT_FALSE(has_def(r, "Method", "Nested"));
cbm_free_result(r);
PASS();
}
/* --- Swift --- */
TEST(swift_class) {
CBMFileResult *r = extract("class Vehicle {\n var speed: Int = 0\n func accelerate() { "
@@ -1777,6 +1860,33 @@ TEST(haskell_function) {
PASS();
}
/* #2440: an infix-form definition (`a <+> b = ...`, ``x `plus` y = ...``) has
* no `name` field; its first named child is an `infix` node whose first child
* is the LEFT OPERAND, so the def was named `a` / `x`. It must be named after
* the operator, consistent with the prefix form `(<+>) a b = ...`. */
TEST(haskell_infix_definition_name) {
CBMFileResult *r = extract("module M where\n"
"\n"
"(<+>) :: Int -> Int -> Int\n"
"a <+> b = a + b\n"
"\n"
"plus :: Int -> Int -> Int\n"
"x `plus` y = x + y\n"
"\n"
"f :: Int\n"
"f = 1 <+> 2\n",
CBM_LANG_HASKELL, "t", "M.hs");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT(has_def(r, "Function", "f"));
ASSERT(has_def(r, "Function", "plus"));
ASSERT(has_def(r, "Function", "(<+>)"));
ASSERT_FALSE(has_def(r, "Function", "a"));
ASSERT_FALSE(has_def(r, "Function", "x"));
cbm_free_result(r);
PASS();
}
/* --- OCaml --- */
TEST(ocaml_function) {
CBMFileResult *r =
@@ -13129,6 +13239,10 @@ SUITE(extraction) {
RUN_TEST(ruby_module);
RUN_TEST(csharp_class);
RUN_TEST(csharp_interface);
RUN_TEST(csharp_extension_block_members);
RUN_TEST(csharp_constructor_of_type_named_extension);
RUN_TEST(csharp_constructor_of_escaped_type_named_extension);
RUN_TEST(csharp_extension_block_conditional_members);
RUN_TEST(swift_class);
RUN_TEST(swift_protocol);
RUN_TEST(kotlin_function);
@@ -13177,6 +13291,7 @@ SUITE(extraction) {
RUN_TEST(elixir_function);
RUN_TEST(elixir_call_string_argument);
RUN_TEST(haskell_function);
RUN_TEST(haskell_infix_definition_name);
RUN_TEST(ocaml_function);
RUN_TEST(erlang_function);
+1
View File
@@ -93,6 +93,7 @@ static const LabelGolden LABEL_GOLDENS[] = {
{"ruby", "Class:1,Function:1,Module:1"},
{"php", "Class:1,Function:1,Module:1"},
{"c_sharp", "Class:1,Method:1,Module:1"},
{"csharp_extension_declaration", "Class:1,Method:1,Module:1"},
{"bash", "Function:2,Module:1"},
{"zsh", "Function:2,Module:1"},
{"lua", "Function:2,Module:1"},
+15
View File
@@ -81,6 +81,21 @@ const GrammarCase CBM_GRAMMAR_CASES[] = {
"class A {\n void Foo() {}\n}\n",
2,
{"A", "Foo", NULL}},
{"csharp_extension_declaration",
CBM_LANG_CSHARP,
"Extensions.cs",
"using System.Collections.Generic;\n"
"using System.Linq;\n"
"public static class NumberExtensions\n"
"{\n"
" extension(IEnumerable<int> numbers)\n"
" {\n"
" public int SumPositive() =>\n"
" numbers.Where(n => n > 0).Aggregate(0, static (total, n) => total + n);\n"
" }\n"
"}\n",
2,
{"NumberExtensions", "SumPositive", NULL}},
{"bash",
CBM_LANG_BASH,
"a.sh",
+266
View File
@@ -2,10 +2,12 @@
#include "graph_buffer/graph_buffer.h"
#include "pipeline/pipeline_internal.h"
#include "service_patterns.h"
#include "yyjson/yyjson.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) {
const cbm_gbuf_edge_t **edges = NULL;
@@ -385,6 +387,264 @@ TEST(infrascan_http_calls_join_matching_handler_route) {
PASS();
}
/* An unchanged handler's Route is rebuilt from its stored route metadata. The
* metadata is JSON, so a route path with a quote or a backslash (a legal POSIX
* directory name) is stored escaped; reading it up to the first quote named a
* second Route "/api/a\" beside the extracted one, and the handler QN went into
* the HANDLES properties unescaped. Running the pass twice proves the rebuild
* finds the Route it made instead of adding another edge. */
TEST(infrascan_decorator_route_metadata_keeps_its_escapes) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_escapes");
ASSERT_NOT_NULL(gb);
int64_t handler = cbm_gbuf_upsert_node(
gb, "Function", "GET", "test.app.api.a\"b.route.GET", "app/api/a\"b/route.ts", 1, 1,
"{\"route_path\":\"/api/a\\\"b\\\\c\",\"route_method\":\"GET\"}");
ASSERT_GT(handler, 0);
cbm_pipeline_create_route_nodes(gb);
cbm_pipeline_create_route_nodes(gb);
const cbm_gbuf_node_t **routes = NULL;
int route_count = 0;
ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0);
ASSERT_EQ(route_count, 1);
const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, "__route__GET__/api/a\"b\\c");
ASSERT_NOT_NULL(route);
ASSERT_STR_EQ(route->name, "/api/a\"b\\c");
ASSERT_STR_EQ(route->file_path, "app/api/a\"b/route.ts");
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
ASSERT_EQ(cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count), 0);
ASSERT_EQ(edge_count, 1);
ASSERT_EQ(edges[0]->source_id, handler);
yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0);
ASSERT_NOT_NULL(doc);
const char *qn = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler"));
bool same_handler = qn && strcmp(qn, "test.app.api.a\"b.route.GET") == 0;
yyjson_doc_free(doc);
ASSERT_TRUE(same_handler);
cbm_gbuf_free(gb);
PASS();
}
/* Does the one HANDLES edge into Route route_qn hold properties that parse to
* {"handler": qn}? */
static bool handles_names_handler(cbm_gbuf_t *gb, const char *route_qn, const char *qn) {
const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn);
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
if (!route ||
cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count) != 0 ||
edge_count != 1) {
return false;
}
yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0);
const char *handler = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler"));
bool same = handler && strcmp(handler, qn) == 0;
yyjson_doc_free(doc);
return same;
}
/* The HANDLES properties hold the handler's QN escaped. A QN that fills the
* escape buffer left the closing `"}` off them, and one malformed properties
* row fails every query that reads edge properties. The edge extraction emits
* and the one an unchanged handler's metadata rebuilds must both parse. */
TEST(infrascan_handles_properties_hold_a_long_handler_qn) {
char qn[CBM_SZ_512];
memset(qn, 'q', sizeof(qn) - SKIP_ONE);
qn[sizeof(qn) - SKIP_ONE] = '\0';
cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_emit");
ASSERT_NOT_NULL(emitted);
int64_t handler = cbm_gbuf_upsert_node(emitted, "Function", "GET", qn, "server.ts", 1, 1, "{}");
ASSERT_GT(handler, 0);
CBMDefinition def = {0};
def.qualified_name = qn;
def.file_path = "server.ts";
def.route_path = "/long";
def.route_method = "GET";
cbm_pipeline_emit_def_route(emitted, handler, &def, "server.ts");
bool emitted_parses = handles_names_handler(emitted, "__route__GET__/long", qn);
cbm_gbuf_free(emitted);
cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_rebuild");
ASSERT_NOT_NULL(rebuilt);
ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "GET", qn, "server.ts", 1, 1,
"{\"route_path\":\"/long\",\"route_method\":\"GET\"}"),
0);
cbm_pipeline_create_route_nodes(rebuilt);
bool rebuilt_parses = handles_names_handler(rebuilt, "__route__GET__/long", qn);
cbm_gbuf_free(rebuilt);
ASSERT_TRUE(emitted_parses);
ASSERT_TRUE(rebuilt_parses);
PASS();
}
/* Does Route route_qn hold properties that parse to {"method": method}? */
static bool route_names_method(cbm_gbuf_t *gb, const char *route_qn, const char *method) {
const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn);
if (!route || !route->properties_json) {
return false;
}
yyjson_doc *doc = yyjson_read(route->properties_json, strlen(route->properties_json), 0);
const char *got = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "method"));
bool same = got && strcmp(got, method) == 0;
yyjson_doc_free(doc);
return same;
}
/* A route method is source text where a Python DRF action names it
* (`@action(methods=['a"b'])`), so the Route's properties hold it escaped:
* both the Route extraction emits and the one rebuilt from the handler's
* metadata must parse. */
TEST(infrascan_route_properties_escape_the_method) {
cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_emit");
ASSERT_NOT_NULL(emitted);
int64_t handler =
cbm_gbuf_upsert_node(emitted, "Function", "act", "test.views.act", "views.py", 1, 1, "{}");
ASSERT_GT(handler, 0);
CBMDefinition def = {0};
def.qualified_name = "test.views.act";
def.file_path = "views.py";
def.route_path = "/m";
def.route_method = "A\"B";
cbm_pipeline_emit_def_route(emitted, handler, &def, "views.py");
bool emitted_parses = route_names_method(emitted, "__route__A\"B__/m", "A\"B");
cbm_gbuf_free(emitted);
cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_rebuild");
ASSERT_NOT_NULL(rebuilt);
ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "act", "test.views.act", "views.py", 1, 1,
"{\"route_path\":\"/m\",\"route_method\":\"A\\\"B\"}"),
0);
cbm_pipeline_create_route_nodes(rebuilt);
bool rebuilt_parses = route_names_method(rebuilt, "__route__A\"B__/m", "A\"B");
cbm_gbuf_free(rebuilt);
ASSERT_TRUE(emitted_parses);
ASSERT_TRUE(rebuilt_parses);
PASS();
}
/* A SvelteKit +server handler's HANDLES properties hold its QN escaped, as
* every other HANDLES edge's do: a quote in a route directory, or a QN longer
* than the old 256-byte properties buffer, made them malformed. */
TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn) {
char dir[CBM_SZ_512];
snprintf(dir, sizeof(dir), "a\"b/%0300d", 0);
char file[CBM_SZ_1K];
snprintf(file, sizeof(file), "src/routes/%s/+server.ts", dir);
char qn[CBM_SZ_1K];
snprintf(qn, sizeof(qn), "test.src.routes.%s.+server.GET", dir);
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_sveltekit_handles");
ASSERT_NOT_NULL(gb);
int64_t file_id =
cbm_gbuf_upsert_node(gb, "File", "+server.ts", "test.server", file, 1, 1, "{}");
int64_t handler = cbm_gbuf_upsert_node(gb, "Function", "GET", qn, file, 1, 1, "{}");
ASSERT_GT(file_id, 0);
ASSERT_GT(handler, 0);
cbm_gbuf_insert_edge(gb, file_id, handler, "DEFINES", "{}");
cbm_pipeline_create_route_nodes(gb);
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
bool same = false;
if (cbm_gbuf_find_edges_by_source_type(gb, handler, "HANDLES", &edges, &edge_count) == 0 &&
edge_count == 1) {
yyjson_doc *doc =
yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0);
yyjson_val *root = yyjson_doc_get_root(doc);
const char *got = yyjson_get_str(yyjson_obj_get(root, "handler"));
const char *framework = yyjson_get_str(yyjson_obj_get(root, "framework"));
same = got && strcmp(got, qn) == 0 && framework && strcmp(framework, "sveltekit") == 0;
yyjson_doc_free(doc);
}
cbm_gbuf_free(gb);
ASSERT_TRUE(same);
PASS();
}
/* Route metadata that does not parse, is not a top-level string, holds a NUL or
* does not fit the path buffer names no Route at all, rather than whatever text
* happens to sit between two quotes. The 255-byte path is the longest that fits
* and must still route. */
TEST(infrascan_decorator_route_metadata_rejects_malformed_values) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_malformed");
ASSERT_NOT_NULL(gb);
char longest[CBM_SZ_256 + CBM_SZ_64];
char too_long[CBM_SZ_256 + CBM_SZ_64];
char path[CBM_SZ_256 + 1];
path[0] = '/';
memset(path + 1, 'x', CBM_SZ_256 - 1);
path[CBM_SZ_256] = '\0';
snprintf(too_long, sizeof(too_long), "{\"route_path\":\"%s\"}", path);
path[CBM_SZ_256 - 1] = '\0';
snprintf(longest, sizeof(longest), "{\"route_path\":\"%s\"}", path);
const char *props[] = {
"{\"route_path\":\"/truncated\"",
"{\"meta\":{\"route_path\":\"/nested\"}}",
"{\"route_path\":[\"/array\"]}",
"{\"route_path\":42}",
"{\"route_path\":\"/a\\u0000b\"}",
"{\"route_path\":\"\"}",
"{\"route_path\":\"relative\"}",
"[\"route_path\",\"/not-an-object\"]",
too_long,
};
const int prop_count = (int)(sizeof(props) / sizeof(props[0]));
for (int i = 0; i < prop_count; i++) {
char qn[CBM_SZ_32];
snprintf(qn, sizeof(qn), "test.bad%d", i);
ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", qn, qn, "server.ts", 1, 1, props[i]), 0);
}
cbm_pipeline_create_route_nodes(gb);
ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 0);
const cbm_gbuf_node_t **routes = NULL;
int route_count = 0;
ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0);
ASSERT_EQ(route_count, 0);
int64_t fits =
cbm_gbuf_upsert_node(gb, "Function", "fits", "test.fits", "server.ts", 1, 1, longest);
ASSERT_GT(fits, 0);
cbm_pipeline_create_route_nodes(gb);
ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0);
ASSERT_EQ(route_count, 1);
ASSERT_STR_EQ(routes[0]->name, path);
ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 1);
cbm_gbuf_free(gb);
PASS();
}
/* Properties hold source text as its bytes, which need not be UTF-8: a
* Latin-1 docstring beside the route metadata, or a Latin-1 route path. The
* Route extraction emits for them must come back from the stored metadata too,
* as it did before the metadata was parsed. */
TEST(infrascan_decorator_route_metadata_survives_invalid_utf8) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_latin1");
ASSERT_NOT_NULL(gb);
ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "doc", "test.doc", "views.py", 1, 1,
"{\"docstring\":\"caf\xe9\",\"route_path\":\"/doc\","
"\"route_method\":\"GET\"}"),
0);
ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "GET", "test.app.caf\xe9.route.GET",
"app/caf\xe9/route.ts", 1, 1,
"{\"route_path\":\"/caf\xe9\",\"route_method\":\"GET\"}"),
0);
cbm_pipeline_create_route_nodes(gb);
const cbm_gbuf_node_t *doc_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/doc");
const cbm_gbuf_node_t *dir_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/caf\xe9");
bool both = doc_route && dir_route && strcmp(dir_route->name, "/caf\xe9") == 0 &&
cbm_gbuf_edge_count_by_type(gb, "HANDLES") == 2;
cbm_gbuf_free(gb);
ASSERT_TRUE(both);
PASS();
}
/* FD16: a route's DATA_FLOWS were built from the first 64 callers and the first 32 infra handlers
* in edge-insertion order (parallel worker interleaving), so the flow set changed between two
* indexes of the same tree and every caller past the 64th lost its flow. */
@@ -557,6 +817,12 @@ SUITE(infrascan) {
RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns);
RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths);
RUN_TEST(infrascan_http_calls_join_matching_handler_route);
RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes);
RUN_TEST(infrascan_decorator_route_metadata_rejects_malformed_values);
RUN_TEST(infrascan_decorator_route_metadata_survives_invalid_utf8);
RUN_TEST(infrascan_handles_properties_hold_a_long_handler_qn);
RUN_TEST(infrascan_route_properties_escape_the_method);
RUN_TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn);
RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route);
RUN_TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order);
RUN_TEST(infrascan_data_flows_reach_every_infra_handler);
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more